]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Sanitize toast HTML and optimize notification service
authorAfreen Misbah <afreen@ibm.com>
Tue, 21 Jul 2026 12:31:36 +0000 (18:01 +0530)
committerAfreen Misbah <afreen@ibm.com>
Tue, 21 Jul 2026 16:04:11 +0000 (21:34 +0530)
- 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 <afreen@ibm.com>
17 files changed:
src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/cypress/e2e/ui/notification.po.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-item/notification-item.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notification-toast/notification-toast.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts

index 17c2af07a2c70b08db3731edcf1610f980b2a66a..4039694e61aff59ff1fe62dd430ba5d705dab99e 100644 (file)
@@ -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);
+    });
+  });
+});
index 897e11153f57b79a591277180a6465e7a8f0179e..36b2b003c6cdb3975ff30130db4c37f883061cb5 100644 (file)
@@ -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');
+  }
 }
index 08a67d7e9f723dc68d628f68bb7bd3d4001ae621..e85813bf05b4b33a861d43338af6df5de84191a4 100644 (file)
@@ -61,6 +61,7 @@
           [timestamp]="notification.timestamp"
           [message]="notification.message"
           [notificationId]="notification.id"
+          [occurrences]="notification.occurrences"
         >
         </cd-notification-item>
       </div>
index 28569607d15ec8350d39d0a577de5a58731882b3..c10bc51c41fa5d03d9d62321df495619b38e4ffb 100644 (file)
@@ -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 {
index 729361ed41dde5fd05242e9ed1c39d496aa5919e..5b0639b8ec1d2c4327eb1d2390c83313cdeda061 100644 (file)
       </p>
     }
   }
+  @if (occurrences > 1) {
+    <span
+      class="cd-notification-item__occurrences cds--type-label-01"
+      i18n
+    >
+      Occurrences: {{ occurrences }}
+    </span>
+  }
 </div>
 <!-- RIGHT SECTION -->
 <div class="cd-notification-item__right">
index 94d7b75d64d09fd8c26d38befe3750c897442ff3..b359d84df1d21db5fb3548af1844c10eded10e43 100644 (file)
@@ -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);
 }
index cf749081f866913b2195fd1c1ad0fbf470727ce2..408199884f2f3ba33bc03175a87b3b69ddafd457 100644 (file)
@@ -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: `
index 04aabf9e61f02edbdac21ad7fa50ea801ba80358..a15184bb0142621e7b49b9b480a0b3eef3665922 100644 (file)
@@ -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<string>();
 
index ad19191f33910b13961bbe7041a0998b9e4c66ca..68f8224e89ee8527f5f2a37ec8e8bf1d4b90be40 100644 (file)
@@ -74,6 +74,7 @@
             [timestamp]="notification.timestamp"
             [message]="notification.displayPreview"
             [notificationId]="notification.id"
+            [occurrences]="notification.occurrences"
             [showChevron]="true"
             [showUnread]="!readMap()[notification.id]"
           >
           >
             No notifications available
           </p>
+          <p
+            class="cds-mt-3 cds--type-helper-text-01"
+            i18n
+          >
+            Notifications are stored locally and cleared on browser reset.
+          </p>
         </div>
       }
     </div>
         </div>
         <div class="notifications-page__detail-body cds-mt-5">
           @if (selected.occurrences > 1) {
-            <p class="notifications-page__occurrences cds--type-label-01" i18n>
+            <p
+              class="notifications-page__occurrences cds--type-label-01"
+              i18n
+            >
               Occurrences: {{ selected.occurrences }}
             </p>
           }
index 22ae569fa38fc96fadcace25dcb1c1b82407eb2d..1a5f450bca508d126b6e3f63d594bc6f4c45888a 100644 (file)
@@ -22,6 +22,7 @@ describe('NotificationsPageComponent', () => {
   let readMapSubject: BehaviorSubject<Record<string, boolean>>;
   let notificationService: any;
   let mockLocation: any;
+  let queryParamsSubject: BehaviorSubject<any>;
 
   const createMockNotificationService = () => {
     dataSourceSubject = new BehaviorSubject<CdNotification[]>([]);
@@ -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<any>({});
 
     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', () => {
index b9c517eea79ec49964e6b660ead9832b9900dbb9..6d79b123d0686798dd87b60f60dff40392365a65 100644 (file)
@@ -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();
index ee41505a363d189b815909d71826913479539ec9..9b10a25308d31d9d67e51679bc7442d1093a7aa4 100644 (file)
@@ -1,10 +1,10 @@
-@if (isMuted) {
+@if (isMuted()) {
   <cd-icon
     type="notificationOff"
     [size]="iconSize"
   >
   </cd-icon>
-} @else if (!isMuted && (hasRunningTasks || hasNotifications)) {
+} @else if (hasRunningTasks() || hasNotifications()) {
   <cd-icon
     type="notificationNew"
     [size]="iconSize"
index fda5fbeaa8a360fb246743988d7e04e6fb632063..93fc821fef0fd07d1e762e2cbbc3f714e2e4cb22 100644 (file)
@@ -1,44 +1,41 @@
-import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { RouterTestingModule } from '@angular/router/testing';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+
+import { BehaviorSubject } from 'rxjs';
 
-import { CdNotification, CdNotificationConfig } from '~/app/shared/models/cd-notification';
 import { ExecutingTask } from '~/app/shared/models/executing-task';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { SummaryService } from '~/app/shared/services/summary.service';
-import { SharedModule } from '~/app/shared/shared.module';
 import { configureTestBed } from '~/testing/unit-test-helper';
 import { NotificationsComponent } from './notifications.component';
-import { BehaviorSubject } from 'rxjs';
 
 describe('NotificationsComponent', () => {
   let component: NotificationsComponent;
   let fixture: ComponentFixture<NotificationsComponent>;
   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');
+  });
 });
index f7d0d3bc46a93180b1a68961ac33f3a2ad2cdc58..3c86778afe92060a6b041cb1ecd11ccdf52b5f1f 100644 (file)
@@ -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
-  ) {}
-
-  ngOnInit() {
-    this.subs.add(
-      this.summaryService.subscribe((summary) => {
-        this.hasRunningTasks = summary.executing_tasks.length > 0;
-      })
-    );
 
-    this.subs.add(
-      this.notificationService.muteState$.subscribe((isMuted) => {
-        this.isMuted = isMuted;
-      })
-    );
+  private notificationService = inject(NotificationService);
+  private summaryService = inject(SummaryService);
 
-    this.subs.add(
-      this.notificationService.hasUnread$.subscribe(
-        (hasUnread) => (this.hasNotifications = hasUnread)
-      )
-    );
-  }
+  hasRunningTasks: Signal<boolean> = toSignal(
+    this.summaryService.summaryData$.pipe(map((summary) => summary?.executing_tasks?.length > 0)),
+    { initialValue: false }
+  );
 
-  ngOnDestroy(): void {
-    this.subs.unsubscribe();
-  }
+  isMuted = toSignal(this.notificationService.muteState$, { initialValue: false });
+  hasNotifications = toSignal(this.notificationService.hasUnread$, { initialValue: false });
 }
index 6c5446cfea2e2f7373525f0c4275d6a19e743b3c..889929cebfb959c221b2f717e44f659b3db6e57f 100644 (file)
@@ -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<ToastContent[]>;
+  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';
     });
   }
index 0eb6a5313d55d832b542ec8bbdd358ac9a04aa36..6547c560b2e993e2a3ce887817951db592b0de62 100644 (file)
@@ -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);
+    });
+  });
 });
index 98dc131c118b9d551e457a9f41a482d43c806fc3..0af14122d0ada08f196ecc36a78b3ea5d8e9f511 100644 (file)
@@ -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, '&amp;')
+    .replace(/</g, '&lt;')
+    .replace(/>/g, '&gt;')
+    .replace(/"/g, '&quot;')
+    .replace(/'/g, '&#39;');
+}
+
 @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<string, boolean>) {
-    localStorage.setItem(this.LOCAL_STORAGE_READ_KEY, JSON.stringify(readMap));
+    const ids = new Set(this.dataSource.getValue().map((n) => n.id));
+    const pruned: Record<string, boolean> = {};
+    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 = '<ul>' + configs.map((c) => `<li>${c.message}</li>`).join('') + '</ul>';
-      }
-      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 `<div class="toast-caption-container">
-      <small class="date">${this.cdDatePipe.transform(notification.timestamp)}</small>
-      <a class="toast-view-more cds--type-label-01" href="#/notifications?id=${notification.id}" i18n>View more</a>
+      <small class="date">${date}</small>
+      <a class="toast-view-more cds--type-label-01" href="#/notifications?id=${id}" i18n>View more</a>
     </div>`;
   }
 
@@ -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 {