From 3b7f26e2b7ed6be59a21794a99430c1022e6535a Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Fri, 19 Dec 2025 03:16:05 +0530 Subject: [PATCH] qmgr/dashboard: Some refactors and bugs fixes Signed-off-by: Afreen Misbah --- .../frontend/src/app/app-routing.module.ts | 2 +- .../app/core/navigation/navigation.module.ts | 2 +- .../navigation/navigation.component.html | 15 +- .../navigation/navigation.component.ts | 19 +- .../notification-area.component.html | 8 +- .../notification-area.component.scss | 17 +- .../notification-area.component.spec.ts | 3 +- .../notification-area.component.ts | 7 +- .../notification-footer.component.html | 1 + .../notification-footer.component.scss | 19 +- .../notification-footer.component.ts | 2 +- .../notification-header.component.html | 39 +-- .../notification-header.component.scss | 23 +- .../notification-header.component.spec.ts | 2 + .../notification-panel.component.html | 21 +- .../notification-panel.component.scss | 17 +- .../notification-panel.component.spec.ts | 39 +-- .../notification-panel.component.ts | 22 +- .../notifications-page.component.html | 31 +-- .../notifications-page.component.spec.ts | 18 +- .../notifications-page.component.ts | 52 ++-- .../notifications.component.html | 18 +- .../notifications.component.spec.ts | 29 ++- .../notifications/notifications.component.ts | 7 +- .../notifications-sidebar.component.spec.ts | 37 --- .../notifications-sidebar.component.ts | 4 +- .../app/shared/models/cd-notification.spec.ts | 16 +- .../src/app/shared/models/cd-notification.ts | 7 + .../app/shared/models/prometheus-alerts.ts | 1 - .../services/api-interceptor.service.spec.ts | 11 +- .../shared/services/notification.service.ts | 222 +++++++++++------- .../prometheus-alert-formatter.spec.ts | 87 +++++-- .../services/prometheus-alert-formatter.ts | 23 +- .../services/prometheus-alert.service.spec.ts | 8 +- 34 files changed, 441 insertions(+), 388 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 430d66b0471e..34cd097b40a2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -110,7 +110,7 @@ const routes: Routes = [ { path: 'notifications', data: { - breadcrumbs: 'Cluster/Notifications' + breadcrumbs: 'Overview/Notifications' }, component: NotificationsPageComponent }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts index 013fc5ca4c94..9b642e8f35fe 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation.module.ts @@ -88,7 +88,7 @@ import { NotificationFooterComponent } from './notification-panel/notification-f TagModule, ProgressBarModule, StructuredListModule, - SearchModule, + SearchModule ], declarations: [ AboutComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html index 487e9d3e8a5a..9074d0c129a2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -2,9 +2,9 @@ - + @if(notificationService.panelState$ | async; as isNotifPanelOpen) { + + } @@ -34,11 +34,10 @@ - - - +
+ +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts index 3759bd2a45ec..4f6c11a457ff 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.ts @@ -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(); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html index 5e7f72baadb0..a8ac59e0d3c4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.html @@ -1,3 +1,6 @@ +
@if (executingTasks.length > 0) {
-@for (notification of todayNotifications; track notification.timestamp; let last = $last) { +@for (notification of todayNotifications; track notification.id; let last = $last) { @@ -102,7 +105,7 @@ Previous
-@for (notification of previousNotifications; track notification.timestamp; let last = $last) { +@for (notification of previousNotifications; track notification.id; let last = $last) { @@ -115,3 +118,4 @@
No notifications
} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss index 7c082965ced3..372744424489 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.scss @@ -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; } @@ -107,11 +107,6 @@ } } -.notification-icon { - flex-shrink: 0; - margin-top: 0; -} - .notification-content { flex: 1; min-width: 0; @@ -125,13 +120,15 @@ 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); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts index a7ef567b89fe..3065eca6c871 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.spec.ts @@ -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 }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts index 7cf0e76dd9b8..ee5d7f5c9e41 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-area/notification-area.component.ts @@ -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 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.html index 4d6475a55e86..3afd57c71bdb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.html @@ -2,6 +2,7 @@ View all diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.scss index 0a513f7475a1..29250ed22d5d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.scss @@ -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; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.ts index f26660ef3aaf..0da5d93d3157 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.ts @@ -13,6 +13,6 @@ export class NotificationFooterComponent { closePanel(event: Event) { event.preventDefault(); event.stopPropagation(); - this.notificationService.toggleSidebar(false, true); + this.notificationService.setPanelState(false); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.html index 89267f69081b..55e30a50e554 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.html @@ -5,24 +5,25 @@
-
- Tasks and Notifications - +
+

Tasks and Notifications

+
-
\ No newline at end of file + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.scss index d903bf860d62..332ceaf53771 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.scss @@ -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; } - } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.spec.ts index 444a6d115688..1c6d55ac0981 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.spec.ts @@ -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(false); await TestBed.configureTestingModule({ declarations: [NotificationHeaderComponent], + imports: [ToggleModule, GridModule], providers: [ { provide: NotificationService, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html index 826957a02067..b9165ba7e2a5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html @@ -1,16 +1,7 @@ - - - - +
+ - - - - + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.scss index 40f81de4c837..e7d227fb82c7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.scss @@ -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; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.spec.ts index 67bc2924686c..8108a4512b2f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.spec.ts @@ -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; - 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(); - }); - }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.ts index 1eb245f4c84a..495da61d2adb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.ts @@ -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() {} } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html index bbc832bef4ac..fa63eb30694e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html @@ -5,7 +5,7 @@ class="notifications-page__container">
+ [columnNumbers]="{sm: 4, md: 4, lg: 6}"> @@ -56,27 +56,28 @@ -
- -

No notifications match your search

-

No notifications available

-
+
+ +

No notifications match your search

+

No notifications available

+
+ [columnNumbers]="{sm: 12, md: 12, lg: 10}">
+ [columnNumbers]="{sm: 16, md: 16, lg: 16}">

{{ selectedNotification.title }}

{ // 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(''); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts index d8952ec0fe42..c19181bf26f3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts @@ -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; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html index da785b10266b..ea5dcc953869 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.html @@ -1,15 +1,15 @@ @if(isMuted) { - + } -@else if(!isMuted && hasRunningTasks) { - - +@else if(!isMuted && ( hasRunningTasks || hasNotifications)) { + + } @else { - - + + } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts index 8fea818cf471..a5c7ccba21be 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.spec.ts @@ -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; 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'); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts index 05655e310919..f7d0d3bc46a9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.ts @@ -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 { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts index 627fefb4fe02..ff15f78111ee 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts @@ -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(); - }); - }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts index 3d37be32cffe..2ba6d3d31429 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.ts @@ -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) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts index 60e64bfe4f83..38a8f72c52d0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.spec.ts @@ -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', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts index a5f6ad363536..2192f97cf6ea 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cd-notification.ts @@ -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); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts index 6f811f16361f..b3360d44001a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/prometheus-alerts.ts @@ -92,7 +92,6 @@ export class PrometheusCustomAlert { description: string; fingerprint?: string | boolean; labels?: PrometheusAlertLabels; - annotations?: Annotations; } export const AlertState = { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts index ca6a794fb357..8c62230f12f9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/api-interceptor.service.spec.ts @@ -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); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts index 8185be6eebb8..017e6ae71913 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/notification.service.ts @@ -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([]); - private panelStateSource = new BehaviorSubject<{ isOpen: boolean; useNewPanel: boolean }>({ - isOpen: false, - useNewPanel: true - }); + private panelState = new BehaviorSubject(false); private muteStateSource = new BehaviorSubject(false); private activeToastsSource = new BehaviorSubject([]); - sidebarSubject = new Subject(); + private hasUnreadSource = new BehaviorSubject(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 = '
    ' + configs.map((c) => `
  • ${c.message}
  • `).join('') + '
'; @@ -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 = `
+ ${this.cdDatePipe.transform(notification.timestamp)}`; + + html += '
'; + 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 = `
- ${this.cdDatePipe.transform(notification.timestamp)}`; - - html += '
'; - 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; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts index 621b866c7a49..b6da64593c80 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.spec.ts @@ -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 ' + - '', - undefined, - 'Prometheus' - ) + + const expected = new CdNotificationConfig( + NotificationType.error, + 'Some alert (active)', + 'Some alert is active ' + + '', + 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 ' + - '', - undefined, - 'Prometheus' - ) + + const expected = new CdNotificationConfig( + NotificationType.warning, + 'Warning alert (active)', + 'Warning alert is active ' + + '', + 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); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts index 972453624607..e3eb4bb43762 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert-formatter.ts @@ -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))]; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts index c734442cf17a..bf83bd7e9c35 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts @@ -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' ) -- 2.47.3