Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
03438ef
#2434 Introduce appVersion, that is pushed on (re)connected SSE chann…
jhou-pro Aug 6, 2026
9310515
#2434 Avoid watermark to prevent taps for anything behind it.
jhou-pro Aug 5, 2026
14c5727
#2434 Correct broken tooltip in message panel for login screen (missi…
jhou-pro Aug 6, 2026
c47161a
#2434 Toast to show why custom IP layout was reset (DSL change).
jhou-pro Jun 10, 2025
fefb3b3
#2434 Introduce tg-sticky-toast and use it for the application update…
jhou-pro Aug 6, 2026
290b0bf
#2434 De-emphasise the Later action in the application update message.
jhou-pro Aug 6, 2026
ac0f280
#2434 Show the custom layout reset message in the sticky toast.
jhou-pro Aug 6, 2026
09125f6
#2434 Revert accidentally committed local test value for appVersion.
jhou-pro Aug 6, 2026
cee85be
#2434 Enforce bolder font (from 400 to 500) and white colour for main…
jhou-pro Aug 7, 2026
c1990d2
#2434 Emphasise 'Reload' button more - raised with standard blue colo…
jhou-pro Aug 7, 2026
1f29cc1
#2434 Doc markdownisation and improvements.
jhou-pro Aug 7, 2026
4c76d39
#2434 Missing imports and more readable colors.
jhou-pro Aug 7, 2026
1838e64
#2434 Remove redundant 'format' calls. Also remove TODO - not a part …
jhou-pro Aug 7, 2026
c0f2bfd
#2434 Re-vulcanise login resources to pick up tg-message-panel toolti…
jhou-pro Aug 7, 2026
2dec2ca
#2434 Push also the rest re-vulcanised TG Example files for consisten…
jhou-pro Aug 7, 2026
d08f228
#2434 Remove unused getter.
jhou-pro Aug 7, 2026
b214bd1
#2434 Use paper-buttons for sticky toast actions.
jhou-pro Aug 7, 2026
103371f
#2434 Revulcanised TG Example files for completness again.
jhou-pro Aug 7, 2026
d2ee5e6
#2434 Minor doc change.
jhou-pro Aug 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ public AbstractWebUiConfig(
this.independentTimeZone = independentTimeZone;
this.masterActionOptions = masterActionOptions.orElse(ALL_OFF).name();
this.webUiBuilder = new WebUiBuilder(this);
this.dispatchingEmitter = new EventSourceDispatchingEmitter();
this.dispatchingEmitter = new EventSourceDispatchingEmitter(this::appVersion);
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
logger.info("Closing Event Source Dispatching Emitter with all registered emitters...");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ static LinkedHashMap<String, Object> buildConfiguration(
// IDates uses 1–7 for Mon–Sun; JS date pickers use 0 for Sun, so convert accordingly.
configs.put("firstDayOfWeek", dates.startOfWeek() % 7);
configs.put("title", webUiConfig.title());
// The version the client is loaded with, later compared against the server version.
// Server version is announced upon SSE (re)connection to detect a new deployment.
configs.put("appVersion", webUiConfig.appVersion());
configs.put("ideaUri", webUiConfig.ideaUri());
configs.put("panelColor", webUiConfig.mainPanelColor());
configs.put("watermark", webUiConfig.watermark());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package ua.com.fielden.platform.web.sse;

import static java.lang.String.format;
import static org.apache.logging.log4j.LogManager.getLogger;
import static ua.com.fielden.platform.error.Result.failure;
import static ua.com.fielden.platform.error.Result.successful;
import static ua.com.fielden.platform.types.tuples.T2.t2;
import org.apache.commons.lang3.mutable.MutableBoolean;
import org.apache.logging.log4j.Logger;
import ua.com.fielden.platform.error.Result;
import ua.com.fielden.platform.security.user.User;
import ua.com.fielden.platform.types.tuples.T2;
import ua.com.fielden.platform.web.sse.exceptions.SseException;

import java.io.IOException;
import java.util.HashMap;
Expand All @@ -15,71 +16,77 @@
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.Supplier;

import org.apache.logging.log4j.Logger;

import ua.com.fielden.platform.error.Result;
import ua.com.fielden.platform.security.user.User;
import ua.com.fielden.platform.types.tuples.T2;
import ua.com.fielden.platform.web.sse.exceptions.SseException;
import static java.lang.String.format;
import static org.apache.logging.log4j.LogManager.getLogger;
import static org.apache.tika.utils.StringUtils.isBlank;
import static ua.com.fielden.platform.error.Result.failure;
import static ua.com.fielden.platform.error.Result.successful;
import static ua.com.fielden.platform.types.tuples.T2.t2;

/**
* {@link IEventSourceEmitter} implementation that acts as a dispatching emitter, which dispatches events to registered emitters.
* Every emitter is added on a request from a web client (every client makes such request), and is associated with a specific user and a unique identifier.
* There can potentially be multiple emitters for the same user. For example, a user who loads an application in 2 browser tabs would have 2 separate emitters associated with that user.
* <p>
* At this stage dispatching happens by means of broadcasting every event to all emitters.
* However, in the future, it is planned to support sending events to emitters, associated with specific users.
* <p>
* Another important role for this class, is to instantiate and register event sources that are specified at the level of Entity Centre configurations.
* All such event sources get connected to an instance of this class, which ensures that any emitter registered with this class will have events from all the event sources dispatched to them.
* <p>
* By design, there should be only a single instance of this class per application – one dispatching emitter per application.
*
* @author TG Team
*
*/
/// [IEventSourceEmitter] implementation that acts as a dispatching emitter, which dispatches events to registered emitters.
/// Every emitter is added on a request from a web client (every client makes such request), and is associated with a specific user and a unique identifier.
/// There can potentially be multiple emitters for the same user.
/// For example, a user who loads an application in 2 browser tabs would have 2 separate emitters associated with that user.
///
/// At this stage dispatching happens by means of broadcasting every event to all emitters.
/// However, in the future, it is planned to support sending events to emitters, associated with specific users.
///
/// Another important role for this class, is to instantiate and register event sources that are specified at the level of Entity Centre configurations.
/// All such event sources get connected to an instance of this class.
/// This ensures that any emitter registered with this class will have events from all the event sources dispatched to them.
///
/// By design, there should be only a single instance of this class per application – one dispatching emitter per application.
///
public class EventSourceDispatchingEmitter implements IEventSourceEmitter, IEventSourceEmitterRegister {

private static final Logger LOGGER = getLogger(EventSourceDispatchingEmitter.class);

/**
* A register of emitters. The key is a pair of user id and a client SSE id.
* {@link ConcurrentHashMap} is used as the register to support the concurrent nature of such register.
* It makes it thread-safe to register new emitters, close emitters and dispatch events to emitters concurrently.
*/
/// The name of the SSE event used to announce the current application version to a client upon establishing a connection.
/// The client (see `tg-event-source.js`) listens for an event with this exact name.
///
public static final String APP_VERSION_EVENT_NAME = "application-version";

/// A register of emitters. The key is a pair of user id and a client SSE id.
/// [ConcurrentHashMap] is used as the register to support the concurrent nature of such register.
/// It makes it thread-safe to register new emitters, close emitters and dispatch events to emitters concurrently.
///
private final ConcurrentHashMap<T2<Long, String>, IEventSourceEmitter> register = new ConcurrentHashMap<>(100);

/**
* Controls the state of this dispatching emitter of whether it is open for registration of new emitters and can dispatch events.
* This is required to ensure that no new emitters get registered and no new events are dispatched if the dispatcher was already closed or is being closed.
*/
/// Controls the state of this dispatching emitter of whether it is open for registration of new emitters and can dispatch events.
/// This is required to ensure that no new emitters get registered and no new events are dispatched if the dispatcher was already closed or is being closed.
///
private final AtomicBoolean isActive = new AtomicBoolean(true);

/**
* A helper function that creates a register key from {@code user} and {@code sseUid}.
*/
/// Supplies the current application version, announced to each client upon establishing an SSE connection.
/// A client uses this to detect that a newer application version has been deployed since it was loaded.
///
private final Supplier<String> appVersionSupplier;

/// Creates a dispatching emitter.
///
/// @param appVersionSupplier supplier of String-based version to be announced to each client upon establishing an SSE connection
///
public EventSourceDispatchingEmitter(final Supplier<String> appVersionSupplier) {
this.appVersionSupplier = appVersionSupplier;
}

/// A helper function that creates a register key from `user` and `sseUid`.
///
private static T2<Long, String> key(final User user, final String sseUid) {
if (user == null) {
throw new SseException("A user is required to register an SSE emitter.");
}
return t2(user.getId(), sseUid);
}

/**
* A collection of event sources, specified for various Entity Centres.
* The only reason for this collection is to prevent GC from collecting instantiated event sources, which are required for SSE eventing.
*/
/// A collection of event sources, specified for various Entity Centres.
/// The only reason for this collection is to prevent GC from collecting instantiated event sources, which are required for SSE eventing.
///
private final Map<Class<? extends IEventSource>, IEventSource> eventSources = new HashMap<>();

/**
* Creates and registers an instance of {@code eventSourceClass}, but only if such SSE class was not instantiated before.
* SSE classes may get specified as part of Entity Centre configurations.
*
* @param eventSourceClass
* @param eventSourceSupplier
* @return
* @throws IOException
*/
/// Creates and registers an instance of `eventSourceClass`, but only if such SSE class was not instantiated before.
/// SSE classes may get specified as part of Entity Centre configurations.
///
public EventSourceDispatchingEmitter createAndRegisterEventSource(final Class<? extends IEventSource> eventSourceClass, final Supplier<IEventSource> eventSourceSupplier) throws IOException {
if (isActive.get()) {
eventSources.computeIfAbsent(eventSourceClass, argNotUsed -> {
Expand All @@ -98,18 +105,42 @@ public EventSourceDispatchingEmitter createAndRegisterEventSource(final Class<?
public Result registerEmitter(final User user, final String sseUid, final Supplier<IEventSourceEmitter> emitterFactory) {
LOGGER.info(format("Registering event emitter for web client [%s, %s].", user, sseUid));
if (isActive.get()) {
final IEventSourceEmitter emitter = register.computeIfAbsent(key(user, sseUid), argNotUsed -> emitterFactory.get());
// `computeIfAbsent` runs its mapping function only for a previously unseen client, i.e., a new or re-established connection.
// The application version is announced only for such new emitters.
final var isNewEmitter = new MutableBoolean(false);
final var emitter = register.computeIfAbsent(key(user, sseUid), argNotUsed -> {
isNewEmitter.setTrue();
return emitterFactory.get();
});
if (isNewEmitter.isTrue()) {
announceAppVersion(emitter);
}
logRegisterSize();
return successful(emitter);
}
return failure("The dispatcher is inactive and no new emitters can be registered.");
}

/// Announces the current application version, if any, to `emitter`.
/// This lets a client detect that a newer application version has been deployed since it was loaded, and prompt the user to reload.
///
private void announceAppVersion(final IEventSourceEmitter emitter) {
final var appVersion = appVersionSupplier.get();
if (!isBlank(appVersion)) {
try {
emitter.event(APP_VERSION_EVENT_NAME, appVersion);
} catch (final IOException ex) {
// A failure here is non-critical: the client will receive the announcement upon its next (re)connection.
LOGGER.warn(format("Could not announce application version [%s] to a newly connected SSE client.", appVersion), ex);
}
}
}

@Override
public void deregisterEmitter(final User user, final String sseUid) {
LOGGER.info(format("Deregistering event emitter for web client [%s, %s].", user, sseUid));
// no exceptions are expected during the emitter removal and closing, but let's be defensive
// and because we cannot do much in such a case, we simply log the error for further analysis
// No exceptions are expected during the emitter removal and closing, but let's be defensive.
// Because we cannot do much in such a case, we simply log the error for further analysis.
try {
final IEventSourceEmitter emitter = register.remove(key(user, sseUid));
if (emitter != null) {
Expand All @@ -122,9 +153,8 @@ public void deregisterEmitter(final User user, final String sseUid) {
}
}

/**
* A helper method to report the number of SSE connections – a distinct by user and a total number.
*/
/// A helper method to report the number of SSE connections – a distinct by user and a total number.
///
private void logRegisterSize() {
final KeySetView<T2<Long, String>, IEventSourceEmitter> keySet = register.keySet();
final long distinctUserConnections = keySet.stream().map(t2 -> t2._1).distinct().count();
Expand All @@ -137,13 +167,14 @@ public IEventSourceEmitter getEmitter(final User user, final String sseUid) {
return register.get(key(user, sseUid));
}

/**
* Broadcasts an event to all registered emitters (i.e., clients).
* This method is thread-safe and could in practice get invoked by multiple threads.
* <p>
* Iterating over emitters, which are stored in a concurrent map, is thread-safe with "weak consistency".
* This means that iterators obtained for {@link ConcurrentHashMap} can tolerate concurrent modification, traverses elements as they existed when an iterator was constructed and may (but not guaranteed to) reflect modifications to the collection after the construction of an iterator.
*/
/// Broadcasts an event to all registered emitters (i.e., clients).
/// This method is thread-safe and could in practice get invoked by multiple threads.
///
/// Iterating over emitters, which are stored in a concurrent map, is thread-safe with "weak consistency".
/// This means that iterators obtained for [ConcurrentHashMap] can tolerate concurrent modification.
/// And it traverses elements as they existed when an iterator was constructed.
/// And it may (but not guaranteed to) reflect modifications to the collection after the construction of an iterator.
///
@Override
public void event(final String eventName, final String data) throws IOException {
if (isActive.get()) {
Expand All @@ -155,10 +186,9 @@ public void event(final String eventName, final String data) throws IOException
}
}

/**
* Broadcasts {@code data} to all registered emitters (i.e., clients).
* This method is thread-safe and could in practice get invoked by multiple threads, as per explanation in {@link #event(String, String)}.
*/
/// Broadcasts `data` to all registered emitters (i.e., clients).
/// This method is thread-safe and could in practice get invoked by multiple threads, as per explanation in [#event(String,String)].
///
@Override
public void data(final String data) throws IOException {
if (isActive.get()) {
Expand All @@ -170,10 +200,9 @@ public void data(final String data) throws IOException {
}
}

/**
* Broadcasts {@code comment} to all registered emitters (i.e., clients).
* This method is thread-safe and could in practice get invoked by multiple threads, as per explanation in {@link #event(String, String)}.
*/
/// Broadcasts `comment` to all registered emitters (i.e., clients).
/// This method is thread-safe and could in practice get invoked by multiple threads, as per explanation in [#event(String,String)].
///
@Override
public void comment(final String comment) throws IOException {
if (isActive.get()) {
Expand All @@ -185,9 +214,8 @@ public void comment(final String comment) throws IOException {
}
}

/**
* Removes and closes all emitters, registered previously.
*/
/// Removes and closes all emitters, registered previously.
///
@Override
public void close() {
if (isActive.getAndSet(false)) {
Expand All @@ -198,7 +226,7 @@ public void close() {
eventSource.disconnect();
iter.remove();
} catch (final Throwable ex) {
LOGGER.warn(format("Non critical error during closing of emitters."), ex);
LOGGER.warn("Non critical error during closing of emitters.", ex);
}
}

Expand All @@ -209,7 +237,7 @@ public void close() {
try {
emitter.close();
} catch (final Throwable ex) {
LOGGER.warn(format("Non critical error during closing of emitters."), ex);
LOGGER.warn("Non critical error during closing of emitters.", ex);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,15 @@ default boolean isEmbeddedCentreAndNotAllowCustomised(final Class<? extends MiWi
///
String title();

/// Returns the application version identifier, used to detect that a newer application version has been deployed.
/// The client compares the version it was loaded with against the current server version, announced upon SSE (re)connection.
/// Then client prompts the user to reload when versions differ.
///
default String appVersion() {
// By default this is the application `title()`, which by convention encodes the application version.
return title();
}

/// Returns the URI of the “idea” action.
///
String ideaUri();
Expand Down
Loading