@eclipse-scout/core
Version:
Eclipse Scout runtime
249 lines (218 loc) • 9.84 kB
text/typescript
/*
* Copyright (c) 2010, 2025 BSI Business Systems Integration AG
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
*/
import {
ajax, AjaxCall, AjaxError, App, arrays, BackgroundJobPollingStatus, config, ConfigProperties, ErrorHandler, InitModelOf, JsonErrorResponse, LogLevel, MainConfigProperties, objects, PropertyEventEmitter, scout, Session, TopicDo,
UiNotificationDo, UiNotificationPollerEventMap, UiNotificationRequest, UiNotificationResponse, UiNotificationSystem
} from '../index';
import $ from 'jquery';
export class UiNotificationPoller extends PropertyEventEmitter {
declare eventMap: UiNotificationPollerEventMap;
declare self: UiNotificationPoller;
/**
* Configures in milliseconds the time to wait after an error occurs, before the polling will be retried.
*/
static RESPONSE_ERROR_RETRY_INTERVAL = 30_000;
/**
* Configures in milliseconds the time to wait after a connection error occurs, before the polling will be retried.
*/
static CONNECTION_ERROR_RETRY_INTERVALS = [300, 500, 1000, 5000];
/**
* The number of notifications per topic that should be kept until the topic is unsubscribed.
*/
static HISTORY_COUNT = 10;
static DEFAULT_BACKEND_TIMEOUT = 60_000;
static BACKEND_TIMEOUT_OFFSET = 15_000;
/**
* Configures in milliseconds how long the connection is allowed to stay open before it will be aborted.
* This is more like a last resort timeout, the server will release the connection earlier (see scout.uinotification.waitTimeout).
*/
requestTimeout: number;
status: BackgroundJobPollingStatus;
/**
* Stores the received notifications per topic and per cluster node but not more than {@link UiNotificationPoller.HISTORY_COUNT}.
*/
notifications: Map<string, Map<string, UiNotificationDo[]>>;
url: string;
system: UiNotificationSystem;
protected _call: AjaxCall;
constructor() {
super();
this.requestTimeout = UiNotificationPoller.DEFAULT_BACKEND_TIMEOUT + UiNotificationPoller.BACKEND_TIMEOUT_OFFSET;
this.status = BackgroundJobPollingStatus.STOPPED;
this.notifications = new Map();
}
protected override _init(model: InitModelOf<this>) {
super._init(model);
let timeoutPropertyKey: keyof MainConfigProperties = 'scout.uinotification.waitTimeout';
let system = this.system.name as keyof ConfigProperties;
let backendTimeout = scout.nvl(config.get(timeoutPropertyKey, system)?.value, config.get(timeoutPropertyKey)?.value, UiNotificationPoller.DEFAULT_BACKEND_TIMEOUT);
this.requestTimeout = backendTimeout + UiNotificationPoller.BACKEND_TIMEOUT_OFFSET;
}
setTopics(topics: string[]) {
// Create a new map but keep existing notifications per topic
this.notifications = new Map<string, Map<string, UiNotificationDo[]>>(topics.map(topic => [topic, this.notifications.get(topic) || new Map()]));
}
get topics(): string[] {
return Array.from(this.notifications.keys());
}
get topicsWithLastNotifications(): TopicDo[] {
// Create an array of TopicDOs containing the last notification of each topic per node
return Array.from(this.notifications.entries())
.map(([name, notificationsByNode]) => {
const lastNotifications = Array.from(notificationsByNode.values()).map(notifications => {
const lastNotification = arrays.last(notifications);
return scout.create(UiNotificationDo, {
id: lastNotification.id,
creationTime: lastNotification.creationTime,
nodeId: lastNotification.nodeId
});
});
return scout.create(TopicDo, {name, lastNotifications: lastNotifications.length === 0 ? undefined : lastNotifications});
});
}
restart() {
this.stop();
this.start();
}
start() {
if (this.status === BackgroundJobPollingStatus.RUNNING) {
return;
}
this.poll();
}
stop() {
if (this.status === BackgroundJobPollingStatus.STOPPED) {
return;
}
this._call?.abort();
this.setStatus(BackgroundJobPollingStatus.STOPPED);
}
poll() {
this._poll();
this.setStatus(BackgroundJobPollingStatus.RUNNING);
}
protected _schedulePoll(timeout?: number) {
if (this.status === BackgroundJobPollingStatus.STOPPED) {
return;
}
setTimeout(() => {
if (this.status === BackgroundJobPollingStatus.STOPPED) {
return;
}
this.poll();
}, scout.nvl(timeout, 0));
}
protected _poll() {
this._call?.abort(); // abort in case there is already a call running
const ajaxOptions = {
url: this.url,
timeout: this.requestTimeout
};
const request = scout.create(UiNotificationRequest, {topics: this.topicsWithLastNotifications});
this._call = ajax.createCallDataObject(ajaxOptions, request, {
maxRetries: -1, // unlimited retries on connection errors
retryIntervals: UiNotificationPoller.CONNECTION_ERROR_RETRY_INTERVALS
});
this._call.call()
.then((response: UiNotificationResponse) => this._onSuccess(response))
.catch(error => this._onError(error));
}
protected _onSuccess(response: UiNotificationResponse) {
if (response.error) {
this._onSuccessError(response.error);
return;
}
if (this.status === BackgroundJobPollingStatus.STOPPED) {
// Don't do anything if poller was stopped in the meantime -> discard notifications
// In case the poller will be started again, the discarded notifications will be sent again by the server
return;
}
let notifications = response.notifications || [];
$.log.isInfoEnabled() && $.log.info(`${notifications.length} UI notification(s) received.`);
notifications = notifications.filter(notification => {
let {topic, id, nodeId} = notification;
if (!this.notifications.has(topic)) {
// Ignore topics that have been unsubscribed in the meantime
return false;
}
// Add to notification history and drop the oldest ones
let topicNotifications = this.notifications.get(topic);
let nodeNotifications = objects.getOrSetIfAbsent(topicNotifications, nodeId, () => []);
if (nodeNotifications.some(existingNotification => existingNotification.id === id)) {
// Notification already known, ignore it
$.log.isInfoEnabled() && $.log.info(`UI notification with id '${id}' is already known, dropping it.`);
return false;
}
nodeNotifications.push(notification);
nodeNotifications = nodeNotifications.sort((n1, n2) => n1.creationTime.getTime() - n2.creationTime.getTime());
if (nodeNotifications.length > UiNotificationPoller.HISTORY_COUNT) {
nodeNotifications.splice(0, 1);
}
if (notification.subscriptionStart) {
$.log.isInfoEnabled() && $.log.info(`UI notification with id ${id} marks subscription start.`);
this.trigger('subscriptionStart', {notification});
// Just a marker notification -> discard it
return false;
}
return true;
});
if (notifications.length) {
$.log.isInfoEnabled() && $.log.info(`Dispatching UI notifications with ids ${notifications.map(n => n.id)}.`);
this.trigger('notifications', {notifications});
}
this._schedulePoll();
}
protected _onSuccessError(error: JsonErrorResponse) {
if (error.code === Session.JsonResponseError.SESSION_TIMEOUT) {
$.log.isInfoEnabled() && $.log.info('Stopping ui notification poller due to session timeout');
this.stop();
} else {
// Log every other error, even though they should actually never happen
scout.create(ErrorHandler, {displayError: false}).handle(error);
}
}
protected _onError(error: AjaxError) {
if (this._call.pendingCall || this._call.callTimeoutId) {
// Poller has probably been aborted but already been restarted or scheduled for a retry (callTimeoutId is set) -> ignore error and don't reschedule poll
return;
}
if (error.textStatus === 'abort' || this._call.aborted) {
// Don't report errors if polling was aborted.
// Checking the aborted flag is necessary because textStatus may be wrong if AjaxCall was aborted between two retries (textStatus is only set to aborted if the actual request was aborted).
// This ensures no error is reported even if poller was stopped between two retries.
if (this.status === BackgroundJobPollingStatus.STOPPED) {
return;
}
this.setStatus(BackgroundJobPollingStatus.FAILURE);
// If poller is supposed to run, reschedule it
this._schedulePoll(UiNotificationPoller.RESPONSE_ERROR_RETRY_INTERVAL);
return;
}
this.setStatus(BackgroundJobPollingStatus.FAILURE);
if (scout.isOneOf(error.jqXHR.status, 401, 403)) {
// Stop polling on session timeout
$.log.isInfoEnabled() && $.log.info(`Stopping ui notification poller because operation is not permitted (${error.jqXHR.status})`);
this.trigger('error', {error});
this.stop();
return;
}
this.trigger('error', {error});
App.get().errorHandler.analyzeError(error).then(errorInfo => {
scout.getSession()?.sendLogRequest(`UI notification poller failed, call will be retried in ${UiNotificationPoller.RESPONSE_ERROR_RETRY_INTERVAL} ms.\nError message:\n${errorInfo.log}\n()`, LogLevel.INFO);
});
this._schedulePoll(UiNotificationPoller.RESPONSE_ERROR_RETRY_INTERVAL);
}
setStatus(status: BackgroundJobPollingStatus) {
const changed = this.setProperty('status', status);
if (changed) {
$.log.isInfoEnabled() && $.log.info('UI notification poller status changed: ' + status);
}
}
}