From a9a6255cd2d2d61b48ccfe18af5b4751817832ba Mon Sep 17 00:00:00 2001
From: Afreen Misbah
Date: Tue, 21 Jul 2026 18:01:36 +0530
Subject: [PATCH] mgr/dashboard: Sanitize toast HTML and optimize notification
service
- Escape user-controlled content before Carbon innerHTML rendering
- Whitelist localStorage deserialization keys, prune stale read map
- Replace deep equality with ID comparison in removeToast
- Gate ngAfterViewChecked DOM queries behind a dirty flag
- Deduplicate by title+type+message; show occurrences count
- Fix bell icon unread dot clearing on new occurrences
- Fix view more pre-selection via route queryParams observable
- Save individual notifications instead of HTML-merged messages
- Convert notifications bell component to signals with OnPush
- Add OnPush to notification-item and toast components
- Add e2e tests
- Fix styling of notification panel
Signed-off-by: Afreen Misbah
---
.../cypress/e2e/ui/notification.e2e-spec.ts | 240 ++++++++++++++++++
.../cypress/e2e/ui/notification.po.ts | 60 +++++
.../notification-area.component.html | 1 +
.../notification-area.component.scss | 12 +
.../notification-item.component.html | 8 +
.../notification-item.component.scss | 5 +
.../notification-item.component.spec.ts | 1 -
.../notification-item.component.ts | 12 +-
.../notifications-page.component.html | 12 +-
.../notifications-page.component.spec.ts | 48 ++--
.../notifications-page.component.ts | 27 +-
.../notifications.component.html | 4 +-
.../notifications.component.spec.ts | 59 +++--
.../notifications/notifications.component.ts | 47 +---
.../notification-toast.component.ts | 14 +-
.../services/notification.service.spec.ts | 87 ++++++-
.../shared/services/notification.service.ts | 111 +++++---
17 files changed, 608 insertions(+), 140 deletions(-)
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.e2e-spec.ts
index 17c2af07a2c..4039694e61a 100644
--- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.e2e-spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.e2e-spec.ts
@@ -54,3 +54,243 @@ describe('Notification page', () => {
});
});
});
+
+describe('Notification panel to page navigation', () => {
+ const notification = new NotificationSidebarPageHelper();
+
+ beforeEach(() => {
+ cy.login();
+ cy.visit('#/');
+ cy.wait(5000);
+ });
+
+ it('should navigate to notifications page via View all', () => {
+ notification.navigateToNotificationsPage();
+ cy.url().should('include', '/notifications');
+ notification.getNotificationsPage().should('exist');
+ });
+
+ it('should navigate from panel notification click to page with pre-selected detail', () => {
+ notification.open();
+ notification.getNotifications().then(($items) => {
+ if ($items.length === 0) {
+ cy.log('No notifications in panel — skipping');
+ return;
+ }
+ notification.getNotifications().first().click();
+ cy.url().should('include', '/notifications');
+ cy.url().should('include', 'id=');
+ notification.getNotificationsPage().should('exist');
+ notification.getNotificationsPageDetail().find('cd-notification-item').should('exist');
+ notification
+ .getNotificationsPageListItems()
+ .first()
+ .should('have.class', 'notifications-page__item--active');
+ });
+ });
+
+ it('should show back button and navigate back', () => {
+ notification.navigateToNotificationsPage();
+ cy.contains('Back').should('be.visible').click();
+ cy.url().should('not.include', '/notifications');
+ });
+});
+
+describe('Notifications page actions', () => {
+ const notification = new NotificationSidebarPageHelper();
+
+ beforeEach(() => {
+ cy.login();
+ cy.visit('#/');
+ cy.wait(3000);
+ });
+
+ it('should show kebab menu with mark all as read and clear all', () => {
+ notification.navigateToNotificationsPage();
+ notification.getKebabMenu().click();
+ notification.getKebabMenuItems().should('have.length', 2);
+ cy.contains('.cds--overflow-menu-options__btn', 'Mark all as read').should('exist');
+ cy.contains('.cds--overflow-menu-options__btn', 'Clear all').should('exist');
+ });
+
+ it('should show empty state when no notifications', () => {
+ notification.open();
+ notification.clearNotifications();
+ notification.getViewAllBtn().click({ force: true });
+ notification.getNotificationsPage().should('exist');
+ cy.contains('No notifications available').should('be.visible');
+ });
+
+ it('should disable kebab actions when no notifications', () => {
+ notification.open();
+ notification.clearNotifications();
+ notification.getViewAllBtn().click({ force: true });
+ notification.getKebabMenu().click();
+ cy.contains('.cds--overflow-menu-options__btn', 'Mark all as read').should('be.disabled');
+ cy.contains('.cds--overflow-menu-options__btn', 'Clear all').should('be.disabled');
+ });
+});
+
+describe('Notification selection and detail', () => {
+ const notification = new NotificationSidebarPageHelper();
+
+ beforeEach(() => {
+ cy.login();
+ cy.visit('#/');
+ cy.wait(5000);
+ });
+
+ it('should select a notification and show detail in right panel', () => {
+ notification.navigateToNotificationsPage();
+ notification.getNotificationsPageListItems().then(($items) => {
+ if ($items.length === 0) {
+ cy.log('No notifications to select — skipping');
+ return;
+ }
+ cy.wrap($items.first()).click();
+ cy.wrap($items.first()).should('have.class', 'notifications-page__item--active');
+ notification.getNotificationsPageDetail().find('cd-notification-item').should('exist');
+ notification
+ .getNotificationsPageDetail()
+ .find('.notifications-page__detail-text')
+ .should('exist');
+ });
+ });
+
+ it('should show occurrences count when notification has duplicates', () => {
+ notification.navigateToNotificationsPage();
+ notification.getNotificationsPageListItems().then(($items) => {
+ if ($items.length === 0) {
+ cy.log('No notifications — skipping');
+ return;
+ }
+ cy.wrap($items.first()).click();
+ notification.getNotificationsPageDetail().then(($detail) => {
+ const hasOccurrences = $detail.find('.notifications-page__occurrences').length > 0;
+ if (hasOccurrences) {
+ cy.get('.notifications-page__occurrences')
+ .should('be.visible')
+ .and('contain', 'Occurrences:');
+ } else {
+ cy.log('First notification has no duplicates — occurrences label correctly hidden');
+ }
+ });
+ });
+ });
+
+ it('should clear all notifications via kebab menu', () => {
+ notification.navigateToNotificationsPage();
+ notification.getNotificationsPageListItems().then(($items) => {
+ if ($items.length === 0) {
+ cy.log('No notifications to clear — skipping');
+ return;
+ }
+ notification.getKebabMenu().click();
+ cy.contains('.cds--overflow-menu-options__btn', 'Clear all').click();
+ cy.contains('No notifications available').should('be.visible');
+ });
+ });
+});
+
+describe('Notification toast deduplication', () => {
+ const notification = new NotificationSidebarPageHelper();
+
+ beforeEach(() => {
+ cy.login();
+ cy.visit('#/');
+ });
+
+ it('should show +N more count on duplicate toasts', () => {
+ cy.wait(8000);
+ cy.get('body').then(($body) => {
+ if ($body.find('.toast-duplicate-count').length > 0) {
+ notification
+ .getToastDuplicateCount()
+ .first()
+ .should('be.visible')
+ .invoke('text')
+ .should('match', /\(\+\d+ more\)/);
+ } else if ($body.find('cds-toast').length > 0) {
+ cy.log('Toasts present but no duplicates yet — only unique errors firing');
+ } else {
+ cy.log('No toasts appeared — skipping');
+ }
+ });
+ });
+
+ it('should show view more link when toast text is truncated', () => {
+ cy.wait(5000);
+ cy.get('body').then(($body) => {
+ if ($body.find('.toast-view-more:visible').length > 0) {
+ notification
+ .getToastViewMoreLink()
+ .first()
+ .should('be.visible')
+ .and('contain', 'View more');
+ } else {
+ cy.log('No truncated toasts with view more — skipping');
+ }
+ });
+ });
+
+ it('should navigate to notifications page and dismiss toasts on view more click', () => {
+ cy.wait(5000);
+ cy.get('body').then(($body) => {
+ if ($body.find('.toast-view-more:visible').length > 0) {
+ notification.getToastViewMoreLink().first().click({ force: true });
+ cy.url().should('include', '/notifications');
+ notification.getNotificationsPage().should('exist');
+ notification.getToasts().should('not.exist');
+ } else {
+ cy.log('No visible view more link — skipping');
+ }
+ });
+ });
+});
+
+describe('Notification read state', () => {
+ const notification = new NotificationSidebarPageHelper();
+
+ beforeEach(() => {
+ cy.login();
+ cy.visit('#/');
+ cy.wait(5000);
+ });
+
+ it('should mark notification as read on selection', () => {
+ notification.navigateToNotificationsPage();
+ notification.getNotificationsPageListItems().then(($items) => {
+ if ($items.length === 0) {
+ cy.log('No notifications — skipping');
+ return;
+ }
+ cy.wrap($items.first()).click();
+ cy.wrap($items.first()).should('not.have.class', 'notifications-page__item--unread');
+ });
+ });
+
+ it('should mark all as read via kebab menu', () => {
+ notification.navigateToNotificationsPage();
+ notification.getNotificationsPageListItems().then(($items) => {
+ if ($items.length === 0) {
+ cy.log('No notifications — skipping');
+ return;
+ }
+ notification.getKebabMenu().click();
+ cy.contains('.cds--overflow-menu-options__btn', 'Mark all as read').click();
+ notification.getNotificationsPageListItems().each(($item) => {
+ cy.wrap($item).should('not.have.class', 'notifications-page__item--unread');
+ });
+ });
+ });
+
+ it('should show unread indicator on bell icon when unread notifications exist', () => {
+ cy.visit('#/');
+ cy.wait(5000);
+ cy.get('[data-testid="header-notification-icon"]').then(($icon) => {
+ const iconType = $icon.find('cd-icon').attr('type');
+ cy.log(`Bell icon type: ${iconType}`);
+ expect(['notification', 'notificationNew']).to.include(iconType);
+ });
+ });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.po.ts
index 897e11153f5..36b2b003c6c 100644
--- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.po.ts
+++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.po.ts
@@ -1,6 +1,10 @@
import { PageHelper } from '../page-helper.po';
export class NotificationSidebarPageHelper extends PageHelper {
+ pages = {
+ index: { url: '#/notifications', id: 'cd-notifications-page' }
+ };
+
getNotificationIcon() {
return cy.get(`[data-testid='header-notification-icon']`);
}
@@ -31,6 +35,10 @@ export class NotificationSidebarPageHelper extends PageHelper {
return cy.get('cd-notification-panel .notification-header__dismiss-btn');
}
+ getViewAllBtn() {
+ return cy.get('.notification-footer__view-all-button');
+ }
+
open() {
this.getNotificationIcon().click({ force: true });
this.getPanel().should('exist');
@@ -48,4 +56,56 @@ export class NotificationSidebarPageHelper extends PageHelper {
this.clearNotifications();
});
}
+
+ // Notifications page helpers
+
+ getNotificationsPage() {
+ return cy.get('.notifications-page');
+ }
+
+ getNotificationsPageList() {
+ return cy.get('.notifications-page__list');
+ }
+
+ getNotificationsPageListItems() {
+ return cy.get('.notifications-page__item');
+ }
+
+ getNotificationsPageDetail() {
+ return cy.get('.notifications-page__detail');
+ }
+
+ getKebabMenu() {
+ return cy.get('.notifications-page__header-menu cds-overflow-menu');
+ }
+
+ getKebabMenuItems() {
+ return cy.get('.cds--overflow-menu-options__btn');
+ }
+
+ getOccurrencesLabel() {
+ return cy.get('.cd-notification-item__occurrences');
+ }
+
+ getToastContainer() {
+ return cy.get('.cds--toast-notification-container');
+ }
+
+ getToasts() {
+ return cy.get('cds-toast');
+ }
+
+ getToastViewMoreLink() {
+ return cy.get('.toast-view-more');
+ }
+
+ getToastDuplicateCount() {
+ return cy.get('.toast-duplicate-count');
+ }
+
+ navigateToNotificationsPage() {
+ this.open();
+ this.getViewAllBtn().click({ force: true });
+ this.getNotificationsPage().should('exist');
+ }
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html
index 08a67d7e9f7..e85813bf05b 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html
@@ -61,6 +61,7 @@
[timestamp]="notification.timestamp"
[message]="notification.message"
[notificationId]="notification.id"
+ [occurrences]="notification.occurrences"
>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss
index 28569607d15..c10bc51c41f 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss
@@ -59,6 +59,18 @@ cd-notification-area {
&:last-child {
padding-bottom: var(--cds-spacing-05);
}
+
+ > .border-subtle-bottom {
+ margin: 0 calc(-1 * var(--cds-spacing-05));
+ }
+
+ &:has(.notification-item--clickable) {
+ cursor: pointer;
+
+ &:hover {
+ background-color: var(--cds-layer-hover);
+ }
+ }
}
.notification-item {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.html
index 729361ed41d..5b0639b8ec1 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.html
@@ -36,6 +36,14 @@
}
}
+ @if (occurrences > 1) {
+
+ Occurrences: {{ occurrences }}
+
+ }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.scss
index 94d7b75d64d..b359d84df1d 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.scss
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.scss
@@ -83,6 +83,11 @@ cd-notification-item {
height: 1rem;
}
+.cd-notification-item__occurrences {
+ color: var(--cds-text-secondary);
+ margin: 0;
+}
+
.cd-notification-item__chevron svg {
fill: var(--cds-icon-secondary);
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts
index cf749081f86..408199884f2 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts
@@ -9,7 +9,6 @@ import {
} from '~/app/shared/enum/notification-type.enum';
import { NotificationService } from '~/app/shared/services/notification.service';
import { SharedModule } from '~/app/shared/shared.module';
-import { CdNotification, CdNotificationConfig } from '~/app/shared/models/cd-notification';
@Component({
template: `
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts
index 04aabf9e61f..a15184bb014 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts
@@ -1,4 +1,11 @@
-import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
+import {
+ Component,
+ EventEmitter,
+ Input,
+ Output,
+ ViewEncapsulation,
+ ChangeDetectionStrategy
+} from '@angular/core';
import {
NotificationApplication,
NotificationType
@@ -10,6 +17,7 @@ import { NotificationService } from '~/app/shared/services/notification.service'
templateUrl: './notification-item.component.html',
styleUrls: ['./notification-item.component.scss'],
encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false
})
export class NotificationItemComponent {
@@ -29,6 +37,8 @@ export class NotificationItemComponent {
@Input() showChevron: boolean = false;
/* Replace severity icon with unread dot */
@Input() showUnread: boolean = false;
+ /* Number of deduplicated occurrences */
+ @Input() occurrences: number = 1;
/* Emitted after notification is deleted via the service */
@Output() deleted = new EventEmitter
();
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html
index ad19191f339..68f8224e89e 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html
@@ -74,6 +74,7 @@
[timestamp]="notification.timestamp"
[message]="notification.displayPreview"
[notificationId]="notification.id"
+ [occurrences]="notification.occurrences"
[showChevron]="true"
[showUnread]="!readMap()[notification.id]"
>
@@ -92,6 +93,12 @@
>
No notifications available
+
+ Notifications are stored locally and cleared on browser reset.
+
}
@@ -110,7 +117,10 @@
@if (selected.occurrences > 1) {
-
+
Occurrences: {{ selected.occurrences }}
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts
index 22ae569fa38..1a5f450bca5 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts
@@ -22,6 +22,7 @@ describe('NotificationsPageComponent', () => {
let readMapSubject: BehaviorSubject
>;
let notificationService: any;
let mockLocation: any;
+ let queryParamsSubject: BehaviorSubject;
const createMockNotificationService = () => {
dataSourceSubject = new BehaviorSubject([]);
@@ -50,7 +51,8 @@ describe('NotificationsPageComponent', () => {
readMapSubject.next(updated);
localStorage.setItem('cdNotificationsRead', JSON.stringify(updated));
}
- })
+ }),
+ getNotificationsSnapshot: () => dataSourceSubject.getValue()
};
};
@@ -118,6 +120,7 @@ describe('NotificationsPageComponent', () => {
mockLocation = { back: jasmine.createSpy('back') };
const mockNotificationService = createMockNotificationService();
notificationService = mockNotificationService;
+ queryParamsSubject = new BehaviorSubject({});
localStorage.removeItem('cdNotificationsRead');
@@ -130,7 +133,13 @@ describe('NotificationsPageComponent', () => {
{ provide: PrometheusNotificationService, useValue: mockPrometheusNotificationService },
{ provide: AuthStorageService, useValue: mockAuthStorageService },
{ provide: Location, useValue: mockLocation },
- { provide: ActivatedRoute, useValue: { snapshot: { queryParams: {} } } }
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ snapshot: { queryParams: {} },
+ queryParams: queryParamsSubject.asObservable()
+ }
+ }
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
@@ -310,13 +319,8 @@ describe('NotificationsPageComponent', () => {
});
describe('query param pre-selection', () => {
- it('should pre-select notification from id query param', async () => {
- const route = TestBed.inject(ActivatedRoute);
- (route.snapshot.queryParams as any) = { id: '2' };
-
- fixture = TestBed.createComponent(NotificationsPageComponent);
- component = fixture.componentInstance;
- dataSourceSubject.next(mockNotifications);
+ it('should pre-select notification from id query param', () => {
+ queryParamsSubject.next({ id: '2' });
fixture.detectChanges();
expect(component.selectedNotificationID()).toBe('2');
@@ -324,25 +328,16 @@ describe('NotificationsPageComponent', () => {
});
it('should not pre-select if id does not match any notification', () => {
- const route = TestBed.inject(ActivatedRoute);
- (route.snapshot.queryParams as any) = { id: 'nonexistent' };
-
- fixture = TestBed.createComponent(NotificationsPageComponent);
- component = fixture.componentInstance;
- dataSourceSubject.next(mockNotifications);
+ queryParamsSubject.next({ id: 'nonexistent' });
fixture.detectChanges();
expect(component.selectedNotificationID()).toBeNull();
});
it('should not override manual selection on subsequent data emissions', () => {
- const route = TestBed.inject(ActivatedRoute);
- (route.snapshot.queryParams as any) = { id: '2' };
-
- fixture = TestBed.createComponent(NotificationsPageComponent);
- component = fixture.componentInstance;
- dataSourceSubject.next(mockNotifications);
+ queryParamsSubject.next({ id: '2' });
fixture.detectChanges();
+ expect(component.selectedNotificationID()).toBe('2');
component.onNotificationSelect(component.notifications()[0]);
expect(component.selectedNotificationID()).toBe('1');
@@ -351,6 +346,17 @@ describe('NotificationsPageComponent', () => {
fixture.detectChanges();
expect(component.selectedNotificationID()).toBe('1');
});
+
+ it('should pre-select when navigating from toast view more link', () => {
+ dataSourceSubject.next(mockNotifications);
+ fixture.detectChanges();
+
+ queryParamsSubject.next({ id: '3' });
+ fixture.detectChanges();
+
+ expect(component.selectedNotificationID()).toBe('3');
+ expect(notificationService.markAsRead).toHaveBeenCalledWith('3');
+ });
});
it('should set up interval for Prometheus alerts when permissions exist', () => {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts
index b9c517eea79..6d79b123d06 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts
@@ -48,6 +48,7 @@ export class NotificationsPageComponent implements OnInit, OnDestroy {
private sub: Subscription;
private interval: number;
+ private _pendingId: string | null = null;
constructor(
private notificationService: NotificationService,
@@ -81,15 +82,15 @@ export class NotificationsPageComponent implements OnInit, OnDestroy {
)
);
- const id = this.route.snapshot.queryParams['id'];
- if (id && !this.selectedNotificationID()) {
- const match = notifications.find((n) => n.id === id);
- if (match) {
- this.selectedNotificationID.set(id);
- this.notificationService.markAsRead(id);
- }
- }
+ this._tryPreselect(notifications);
});
+
+ this.sub.add(
+ this.route.queryParams.subscribe((params) => {
+ this._pendingId = params['id'] || null;
+ this._tryPreselect(this.notificationService.getNotificationsSnapshot());
+ })
+ );
}
ngOnDestroy(): void {
@@ -132,6 +133,16 @@ export class NotificationsPageComponent implements OnInit, OnDestroy {
}
}
+ private _tryPreselect(notifications: CdNotification[]): void {
+ if (!this._pendingId || this.selectedNotificationID()) return;
+ const match = notifications.find((n) => n.id === this._pendingId);
+ if (match) {
+ this.selectedNotificationID.set(this._pendingId);
+ this.notificationService.markAsRead(this._pendingId);
+ this._pendingId = null;
+ }
+ }
+
private triggerPrometheusAlerts(): void {
this.prometheusAlertService.refresh();
this.prometheusNotificationService.refresh();
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html
index ee41505a363..9b10a25308d 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html
@@ -1,10 +1,10 @@
-@if (isMuted) {
+@if (isMuted()) {
-} @else if (!isMuted && (hasRunningTasks || hasNotifications)) {
+} @else if (hasRunningTasks() || hasNotifications()) {
{
let component: NotificationsComponent;
let fixture: ComponentFixture;
let summaryService: SummaryService;
- let notificationService: NotificationService;
+
const hasUnreadSource = new BehaviorSubject(false);
+ const muteSource = new BehaviorSubject(false);
const notificationServiceMock = {
- dataSource: new BehaviorSubject([]),
- hasUnreadSource,
- get hasUnread$() {
- return hasUnreadSource.asObservable();
- },
- muteState$: new BehaviorSubject(false)
+ hasUnread$: hasUnreadSource.asObservable(),
+ muteState$: muteSource.asObservable()
};
configureTestBed({
- imports: [HttpClientTestingModule, SharedModule, RouterTestingModule],
+ imports: [HttpClientTestingModule],
declarations: [NotificationsComponent],
- providers: [{ provide: NotificationService, useValue: notificationServiceMock }]
+ providers: [{ provide: NotificationService, useValue: notificationServiceMock }],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
});
beforeEach(() => {
+ hasUnreadSource.next(false);
+ muteSource.next(false);
fixture = TestBed.createComponent(NotificationsComponent);
component = fixture.componentInstance;
summaryService = TestBed.inject(SummaryService);
- notificationService = TestBed.inject(NotificationService);
-
fixture.detectChanges();
});
@@ -46,24 +43,38 @@ describe('NotificationsComponent', () => {
expect(component).toBeTruthy();
});
- it('should subscribe and check if there are running tasks', () => {
- expect(component.hasRunningTasks).toBeFalsy();
+ it('should reflect running tasks from summary service', () => {
+ expect(component.hasRunningTasks()).toBe(false);
const task = new ExecutingTask('task', { name: 'name' });
summaryService['summaryDataSource'].next({ executing_tasks: [task] });
- expect(component.hasRunningTasks).toBeTruthy();
+ expect(component.hasRunningTasks()).toBe(true);
});
- it('should show notificationNew icon if there are notifications', () => {
- const notification = new CdNotification(new CdNotificationConfig());
- notificationService['dataSource'].next([notification]);
- notificationService['hasUnreadSource'].next(true);
-
+ it('should show notificationNew icon when unread notifications exist', () => {
+ hasUnreadSource.next(true);
fixture.detectChanges();
const icon = fixture.debugElement.nativeElement.querySelector('cd-icon');
expect(icon).toBeTruthy();
expect(icon.getAttribute('type')).toBe('notificationNew');
});
+
+ it('should show notification icon when no unread and no running tasks', () => {
+ fixture.detectChanges();
+
+ const icon = fixture.debugElement.nativeElement.querySelector('cd-icon');
+ expect(icon).toBeTruthy();
+ expect(icon.getAttribute('type')).toBe('notification');
+ });
+
+ it('should show notificationOff icon when muted', () => {
+ muteSource.next(true);
+ fixture.detectChanges();
+
+ const icon = fixture.debugElement.nativeElement.querySelector('cd-icon');
+ expect(icon).toBeTruthy();
+ expect(icon.getAttribute('type')).toBe('notificationOff');
+ });
});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts
index f7d0d3bc46a..3c86778afe9 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts
@@ -1,6 +1,6 @@
-import { Component, OnDestroy, OnInit } from '@angular/core';
-
-import { Subscription } from 'rxjs';
+import { Component, ChangeDetectionStrategy, inject, Signal } from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { map } from 'rxjs/operators';
import { ICON_TYPE, IconSize } from '~/app/shared/enum/icons.enum';
import { NotificationService } from '~/app/shared/services/notification.service';
@@ -10,42 +10,21 @@ import { SummaryService } from '~/app/shared/services/summary.service';
selector: 'cd-notifications',
templateUrl: './notifications.component.html',
styleUrls: ['./notifications.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
standalone: false
})
-export class NotificationsComponent implements OnInit, OnDestroy {
+export class NotificationsComponent {
icons = ICON_TYPE;
iconSize = IconSize.size20;
- hasRunningTasks = false;
- hasNotifications = false;
- isMuted = false;
- private subs = new Subscription();
- constructor(
- public notificationService: NotificationService,
- private summaryService: SummaryService
- ) {}
+ private notificationService = inject(NotificationService);
+ private summaryService = inject(SummaryService);
- ngOnInit() {
- this.subs.add(
- this.summaryService.subscribe((summary) => {
- this.hasRunningTasks = summary.executing_tasks.length > 0;
- })
- );
+ hasRunningTasks: Signal = toSignal(
+ this.summaryService.summaryData$.pipe(map((summary) => summary?.executing_tasks?.length > 0)),
+ { initialValue: false }
+ );
- this.subs.add(
- this.notificationService.muteState$.subscribe((isMuted) => {
- this.isMuted = isMuted;
- })
- );
-
- this.subs.add(
- this.notificationService.hasUnread$.subscribe(
- (hasUnread) => (this.hasNotifications = hasUnread)
- )
- );
- }
-
- ngOnDestroy(): void {
- this.subs.unsubscribe();
- }
+ isMuted = toSignal(this.notificationService.muteState$, { initialValue: false });
+ hasNotifications = toSignal(this.notificationService.hasUnread$, { initialValue: false });
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts
index 6c5446cfea2..889929cebfb 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts
@@ -3,7 +3,8 @@ import {
OnInit,
AfterViewChecked,
HostListener,
- ElementRef
+ ElementRef,
+ ChangeDetectionStrategy
} from '@angular/core';
import { animate, style, transition, trigger } from '@angular/animations';
import { Router } from '@angular/router';
@@ -15,6 +16,7 @@ import { NotificationService } from '../../services/notification.service';
selector: 'cd-toast',
templateUrl: './notification-toast.component.html',
styleUrls: ['./notification-toast.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('toastAnimation', [
transition(
@@ -39,6 +41,7 @@ import { NotificationService } from '../../services/notification.service';
})
export class ToastComponent implements OnInit, AfterViewChecked {
activeToasts$: Observable;
+ private toastsDirty = false;
constructor(
private notificationService: NotificationService,
@@ -48,15 +51,22 @@ export class ToastComponent implements OnInit, AfterViewChecked {
ngOnInit() {
this.activeToasts$ = this.notificationService.activeToasts$;
+ this.notificationService.activeToasts$.subscribe(() => {
+ this.toastsDirty = true;
+ });
}
ngAfterViewChecked() {
+ if (!this.toastsDirty) return;
+ this.toastsDirty = false;
+
const toasts = this.el.nativeElement.querySelectorAll('cds-toast');
toasts.forEach((toast: HTMLElement) => {
const subtitle = toast.querySelector('.cds--toast-notification__subtitle');
const viewMore = toast.querySelector('.toast-view-more') as HTMLElement;
if (!subtitle || !viewMore) return;
- const isTruncated = subtitle.scrollHeight > subtitle.clientHeight;
+ const textEl = subtitle.querySelector('.toast-message') || subtitle;
+ const isTruncated = textEl.scrollHeight > textEl.clientHeight;
viewMore.style.display = isTruncated ? '' : 'none';
});
}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts
index 0eb6a5313d5..6547c560b2e 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts
@@ -5,7 +5,7 @@ import _ from 'lodash';
import { configureTestBed } from '~/testing/unit-test-helper';
import { RbdService } from '../api/rbd.service';
import { NotificationType } from '../enum/notification-type.enum';
-import { CdNotificationConfig } from '../models/cd-notification';
+import { CdNotification, CdNotificationConfig } from '../models/cd-notification';
import { FinishedTask } from '../models/finished-task';
import { CdDatePipe } from '../pipes/cd-date.pipe';
import { NotificationService } from './notification.service';
@@ -157,15 +157,25 @@ describe('NotificationService', () => {
flush();
}));
- it('combines different notifications with the same title', fakeAsync(() => {
+ it('deduplicates notifications with the same title, type, and message', fakeAsync(() => {
service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path a');
- tick(60);
+ tick(600);
+ service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path a');
+ tick(600);
+ const notifications = service['dataSource'].getValue();
+ expect(notifications.length).toBe(1);
+ expect(notifications[0].title).toBe('502 - Bad Gateway');
+ expect(notifications[0].occurrences).toBe(2);
+ flush();
+ }));
+
+ it('does not deduplicate notifications with different messages', fakeAsync(() => {
+ service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path a');
+ tick(600);
service.show(NotificationType.error, '502 - Bad Gateway', 'Error occurred in path b');
- expectSavedNotificationToHave({
- type: NotificationType.error,
- title: '502 - Bad Gateway',
- message: 'Error occurred in path a (+1 more)'
- });
+ tick(600);
+ const notifications = service['dataSource'].getValue();
+ expect(notifications.length).toBe(2);
flush();
}));
@@ -286,4 +296,65 @@ describe('NotificationService', () => {
flush();
}));
});
+
+ describe('Active toast limit', () => {
+ it('should show only the latest 2 toasts', fakeAsync(() => {
+ for (let i = 0; i < 5; i++) {
+ service.show(NotificationType.error, `Error ${i}`, `Message ${i}`);
+ tick(600);
+ }
+ const toasts = service['activeToastsSource'].getValue();
+ expect(toasts.length).toBe(2);
+ expect(toasts[0].title).toBe('Error 4');
+ expect(toasts[1].title).toBe('Error 3');
+ flush();
+ }));
+ });
+
+ describe('Read state', () => {
+ it('should mark a notification as read', () => {
+ const config = new CdNotificationConfig(NotificationType.info, 'Test');
+ const notification = new CdNotification(config);
+ service.save(notification);
+
+ expect(service['hasUnreadSource'].getValue()).toBe(true);
+ service.markAsRead(notification.id);
+ expect(service['hasUnreadSource'].getValue()).toBe(false);
+ });
+
+ it('should mark all notifications as read', () => {
+ const n1 = new CdNotification(new CdNotificationConfig(NotificationType.info, 'A'));
+ const n2 = new CdNotification(new CdNotificationConfig(NotificationType.error, 'B'));
+ service.save(n1);
+ service.save(n2);
+
+ expect(service['hasUnreadSource'].getValue()).toBe(true);
+ service.markAllAsRead();
+ expect(service['hasUnreadSource'].getValue()).toBe(false);
+ });
+
+ it('should clear read state when a notification gets a new occurrence', () => {
+ const n1 = new CdNotification(new CdNotificationConfig(NotificationType.error, 'Err', 'msg'));
+ service.save(n1);
+ service.markAsRead(n1.id);
+ expect(service['hasUnreadSource'].getValue()).toBe(false);
+
+ const n2 = new CdNotification(new CdNotificationConfig(NotificationType.error, 'Err', 'msg'));
+ service.save(n2);
+ expect(service['hasUnreadSource'].getValue()).toBe(true);
+ expect(service['dataSource'].getValue()[0].occurrences).toBe(2);
+ });
+
+ it('should remove by id', () => {
+ const n1 = new CdNotification(new CdNotificationConfig(NotificationType.info, 'A'));
+ service.save(n1);
+ expect(service['dataSource'].getValue().length).toBe(1);
+ expect(service.removeById(n1.id)).toBe(true);
+ expect(service['dataSource'].getValue().length).toBe(0);
+ });
+
+ it('should return false when removing non-existent id', () => {
+ expect(service.removeById('nonexistent')).toBe(false);
+ });
+ });
});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
index 98dc131c118..0af14122d0a 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
@@ -13,6 +13,18 @@ import { FinishedTask } from '../models/finished-task';
import { CdDatePipe } from '../pipes/cd-date.pipe';
import { TaskMessageService } from './task-message.service';
+// Carbon's cds-toast renders title, subtitle, and caption via [innerHTML].
+// Angular's DomSanitizer only partially protects against XSS in innerHTML bindings,
+// so we escape untrusted input at the source before passing it to Carbon.
+function escapeHtml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
@Injectable({
providedIn: 'root'
})
@@ -24,6 +36,7 @@ export class NotificationService {
[NotificationType.warning]: 'warning'
};
private readonly MAX_NOTIFICATIONS = 10;
+ private readonly MAX_ACTIVE_TOASTS = 2;
private readonly SHOW_DELAY = 10;
private readonly QUEUE_DELAY = 500;
private readonly ERROR_TOAST_DURATION = 10000;
@@ -66,9 +79,21 @@ export class NotificationService {
let notifications: CdNotification[] = [];
if (_.isString(stringNotifications)) {
try {
+ const ALLOWED_KEYS = [
+ 'id',
+ 'title',
+ 'message',
+ 'type',
+ 'timestamp',
+ 'application',
+ 'prometheusAlert',
+ 'isFinishedTask',
+ 'alertSilenced',
+ 'silenceId'
+ ];
notifications = JSON.parse(stringNotifications, (_key, value) => {
if (_.isPlainObject(value)) {
- return _.assign(new CdNotification(), value);
+ return _.assign(new CdNotification(), _.pick(value, ALLOWED_KEYS));
}
return value;
});
@@ -90,7 +115,17 @@ export class NotificationService {
}
private _persistReadMap(readMap: Record) {
- localStorage.setItem(this.LOCAL_STORAGE_READ_KEY, JSON.stringify(readMap));
+ const ids = new Set(this.dataSource.getValue().map((n) => n.id));
+ const pruned: Record = {};
+ for (const id of Object.keys(readMap)) {
+ if (ids.has(id)) pruned[id] = true;
+ }
+ try {
+ localStorage.setItem(this.LOCAL_STORAGE_READ_KEY, JSON.stringify(pruned));
+ } catch {
+ // quota exceeded — clear stale read state
+ localStorage.removeItem(this.LOCAL_STORAGE_READ_KEY);
+ }
}
private _recomputeHasUnread(notifications: CdNotification[]) {
@@ -130,18 +165,30 @@ export class NotificationService {
/**
* Saving a shown notification in local storage
*/
- save(notification: CdNotification) {
+ save(notification: CdNotification): string {
const current = this.dataSource.getValue();
const existing = current.find(
- (n) => n.title === notification.title && n.type === notification.type
+ (n) =>
+ n.title === notification.title &&
+ n.type === notification.type &&
+ n.message === notification.message
);
let notifications: CdNotification[];
+ let storedId: string;
if (existing) {
existing.occurrences = (existing.occurrences || 1) + 1;
existing.timestamp = notification.timestamp;
+ existing.message = notification.message;
+ storedId = existing.id;
notifications = [...current];
+
+ const readMap = { ...this.readMapSource.getValue() };
+ delete readMap[existing.id];
+ this.readMapSource.next(readMap);
+ this._persistReadMap(readMap);
} else {
+ storedId = notification.id;
notifications = [notification, ...current];
}
@@ -152,6 +199,7 @@ export class NotificationService {
this.dataSource.next(limited);
this._recomputeHasUnread(limited);
this._persistNotifications(limited);
+ return storedId;
}
/**
@@ -240,42 +288,20 @@ export class NotificationService {
}
private _showQueued() {
- this._getUnifiedTitleQueue().forEach((config) => {
+ this.queued.forEach((config) => {
const notification = new CdNotification(config);
if (!notification.isFinishedTask) {
- this.save(notification);
+ const storedId = this.save(notification);
+ notification.id = storedId;
}
this._showToasty(notification);
});
this.queued = [];
}
- private _getUnifiedTitleQueue(): CdNotificationConfig[] {
- return Object.values(this._queueShiftByTitle()).map((configs) => {
- const config = configs[0];
- if (configs.length > 1) {
- config.message = '' + configs.map((c) => `- ${c.message}
`).join('') + '
';
- }
- return config;
- });
- }
-
- private _queueShiftByTitle(): { [key: string]: CdNotificationConfig[] } {
- const byTitle: { [key: string]: CdNotificationConfig[] } = {};
- let config: CdNotificationConfig;
- while ((config = this.queued.shift())) {
- if (!byTitle[config.title]) {
- byTitle[config.title] = [];
- }
- byTitle[config.title].push(config);
- }
- return byTitle;
- }
-
private _showToasty(notification: CdNotification) {
- // Exit immediately if no toasty should be displayed.
- if (this.hideToasties) {
+ if (this.hideToasties || this.panelState.value) {
return;
}
@@ -283,8 +309,11 @@ export class NotificationService {
const carbonType = this.NOTIFICATION_TYPE_MAP[notification.type] || 'info';
const lowContrast = notification.options?.lowContrast || false;
+ const escapedTitle = escapeHtml(notification.title || '');
+ const escapedMessage = escapeHtml(notification.message || '');
const existing = this.activeToasts.find(
- (t) => t.title === notification.title && t.type === carbonType
+ (t) =>
+ t.title === escapedTitle && t.type === carbonType && t.originalSubtitle === escapedMessage
);
if (existing) {
existing.duplicateCount = (existing.duplicateCount || 1) + 1;
@@ -295,10 +324,9 @@ export class NotificationService {
return;
}
- const subtitle = notification.message || '';
const toast: ToastContent = {
- title: notification.title,
- subtitle,
+ title: escapedTitle,
+ subtitle: escapedMessage,
caption: this._renderTimeAndApplicationHtml(notification),
type: carbonType,
lowContrast: lowContrast,
@@ -310,10 +338,13 @@ export class NotificationService {
: this.DEFAULT_TOAST_DURATION),
notificationId: notification.id,
duplicateCount: 1,
- originalSubtitle: subtitle
+ originalSubtitle: escapedMessage
};
this.activeToasts.unshift(toast);
+ while (this.activeToasts.length > this.MAX_ACTIVE_TOASTS) {
+ this.activeToasts.pop();
+ }
this.activeToastsSource.next(this.activeToasts);
if (toast.duration && toast.duration > 0) {
@@ -328,9 +359,11 @@ export class NotificationService {
}
private _renderTimeAndApplicationHtml(notification: CdNotification): string {
+ const date = escapeHtml(this.cdDatePipe.transform(notification.timestamp) || '');
+ const id = encodeURIComponent(notification.id);
return ``;
}
@@ -350,7 +383,7 @@ export class NotificationService {
}
removeToast(toast: ToastContent) {
- this.activeToasts = this.activeToasts.filter((t) => !_.isEqual(t, toast));
+ this.activeToasts = this.activeToasts.filter((t) => t.notificationId !== toast.notificationId);
this.activeToastsSource.next(this.activeToasts);
}
@@ -404,10 +437,12 @@ export class NotificationService {
*/
togglePanel(isOpen: boolean) {
this.panelState.next(isOpen);
+ if (isOpen) this.clearAllToasts();
}
setPanelState(isOpen: boolean) {
this.panelState.next(isOpen);
+ if (isOpen) this.clearAllToasts();
}
getPanelState(): boolean {