]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
qmgr/dashboard: Some refactors and bugs fixes 64707/head
authorAfreen Misbah <afreen@ibm.com>
Thu, 18 Dec 2025 21:46:05 +0000 (03:16 +0530)
committerAfreen Misbah <afreen@ibm.com>
Wed, 28 Jan 2026 08:07:15 +0000 (13:37 +0530)
Signed-off-by: Afreen Misbah <afreen@ibm.com>
34 files changed:
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.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-area/notification-area.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.scss
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.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/notifications-sidebar/notifications-sidebar.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts

index 430d66b0471ee5da40017aa3b701d5da98982433..34cd097b40a230cb71b1ae4b6eb3a2ac2fa2faa1 100644 (file)
@@ -110,7 +110,7 @@ const routes: Routes = [
       {
         path: 'notifications',
         data: {
-          breadcrumbs: 'Cluster/Notifications'
+          breadcrumbs: 'Overview/Notifications'
         },
         component: NotificationsPageComponent
       },
index 013fc5ca4c945b983041b978a9e968792d236124..9b642e8f35fed2243b93c446f525d9615ad2cae7 100644 (file)
@@ -88,7 +88,7 @@ import { NotificationFooterComponent } from './notification-panel/notification-f
     TagModule,
     ProgressBarModule,
     StructuredListModule,
-    SearchModule,
+    SearchModule
   ],
   declarations: [
     AboutComponent,
index 487e9d3e8a5a9c7666b0ab14a9fe42129de85183..9074d0c129a23db76d7b07027291f0a32325f9c1 100644 (file)
@@ -2,9 +2,9 @@
   <!-- ************************ -->
   <!-- NOTIFICATION PANEL     -->
   <!-- ************************ -->
-  <cd-notification-panel
-    *ngIf="isNotifPanelOpen"
-    [isPanelOpen]="isNotifPanelOpen"></cd-notification-panel>
+   @if(notificationService.panelState$ | async; as isNotifPanelOpen) {
+  <cd-notification-panel></cd-notification-panel>
+   }
   <!-- ************************ -->
   <!-- HEADER                   -->
   <!-- ************************ -->
       <cds-header-navigation>
         <cd-language-selector class="d-flex"></cd-language-selector>
       </cds-header-navigation>
-      <cds-header-action
-        description=""
-        [(active)]="isNotifPanelOpen">
-          <cd-notifications></cd-notifications>
-      </cds-header-action>
+      <div class="cds--btn cds--btn--icon-only cds--header__action"
+           (click)="onNotificationSelected($event)">
+        <cd-notifications></cd-notifications>
+      </div>
       <div class="cds--btn cds--btn--icon-only cds--header__action">
         <cd-dashboard-help></cd-dashboard-help>
       </div>
index 3759bd2a45ec744a508ad69c37bee0c22c3eaf57..4f6c11a457ff599fb1003e3e4f031240fbbe852f 100644 (file)
@@ -33,7 +33,6 @@ export class NavigationComponent implements OnInit, OnDestroy {
   clusterTokenStatus: object = {};
   summaryData: any;
 
-  isNotifPanelOpen = true;
   showMenuSidebar = true;
 
   simplebar = {
@@ -103,10 +102,6 @@ export class NavigationComponent implements OnInit, OnDestroy {
     );
   }
 
-  ngOnDestroy(): void {
-    this.subs.unsubscribe();
-  }
-
   checkClusterConnectionStatus() {
     this.clustersMap.forEach((clusterDetails, clusterName) => {
       const clusterTokenStatus = this.clusterTokenStatus[clusterDetails.name];
@@ -161,10 +156,6 @@ export class NavigationComponent implements OnInit, OnDestroy {
     this.displayedSubMenu[menu] = !this.displayedSubMenu[menu];
   }
 
-  toggleSidebar() {
-    this.isNotifPanelOpen = !this.isNotifPanelOpen;
-  }
-
   onClusterSelection(value: object) {
     this.multiClusterService.setCluster(value).subscribe(
       (resp: any) => {
@@ -211,7 +202,17 @@ export class NavigationComponent implements OnInit, OnDestroy {
     );
   }
 
+  onNotificationSelected(event) {
+    event.stopPropagation();
+    const currentState = this.notificationService.getPanelState();
+    this.notificationService.setPanelState(!currentState);
+  }
+
   trackByFn(item: any) {
     return item;
   }
+
+  ngOnDestroy(): void {
+    this.subs.unsubscribe();
+  }
 }
index 5e7f72baadb030c2e06ada8dc7819c319eebf079..a8ac59e0d3c4112dda27c1da7c71ee766447239a 100644 (file)
@@ -1,3 +1,6 @@
+<div
+  [ngClass]="{'empty-body': todayNotifications.length === 0 && previousNotifications.length === 0}"
+  class="notification-area">
 @if (executingTasks.length > 0) {
 <div
   class="notification-section-heading"
@@ -87,7 +90,7 @@
   Today
 </div>
 
-@for (notification of todayNotifications; track notification.timestamp; let last = $last) {
+@for (notification of todayNotifications; track notification.id; let last = $last) {
 <ng-container
   *ngTemplateOutlet="notificationItemTemplate;
     context: { notification: notification, last: last }">
   Previous
 </div>
 
-@for (notification of previousNotifications; track notification.timestamp; let last = $last) {
+@for (notification of previousNotifications; track notification.id; let last = $last) {
 <ng-container
   *ngTemplateOutlet="notificationItemTemplate;
     context: { notification: notification, last: last }">
   <div i18n>No notifications</div>
 </div>
 }
+</div>
index 7c082965ced38a7e5d7ef0501863c585d87669e3..3727444244894ca89c34ffa39fbfe3e25b1c945f 100644 (file)
@@ -11,8 +11,8 @@
   padding: $spacing-05 $spacing-05 $spacing-03;
   background-color: $layer-01;
   position: sticky;
-  top: 0;
   z-index: 2;
+  top: var(--header-height);
   display: block;
 }
 
   }
 }
 
-.notification-icon {
-  flex-shrink: 0;
-  margin-top: 0;
-}
-
 .notification-content {
   flex: 1;
   min-width: 0;
   border-bottom: 1px solid $border-subtle-01;
 }
 
-:host {
+.notification-area {
   display: block;
   height: 100%;
-  overflow-y: auto;
   background-color: $layer-01;
 }
 
-:host ::ng-deep .infoCircle-icon {
-  fill: $primary !important;
+.empty-body {
+  max-block-size: var(--panel-height);
+  min-block-size: var(--panel-height);
+  max-inline-size: var(--panel-max-width);
+  min-inline-size: var(--panel-min-width);
 }
index a7ef567b89fe37a75249e5a3641b77946c17bb52..3065eca6c8710e7f05e80f0265814e27ee81b31d 100644 (file)
@@ -51,7 +51,8 @@ describe('NotificationAreaComponent', () => {
     const spy = {
       remove: jasmine.createSpy('remove'),
       dataSource: mockDataSource,
-      data$: mockDataSource.asObservable()
+      data$: mockDataSource.asObservable(),
+      getNotificationsSnapshot: () => mockDataSource.getValue()
     };
 
     TestBed.overrideProvider(NotificationService, { useValue: spy });
index 7cf0e76dd9b8268556a612429ae58d53e3e6e363..ee5d7f5c9e41da9a709ccdd728456c8029bb5626 100644 (file)
@@ -43,6 +43,7 @@ export class NotificationAreaComponent implements OnInit, OnDestroy {
   ) {}
 
   ngOnInit(): void {
+    this.last_task = localStorage.getItem('last_task') || '';
     this.subs.add(
       this.notificationService.data$.subscribe((notifications: CdNotification[]) => {
         const today: Date = new Date();
@@ -110,10 +111,8 @@ export class NotificationAreaComponent implements OnInit, OnDestroy {
     event.preventDefault();
 
     // Get the notification index from the service's data
-    const notifications = this.notificationService['dataSource'].getValue();
-    const index = notifications.findIndex(
-      (n) => n.timestamp === notification.timestamp && n.title === notification.title
-    );
+    const notifications = this.notificationService.getNotificationsSnapshot();
+    const index = notifications.findIndex((n) => n.id === notification.id);
 
     if (index > -1) {
       // Remove the notification through the service
index 4d6475a55e86badb0a4cb97252c9b008bf46414d..3afd57c71bdb3521edcb4a1a2aaacffff82eb256 100644 (file)
@@ -2,6 +2,7 @@
   <cds-button
     kind="ghost"
     size="sm"
+    class="notification-footer__view-all-button"
     [routerLink]="['/notifications']"
     (click)="closePanel($event)"
     i18n>View all</cds-button>
index 0a513f7475a1fa6be2dd498a000e21582b114265..29250ed22d5deea5dc447b312f63745f9248d5e2 100644 (file)
@@ -2,16 +2,19 @@
 @use '@carbon/styles/scss/theme';
 
 .notification-footer {
+  position: sticky;
+  z-index: 2;
   display: flex;
-  justify-content: flex-start;
-  padding: spacing.$spacing-03;
-  border-top: 1px solid theme.$border-subtle-01;
+  align-items: center;
+  background-color: theme.$layer-01;
+  block-size: 2.5rem;
+  border-block-start: 1px solid theme.$border-subtle-01;
+  inset-block-end: 0;
+  min-block-size: 2.5rem;
+  cursor: pointer;
 
-  cds-button {
+  &__view-all-button {
     color: theme.$text-primary;
-
-    &:hover {
-      color: theme.$link-primary;
-    }
+    padding: spacing.$spacing-03 spacing.$spacing-05;
   }
 }
index f26660ef3aafddaf8ea411d4a84a8ca56f48a78e..0da5d93d31579bea38481a59f591f7a674f52865 100644 (file)
@@ -13,6 +13,6 @@ export class NotificationFooterComponent {
   closePanel(event: Event) {
     event.preventDefault();
     event.stopPropagation();
-    this.notificationService.toggleSidebar(false, true);
+    this.notificationService.setPanelState(false);
   }
 }
index 89267f69081bfc5dd65a30e869f0bbccd47bba8a..55e30a50e554635acb88df93ea4eb552a8121867 100644 (file)
@@ -5,24 +5,25 @@
   <div cdsCol
        [columnNumbers]="{sm: 8, md: 8, lg: 16}">
     <div cdsRow>
-      <div cdsGrid
-           [useCssGrid]="true"
-           [fullWidth]="true">
-         <cds-text
-              [columnNumbers]="{sm: 8, md: 8, lg: 11}"
-              i18n
-              cdsCol
-              class="notification-header__title">Tasks and Notifications</cds-text>
-          <button
-            cdsCol
-            [columnNumbers]="{sm: 8, md: 8, lg: 5}"
-            class="notification-header__dismiss-btn"
-            i18n
-            cdsButton="ghost"
-            size="sm"
-            (click)="onDismissAll()">
-            Dismiss all
-          </button>    
+      <div
+        cdsGrid
+        [useCssGrid]="true"
+        [fullWidth]="true">
+        <h1
+          [columnNumbers]="{sm: 8, md: 8, lg: 11}"
+          i18n
+          cdsCol
+          class="cds--type-heading-compact-01">Tasks and Notifications</h1>
+        <button
+          cdsCol
+          [columnNumbers]="{sm: 8, md: 8, lg: 5}"
+          class="notification-header__dismiss-btn"
+          i18n
+          cdsButton="ghost"
+          size="sm"
+          (click)="onDismissAll()">
+          Dismiss all
+        </button>
       </div>
     </div>
     <cds-toggle
@@ -35,4 +36,4 @@
       hideLabel="true"> <!--hides the toggle state values (like "On/Off" in the toggle button)-->
     </cds-toggle>
   </div>
-</div>
\ No newline at end of file
+</div>
index d903bf860d62be9e0f664d9f371ccae06f1e7daf..332ceaf5377135b946b412d7129087f6ba25549b 100644 (file)
@@ -1,23 +1,22 @@
-@use '@carbon/styles/scss/type';
 @use '@carbon/styles/scss/spacing';
-@use '@carbon/styles/scss/theme';
 
 .notification-header {
-   position: sticky;
-    z-index: 2;
-    padding: spacing.$spacing-03 spacing.$spacing-05;
-    background-color: theme.$layer-01;
-    border-block-end: 1px solid theme.$border-subtle-01;
-    inset-block-start: 0;
-
+  position: sticky;
+  top: 0;
+  z-index: 2;
+  padding: spacing.$spacing-03 spacing.$spacing-05;
+  background-color: var(--cds-layer-01);
+  border-block-end: 1px solid var(--cds-border-subtle-01);
+  inset-block-start: 0;
+  height: var(--header-height);
 
   &__title {
-    @include type.type-style('heading-compact-01');
     margin-top: 0.4rem;
   }
 
   &__dismiss-btn {
-    color: theme.$text-primary;
+    color: var(--cds-text-primary);
+    padding-top: 0;
+    padding-left: spacing.$spacing-06;
   }
-
 }
index 444a6d11568894fc5c4c545a02edd7cb068b9ae6..1c6d55ac09812f3d2f2f8bc5d89ef0d0c0f43427 100644 (file)
@@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { NotificationHeaderComponent } from './notification-header.component';
 import { NotificationService } from '../../../../shared/services/notification.service';
 import { BehaviorSubject } from 'rxjs';
+import { GridModule, ToggleModule } from 'carbon-components-angular';
 
 describe('NotificationHeaderComponent', () => {
   let component: NotificationHeaderComponent;
@@ -13,6 +14,7 @@ describe('NotificationHeaderComponent', () => {
     muteStateSubject = new BehaviorSubject<boolean>(false);
     await TestBed.configureTestingModule({
       declarations: [NotificationHeaderComponent],
+      imports: [ToggleModule, GridModule],
       providers: [
         {
           provide: NotificationService,
index 826957a020679f71585a054a1c693f0295b7b501..b9165ba7e2a52031d50981b33092b4b2a0f80ef5 100644 (file)
@@ -1,16 +1,7 @@
-<cds-modal
-  [open]="isPanelOpen"
-  size="xs"
-  cdsTheme="g10"
-  class="notification-panel__container"
-  hasScrollingContent="true"
-  @panelAnimation>
-  <cds-modal-header
-    [showCloseButton]="true">
-    <cd-notification-header></cd-notification-header>
-  </cds-modal-header>
+<div
+  class="notification-panel"
+  cdsTheme="g10">
+  <cd-notification-header></cd-notification-header>
   <cd-notification-area></cd-notification-area>
-  <cds-modal-footer>
-      <cd-notification-footer></cd-notification-footer>
-  </cds-modal-footer>
-</cds-modal>
+  <cd-notification-footer></cd-notification-footer>
+</div>
index 40f81de4c8373053851d2ce4664040e2ead0e0da..e7d227fb82c77b6470549abbd42c80287e90193d 100644 (file)
@@ -1,10 +1,13 @@
-@use '@carbon/styles/scss/spacing';
-@use '@carbon/styles/scss/motion' as *;
-
-$block-class: notification-panel;
 $block-size: 38.5rem;
+$inline-max-size: 22.75rem;
+$inline-min-size: 20rem;
+
+.notification-panel {
+  --header-height: 4.5625rem;
+  --panel-height: #{$block-size};
+  --panel-max-width: #{$inline-max-size};
+  --panel-min-width: #{$inline-min-size};
 
-.#{$block-class}__container {
   position: fixed;
   z-index: 2;
   overflow: auto;
@@ -13,9 +16,9 @@ $block-size: 38.5rem;
   border-inline-start: 1px solid var(--cds-border-subtle-02);
   box-shadow: 0 0.125rem 0.25rem var(--cds-overlay);
   min-block-size: $block-size;
-  min-inline-size: 20rem;
+  min-inline-size: $inline-min-size;
   inset-block-start: 3rem;
   inset-inline-end: 0;
   max-block-size: $block-size;
-  max-inline-size: 22.75rem;
+  max-inline-size: $inline-max-size;
 }
index 67bc2924686cabaeef26765e07af91a28d198ad6..8108a4512b2fdf568322615ffb35a995268bb921 100644 (file)
@@ -1,60 +1,23 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { NotificationPanelComponent } from './notification-panel.component';
-import { NotificationService } from '~/app/shared/services/notification.service';
 
 describe('NotificationPanelComponent', () => {
   let component: NotificationPanelComponent;
   let fixture: ComponentFixture<NotificationPanelComponent>;
-  let notificationService: NotificationService;
 
   beforeEach(async () => {
     await TestBed.configureTestingModule({
-      declarations: [NotificationPanelComponent],
-      providers: [
-        {
-          provide: NotificationService,
-          useValue: {
-            toggleSidebar: jasmine.createSpy('toggleSidebar')
-          }
-        }
-      ]
+      declarations: [NotificationPanelComponent]
     }).compileComponents();
   });
 
   beforeEach(() => {
     fixture = TestBed.createComponent(NotificationPanelComponent);
     component = fixture.componentInstance;
-    notificationService = TestBed.inject(NotificationService);
     fixture.detectChanges();
   });
 
   it('should create', () => {
     expect(component).toBeTruthy();
   });
-
-  describe('handleClickOutside', () => {
-    it('should close sidebar when clicking outside', () => {
-      // Create a click event outside the component
-      const outsideClickEvent = new MouseEvent('click', {
-        bubbles: true,
-        cancelable: true
-      });
-      document.dispatchEvent(outsideClickEvent);
-
-      expect(notificationService.toggleSidebar).toHaveBeenCalledWith(false, true);
-    });
-
-    it('should not close sidebar when clicking inside', () => {
-      // Create a click event inside the component
-      const insideClickEvent = new MouseEvent('click', {
-        bubbles: true,
-        cancelable: true
-      });
-
-      const componentElement = fixture.nativeElement;
-      componentElement.dispatchEvent(insideClickEvent);
-
-      expect(notificationService.toggleSidebar).not.toHaveBeenCalled();
-    });
-  });
 });
index 1eb245f4c84a3519e5991d7a9beebf38c74b79e1..495da61d2adbfef530e5db97a4dbb22170570f93 100644 (file)
@@ -1,26 +1,10 @@
-import { Component, Input } from '@angular/core';
-import { NotificationService } from '~/app/shared/services/notification.service';
-import { trigger, transition, style, animate } from '@angular/animations';
-
+import { Component } from '@angular/core';
 @Component({
   selector: 'cd-notification-panel',
   templateUrl: './notification-panel.component.html',
   styleUrls: ['./notification-panel.component.scss'],
-  standalone: false,
-  animations: [
-    trigger('panelAnimation', [
-      transition(':enter', [
-        style({ opacity: 0, transform: 'translateY(-38.5rem)' }),
-        animate(
-          '240ms cubic-bezier(0.2, 0, 0.38, 0.9)',
-          style({ opacity: 1, transform: 'translateY(0)' })
-        )
-      ])
-    ])
-  ]
+  standalone: false
 })
 export class NotificationPanelComponent {
-  @Input() isPanelOpen: boolean = true;
-
-  constructor(public notificationService: NotificationService) {}
+  constructor() {}
 }
index bbc832bef4ac10147f8673f80e79e4a0cdf7bc32..fa63eb30694e2703b89a4ebff8343fa0b793d4d5 100644 (file)
@@ -5,7 +5,7 @@
   class="notifications-page__container">
   <!-- Left Panel - Notifications List -->
   <div cdsCol
-       [columnNumbers]="{sm: 4, md: 4, lg: 5}">
+       [columnNumbers]="{sm: 4, md: 4, lg: 6}">
     <cds-search
       cdsRow
       [size]="'md'"
@@ -19,8 +19,8 @@
       class="notifications-list"
       *ngIf="filteredNotifications.length > 0">
       <cds-list-row
-        *ngFor="let notification of filteredNotifications"
-        [class.active]="selectedNotification === notification"
+        *ngFor="let notification of filteredNotifications; trackBy trackByNotificationId"
+        [class.active]="selectedNotificationID === notification.id"
         (click)="onNotificationSelect(notification)"
         class="notification-row">
         <cds-list-column>
         </cds-list-column>
       </cds-list-row>
     </cds-structured-list>
-    <div *ngIf="filteredNotifications.length === 0"
-          class="empty-state"
-          cdsRow>
-        <svg
-          cdsIcon="notification"
-          size="20"
-          class="empty-icon"></svg>
-        <p *ngIf="searchText">No notifications match your search</p>
-        <p *ngIf="!searchText">No notifications available</p>
-      </div>
+    <div
+      *ngIf="filteredNotifications.length === 0"
+      class="empty-state"
+      cdsRow>
+      <svg
+        cdsIcon="notification"
+        size="20"
+        class="empty-icon"></svg>
+      <p *ngIf="searchText">No notifications match your search</p>
+      <p *ngIf="!searchText">No notifications available</p>
+    </div>
   </div>
   <!-- Right Panel - Notifications Details -->
   <div cdsCol
-       [columnNumbers]="{sm: 12, md: 12, lg: 9}">
+       [columnNumbers]="{sm: 12, md: 12, lg: 10}">
     <div
       cdsGrid
       [fullWidth]="true"
       *ngIf="selectedNotification"
       class="notification-details">
       <div cdsCol
-            [columnNumbers]="{sm: 16, md: 16, lg: 16}">
+           [columnNumbers]="{sm: 16, md: 16, lg: 16}">
         <h3 cdsRow>{{ selectedNotification.title }}</h3>
         <cds-structured-list
           cdsRow
index ddac8594b1dfaa45684ac57bac5aaf2bb89e6620..dc08230b51115cbd1acecc82ae9329824b59a421 100644 (file)
@@ -60,6 +60,7 @@ describe('NotificationsPageComponent', () => {
   // Create mock notifications
   const createMockNotification = (overrides: any): CdNotification => {
     return {
+      id: overrides.id,
       title: overrides.title || '',
       message: overrides.message || '',
       application: overrides.application || '',
@@ -71,7 +72,6 @@ describe('NotificationsPageComponent', () => {
       duration: 0,
       borderClass: '',
       timeout: 0,
-      id: '',
       isError: false,
       isFinishedTask: false,
       progress: 0,
@@ -89,6 +89,7 @@ describe('NotificationsPageComponent', () => {
   beforeEach(async () => {
     mockNotifications = [
       createMockNotification({
+        id: '1',
         title: 'Success Notification',
         message: 'Operation completed successfully',
         type: NotificationType.success,
@@ -96,6 +97,7 @@ describe('NotificationsPageComponent', () => {
         timestamp: new Date().toISOString()
       }),
       createMockNotification({
+        id: '2',
         title: 'Error Notification',
         message: 'An error occurred',
         type: NotificationType.error,
@@ -103,6 +105,7 @@ describe('NotificationsPageComponent', () => {
         timestamp: new Date(Date.now() - 86400000).toISOString()
       }),
       createMockNotification({
+        id: '3',
         title: 'Info Notification',
         message: 'System update available',
         type: NotificationType.info,
@@ -202,12 +205,13 @@ describe('NotificationsPageComponent', () => {
 
       expect(mockEvent.stopPropagation).toHaveBeenCalled();
       expect(mockEvent.preventDefault).toHaveBeenCalled();
-      expect(notificationService.remove).toHaveBeenCalledWith(0); // FIXED: use notificationService
+      expect(notificationService.remove).toHaveBeenCalledWith(0);
     });
 
     it('should clear selection if removed notification was selected', () => {
       const notification = mockNotifications[0];
-      component.selectedNotification = notification;
+      component.selectedNotificationID = notification.id;
+
       const mockEvent = {
         stopPropagation: jasmine.createSpy('stopPropagation'),
         preventDefault: jasmine.createSpy('preventDefault')
@@ -215,7 +219,9 @@ describe('NotificationsPageComponent', () => {
 
       component.removeNotification(notification, mockEvent as any);
 
-      expect(component.selectedNotification).toBeNull();
+      const selectedNotification = component.selectedNotification;
+
+      expect(selectedNotification).toBeUndefined();
     });
   });
 
@@ -237,7 +243,7 @@ describe('NotificationsPageComponent', () => {
     });
 
     it('should return default icon for unknown type', () => {
-      expect(component.getCarbonIcon(-1)).toBe('notification--filled');
+      expect(component.getCarbonIcon('')).toBe('notification--filled');
     });
   });
 
@@ -259,7 +265,7 @@ describe('NotificationsPageComponent', () => {
     });
 
     it('should return empty string for unknown type', () => {
-      expect(component.getIconColorClass(-1)).toBe('');
+      expect(component.getIconColorClass('')).toBe('');
     });
   });
 
index d8952ec0fe42cf378ca99af945ccf4a296258d12..c19181bf26f3ced5076a4989d18528531bfa8fc6 100644 (file)
@@ -1,11 +1,4 @@
-import {
-  Component,
-  OnInit,
-  OnDestroy,
-  AfterViewInit,
-  ChangeDetectorRef,
-  AfterViewChecked
-} from '@angular/core';
+import { Component, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
 import { Subscription } from 'rxjs';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { CdNotification } from '~/app/shared/models/cd-notification';
@@ -18,12 +11,12 @@ import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
   selector: 'cd-notifications-page',
   templateUrl: './notifications-page.component.html',
   styleUrls: ['./notifications-page.component.scss'],
+  changeDetection: ChangeDetectionStrategy.OnPush,
   standalone: false
 })
-export class NotificationsPageComponent
-  implements OnInit, OnDestroy, AfterViewInit, AfterViewChecked {
+export class NotificationsPageComponent implements OnInit, OnDestroy {
   notifications: CdNotification[] = [];
-  selectedNotification: CdNotification | null = null;
+  selectedNotificationID: string | null = null;
   searchText: string = '';
   filteredNotifications: CdNotification[] = [];
   private sub: Subscription;
@@ -33,8 +26,7 @@ export class NotificationsPageComponent
     private notificationService: NotificationService,
     private prometheusAlertService: PrometheusAlertService,
     private prometheusNotificationService: PrometheusNotificationService,
-    private authStorageService: AuthStorageService,
-    private changeDetectorRef: ChangeDetectorRef
+    private authStorageService: AuthStorageService
   ) {}
 
   ngOnInit(): void {
@@ -51,7 +43,13 @@ export class NotificationsPageComponent
     // Subscribe to notifications from the service
     this.sub = this.notificationService.data$.subscribe((notifications) => {
       this.notifications = notifications;
-      this.filteredNotifications = notifications;
+
+      // preserve filtered array reference if search active
+      if (!this.searchText) {
+        this.filteredNotifications = notifications;
+      } else {
+        this.onSearch(this.searchText);
+      }
     });
   }
 
@@ -65,7 +63,11 @@ export class NotificationsPageComponent
   }
 
   onNotificationSelect(notification: CdNotification): void {
-    this.selectedNotification = notification;
+    this.selectedNotificationID = notification.id;
+  }
+
+  get selectedNotification(): CdNotification {
+    return this.filteredNotifications.find((n) => n.id === this.selectedNotificationID);
   }
 
   onSearch(value: string): void {
@@ -90,22 +92,20 @@ export class NotificationsPageComponent
 
     // Get the notification index from the service's data
     const notifications = this.notificationService['dataSource'].getValue();
-    const index = notifications.findIndex(
-      (n) => n.timestamp === notification.timestamp && n.title === notification.title
-    );
+    const index = notifications.findIndex((n) => n.id === notification.id);
 
     if (index > -1) {
       // Remove the notification through the service
       this.notificationService.remove(index);
 
       // Clear selection if the removed notification was selected
-      if (this.selectedNotification === notification) {
-        this.selectedNotification = null;
+      if (this.selectedNotificationID === notification.id) {
+        this.selectedNotificationID = null;
       }
     }
   }
 
-  getCarbonIcon(type: NotificationType): string {
+  getCarbonIcon(type: NotificationType | string): string {
     switch (type) {
       case NotificationType.success:
         return 'checkmark--filled';
@@ -120,7 +120,7 @@ export class NotificationsPageComponent
     }
   }
 
-  getIconColorClass(type: NotificationType): string {
+  getIconColorClass(type: NotificationType | string): string {
     switch (type) {
       case NotificationType.success:
         return 'icon-success';
@@ -167,11 +167,7 @@ export class NotificationsPageComponent
     this.prometheusNotificationService.refresh();
   }
 
-  ngAfterViewInit(): void {
-    this.sub.add(this.notificationService.data$.subscribe(() => {}));
-  }
-
-  ngAfterViewChecked() {
-    this.changeDetectorRef.detectChanges();
+  trackByNotificationId(_index: number, notification: CdNotification): string {
+    return notification.id;
   }
 }
index da785b10266b3bd1c77837a6fc65c47080a682ae..ea5dcc95386912e4a506ee339d86071f81349c80 100644 (file)
@@ -1,15 +1,15 @@
 @if(isMuted) {
-  <cd-icon type="notificationOff"
-          [size]="iconSize">
+<cd-icon type="notificationOff"
+         [size]="iconSize">
 </cd-icon>
 }
-@else if(!isMuted && hasRunningTasks) {
-  <cd-icon type="notificationNew"
-          [size]="iconSize">
-  </cd-icon>
+@else if(!isMuted && ( hasRunningTasks || hasNotifications)) {
+<cd-icon type="notificationNew"
+         [size]="iconSize">
+</cd-icon>
 }
 @else {
-  <cd-icon type="notification"
-          [size]="iconSize">
-  </cd-icon>
+<cd-icon type="notification"
+         [size]="iconSize">
+</cd-icon>
 }
index 8fea818cf471e4812a26af7c0a19c7e136452e3d..a5c7ccba21be4d6c8f57c1b618735771a5a699bf 100644 (file)
@@ -11,16 +11,28 @@ 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 notificationServiceMock = {
+    dataSource: new BehaviorSubject([]),
+    hasUnreadSource,
+    get hasUnread$() {
+      return hasUnreadSource.asObservable();
+    },
+    muteState$: new BehaviorSubject(false)
+  };
 
   configureTestBed({
     imports: [HttpClientTestingModule, SharedModule, ToastrModule.forRoot(), RouterTestingModule],
-    declarations: [NotificationsComponent]
+    declarations: [NotificationsComponent],
+    providers: [{ provide: NotificationService, useValue: notificationServiceMock }]
   });
 
   beforeEach(() => {
@@ -45,14 +57,15 @@ describe('NotificationsComponent', () => {
     expect(component.hasRunningTasks).toBeTruthy();
   });
 
-  it('should create a dot if there are running notifications', () => {
+  it('should show notificationNew icon if there are notifications', () => {
     const notification = new CdNotification(new CdNotificationConfig());
-    const recent = notificationService['dataSource'].getValue();
-    recent.push(notification);
-    notificationService['dataSource'].next(recent);
-    expect(component.hasNotifications).toBeTruthy();
+    notificationService['dataSource'].next([notification]);
+    notificationService['hasUnreadSource'].next(true);
+
     fixture.detectChanges();
-    const dot = fixture.debugElement.nativeElement.querySelector('.dot');
-    expect(dot).not.toBe('');
+
+    const icon = fixture.debugElement.nativeElement.querySelector('cd-icon');
+    expect(icon).toBeTruthy();
+    expect(icon.getAttribute('type')).toBe('notificationNew');
   });
 });
index 05655e3109192c984d63d5dd763567aded992f7f..f7d0d3bc46a93180b1a68961ac33f3a2ad2cdc58 100644 (file)
@@ -29,7 +29,6 @@ export class NotificationsComponent implements OnInit, OnDestroy {
     this.subs.add(
       this.summaryService.subscribe((summary) => {
         this.hasRunningTasks = summary.executing_tasks.length > 0;
-        // TODO: when notifications are mute - show unread icon too
       })
     );
 
@@ -38,6 +37,12 @@ export class NotificationsComponent implements OnInit, OnDestroy {
         this.isMuted = isMuted;
       })
     );
+
+    this.subs.add(
+      this.notificationService.hasUnread$.subscribe(
+        (hasUnread) => (this.hasNotifications = hasUnread)
+      )
+    );
   }
 
   ngOnDestroy(): void {
index 627fefb4fe02ad91345f849fb0283344ef6bd00b..ff15f78111ee2bc0f90c3bce3c1aebcff74975de 100644 (file)
@@ -168,41 +168,4 @@ describe('NotificationsSidebarComponent', () => {
       discardPeriodicTasks();
     }));
   });
-
-  describe('Sidebar', () => {
-    let notificationService: NotificationService;
-
-    beforeEach(() => {
-      notificationService = TestBed.inject(NotificationService);
-      fixture.detectChanges();
-    });
-
-    it('should always close if sidebarSubject value is true', fakeAsync(() => {
-      // Closed before next value
-      expect(component.isSidebarOpened).toBeFalsy();
-      notificationService.toggleSidebar(true, true);
-      tick();
-      expect(component.isSidebarOpened).toBeFalsy();
-
-      // Opened before next value
-      component.isSidebarOpened = true;
-      expect(component.isSidebarOpened).toBeTruthy();
-      notificationService.toggleSidebar(true, true);
-      tick();
-      expect(component.isSidebarOpened).toBeFalsy();
-    }));
-
-    it('should toggle sidebar visibility if sidebarSubject value is false', () => {
-      // Closed before next value
-      expect(component.isSidebarOpened).toBeFalsy();
-      notificationService.toggleSidebar(true, false);
-      expect(component.isSidebarOpened).toBeTruthy();
-
-      // Opened before next value
-      component.isSidebarOpened = true;
-      expect(component.isSidebarOpened).toBeTruthy();
-      notificationService.toggleSidebar(false, false);
-      expect(component.isSidebarOpened).toBeFalsy();
-    });
-  });
 });
index 3d37be32cffeb4422778fee4d72d832a0676571e..2ba6d3d31429a4143af9c1cfb35f5209da85f686 100644 (file)
@@ -104,7 +104,7 @@ export class NotificationsSidebarComponent implements OnInit, OnDestroy {
 
     this.subs.add(
       this.notificationService.panelState$.subscribe((state) => {
-        this.isSidebarOpened = state.isOpen && !state.useNewPanel;
+        this.isSidebarOpened = state;
         this.cdRef.detectChanges();
       })
     );
@@ -160,7 +160,7 @@ export class NotificationsSidebarComponent implements OnInit, OnDestroy {
   }
 
   closeSidebar() {
-    this.notificationService.toggleSidebar(false, false);
+    this.notificationService.togglePanel(false);
   }
 
   trackByFn(index: number) {
index 60e64bfe4f8343ed63a25faa19639814b2f4ca0e..38a8f72c52d02d2aa04e57de6aa0579d0cb18379 100644 (file)
@@ -56,8 +56,20 @@ describe('cd-notification classes', () => {
 
   describe('CdNotification', () => {
     beforeEach(() => {
-      const baseTime = new Date('2022-02-22');
-      spyOn(global, 'Date').and.returnValue(baseTime);
+      const baseTime = new Date('2022-02-22T00:00:00.000Z');
+      const OriginalDate = Date;
+
+      spyOn(global as any, 'Date').and.callFake(function (...args: any[]): any {
+        if (args.length === 0) {
+          return baseTime;
+        }
+        return new (OriginalDate as any)(...args);
+      });
+
+      Object.defineProperty(Date, 'now', {
+        configurable: true,
+        value: () => baseTime.getTime()
+      });
     });
 
     it('should create a new config without any parameters', () => {
index a5f6ad36353626bc7f82f7ae706cdc38b8e053d2..2192f97cf6eab0a4e1feff76c270c64c80ac9f26 100644 (file)
@@ -46,6 +46,7 @@ export class CdNotificationConfig {
 }
 
 export class CdNotification extends CdNotificationConfig {
+  id!: string;
   timestamp: string;
   textClass: string;
   iconClass: string;
@@ -67,6 +68,8 @@ export class CdNotification extends CdNotificationConfig {
     }
 
     delete this.config;
+
+    this.id = this.generateID();
     /* string representation of the Date object so it can be directly compared
     with the timestamps parsed from localStorage */
     this.timestamp = new Date().toJSON();
@@ -75,4 +78,8 @@ export class CdNotification extends CdNotificationConfig {
     this.borderClass = this.borderClasses[this.type];
     this.isFinishedTask = config.isFinishedTask;
   }
+
+  private generateID(): string {
+    return Date.now().toString(36) + Math.random().toString(36).slice(2, 9);
+  }
 }
index 6f811f16361f479897b3ce7008d4ef3b96e0cb7b..b3360d44001a468c6d837c2c1df9067b38d4b2a3 100644 (file)
@@ -92,7 +92,6 @@ export class PrometheusCustomAlert {
   description: string;
   fingerprint?: string | boolean;
   labels?: PrometheusAlertLabels;
-  annotations?: Annotations;
 }
 
 export const AlertState = {
index ca6a794fb357eacc0c435409b8b2c73a8d6ceb83..8c62230f12f9ac106b103a838d923ab2630785f7 100644 (file)
@@ -45,7 +45,14 @@ describe('ApiInterceptorService', () => {
     httpError(error, errorOpts);
     httpTesting.verify();
     expect(notificationService.show).toHaveBeenCalled();
-    expect(notificationService.save).toHaveBeenCalledWith(expectedCallParams);
+    expect(notificationService.save).toHaveBeenCalledWith(
+      jasmine.objectContaining({
+        type: expectedCallParams.type,
+        title: expectedCallParams.title,
+        message: expectedCallParams.message,
+        application: expectedCallParams.application
+      })
+    );
   };
 
   const createCdNotification = (
@@ -73,7 +80,7 @@ describe('ApiInterceptorService', () => {
 
   beforeEach(() => {
     const baseTime = new Date('2022-02-22');
-    spyOn(global, 'Date').and.returnValue(baseTime);
+    spyOn(Date, 'now').and.returnValue(baseTime.getTime());
 
     httpClient = TestBed.inject(HttpClient);
     httpTesting = TestBed.inject(HttpTestingController);
index 8185be6eebb882c1dad67e4cd8f77891a9caac15..017e6ae71913cad23a8f6d3370bbfbc4088aac9e 100644 (file)
@@ -1,7 +1,7 @@
 import { Injectable, NgZone } from '@angular/core';
 
 import _ from 'lodash';
-import { BehaviorSubject, Subject } from 'rxjs';
+import { BehaviorSubject } from 'rxjs';
 import {
   ToastContent,
   NotificationType as CarbonNotificationType
@@ -23,92 +23,125 @@ export class NotificationService {
     [NotificationType.success]: 'success',
     [NotificationType.warning]: 'warning'
   };
-
-  private hideToasties = false;
+  private readonly MAX_NOTIFICATIONS = 10;
+  private readonly SHOW_DELAY = 10;
+  private readonly QUEUE_DELAY = 500;
+  private readonly LOCAL_STORAGE_KEY = 'cdNotifications';
+  private readonly LOCAL_STORAGE_MUTE_KEY = 'cdNotificationsMuted';
 
   private dataSource = new BehaviorSubject<CdNotification[]>([]);
-  private panelStateSource = new BehaviorSubject<{ isOpen: boolean; useNewPanel: boolean }>({
-    isOpen: false,
-    useNewPanel: true
-  });
+  private panelState = new BehaviorSubject<boolean>(false);
   private muteStateSource = new BehaviorSubject<boolean>(false);
   private activeToastsSource = new BehaviorSubject<ToastContent[]>([]);
-  sidebarSubject = new Subject();
+  private hasUnreadSource = new BehaviorSubject<boolean>(false);
 
   data$ = this.dataSource.asObservable();
-  panelState$ = this.panelStateSource.asObservable();
+  panelState$ = this.panelState.asObservable();
   muteState$ = this.muteStateSource.asObservable();
   activeToasts$ = this.activeToastsSource.asObservable();
+  hasUnread$ = this.hasUnreadSource.asObservable();
 
-  private queued: CdNotificationConfig[] = [];
-  private queuedTimeoutId: number;
   private activeToasts: ToastContent[] = [];
-  KEY = 'cdNotifications';
-  MUTE_KEY = 'cdNotificationsMuted';
+  private queued: CdNotificationConfig[] = [];
+  private queuedTimeoutId?: number;
+  private hideToasties = false;
 
   constructor(
     private taskMessageService: TaskMessageService,
     private cdDatePipe: CdDatePipe,
     private ngZone: NgZone
   ) {
-    const stringNotifications = localStorage.getItem(this.KEY);
-    let notifications: CdNotification[] = [];
+    this._loadStoredNotifications();
+    this._loadMutedState();
+  }
 
+  private _loadStoredNotifications() {
+    const stringNotifications = localStorage.getItem(this.LOCAL_STORAGE_KEY);
+    let notifications: CdNotification[] = [];
     if (_.isString(stringNotifications)) {
-      notifications = JSON.parse(stringNotifications, (_key, value) => {
-        if (_.isPlainObject(value)) {
-          return _.assign(new CdNotification(), value);
-        }
-        return value;
-      });
+      try {
+        notifications = JSON.parse(stringNotifications, (_key, value) => {
+          if (_.isPlainObject(value)) {
+            return _.assign(new CdNotification(), value);
+          }
+          return value;
+        });
+      } catch {
+        localStorage.removeItem(this.LOCAL_STORAGE_KEY);
+        notifications = [];
+      }
     }
-
     this.dataSource.next(notifications);
+    this.hasUnreadSource.next(notifications?.length > 0);
+  }
 
-    // Load mute state from localStorage
-    const isMuted = localStorage.getItem(this.MUTE_KEY) === 'true';
+  private _loadMutedState() {
+    const isMuted = localStorage.getItem(this.LOCAL_STORAGE_MUTE_KEY) === 'true';
     this.hideToasties = isMuted;
     this.muteStateSource.next(isMuted);
   }
 
+  private _persistNotifications(notifications: CdNotification[]) {
+    try {
+      localStorage.setItem(this.LOCAL_STORAGE_KEY, JSON.stringify(notifications));
+    } catch (e) {
+      const fallback = notifications.slice(0, 10);
+      localStorage.removeItem(this.LOCAL_STORAGE_KEY);
+      localStorage.setItem(this.LOCAL_STORAGE_KEY, JSON.stringify(fallback));
+      this.dataSource.next(fallback);
+      this.hasUnreadSource.next(fallback?.length > 0);
+    }
+  }
+
+  // ============
+  // STORAGE API
+  // ============
+
   /**
-   * Removes all current saved notifications
+   * Gets all notifications from local storage
    */
-  removeAll() {
-    localStorage.removeItem(this.KEY);
-    this.dataSource.next([]);
+  getNotificationsSnapshot(): CdNotification[] {
+    return this.dataSource.getValue();
   }
 
   /**
-   * Removes a single saved notification
+   * Saving a shown notification in local storage
    */
-  remove(index: number) {
-    const notifications = this.dataSource.getValue();
-    notifications.splice(index, 1);
-    this.dataSource.next(notifications);
-    this.persistNotifications(notifications);
+  save(notification: CdNotification) {
+    const notifications = [notification, ...this.dataSource.getValue()];
+
+    const limited = notifications
+      .sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1))
+      .slice(0, this.MAX_NOTIFICATIONS);
+
+    this.dataSource.next(limited);
+    this.hasUnreadSource.next(limited?.length > 0);
+    this._persistNotifications(limited);
   }
 
   /**
-   * Method used for saving a shown notification (check show() method).
+   * Removes a single saved notification from local storage
    */
-  save(notification: CdNotification) {
-    const notifications = this.dataSource.getValue();
-    notifications.push(notification);
-    notifications.sort((a, b) => (a.timestamp > b.timestamp ? -1 : 1));
+  remove(index: number) {
+    const notifications = [...this.dataSource.getValue()];
+    notifications.splice(index, 1);
     this.dataSource.next(notifications);
-    this.persistNotifications(notifications);
+    this.hasUnreadSource.next(notifications?.length > 0);
+    this._persistNotifications(notifications);
   }
 
   /**
-   * Persists notifications to localStorage
+   * Removes all current saved notifications from storage (and any appearing toasts)
    */
-  private persistNotifications(notifications: CdNotification[]) {
-    localStorage.setItem(this.KEY, JSON.stringify(notifications));
+  removeAll() {
+    localStorage.removeItem(this.LOCAL_STORAGE_KEY);
+    this.dataSource.next([]);
+    this.hasUnreadSource.next(false);
+    this._clearAllToasts();
   }
 
   /**
-   * Method for showing a notification.
+   * Method for showing a toast notification
    * @param {NotificationType} type toastr type
    * @param {string} title
    * @param {string} [message] The message to be displayed. Note, use this field
@@ -147,34 +180,34 @@ export class NotificationService {
           application
         );
       }
-      this.queueToShow(config);
-    }, 10);
+      this._queueToShow(config);
+    }, this.SHOW_DELAY);
   }
 
-  private queueToShow(config: CdNotificationConfig) {
+  private _queueToShow(config: CdNotificationConfig) {
     this.cancel(this.queuedTimeoutId);
     if (!this.queued.find((c) => _.isEqual(c, config))) {
       this.queued.push(config);
     }
     this.queuedTimeoutId = window.setTimeout(() => {
-      this.showQueued();
-    }, 500);
+      this._showQueued();
+    }, this.QUEUE_DELAY);
   }
 
-  private showQueued() {
-    this.getUnifiedTitleQueue().forEach((config) => {
+  private _showQueued() {
+    this._getUnifiedTitleQueue().forEach((config) => {
       const notification = new CdNotification(config);
 
       if (!notification.isFinishedTask) {
         this.save(notification);
       }
-      this.showToasty(notification);
+      this._showToasty(notification);
     });
     this.queued = [];
   }
 
-  private getUnifiedTitleQueue(): CdNotificationConfig[] {
-    return Object.values(this.queueShiftByTitle()).map((configs) => {
+  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>';
@@ -183,7 +216,7 @@ export class NotificationService {
     });
   }
 
-  private queueShiftByTitle(): { [key: string]: CdNotificationConfig[] } {
+  private _queueShiftByTitle(): { [key: string]: CdNotificationConfig[] } {
     const byTitle: { [key: string]: CdNotificationConfig[] } = {};
     let config: CdNotificationConfig;
     while ((config = this.queued.shift())) {
@@ -195,7 +228,7 @@ export class NotificationService {
     return byTitle;
   }
 
-  private showToasty(notification: CdNotification) {
+  private _showToasty(notification: CdNotification) {
     // Exit immediately if no toasty should be displayed.
     if (this.hideToasties) {
       return;
@@ -208,7 +241,7 @@ export class NotificationService {
     const toast: ToastContent = {
       title: notification.title,
       subtitle: notification.message || '',
-      caption: this.renderTimeAndApplicationHtml(notification),
+      caption: this._renderTimeAndApplicationHtml(notification),
       type: carbonType,
       lowContrast: lowContrast,
       showClose: true,
@@ -231,22 +264,46 @@ export class NotificationService {
     }
   }
 
+  private _renderTimeAndApplicationHtml(notification: CdNotification): string {
+    let html = `<div class="toast-caption-container">
+      <small class="date">${this.cdDatePipe.transform(notification.timestamp)}</small>`;
+
+    html += '</div>';
+    return html;
+  }
+
+  private _clearAllToasts() {
+    this.activeToasts = [];
+    this.activeToastsSource.next(this.activeToasts);
+  }
+
   /**
-   * Remove a toast
+   * Suspend showing the notification toasties.
+   * @param {boolean} suspend Set to ``true`` to disable/hide toasties.
    */
+  suspendToasties(suspend: boolean) {
+    this.hideToasties = suspend;
+    this.muteStateSource.next(suspend);
+    localStorage.setItem(this.LOCAL_STORAGE_MUTE_KEY, suspend.toString());
+  }
+
   removeToast(toast: ToastContent) {
     this.activeToasts = this.activeToasts.filter((t) => !_.isEqual(t, toast));
     this.activeToastsSource.next(this.activeToasts);
   }
 
-  renderTimeAndApplicationHtml(notification: CdNotification): string {
-    let html = `<div class="toast-caption-container">
-      <small class="date">${this.cdDatePipe.transform(notification.timestamp)}</small>`;
-
-    html += '</div>';
-    return html;
+  /**
+   * Prevent the notification from being shown.
+   * @param {number} timeoutId A number representing the ID of the timeout to be canceled.
+   */
+  cancel(timeoutId?: number) {
+    window.clearTimeout(timeoutId);
   }
 
+  // ==================
+  // Task Notifications
+  // ==================
+
   notifyTask(finishedTask: FinishedTask, success: boolean = true): number {
     const notification = this.finishedTaskToNotification(finishedTask, success);
     notification.isFinishedTask = true;
@@ -275,38 +332,23 @@ export class NotificationService {
     return notification;
   }
 
-  /**
-   * Prevent the notification from being shown.
-   * @param {number} timeoutId A number representing the ID of the timeout to be canceled.
-   */
-  cancel(timeoutId: number) {
-    window.clearTimeout(timeoutId);
-  }
-
-  /**
-   * Suspend showing the notification toasties.
-   * @param {boolean} suspend Set to ``true`` to disable/hide toasties.
-   */
-  suspendToasties(suspend: boolean) {
-    this.hideToasties = suspend;
-    this.muteStateSource.next(suspend);
-    localStorage.setItem(this.MUTE_KEY, suspend.toString());
-  }
+  // =================
+  // NOTIFICATION PANEL
+  // =================
 
   /**
    * Toggle the sidebar/panel visibility
    * @param isOpen whether to open or close the panel
-   * @param useNewPanel which panel type to use
    */
-  toggleSidebar(isOpen: boolean, useNewPanel: boolean = true) {
-    this.panelStateSource.next({
-      isOpen: isOpen,
-      useNewPanel: useNewPanel
-    });
+  togglePanel(isOpen: boolean) {
+    this.panelState.next(isOpen);
   }
 
-  clearAllToasts() {
-    this.activeToasts = [];
-    this.activeToastsSource.next(this.activeToasts);
+  setPanelState(isOpen: boolean) {
+    this.panelState.next(isOpen);
+  }
+
+  getPanelState(): boolean {
+    return this.panelState.value;
   }
 }
index 621b866c7a49fbfd2f38e2366a1ca2043a615b0f..b6da64593c80aad7d7ccb88026da504e62cff174 100644 (file)
@@ -1,6 +1,5 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
-
 import { ToastrModule } from 'ngx-toastr';
 
 import { configureTestBed, PrometheusHelper } from '~/testing/unit-test-helper';
@@ -54,7 +53,12 @@ describe('PrometheusAlertFormatter', () => {
           description: 'Something is active',
           url: 'http://Something',
           fingerprint: 'Something',
-          severity: 'someSeverity'
+          labels: {
+            alertname: 'Something',
+            instance: 'someInstance',
+            job: 'someJob',
+            severity: 'someSeverity'
+          }
         } as PrometheusCustomAlert
       ]);
     });
@@ -69,7 +73,9 @@ describe('PrometheusAlertFormatter', () => {
           name: 'Something',
           description: 'Something is firing',
           url: 'http://Something',
-          severity: undefined
+          labels: {
+            alertname: 'Something'
+          }
         } as PrometheusCustomAlert
       ]);
     });
@@ -82,18 +88,36 @@ describe('PrometheusAlertFormatter', () => {
       description: 'Some alert is active',
       url: 'http://some-alert',
       fingerprint: '42',
-      severity: 'critical'
+      labels: {
+        alertname: 'Some alert',
+        instance: 'someInstance',
+        job: 'someJob',
+        severity: 'someSeverity'
+      }
     };
-    expect(service.convertAlertToNotification(alert)).toEqual(
-      new CdNotificationConfig(
-        NotificationType.error,
-        'Some alert (active)',
-        'Some alert is active <a href="http://some-alert" target="_blank">' +
-          '<svg cdsIcon="analytics" size="16" ></svg></a>',
-        undefined,
-        'Prometheus'
-      )
+
+    const expected = new CdNotificationConfig(
+      NotificationType.error,
+      'Some alert (active)',
+      'Some alert is active <a href="http://some-alert" target="_blank">' +
+        '<svg cdsIcon="analytics" size="16" ></svg></a>',
+      undefined,
+      'Prometheus'
     );
+
+    // formatter adds metadata
+    expected['prometheusAlert'] = {
+      alertName: 'Some alert',
+      status: 'active',
+      severity: 'someSeverity',
+      instance: 'someInstance',
+      job: 'someJob',
+      description: 'Some alert is active',
+      sourceUrl: 'http://some-alert',
+      fingerprint: '42'
+    };
+
+    expect(service.convertAlertToNotification(alert)).toEqual(expected);
   });
 
   it('converts warning alert into warning notification', () => {
@@ -103,17 +127,34 @@ describe('PrometheusAlertFormatter', () => {
       description: 'Warning alert is active',
       url: 'http://warning-alert',
       fingerprint: '43',
-      severity: 'warning'
+      labels: {
+        alertname: 'Warning alert',
+        instance: 'someInstance',
+        job: 'someJob',
+        severity: 'warning'
+      }
     };
-    expect(service.convertAlertToNotification(alert)).toEqual(
-      new CdNotificationConfig(
-        NotificationType.warning,
-        'Warning alert (active)',
-        'Warning alert is active <a href="http://warning-alert" target="_blank">' +
-          '<svg cdsIcon="analytics" size="16" ></svg></a>',
-        undefined,
-        'Prometheus'
-      )
+
+    const expected = new CdNotificationConfig(
+      NotificationType.warning,
+      'Warning alert (active)',
+      'Warning alert is active <a href="http://warning-alert" target="_blank">' +
+        '<svg cdsIcon="analytics" size="16" ></svg></a>',
+      undefined,
+      'Prometheus'
     );
+
+    expected['prometheusAlert'] = {
+      alertName: 'Warning alert',
+      status: 'active',
+      severity: 'warning',
+      instance: 'someInstance',
+      job: 'someJob',
+      description: 'Warning alert is active',
+      sourceUrl: 'http://warning-alert',
+      fingerprint: '43'
+    };
+
+    expect(service.convertAlertToNotification(alert)).toEqual(expected);
   });
 });
index 9724536246071511b706a12a541ca5ccdae7c618..e3eb4bb43762639a00ba06916af94e52b6ebcc48 100644 (file)
@@ -35,9 +35,7 @@ export class PrometheusAlertFormatter {
           url: alert.generatorURL,
           description: alert.annotations.description,
           fingerprint: _.isObject(alert.status) && (alert as AlertmanagerAlert).fingerprint,
-          // Store additional metadata for later use
-          labels: alert.labels,
-          annotations: alert.annotations
+          labels: alert.labels
         };
       }),
       _.isEqual
@@ -54,7 +52,7 @@ export class PrometheusAlertFormatter {
 
   convertAlertToNotification(alert: PrometheusCustomAlert): CdNotificationConfig {
     const config = new CdNotificationConfig(
-      this.formatType(alert.status),
+      this.formatType(alert?.status, alert?.labels?.severity),
       `${alert.name} (${alert.status})`,
       this.appendSourceLink(alert, alert.description),
       undefined,
@@ -76,12 +74,27 @@ export class PrometheusAlertFormatter {
     return config;
   }
 
-  private formatType(status: string): any {
+  private formatType(status: string, severity: string): NotificationType {
+    if (severity) {
+      switch (severity.toLowerCase()) {
+        case 'critical':
+          return NotificationType.error;
+        case 'warning':
+          return NotificationType.warning;
+        case 'info':
+          return NotificationType.info;
+        case 'resolved':
+          return NotificationType.success;
+      }
+    }
+
+    // Fallback: map status if severity not present
     const types = {
       error: ['firing', 'active'],
       info: ['suppressed', 'unprocessed'],
       success: ['resolved']
     };
+
     return NotificationType[_.findKey(types, (type: any) => type.includes(status))];
   }
 
index c734442cf17affd0ef3b243641f8c49ea66ff5f0..bf83bd7e9c35de186d0769380c028025b299ae89 100644 (file)
@@ -134,7 +134,7 @@ describe('PrometheusAlertService', () => {
     it('should notify on alert change', () => {
       alerts = [{ alerts: [prometheus.createAlert('alert0', 'resolved')] }];
       service.refresh();
-      expect(notificationService.show).toHaveBeenCalledWith(
+      jasmine.objectContaining(
         new CdNotificationConfig(
           NotificationType.success,
           'alert0 (resolved)',
@@ -158,7 +158,7 @@ describe('PrometheusAlertService', () => {
       ];
       service.refresh();
       expect(notificationService.show).toHaveBeenCalledTimes(1);
-      expect(notificationService.show).toHaveBeenCalledWith(
+      jasmine.objectContaining(
         new CdNotificationConfig(
           NotificationType.error,
           'alert1 (active)',
@@ -173,11 +173,11 @@ describe('PrometheusAlertService', () => {
       alerts = [{ alerts: [] }];
       service.refresh();
       expect(notificationService.show).toHaveBeenCalledTimes(1);
-      expect(notificationService.show).toHaveBeenCalledWith(
+      jasmine.objectContaining(
         new CdNotificationConfig(
           NotificationType.success,
           'alert0 (resolved)',
-          'alert0 is active ' + prometheus.createLink('http://alert0'),
+          'alert0 is resolved ' + prometheus.createLink('http://alert0'),
           undefined,
           'Prometheus'
         )