Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -73,6 +73,7 @@ declare global {
externalViews: ExternalView[];
viewSettings: ViewSettings[];
enableToasts: boolean;
browserNotificationTimeout: number;
hideInstanceUrl: boolean;
disableInstanceUrl: boolean;
allowUnsafeHtml: boolean;
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
Comment on lines +32 to +34
});

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();
});
});
});
47 changes: 43 additions & 4 deletions spring-boot-admin-server-ui/src/main/frontend/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Comment on lines 19 to +31

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';
Expand All @@ -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}`,
{
Expand All @@ -44,7 +82,8 @@ const notifyForSingleChange = (application, oldApplication) => {
? sbaConfig.uiSettings.favicon
: sbaConfig.uiSettings.faviconDanger,
renotify: true,
timeout: 5000,
timeout: getNotificationTimeout(),
url,
},
);
};
Expand All @@ -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');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the backend properties, this should ideally be defined as Duration and only converted to milliseconds when passed to the UI.


/**
* Set to <code>true</code> to hide service URLs as well as actions that require them
* in UI (e.g. jump to /health or /actuator).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,8 @@ public static class Settings {

private final Boolean enableToasts;

private final int browserNotificationTimeout;

private final Boolean hideInstanceUrl;

private final Boolean disableInstanceUrl;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down