From 1cc6b4aeb0d00c00c9ec6bce1f6d70c313fff74b Mon Sep 17 00:00:00 2001 From: abhiramaab Date: Sun, 2 Aug 2026 23:52:52 +0530 Subject: [PATCH 1/4] feat(#5504): customizable browser notifications --- .../src/main/frontend/global.d.ts | 1 + .../src/main/frontend/notifications.spec.ts | 65 +++++++++++++++++++ .../src/main/frontend/notifications.ts | 47 ++++++++++++-- .../src/main/frontend/sba-config.ts | 1 + .../AdminServerUiAutoConfiguration.java | 1 + .../ui/config/AdminServerUiProperties.java | 6 ++ .../admin/server/ui/web/UiController.java | 2 + .../admin/server/ui/web/UiControllerTest.java | 15 +++++ 8 files changed, 134 insertions(+), 4 deletions(-) create mode 100644 spring-boot-admin-server-ui/src/main/frontend/notifications.spec.ts 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..95779108078 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,14 +96,14 @@ 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) { + if (options.url) { notification.onclick = () => { window.focus(); window.open(options.url, '_self'); 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..ced362a5611 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()) .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..29b2a094596 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,12 @@ public class AdminServerUiProperties { */ private Boolean enableToasts = false; + /** + * Auto-dismiss timeout in milliseconds for browser notifications triggered on + * application status changes. Set to {@code 0} to disable auto-dismiss. + */ + private int browserNotificationTimeout = 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..9299ead0b7b 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 int 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..e7eacc32a51 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(10000) + .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(10000); + } + @Test void should_render_login_view_with_anonymous_user_model() throws Exception { UiController.Settings uiSettings = UiController.Settings.builder() From 7b492fc047a39d8c7a1fae532dc605b5479817ae Mon Sep 17 00:00:00 2001 From: abhiramaab Date: Tue, 4 Aug 2026 09:03:09 +0530 Subject: [PATCH 2/4] refactor(#5504): use Duration for browser notification timeout property Define browser-notification-timeout as Duration in AdminServerUiProperties and convert to milliseconds when building UiController.Settings. --- .../server/ui/config/AdminServerUiAutoConfiguration.java | 3 ++- .../admin/server/ui/config/AdminServerUiProperties.java | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) 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 ced362a5611..6ad5ed28149 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,7 +104,8 @@ public UiController homeUiController(UiExtensions uiExtensions) throws IOExcepti .favicon(this.adminUi.getFavicon()) .faviconDanger(this.adminUi.getFaviconDanger()) .enableToasts(this.adminUi.getEnableToasts()) - .browserNotificationTimeout(this.adminUi.getBrowserNotificationTimeout()) + .browserNotificationTimeout( + (int) 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 29b2a094596..5b6f8047971 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 @@ -132,10 +132,11 @@ public class AdminServerUiProperties { private Boolean enableToasts = false; /** - * Auto-dismiss timeout in milliseconds for browser notifications triggered on - * application status changes. Set to {@code 0} to disable auto-dismiss. + * Auto-dismiss timeout for browser notifications triggered on application status + * changes. Set to {@code 0} to disable auto-dismiss. Converted to milliseconds when + * passed to the UI. */ - private int browserNotificationTimeout = 5000; + private Duration browserNotificationTimeout = Duration.ofSeconds(5); /** * Set to true to hide service URLs as well as actions that require them From 85c7828ab3f9ad4931f00cd02bac759a7531a285 Mon Sep 17 00:00:00 2001 From: abhiramaab Date: Wed, 5 Aug 2026 00:37:26 +0530 Subject: [PATCH 3/4] refactor(#5504): keep timeout as long and use DurationUnit.MILLIS Address review feedback: pass milliseconds as long to the UI, annotate the property with @DurationUnit(MILLIS), and document that <= 0 disables auto-dismiss. --- .../server/ui/config/AdminServerUiAutoConfiguration.java | 3 +-- .../boot/admin/server/ui/config/AdminServerUiProperties.java | 5 +++-- .../codecentric/boot/admin/server/ui/web/UiController.java | 2 +- .../boot/admin/server/ui/web/UiControllerTest.java | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) 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 6ad5ed28149..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,8 +104,7 @@ public UiController homeUiController(UiExtensions uiExtensions) throws IOExcepti .favicon(this.adminUi.getFavicon()) .faviconDanger(this.adminUi.getFaviconDanger()) .enableToasts(this.adminUi.getEnableToasts()) - .browserNotificationTimeout( - (int) this.adminUi.getBrowserNotificationTimeout().toMillis()) + .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 5b6f8047971..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 @@ -133,10 +133,11 @@ public class AdminServerUiProperties { /** * Auto-dismiss timeout for browser notifications triggered on application status - * changes. Set to {@code 0} to disable auto-dismiss. Converted to milliseconds when + * changes. Values {@code <= 0} disable auto-dismiss. Converted to milliseconds when * passed to the UI. */ - private Duration browserNotificationTimeout = Duration.ofSeconds(5); + @DurationUnit(ChronoUnit.MILLIS) + private Duration browserNotificationTimeout = Duration.ofMillis(5000); /** * Set to true to hide service URLs as well as actions that require them 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 9299ead0b7b..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,7 +167,7 @@ public static class Settings { private final Boolean enableToasts; - private final int browserNotificationTimeout; + private final long browserNotificationTimeout; private final Boolean hideInstanceUrl; 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 e7eacc32a51..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 @@ -145,7 +145,7 @@ void should_populate_palette_with_matching_rgb_colors_for_all_grades() { @Test void should_expose_browser_notification_timeout_in_settings() throws Exception { UiController.Settings uiSettings = UiController.Settings.builder() - .browserNotificationTimeout(10000) + .browserNotificationTimeout(10000L) .theme(new AdminServerUiProperties.UiTheme()) .build(); MockMvc mockMvc = setupControllerWithView("", UiExtensions.EMPTY, uiSettings); @@ -154,7 +154,7 @@ void should_expose_browser_notification_timeout_in_settings() throws Exception { .andExpect(status().isOk()) .andExpect(model().attribute("uiSettings", uiSettings)); - assertThat(uiSettings.getBrowserNotificationTimeout()).isEqualTo(10000); + assertThat(uiSettings.getBrowserNotificationTimeout()).isEqualTo(10000L); } @Test From 77a040e0db426c578fb0d945f9eb515a3e5d2674 Mon Sep 17 00:00:00 2001 From: abhiramaab Date: Wed, 5 Aug 2026 00:40:14 +0530 Subject: [PATCH 4/4] fix(#5504): keep browser notifications open when timeout is 0 Use requireInteraction when timeout <= 0 so the browser does not auto-dismiss, and only schedule close when timeout > 0. --- .../src/main/frontend/notifications.ts | 20 ++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) 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 95779108078..2a92eceabfc 100644 --- a/spring-boot-admin-server-ui/src/main/frontend/notifications.ts +++ b/spring-boot-admin-server-ui/src/main/frontend/notifications.ts @@ -102,17 +102,23 @@ const notifyForBulkChange = ({ count, status, oldStatus }) => { const createNotification = (title, options: BrowserNotificationOptions) => { if (granted) { - const notification = new window.Notification(title, options); - if (options.url) { + 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); + } + }; } };