UNPKG

@eclipse-scout/core

Version:
249 lines (218 loc) 9.84 kB
/* * 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); } } }