From: Anikait Sehwag Date: Mon, 7 Jul 2025 18:13:04 +0000 (+0530) Subject: mgr/dashboard:Notification Footer+ Notification Page X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=be4e863df01cedd24636f1ed2762313375bafc3c;p=ceph.git mgr/dashboard:Notification Footer+ Notification Page Fixes: https://tracker.ceph.com/issues/71738 A comprehensive notifications management page that allows users to view, search, and manage system notifications with a modern Carbon Design System interface. Signed-off-by: Anikait Sehwag Co-authored-by: Afreen Misbah --- 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 a40c58631af3..fcab76f09888 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 @@ -60,6 +60,7 @@ import { SmbUsersgroupsListComponent } from './ceph/smb/smb-usersgroups-list/smb import { SmbOverviewComponent } from './ceph/smb/smb-overview/smb-overview.component'; import { MultiClusterFormComponent } from './ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component'; import { CephfsMirroringListComponent } from './ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component'; +import { NotificationsPageComponent } from './core/navigation/notification-panel/notifications-page/notifications-page.component'; @Injectable() export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver { @@ -106,6 +107,14 @@ const routes: Routes = [ { path: 'overview', component: DashboardComponent }, { path: 'error', component: ErrorComponent }, + // Notifications + { + path: 'notifications', + data: { + breadcrumbs: 'Cluster/Notifications' + }, + component: NotificationsPageComponent + }, // Cluster { path: 'expand-cluster', 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 a9791bf2179f..089f47a16f2c 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 @@ -12,16 +12,19 @@ import { DialogModule, GridModule, BreadcrumbModule, - ModalModule, ToggleModule, ButtonModule, PlaceholderModule, TagModule, - ProgressBarModule + ProgressBarModule, + StructuredListModule, + SearchModule } from 'carbon-components-angular'; import { AppRoutingModule } from '~/app/app-routing.module'; import { SharedModule } from '~/app/shared/shared.module'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; + import { AuthModule } from '../auth/auth.module'; import { AboutComponent } from './about/about.component'; import { AdministrationComponent } from './administration/administration.component'; @@ -31,15 +34,14 @@ import { DashboardHelpComponent } from './dashboard-help/dashboard-help.componen import { IdentityComponent } from './identity/identity.component'; import { NavigationComponent } from './navigation/navigation.component'; import { NotificationsComponent } from './notifications/notifications.component'; -import { NotificationPanelComponent } from './notification-panel/notification-panel.component'; -import { NotificationHeaderComponent } from './notification-panel/header/notification-header.component'; -import { NotificationAreaComponent } from './notification-panel/notification-area/notification-area.component'; +import { NotificationsPageComponent } from './notification-panel/notifications-page/notifications-page.component'; // Icons import UserFilledIcon from '@carbon/icons/es/user--filled/20'; import SettingsIcon from '@carbon/icons/es/settings/20'; import HelpIcon from '@carbon/icons/es/help/20'; import NotificationIcon from '@carbon/icons/es/notification/20'; +import NotificationOffIcon from '@carbon/icons/es/notification--off/20'; import LaunchIcon from '@carbon/icons/es/launch/16'; import DashboardIcon from '@carbon/icons/es/template/20'; import ClusterIcon from '@carbon/icons/es/web-services--cluster/20'; @@ -51,7 +53,16 @@ import ObservabilityIcon from '@carbon/icons/es/observed--hail/20'; import AdminIcon from '@carbon/icons/es/network--admin-control/20'; import LockedIcon from '@carbon/icons/es/locked/16'; import LogoutIcon from '@carbon/icons/es/logout/16'; -import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import CheckmarkFilledIcon from '@carbon/icons/es/checkmark--filled/16'; +import ErrorFilledIcon from '@carbon/icons/es/error--filled/16'; +import InformationFilledIcon from '@carbon/icons/es/information--filled/16'; +import WarningFilledIcon from '@carbon/icons/es/warning--filled/16'; +import NotificationFilledIcon from '@carbon/icons/es/notification--filled/16'; +import CloseIcon from '@carbon/icons/es/close/16'; +import { NotificationPanelComponent } from './notification-panel/notification-panel/notification-panel.component'; +import { NotificationHeaderComponent } from './notification-panel/notification-header/notification-header.component'; +import { NotificationAreaComponent } from './notification-panel/notification-area/notification-area.component'; +import { NotificationFooterComponent } from './notification-panel/notification-footer/notification-footer.component'; @NgModule({ imports: [ @@ -69,12 +80,13 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; DialogModule, GridModule, BreadcrumbModule, - ModalModule, ToggleModule, ButtonModule, PlaceholderModule, TagModule, - ProgressBarModule + ProgressBarModule, + StructuredListModule, + SearchModule ], declarations: [ AboutComponent, @@ -85,12 +97,22 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; NotificationPanelComponent, NotificationHeaderComponent, NotificationAreaComponent, + NotificationFooterComponent, + NotificationsPageComponent, DashboardHelpComponent, AdministrationComponent, IdentityComponent ], providers: [ModalCdsService], - exports: [NavigationComponent, BreadcrumbsComponent], + exports: [ + NavigationComponent, + NotificationsPageComponent, + NotificationPanelComponent, + NotificationHeaderComponent, + NotificationAreaComponent, + NotificationFooterComponent, + BreadcrumbsComponent + ], schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class NavigationModule { @@ -100,6 +122,7 @@ export class NavigationModule { SettingsIcon, HelpIcon, NotificationIcon, + NotificationOffIcon, LaunchIcon, DashboardIcon, ClusterIcon, @@ -110,7 +133,13 @@ export class NavigationModule { ObservabilityIcon, AdminIcon, LockedIcon, - LogoutIcon + LogoutIcon, + CheckmarkFilledIcon, + ErrorFilledIcon, + InformationFilledIcon, + WarningFilledIcon, + NotificationFilledIcon, + CloseIcon ]); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.html deleted file mode 100644 index 0038d3c8ae8b..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.html +++ /dev/null @@ -1,27 +0,0 @@ -
-
-
- Tasks and Notifications -
- - -
- -
- - -
-
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.scss deleted file mode 100644 index c8ab9aa30cbb..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.scss +++ /dev/null @@ -1,57 +0,0 @@ -@use '@carbon/styles/scss/type'; -@use '@carbon/styles/scss/spacing'; -@use '@carbon/styles/scss/theme'; - -.notification-header { - display: flex; - flex-direction: column; - padding: spacing.$spacing-04; - border-bottom: 1px solid theme.$border-subtle-01; - background-color: theme.$layer-01; - flex-shrink: 0; - - &__top { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - margin-bottom: spacing.$spacing-03; - } - - &__title { - h4 { - @include type.type-style('heading-compact-01'); - - color: theme.$text-primary; - margin: 0; - } - } - - &__dismiss-btn { - color: theme.$text-primary; - - &:hover { - color: theme.$link-primary; - } - } - - &__toggle { - cds-toggle { - margin: 0; - - ::ng-deep { - .cds--toggle__label-text { - color: theme.$text-primary; - } - - .cds--toggle__label { - color: theme.$text-primary; - } - - .cds--toggle__text { - color: theme.$text-primary; - } - } - } - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.spec.ts deleted file mode 100644 index 444a6d115688..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NotificationHeaderComponent } from './notification-header.component'; -import { NotificationService } from '../../../../shared/services/notification.service'; -import { BehaviorSubject } from 'rxjs'; - -describe('NotificationHeaderComponent', () => { - let component: NotificationHeaderComponent; - let fixture: ComponentFixture; - let notificationService: NotificationService; - let muteStateSubject: BehaviorSubject; - - beforeEach(async () => { - muteStateSubject = new BehaviorSubject(false); - await TestBed.configureTestingModule({ - declarations: [NotificationHeaderComponent], - providers: [ - { - provide: NotificationService, - useValue: { - muteState$: muteStateSubject.asObservable(), - removeAll: jasmine.createSpy('removeAll'), - suspendToasties: jasmine.createSpy('suspendToasties') - } - } - ] - }).compileComponents(); - - fixture = TestBed.createComponent(NotificationHeaderComponent); - component = fixture.componentInstance; - notificationService = TestBed.inject(NotificationService); - fixture.detectChanges(); - }); - - it('should create', () => { - expect(component).toBeTruthy(); - }); - - it('should initialize with default mute state', () => { - expect(component.isMuted).toBe(false); - }); - - it('should update mute state when subscription emits', () => { - muteStateSubject.next(true); - fixture.detectChanges(); - expect(component.isMuted).toBe(true); - }); - - it('should emit dismissAll event and call removeAll on dismiss', () => { - spyOn(component.dismissAll, 'emit'); - - component.onDismissAll(); - - expect(component.dismissAll.emit).toHaveBeenCalled(); - expect(notificationService.removeAll).toHaveBeenCalled(); - }); - - it('should toggle mute state', () => { - component.isMuted = false; - component.onToggleMute(); - expect(notificationService.suspendToasties).toHaveBeenCalledWith(true); - - component.isMuted = true; - component.onToggleMute(); - expect(notificationService.suspendToasties).toHaveBeenCalledWith(false); - }); - - it('should unsubscribe on destroy', () => { - spyOn(component['subs'], 'unsubscribe'); - component.ngOnDestroy(); - expect(component['subs'].unsubscribe).toHaveBeenCalled(); - }); -}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.ts deleted file mode 100644 index e3f9113fc764..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/header/notification-header.component.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Component, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core'; -import { NotificationService } from '../../../../shared/services/notification.service'; -import { Subscription } from 'rxjs'; - -@Component({ - selector: 'cd-notification-header', - templateUrl: './notification-header.component.html', - styleUrls: ['./notification-header.component.scss'], - standalone: false -}) -export class NotificationHeaderComponent implements OnInit, OnDestroy { - @Output() dismissAll = new EventEmitter(); - - isMuted = false; - private subs = new Subscription(); - - constructor(private notificationService: NotificationService) {} - - ngOnInit(): void { - this.subs.add( - this.notificationService.muteState$.subscribe((isMuted) => { - this.isMuted = isMuted; - }) - ); - } - - ngOnDestroy(): void { - this.subs.unsubscribe(); - } - - onDismissAll(): void { - this.dismissAll.emit(); - this.notificationService.removeAll(); - } - - onToggleMute(): void { - this.notificationService.suspendToasties(!this.isMuted); - } -} 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 ec1d8ec7e468..7c082965ced3 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 @@ -107,6 +107,11 @@ } } +.notification-icon { + flex-shrink: 0; + margin-top: 0; +} + .notification-content { flex: 1; min-width: 0; 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 new file mode 100644 index 000000000000..4d6475a55e86 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.html @@ -0,0 +1,8 @@ + 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 new file mode 100644 index 000000000000..0a513f7475a1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.scss @@ -0,0 +1,17 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/theme'; + +.notification-footer { + display: flex; + justify-content: flex-start; + padding: spacing.$spacing-03; + border-top: 1px solid theme.$border-subtle-01; + + cds-button { + color: theme.$text-primary; + + &:hover { + color: theme.$link-primary; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.spec.ts new file mode 100644 index 000000000000..65d51d6af8ff --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.spec.ts @@ -0,0 +1,29 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NotificationFooterComponent } from './notification-footer.component'; + +describe('NotificationFooterComponent', () => { + let component: NotificationFooterComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NotificationFooterComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotificationFooterComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render view all button', () => { + const compiled = fixture.nativeElement as HTMLElement; + const button = compiled.querySelector('cds-button'); + expect(button?.textContent).toContain('View all'); + }); +}); 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 new file mode 100644 index 000000000000..5dcf0d39c367 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-footer/notification-footer.component.ts @@ -0,0 +1,17 @@ +import { Component } from '@angular/core'; +import { NotificationService } from '~/app/shared/services/notification.service'; + +@Component({ + selector: 'cd-notification-footer', + templateUrl: './notification-footer.component.html', + styleUrls: ['./notification-footer.component.scss'] +}) +export class NotificationFooterComponent { + constructor(public notificationService: NotificationService) {} + + closePanel(event: Event) { + event.preventDefault(); + event.stopPropagation(); + this.notificationService.toggleSidebar(false, true); + } +} 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 new file mode 100644 index 000000000000..0038d3c8ae8b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.html @@ -0,0 +1,27 @@ +
+
+
+ Tasks and Notifications +
+ + +
+ +
+ + +
+
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 new file mode 100644 index 000000000000..c8ab9aa30cbb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.scss @@ -0,0 +1,57 @@ +@use '@carbon/styles/scss/type'; +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/theme'; + +.notification-header { + display: flex; + flex-direction: column; + padding: spacing.$spacing-04; + border-bottom: 1px solid theme.$border-subtle-01; + background-color: theme.$layer-01; + flex-shrink: 0; + + &__top { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-bottom: spacing.$spacing-03; + } + + &__title { + h4 { + @include type.type-style('heading-compact-01'); + + color: theme.$text-primary; + margin: 0; + } + } + + &__dismiss-btn { + color: theme.$text-primary; + + &:hover { + color: theme.$link-primary; + } + } + + &__toggle { + cds-toggle { + margin: 0; + + ::ng-deep { + .cds--toggle__label-text { + color: theme.$text-primary; + } + + .cds--toggle__label { + color: theme.$text-primary; + } + + .cds--toggle__text { + color: theme.$text-primary; + } + } + } + } +} 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 new file mode 100644 index 000000000000..444a6d115688 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.spec.ts @@ -0,0 +1,72 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NotificationHeaderComponent } from './notification-header.component'; +import { NotificationService } from '../../../../shared/services/notification.service'; +import { BehaviorSubject } from 'rxjs'; + +describe('NotificationHeaderComponent', () => { + let component: NotificationHeaderComponent; + let fixture: ComponentFixture; + let notificationService: NotificationService; + let muteStateSubject: BehaviorSubject; + + beforeEach(async () => { + muteStateSubject = new BehaviorSubject(false); + await TestBed.configureTestingModule({ + declarations: [NotificationHeaderComponent], + providers: [ + { + provide: NotificationService, + useValue: { + muteState$: muteStateSubject.asObservable(), + removeAll: jasmine.createSpy('removeAll'), + suspendToasties: jasmine.createSpy('suspendToasties') + } + } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NotificationHeaderComponent); + component = fixture.componentInstance; + notificationService = TestBed.inject(NotificationService); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize with default mute state', () => { + expect(component.isMuted).toBe(false); + }); + + it('should update mute state when subscription emits', () => { + muteStateSubject.next(true); + fixture.detectChanges(); + expect(component.isMuted).toBe(true); + }); + + it('should emit dismissAll event and call removeAll on dismiss', () => { + spyOn(component.dismissAll, 'emit'); + + component.onDismissAll(); + + expect(component.dismissAll.emit).toHaveBeenCalled(); + expect(notificationService.removeAll).toHaveBeenCalled(); + }); + + it('should toggle mute state', () => { + component.isMuted = false; + component.onToggleMute(); + expect(notificationService.suspendToasties).toHaveBeenCalledWith(true); + + component.isMuted = true; + component.onToggleMute(); + expect(notificationService.suspendToasties).toHaveBeenCalledWith(false); + }); + + it('should unsubscribe on destroy', () => { + spyOn(component['subs'], 'unsubscribe'); + component.ngOnDestroy(); + expect(component['subs'].unsubscribe).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.ts new file mode 100644 index 000000000000..06e0a4248c26 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-header/notification-header.component.ts @@ -0,0 +1,39 @@ +import { Component, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { NotificationService } from '~/app/shared/services/notification.service'; + +@Component({ + selector: 'cd-notification-header', + templateUrl: './notification-header.component.html', + styleUrls: ['./notification-header.component.scss'], + standalone: false +}) +export class NotificationHeaderComponent implements OnInit, OnDestroy { + @Output() dismissAll = new EventEmitter(); + + isMuted = false; + private subs = new Subscription(); + + constructor(private notificationService: NotificationService) {} + + ngOnInit(): void { + this.subs.add( + this.notificationService.muteState$.subscribe((isMuted) => { + this.isMuted = isMuted; + }) + ); + } + + ngOnDestroy(): void { + this.subs.unsubscribe(); + } + + onDismissAll(): void { + this.dismissAll.emit(); + this.notificationService.removeAll(); + } + + onToggleMute(): void { + this.notificationService.suspendToasties(!this.isMuted); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.html deleted file mode 100644 index 43e881d37bda..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.html +++ /dev/null @@ -1,4 +0,0 @@ -
- - -
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.scss deleted file mode 100644 index 8370b7c10e31..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.scss +++ /dev/null @@ -1,32 +0,0 @@ -@use '@carbon/styles/scss/theme'; -@use '@carbon/styles/scss/spacing'; -@use '@carbon/styles/scss/themes'; -@use '@carbon/styles/scss/theme' as *; - -.notification-panel { - @include theme.theme(themes.$g10); - - position: absolute; - top: spacing.$spacing-09; - right: 0; - width: 400px; - height: 700px; - background-color: $layer-01; - box-shadow: $shadow; - border: 1px solid $border-subtle-01; - z-index: 6000; - color: $text-primary; - display: flex; - flex-direction: column; - overflow: hidden; - - cd-notification-header { - flex: 0 0 auto; - } - - cd-notification-area { - flex: 1 1 auto; - overflow-y: auto; - min-height: 0; // Failing in firefox without this - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.spec.ts deleted file mode 100644 index e2df0faf7c40..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.spec.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { NotificationPanelComponent } from './notification-panel.component'; -import { NotificationService } from '../../../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') - } - } - ] - }).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.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.ts deleted file mode 100644 index 58066dbe645c..000000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel.component.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Component, ElementRef, HostListener } from '@angular/core'; -import { NotificationService } from '../../../shared/services/notification.service'; - -@Component({ - selector: 'cd-notification-panel', - templateUrl: './notification-panel.component.html', - styleUrls: ['./notification-panel.component.scss'], - standalone: false -}) -export class NotificationPanelComponent { - constructor(public notificationService: NotificationService, private elementRef: ElementRef) {} - - @HostListener('document:click', ['$event']) - handleClickOutside(event: Event) { - const clickedInside = this.elementRef.nativeElement.contains(event.target); - if (!clickedInside) { - this.notificationService.toggleSidebar(false, true); - } - } -} 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 new file mode 100644 index 000000000000..83b6e3332f03 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.html @@ -0,0 +1,5 @@ +
+ + + +
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 new file mode 100644 index 000000000000..862e2c3c92d9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.scss @@ -0,0 +1,32 @@ +@use '@carbon/styles/scss/theme'; +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/themes'; +@use '@carbon/styles/scss/theme' as *; + +.notification-panel { + @include theme.theme(themes.$g10); + + position: fixed; + top: spacing.$spacing-09; + right: 0; + width: 400px; + height: 700px; + background-color: $layer-01; + box-shadow: $shadow; + border: 1px solid $border-subtle-01; + z-index: 6000; + color: $text-primary; + display: flex; + flex-direction: column; + overflow-y: auto; + + cd-notification-header { + flex: 0 0 auto; + } + + cd-notification-area { + flex: 1 1 auto; + overflow-y: auto; + min-height: 0; // Failing in firefox without this + } +} 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 new file mode 100644 index 000000000000..e2df0faf7c40 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.spec.ts @@ -0,0 +1,60 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NotificationPanelComponent } from './notification-panel.component'; +import { NotificationService } from '../../../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') + } + } + ] + }).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 new file mode 100644 index 000000000000..1dcd166d9b6d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notification-panel/notification-panel.component.ts @@ -0,0 +1,20 @@ +import { Component, ElementRef, HostListener } from '@angular/core'; +import { NotificationService } from '~/app/shared/services/notification.service'; + +@Component({ + selector: 'cd-notification-panel', + templateUrl: './notification-panel.component.html', + styleUrls: ['./notification-panel.component.scss'], + standalone: false +}) +export class NotificationPanelComponent { + constructor(public notificationService: NotificationService, private elementRef: ElementRef) {} + + @HostListener('document:click', ['$event']) + handleClickOutside(event: Event) { + const clickedInside = this.elementRef.nativeElement.contains(event.target); + if (!clickedInside) { + this.notificationService.toggleSidebar(false, true); + } + } +} 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 new file mode 100644 index 000000000000..9074523c011d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.html @@ -0,0 +1,214 @@ +
+
Notifications
+
+ +
+ +
+ + +
+ + + + +
+
+ + {{ notification.prometheusAlert.alertName }} + + {{ notification.prometheusAlert.status }} + + + + {{ notification.title }} + +
+ + {{ notification.application }} + + • {{ notification.prometheusAlert.severity }} + + • {{ notification.prometheusAlert.instance }} + + + +
+
+ + {{ formatDate(notification.timestamp) }} + +
+
+ +
+ +

No notifications match your search

+

No notifications available

+
+
+ +
+
Notification Details
+
+
{{ selectedNotification.title }}
+ + + + Application: + {{ selectedNotification.application }} + + + Type: + + + {{ selectedNotification.type === 0 ? 'Error' : + selectedNotification.type === 1 ? 'Info' : + selectedNotification.type === 2 ? 'Success' : 'Warning' }} + + + + + Date: + {{ formatDate(selectedNotification.timestamp) }} + + + Time: + {{ formatTime(selectedNotification.timestamp) }} + + + + + Alert Name: + {{ selectedNotification.prometheusAlert.alertName }} + + + Status: + + + {{ selectedNotification.prometheusAlert.status }} + + + + + Severity: + + + {{ selectedNotification.prometheusAlert.severity }} + + + + + Instance: + {{ selectedNotification.prometheusAlert.instance }} + + + Job: + {{ selectedNotification.prometheusAlert.job }} + + + Description: + +

{{ selectedNotification.prometheusAlert.description }}

+
+
+ + Source: + + + + View in Prometheus + + + + + Fingerprint: + + {{ selectedNotification.prometheusAlert.fingerprint }} + + +
+ + + Message: + +

{{ selectedNotification.message }}

+
+
+
+
+
+ +

Select a notification to view details

+
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss new file mode 100644 index 000000000000..1f0439261887 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.scss @@ -0,0 +1,220 @@ +// Main container +.notifications-page-container { + padding: var(--cds-spacing-05); + background-color: var(--cds-layer-01); + min-height: 100vh; +} + +// Section headings +.notification-section-heading { + font-size: var(--cds-productive-heading-03-font-size); + line-height: var(--cds-productive-heading-03-line-height); + font-weight: var(--cds-productive-heading-03-font-weight); + color: var(--cds-text-primary); + margin-bottom: var(--cds-spacing-05); +} + +// Search container +.search-container { + cds-search { + width: 100%; + } +} + +// Notifications list +.notifications-list { + cds-list-row { + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--cds-layer-hover); + } + + &.active { + background-color: var(--cds-layer-selected); + + .notification-title { + color: var(--cds-text-primary); + font-weight: var(--cds-font-weight-semibold); + } + } + } +} + +// Notification item content +.notification-item-content { + .notification-title { + margin: 0 0 var(--cds-spacing-02) 0; + font-size: var(--cds-productive-heading-compact-01-font-size); + line-height: var(--cds-productive-heading-compact-01-line-height); + font-weight: var(--cds-productive-heading-compact-01-font-weight); + color: var(--cds-text-primary); + } + + .notification-meta { + color: var(--cds-text-secondary); + font-size: var(--cds-label-01-font-size); + line-height: var(--cds-label-01-line-height); + } +} + +.notification-date { + color: var(--cds-text-secondary); + font-size: var(--cds-label-01-font-size); + white-space: nowrap; +} + +// Empty state +.empty-state { + text-align: center; + color: var(--cds-text-secondary); + margin-top: var(--cds-spacing-05); + + p { + margin: var(--cds-spacing-03) 0 0 0; + font-size: var(--cds-body-01-font-size); + line-height: var(--cds-body-01-line-height); + } +} + +// No selection state +.no-selection-state { + text-align: center; + color: var(--cds-text-secondary); + margin-top: var(--cds-spacing-06); + + p { + margin: var(--cds-spacing-03) 0 0 0; + font-size: var(--cds-body-01-font-size); + line-height: var(--cds-body-01-line-height); + } +} + +// Notification details +.notification-details { + padding: var(--cds-spacing-05); + border: 1px solid var(--cds-border-subtle); + border-radius: var(--cds-border-radius); + background-color: var(--cds-layer-01); + + h4 { + margin: 0 0 var(--cds-spacing-05) 0; + font-size: var(--cds-productive-heading-03-font-size); + line-height: var(--cds-productive-heading-03-line-height); + font-weight: var(--cds-productive-heading-03-font-weight); + color: var(--cds-text-primary); + } +} + +// Details list +.details-list { + cds-list-row { + border-bottom: 1px solid var(--cds-border-subtle); + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: transparent; + } + } + + .detail-label { + color: var(--cds-text-secondary); + font-weight: var(--cds-font-weight-semibold); + font-size: var(--cds-body-compact-01-font-size); + min-width: 120px; + } + + cds-list-column:last-child { + color: var(--cds-text-primary); + font-size: var(--cds-body-compact-01-font-size); + line-height: var(--cds-body-compact-01-line-height); + } +} + +// Message content +.message-content { + margin: 0; + padding: var(--cds-spacing-04); + background-color: var(--cds-layer-02); + border-radius: var(--cds-border-radius); + border-left: 3px solid var(--cds-support-info); + font-size: var(--cds-body-compact-01-font-size); + line-height: var(--cds-body-compact-01-line-height); + color: var(--cds-text-primary); +} + +// Timestamp +.timestamp { + color: var(--cds-text-secondary); + font-family: var(--cds-font-mono); + font-size: var(--cds-code-01-font-size); +} + +// Icons +.empty-icon, +.no-selection-icon { + fill: var(--cds-icon-secondary); + margin-bottom: var(--cds-spacing-05); +} + +// Text utilities +.text-muted { + color: var(--cds-text-secondary); +} + +// Typography +h1 { + font-size: var(--cds-productive-heading-05-font-size); + line-height: var(--cds-productive-heading-05-line-height); + font-weight: var(--cds-productive-heading-05-font-weight); + color: var(--cds-text-primary); + margin-bottom: var(--cds-spacing-03); +} + +h3 { + font-size: var(--cds-productive-heading-03-font-size); + line-height: var(--cds-productive-heading-03-line-height); + font-weight: var(--cds-productive-heading-03-font-weight); + color: var(--cds-text-primary); + margin-bottom: var(--cds-spacing-05); +} + +p { + font-size: var(--cds-body-01-font-size); + line-height: var(--cds-body-01-line-height); + color: var(--cds-text-primary); +} + +// Spacing utilities +.mb-4 { + margin-bottom: var(--cds-spacing-05); +} + +.mt-4 { + margin-top: var(--cds-spacing-05); +} + +.mt-5 { + margin-top: var(--cds-spacing-06); +} + +// Responsive design +@media (width <= 768px) { + .notifications-page-container { + padding: var(--cds-spacing-04); + } + + .notification-details { + padding: var(--cds-spacing-04); + } + + .details-list { + .detail-label { + min-width: auto; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts new file mode 100644 index 000000000000..64e7f2728ff5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.spec.ts @@ -0,0 +1,239 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormsModule } from '@angular/forms'; +import { BehaviorSubject } from 'rxjs'; +import { + IconModule, + SearchModule, + StructuredListModule, + TagModule +} from 'carbon-components-angular'; + +import { NotificationsPageComponent } from './notifications-page.component'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { CdNotification } from '~/app/shared/models/cd-notification'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { SharedModule } from '~/app/shared/shared.module'; + +describe('NotificationsPageComponent', () => { + let component: NotificationsPageComponent; + let fixture: ComponentFixture; + let notificationService: NotificationService; + let mockNotifications: CdNotification[]; + let dataSourceSubject: BehaviorSubject; + + // Mock notification service + const createMockNotificationService = () => { + dataSourceSubject = new BehaviorSubject([]); + return { + data$: dataSourceSubject.asObservable(), + dataSource: dataSourceSubject, + remove: jasmine.createSpy('remove') + }; + }; + + beforeEach(async () => { + mockNotifications = [ + { + title: 'Success Notification', + message: 'Operation completed successfully', + timestamp: new Date().toISOString(), + type: NotificationType.success, + application: 'TestApp' + }, + { + title: 'Error Notification', + message: 'An error occurred', + timestamp: new Date(Date.now() - 86400000).toISOString(), // Yesterday + type: NotificationType.error, + application: 'TestApp' + }, + { + title: 'Info Notification', + message: 'System update available', + timestamp: new Date(Date.now() - 172800000).toISOString(), // 2 days ago + type: NotificationType.info, + application: 'Updates' + } + ]; + + await TestBed.configureTestingModule({ + imports: [ + FormsModule, + SharedModule, + IconModule, + SearchModule, + StructuredListModule, + TagModule + ], + declarations: [NotificationsPageComponent], + providers: [{ provide: NotificationService, useFactory: createMockNotificationService }] + }).compileComponents(); + + notificationService = TestBed.inject(NotificationService); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NotificationsPageComponent); + component = fixture.componentInstance; + dataSourceSubject.next(mockNotifications); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load notifications on init', () => { + expect(component.notifications).toEqual(mockNotifications); + expect(component.filteredNotifications).toEqual(mockNotifications); + }); + + it('should select notification when clicked', () => { + const notification = mockNotifications[0]; + component.onNotificationSelect(notification); + expect(component.selectedNotification).toBe(notification); + }); + + describe('search functionality', () => { + it('should filter notifications by title', () => { + component.onSearch('Success'); + expect(component.filteredNotifications.length).toBe(1); + expect(component.filteredNotifications[0].title).toBe('Success Notification'); + }); + + it('should filter notifications by message', () => { + component.onSearch('error'); + expect(component.filteredNotifications.length).toBe(1); + expect(component.filteredNotifications[0].title).toBe('Error Notification'); + }); + + it('should filter notifications by application', () => { + component.onSearch('Updates'); + expect(component.filteredNotifications.length).toBe(1); + expect(component.filteredNotifications[0].application).toBe('Updates'); + }); + + it('should show all notifications when search is cleared', () => { + component.onSearch(''); + expect(component.filteredNotifications).toEqual(mockNotifications); + }); + + it('should be case insensitive', () => { + component.onSearch('SUCCESS'); + expect(component.filteredNotifications.length).toBe(1); + expect(component.filteredNotifications[0].title).toBe('Success Notification'); + }); + }); + + describe('notification removal', () => { + it('should remove notification', () => { + const notification = mockNotifications[0]; + const mockEvent = { + stopPropagation: jasmine.createSpy('stopPropagation'), + preventDefault: jasmine.createSpy('preventDefault') + }; + + // Set up the dataSource with notifications + dataSourceSubject.next(mockNotifications); + fixture.detectChanges(); + + component.removeNotification(notification, mockEvent as any); + + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(notificationService.remove).toHaveBeenCalledWith(0); // Should be called with index 0 + }); + + it('should clear selection if removed notification was selected', () => { + const notification = mockNotifications[0]; + component.selectedNotification = notification; + const mockEvent = { + stopPropagation: jasmine.createSpy('stopPropagation'), + preventDefault: jasmine.createSpy('preventDefault') + }; + + // Set up the dataSource with notifications + dataSourceSubject.next(mockNotifications); + fixture.detectChanges(); + + component.removeNotification(notification, mockEvent as any); + + expect(component.selectedNotification).toBeNull(); + }); + }); + + describe('icon handling', () => { + it('should return correct Carbon icon for success', () => { + expect(component.getCarbonIcon(NotificationType.success)).toBe('checkmark--filled'); + }); + + it('should return correct Carbon icon for error', () => { + expect(component.getCarbonIcon(NotificationType.error)).toBe('error--filled'); + }); + + it('should return correct Carbon icon for info', () => { + expect(component.getCarbonIcon(NotificationType.info)).toBe('information--filled'); + }); + + it('should return correct Carbon icon for warning', () => { + expect(component.getCarbonIcon(NotificationType.warning)).toBe('warning--filled'); + }); + + it('should return default icon for unknown type', () => { + expect(component.getCarbonIcon(-1)).toBe('notification--filled'); + }); + }); + + describe('icon color classes', () => { + it('should return correct class for success', () => { + expect(component.getIconColorClass(NotificationType.success)).toBe('icon-success'); + }); + + it('should return correct class for error', () => { + expect(component.getIconColorClass(NotificationType.error)).toBe('icon-error'); + }); + + it('should return correct class for info', () => { + expect(component.getIconColorClass(NotificationType.info)).toBe('icon-info'); + }); + + it('should return correct class for warning', () => { + expect(component.getIconColorClass(NotificationType.warning)).toBe('icon-warning'); + }); + + it('should return empty string for unknown type', () => { + expect(component.getIconColorClass(-1)).toBe(''); + }); + }); + + describe('date formatting', () => { + it('should format today\'s date as "Today"', () => { + const today = new Date().toISOString(); + expect(component.formatDate(today)).toBe('Today'); + }); + + it('should format yesterday\'s date as "Yesterday"', () => { + const yesterday = new Date(Date.now() - 86400000).toISOString(); + expect(component.formatDate(yesterday)).toBe('Yesterday'); + }); + + it('should format older dates in short format', () => { + const oldDate = new Date('2023-01-15').toISOString(); + expect(component.formatDate(oldDate)).toMatch(/[A-Z][a-z]{2} \d{1,2}/); + }); + }); + + describe('time formatting', () => { + it('should format time in 12-hour format', () => { + const date = new Date('2023-01-15T15:30:00').toISOString(); + const formattedTime = component.formatTime(date); + expect(formattedTime).toMatch(/\d{1,2}:\d{2} [AP]M/); + }); + }); + + it('should unsubscribe on destroy', () => { + const unsubscribeSpy = spyOn(component['sub'], 'unsubscribe'); + component.ngOnDestroy(); + expect(unsubscribeSpy).toHaveBeenCalled(); + }); +}); 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 new file mode 100644 index 000000000000..dd5ef3597b81 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notification-panel/notifications-page/notifications-page.component.ts @@ -0,0 +1,163 @@ +import { Component, OnInit, OnDestroy, AfterViewInit } from '@angular/core'; +import { Subscription } from 'rxjs'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { CdNotification } from '~/app/shared/models/cd-notification'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service'; +import { PrometheusNotificationService } from '~/app/shared/services/prometheus-notification.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; + +@Component({ + selector: 'cd-notifications-page', + templateUrl: './notifications-page.component.html', + styleUrls: ['./notifications-page.component.scss'] +}) +export class NotificationsPageComponent implements OnInit, OnDestroy, AfterViewInit { + notifications: CdNotification[] = []; + selectedNotification: CdNotification | null = null; + searchText: string = ''; + filteredNotifications: CdNotification[] = []; + private sub: Subscription; + private interval: number; + + constructor( + private notificationService: NotificationService, + private prometheusAlertService: PrometheusAlertService, + private prometheusNotificationService: PrometheusNotificationService, + private authStorageService: AuthStorageService + ) {} + + ngOnInit(): void { + // Check permissions and trigger Prometheus alerts refresh + const permissions = this.authStorageService.getPermissions(); + if (permissions.prometheus.read && permissions.configOpt.read) { + this.triggerPrometheusAlerts(); + // Set up periodic refresh similar to sidebar component + this.interval = window.setInterval(() => { + this.triggerPrometheusAlerts(); + }, 5000); + } + + // Subscribe to notifications from the service + this.sub = this.notificationService.data$.subscribe((notifications) => { + this.notifications = notifications; + this.filteredNotifications = notifications; + }); + } + + ngOnDestroy(): void { + if (this.sub) { + this.sub.unsubscribe(); + } + if (this.interval) { + window.clearInterval(this.interval); + } + } + + onNotificationSelect(notification: CdNotification): void { + this.selectedNotification = notification; + } + + onSearch(value: string): void { + this.searchText = value; + if (!value || value.trim() === '') { + this.filteredNotifications = this.notifications; + } else { + const searchLower = value.toLowerCase(); + this.filteredNotifications = this.notifications.filter( + (notification) => + notification.title?.toLowerCase().includes(searchLower) || + notification.message?.toLowerCase().includes(searchLower) || + notification.application?.toLowerCase().includes(searchLower) + ); + } + } + + removeNotification(notification: CdNotification, event: MouseEvent): void { + // Stop event propagation to prevent panel closing + event.stopPropagation(); + 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 + ); + + 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; + } + } + } + + getCarbonIcon(type: NotificationType): string { + switch (type) { + case NotificationType.success: + return 'checkmark--filled'; + case NotificationType.error: + return 'error--filled'; + case NotificationType.info: + return 'information--filled'; + case NotificationType.warning: + return 'warning--filled'; + default: + return 'notification--filled'; + } + } + + getIconColorClass(type: NotificationType): string { + switch (type) { + case NotificationType.success: + return 'icon-success'; + case NotificationType.error: + return 'icon-error'; + case NotificationType.info: + return 'icon-info'; + case NotificationType.warning: + return 'icon-warning'; + default: + return ''; + } + } + + formatDate(timestamp: string): string { + const date = new Date(timestamp); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (date.toDateString() === today.toDateString()) { + return 'Today'; + } else if (date.toDateString() === yesterday.toDateString()) { + return 'Yesterday'; + } else { + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric' + }); + } + } + + formatTime(timestamp: string): string { + const date = new Date(timestamp); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + } + + private triggerPrometheusAlerts(): void { + this.prometheusAlertService.refresh(); + this.prometheusNotificationService.refresh(); + } + + ngAfterViewInit(): void { + this.sub.add(this.notificationService.data$.subscribe(() => {})); + } +} 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 8daf9176b309..77eaddcb6327 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 @@ -4,8 +4,13 @@ (click)="togglePanel($event)">
+ {{ notificationCount }} @@ -14,6 +19,6 @@ Tasks and Notifications - +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss index 999ea22523b2..2ef1e08d0324 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/notifications/notifications.component.scss @@ -40,8 +40,8 @@ a { .notification-count { position: absolute; - top: spacing.$spacing-01; - right: spacing.$spacing-01; + top: spacing.$spacing-02; + right: spacing.$spacing-02; min-width: spacing.$spacing-04; height: spacing.$spacing-04; padding: 0 spacing.$spacing-01; 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 174531dff23f..bbeff3ed7549 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 @@ -20,6 +20,7 @@ export class NotificationsComponent implements OnInit, OnDestroy { isPanelOpen = false; useNewPanel = true; notificationCount = 0; + isMuted = false; private subs = new Subscription(); constructor( @@ -47,6 +48,11 @@ export class NotificationsComponent implements OnInit, OnDestroy { this.useNewPanel = state.useNewPanel; }) ); + this.subs.add( + this.notificationService.muteState$.subscribe((isMuted) => { + this.isMuted = isMuted; + }) + ); } togglePanel(event: Event) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 947c80aece04..9148cb5d0c4b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -100,7 +100,11 @@ import { TearsheetComponent } from './tearsheet/tearsheet.component'; import InfoIcon from '@carbon/icons/es/information/16'; import CopyIcon from '@carbon/icons/es/copy/32'; import downloadIcon from '@carbon/icons/es/download/16'; -import IdeaIcon from '@carbon/icons/es/idea/20'; +import CheckmarkFilledIcon from '@carbon/icons/es/checkmark--filled/16'; +import ErrorFilledIcon from '@carbon/icons/es/error--filled/16'; +import InformationFilledIcon from '@carbon/icons/es/information--filled/16'; +import WarningFilledIcon from '@carbon/icons/es/warning--filled/16'; +import NotificationFilledIcon from '@carbon/icons/es/notification--filled/16'; import CloseIcon from '@carbon/icons/es/close/16'; import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component'; import { ProductiveCardComponent } from './productive-card/productive-card.component'; @@ -257,7 +261,11 @@ export class ComponentsModule { EditIcon, CodeIcon, downloadIcon, - IdeaIcon, + CheckmarkFilledIcon, + ErrorFilledIcon, + InformationFilledIcon, + WarningFilledIcon, + NotificationFilledIcon, CloseIcon ]); } 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 b555494b1926..a5f6ad363536 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 @@ -13,7 +13,19 @@ export class CdNotificationConfig { caption: '' }; - private classes = { + // Prometheus-specific metadata + prometheusAlert?: { + alertName: string; + status: string; + severity: string; + instance?: string; + job?: string; + description: string; + sourceUrl?: string; + fingerprint?: string; + }; + + private classes: { [key: string]: string } = { Ceph: 'ceph-icon', Prometheus: 'prometheus-icon' }; @@ -48,6 +60,12 @@ export class CdNotification extends CdNotificationConfig { constructor(private config: CdNotificationConfig = new CdNotificationConfig()) { super(config.type, config.title, config.message, config.options, config.application); + + // Copy Prometheus metadata if present + if (config.prometheusAlert) { + this.prometheusAlert = config.prometheusAlert; + } + delete this.config; /* string representation of the Date object so it can be directly compared with the timestamps parsed from localStorage */ 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 a27f4c741781..6f811f16361f 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 @@ -91,7 +91,8 @@ export class PrometheusCustomAlert { url: string; description: string; fingerprint?: string | boolean; - severity?: string; + labels?: PrometheusAlertLabels; + annotations?: Annotations; } export const AlertState = { 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 658127aa024c..972453624607 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,7 +35,9 @@ export class PrometheusAlertFormatter { url: alert.generatorURL, description: alert.annotations.description, fingerprint: _.isObject(alert.status) && (alert as AlertmanagerAlert).fingerprint, - severity: alert.labels.severity + // Store additional metadata for later use + labels: alert.labels, + annotations: alert.annotations }; }), _.isEqual @@ -51,29 +53,55 @@ export class PrometheusAlertFormatter { } convertAlertToNotification(alert: PrometheusCustomAlert): CdNotificationConfig { - return new CdNotificationConfig( - this.formatType(alert.status, alert.severity), + const config = new CdNotificationConfig( + this.formatType(alert.status), `${alert.name} (${alert.status})`, this.appendSourceLink(alert, alert.description), undefined, 'Prometheus' ); - } - private formatType(status: string, severity?: string): NotificationType { - if (status === 'active' && severity === 'warning') { - return NotificationType.warning; - } + // Add Prometheus-specific metadata + config['prometheusAlert'] = { + alertName: alert.name, + status: alert.status, + severity: alert.labels?.severity || this.mapStatusToSeverity(alert.status), + instance: alert.labels?.instance, + job: alert.labels?.job, + description: alert.description, + sourceUrl: alert.url, + fingerprint: alert.fingerprint ? String(alert.fingerprint) : undefined + }; + + return config; + } + private formatType(status: string): any { const types = { error: ['firing', 'active'], info: ['suppressed', 'unprocessed'], success: ['resolved'] }; - return NotificationType[_.findKey(types, (type) => type.includes(status))]; + return NotificationType[_.findKey(types, (type: any) => type.includes(status))]; } private appendSourceLink(alert: PrometheusCustomAlert, message: string): string { return `${message} `; } + + private mapStatusToSeverity(status: string): string { + switch (status) { + case 'active': + case 'firing': + return 'critical'; + case 'resolved': + return 'resolved'; + case 'suppressed': + return 'suppressed'; + case 'unprocessed': + return 'warning'; + default: + return 'unknown'; + } + } }