diff --git a/spring-boot-admin-server-ui/src/main/frontend/global.d.ts b/spring-boot-admin-server-ui/src/main/frontend/global.d.ts index 065b27a5eb2..8142ed6f5cb 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/global.d.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/global.d.ts @@ -73,6 +73,7 @@ declare global { externalViews: ExternalView[]; viewSettings: ViewSettings[]; enableToasts: boolean; + browserNotificationTimeout: number; hideInstanceUrl: boolean; disableInstanceUrl: boolean; allowUnsafeHtml: boolean; diff --git a/spring-boot-admin-server-ui/src/main/frontend/notifications.spec.ts b/spring-boot-admin-server-ui/src/main/frontend/notifications.spec.ts new file mode 100644 index 00000000000..61fb74ca6da --- /dev/null +++ b/spring-boot-admin-server-ui/src/main/frontend/notifications.spec.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; + +import Application from '@/services/application'; +import { + buildInstanceDetailsUrl, + findChangedInstanceId, +} from '@/notifications'; + +const createApplication = ( + name: string, + status: string, + instanceStatuses: string[], +) => { + const instances = instanceStatuses.map((instanceStatus, index) => ({ + id: `instance-${index}`, + statusInfo: { status: instanceStatus }, + registration: { + name, + healthUrl: `http://localhost:${8080 + index}/actuator/health`, + }, + })); + + return new Application({ + name, + status, + instances, + }); +}; + +describe('notifications', () => { + describe('buildInstanceDetailsUrl', () => { + it('builds instance details path', () => { + expect(buildInstanceDetailsUrl('abc123')).toBe('/instances/abc123/details'); + }); + }); + + describe('findChangedInstanceId', () => { + it('returns instance whose status changed', () => { + const application = createApplication('app', 'DOWN', ['UP', 'DOWN']); + const oldApplication = createApplication('app', 'UP', ['UP', 'UP']); + + expect(findChangedInstanceId(application, oldApplication)).toBe( + 'instance-1', + ); + }); + + it('returns sole instance id when only one instance exists', () => { + const application = createApplication('app', 'DOWN', ['DOWN']); + const oldApplication = createApplication('app', 'UP', ['UP']); + + expect(findChangedInstanceId(application, oldApplication)).toBe( + 'instance-0', + ); + }); + + it('returns undefined when multiple instances changed', () => { + const application = createApplication('app', 'DOWN', ['DOWN', 'DOWN']); + const oldApplication = createApplication('app', 'UP', ['UP', 'UP']); + + expect( + findChangedInstanceId(application, oldApplication), + ).toBeUndefined(); + }); + }); +}); diff --git a/spring-boot-admin-server-ui/src/main/frontend/notifications.ts b/spring-boot-admin-server-ui/src/main/frontend/notifications.ts index 515d1f0b27d..2a92eceabfc 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/notifications.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/notifications.ts @@ -17,10 +17,45 @@ import { groupBy, values } from 'lodash-es'; import { Subject, bufferTime, filter } from 'rxjs'; import { HealthStatus } from './HealthStatus'; +import Application from './services/application'; import sbaConfig from './sba-config'; let granted = false; +type BrowserNotificationOptions = NotificationOptions & { + timeout?: number; + url?: string; +}; + +export const buildInstanceDetailsUrl = (instanceId: string) => + `/instances/${instanceId}/details`; + +export const findChangedInstanceId = ( + application: Application, + oldApplication: Application, +) => { + const changedInstanceIds = application.instances + .filter((instance) => { + const oldInstance = oldApplication.findInstance(instance.id); + return ( + oldInstance && + oldInstance.statusInfo.status !== instance.statusInfo.status + ); + }) + .map((instance) => instance.id); + + if (changedInstanceIds.length === 1) { + return changedInstanceIds[0]; + } + if (application.instances.length === 1) { + return application.instances[0].id; + } + return undefined; +}; + +const getNotificationTimeout = () => + sbaConfig.uiSettings.browserNotificationTimeout ?? 5000; + const requestPermissions = async () => { if ('Notification' in window) { granted = window.Notification.permission === 'granted'; @@ -33,6 +68,9 @@ const requestPermissions = async () => { }; const notifyForSingleChange = (application, oldApplication) => { + const instanceId = findChangedInstanceId(application, oldApplication); + const url = instanceId ? buildInstanceDetailsUrl(instanceId) : undefined; + return createNotification( `${application.name} is now ${application.status}`, { @@ -44,7 +82,8 @@ const notifyForSingleChange = (application, oldApplication) => { ? sbaConfig.uiSettings.favicon : sbaConfig.uiSettings.faviconDanger, renotify: true, - timeout: 5000, + timeout: getNotificationTimeout(), + url, }, ); }; @@ -57,23 +96,29 @@ const notifyForBulkChange = ({ count, status, oldStatus }) => { status === HealthStatus.UP ? sbaConfig.uiSettings.favicon : sbaConfig.uiSettings.faviconDanger, - timeout: 5000, + timeout: getNotificationTimeout(), }); }; -const createNotification = (title, options) => { +const createNotification = (title, options: BrowserNotificationOptions) => { if (granted) { - const notification = new window.Notification(title, options); - if (options.url !== null) { + const { timeout = 0, url, ...notificationOptions } = options; + const notification = new window.Notification(title, { + ...notificationOptions, + // Keep visible until the user dismisses when timeout is <= 0 + requireInteraction: !(timeout > 0), + }); + if (url) { notification.onclick = () => { window.focus(); - window.open(options.url, '_self'); + window.open(url, '_self'); }; } - if (options.timeout > 0) { - notification.onshow = () => - setTimeout(() => notification.close(), options.timeout); - } + notification.onshow = () => { + if (timeout > 0) { + setTimeout(() => notification.close(), timeout); + } + }; } }; diff --git a/spring-boot-admin-server-ui/src/main/frontend/sba-config.ts b/spring-boot-admin-server-ui/src/main/frontend/sba-config.ts index 61fed3d960f..b894ae0674d 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/sba-config.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/sba-config.ts @@ -28,6 +28,7 @@ const DEFAULT_CONFIG: SBASettings = { }, rememberMeEnabled: true, enableToasts: false, + browserNotificationTimeout: 5000, externalViews: [] as ExternalView[], favicon: 'assets/img/favicon.png', faviconDanger: 'assets/img/favicon-danger.png', diff --git a/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/config/AdminServerUiAutoConfiguration.java b/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/config/AdminServerUiAutoConfiguration.java index 46e75d6b676..1e4468c6a27 100644 --- a/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/config/AdminServerUiAutoConfiguration.java +++ b/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/config/AdminServerUiAutoConfiguration.java @@ -104,6 +104,7 @@ public UiController homeUiController(UiExtensions uiExtensions) throws IOExcepti .favicon(this.adminUi.getFavicon()) .faviconDanger(this.adminUi.getFaviconDanger()) .enableToasts(this.adminUi.getEnableToasts()) + .browserNotificationTimeout(this.adminUi.getBrowserNotificationTimeout().toMillis()) .hideInstanceUrl(this.adminUi.getHideInstanceUrl()) .disableInstanceUrl(this.adminUi.getDisableInstanceUrl()) .notificationFilterEnabled( diff --git a/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/config/AdminServerUiProperties.java b/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/config/AdminServerUiProperties.java index b36d5be7e02..d6715e3ca19 100644 --- a/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/config/AdminServerUiProperties.java +++ b/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/config/AdminServerUiProperties.java @@ -131,6 +131,14 @@ public class AdminServerUiProperties { */ private Boolean enableToasts = false; + /** + * Auto-dismiss timeout for browser notifications triggered on application status + * changes. Values {@code <= 0} disable auto-dismiss. Converted to milliseconds when + * passed to the UI. + */ + @DurationUnit(ChronoUnit.MILLIS) + private Duration browserNotificationTimeout = Duration.ofMillis(5000); + /** * Set to true to hide service URLs as well as actions that require them * in UI (e.g. jump to /health or /actuator). diff --git a/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/web/UiController.java b/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/web/UiController.java index 8962f2fa00b..0fc2c92ca04 100644 --- a/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/web/UiController.java +++ b/spring-boot-admin-server-ui/src/main/java/de/codecentric/boot/admin/server/ui/web/UiController.java @@ -167,6 +167,8 @@ public static class Settings { private final Boolean enableToasts; + private final long browserNotificationTimeout; + private final Boolean hideInstanceUrl; private final Boolean disableInstanceUrl; diff --git a/spring-boot-admin-server-ui/src/test/java/de/codecentric/boot/admin/server/ui/web/UiControllerTest.java b/spring-boot-admin-server-ui/src/test/java/de/codecentric/boot/admin/server/ui/web/UiControllerTest.java index 34d47a5e806..3c29b74134a 100644 --- a/spring-boot-admin-server-ui/src/test/java/de/codecentric/boot/admin/server/ui/web/UiControllerTest.java +++ b/spring-boot-admin-server-ui/src/test/java/de/codecentric/boot/admin/server/ui/web/UiControllerTest.java @@ -142,6 +142,21 @@ void should_populate_palette_with_matching_rgb_colors_for_all_grades() { CssColorUtils.hexToRgb(palette.getShade900()))); } + @Test + void should_expose_browser_notification_timeout_in_settings() throws Exception { + UiController.Settings uiSettings = UiController.Settings.builder() + .browserNotificationTimeout(10000L) + .theme(new AdminServerUiProperties.UiTheme()) + .build(); + MockMvc mockMvc = setupControllerWithView("", UiExtensions.EMPTY, uiSettings); + + mockMvc.perform(get("http://example/login")) + .andExpect(status().isOk()) + .andExpect(model().attribute("uiSettings", uiSettings)); + + assertThat(uiSettings.getBrowserNotificationTimeout()).isEqualTo(10000L); + } + @Test void should_render_login_view_with_anonymous_user_model() throws Exception { UiController.Settings uiSettings = UiController.Settings.builder()