From: Afreen Misbah Date: Sun, 22 Feb 2026 10:24:41 +0000 (+0530) Subject: mgr/dashboard: Add alerts card X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F67460%2Fhead;p=ceph.git mgr/dashboard: Add alerts card Fixes https://tracker.ceph.com/issues/75066 Signed-off-by: Afreen Misbah --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.html new file mode 100644 index 00000000000..58d0451d12b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.html @@ -0,0 +1,50 @@ + +@if (vm$ | async; as vm) { + +

+ System alerts +

+ +
+ +
+ {{ vm.total }} + +
+ + + {{ vm.statusText }} + + +
+ @if (vm.badges.length) { +
+ @for (b of vm.badges; track b.key; let first = $first) { + + + + {{ b.count }} + + + } +
+ } +
+} +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.scss new file mode 100644 index 00000000000..2eee4ca88cd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.scss @@ -0,0 +1,22 @@ +.overview-alerts-card { + &-badges { + display: flex; + align-items: center; + } + + &-badge { + display: inline-flex; + align-items: center; + padding: 0 var(--cds-spacing-04); + } + + &-badge-with-border { + border-left: 1px solid var(--cds-border-subtle); + } + + &-need-attention { + display: block; + margin-top: var(--cds-spacing-02); + color: var(--cds-text-secondary); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.spec.ts new file mode 100644 index 00000000000..5606ffc1388 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.spec.ts @@ -0,0 +1,121 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BehaviorSubject } from 'rxjs'; + +import { OverviewAlertsCardComponent } from './overview-alerts-card.component'; +import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service'; +import { provideHttpClient } from '@angular/common/http'; +import { provideRouter, RouterModule } from '@angular/router'; +import { take } from 'rxjs/operators'; + +class MockPrometheusAlertService { + private totalSub = new BehaviorSubject(0); + private criticalSub = new BehaviorSubject(0); + private warningSub = new BehaviorSubject(0); + + totalAlerts$ = this.totalSub.asObservable(); + criticalAlerts$ = this.criticalSub.asObservable(); + warningAlerts$ = this.warningSub.asObservable(); + + getGroupedAlerts = jest.fn(); + + emitCounts(total: number, critical: number, warning: number) { + this.totalSub.next(total); + this.criticalSub.next(critical); + this.warningSub.next(warning); + } +} + +describe('OverviewAlertsCardComponent', () => { + let component: OverviewAlertsCardComponent; + let fixture: ComponentFixture; + let mockSvc: MockPrometheusAlertService; + + beforeEach(async () => { + mockSvc = new MockPrometheusAlertService(); + + await TestBed.configureTestingModule({ + imports: [OverviewAlertsCardComponent, RouterModule], + providers: [ + provideRouter([]), + provideHttpClient(), + { provide: PrometheusAlertService, useValue: mockSvc } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(OverviewAlertsCardComponent); + component = fixture.componentInstance; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('ngOnInit should call getGroupedAlerts(true)', () => { + fixture.detectChanges(); + expect(mockSvc.getGroupedAlerts).toHaveBeenCalledWith(true); + }); + + it('vm$ should map no alerts -> success icon, "No active alerts", no badges', async () => { + mockSvc.emitCounts(0, 0, 0); + fixture.detectChanges(); + + const vm = await component.vm$.pipe(take(1)).toPromise(); + + expect(vm.total).toBe(0); + expect(vm.icon).toBe('success'); + expect(vm.statusText).toBe('No active alerts'); + expect(vm.badges).toEqual([]); + }); + + it('vm$ should map critical alerts -> error icon and critical badge', async () => { + mockSvc.emitCounts(5, 2, 3); + fixture.detectChanges(); + + const vm = await component.vm$.pipe(take(1)).toPromise(); + + expect(vm.total).toBe(5); + expect(vm.icon).toBe('error'); + expect(vm.statusText).toBe('Need attention'); + + expect(vm.badges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'critical', icon: 'error', count: 2 }), + expect.objectContaining({ key: 'warning', icon: 'warning', count: 3 }) + ]) + ); + }); + + it('vm$ should map warning-only -> warning icon and warning badge only', async () => { + mockSvc.emitCounts(3, 0, 3); + fixture.detectChanges(); + + const vm = await component.vm$.pipe(take(1)).toPromise(); + + expect(vm.total).toBe(3); + expect(vm.icon).toBe('warning'); + expect(vm.statusText).toBe('Need attention'); + + expect(vm.badges).toEqual([{ key: 'warning', icon: 'warning', count: 3 }]); + }); + + it('template should render border class only on 2nd badge (when both exist)', async () => { + mockSvc.emitCounts(10, 1, 2); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const badgeEls = Array.from( + fixture.nativeElement.querySelectorAll( + '.overview-alerts-card-badges .overview-alerts-card-badge' + ) + ) as HTMLElement[]; + + expect(badgeEls.length).toBe(2); + expect(badgeEls[0].classList.contains('overview-alerts-card-badge-with-border')).toBe(false); + expect(badgeEls[1].classList.contains('overview-alerts-card-badge-with-border')).toBe(true); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.ts new file mode 100644 index 00000000000..07cf4614e57 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.ts @@ -0,0 +1,69 @@ +import { ChangeDetectionStrategy, Component, OnInit, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { combineLatest } from 'rxjs'; + +import { PrometheusAlertService } from '~/app/shared/services/prometheus-alert.service'; +import { ButtonModule, GridModule, LinkModule, TilesModule } from 'carbon-components-angular'; +import { RouterModule } from '@angular/router'; +import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component'; +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { map, shareReplay, startWith } from 'rxjs/operators'; + +const AlertIcon = { + error: 'error', + warning: 'warning', + success: 'success' +}; + +@Component({ + selector: 'cd-overview-alerts-card', + standalone: true, + imports: [ + CommonModule, + GridModule, + TilesModule, + ComponentsModule, + RouterModule, + ProductiveCardComponent, + ButtonModule, + LinkModule + ], + templateUrl: './overview-alerts-card.component.html', + styleUrl: './overview-alerts-card.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class OverviewAlertsCardComponent implements OnInit { + private readonly prometheusAlertService = inject(PrometheusAlertService); + + ngOnInit(): void { + this.prometheusAlertService.getGroupedAlerts(true); + } + + readonly vm$ = combineLatest([ + this.prometheusAlertService.totalAlerts$.pipe(startWith(0)), + this.prometheusAlertService.criticalAlerts$.pipe(startWith(0)), + this.prometheusAlertService.warningAlerts$.pipe(startWith(0)) + ]).pipe( + map(([total, critical, warning]) => { + const hasAlerts = total > 0; + const hasCritical = critical > 0; + const hasWarning = warning > 0; + + const icon = !hasAlerts + ? AlertIcon.success + : hasCritical + ? AlertIcon.error + : AlertIcon.warning; + + const statusText = hasAlerts ? $localize`Need attention` : $localize`No active alerts`; + + const badges = [ + hasCritical && { key: 'critical', icon: AlertIcon.error, count: critical }, + hasWarning && { key: 'warning', icon: AlertIcon.warning, count: warning } + ].filter(Boolean); + + return { total, icon, statusText, badges }; + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html index b23a6c4a11a..007b4f5a66c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html @@ -16,7 +16,7 @@
- Alerts card +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts index f3512c99e15..abcf3cdd723 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts @@ -10,8 +10,8 @@ import { CommonModule } from '@angular/common'; import { GridModule, TilesModule } from 'carbon-components-angular'; import { OverviewHealthCardComponent } from './health-card/overview-health-card.component'; import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { provideRouter } from '@angular/router'; +import { provideRouter, RouterModule } from '@angular/router'; +import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card.component'; describe('OverviewComponent', () => { let component: OverviewComponent; @@ -31,11 +31,13 @@ describe('OverviewComponent', () => { GridModule, TilesModule, OverviewStorageCardComponent, - OverviewHealthCardComponent + OverviewHealthCardComponent, + OverviewAlertsCardComponent, + RouterModule ], providers: [ provideHttpClient(), - provideHttpClientTesting(), + provideRouter([]), { provide: HealthService, useValue: mockHealthService }, { provide: RefreshIntervalService, useValue: mockRefreshIntervalService }, provideRouter([]) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts index 6825d45d817..aaa6db1a741 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts @@ -1,13 +1,15 @@ import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core'; import { GridModule, TilesModule } from 'carbon-components-angular'; import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component'; +import { OverviewHealthCardComponent } from './health-card/overview-health-card.component'; +import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card.component'; import { HealthService } from '~/app/shared/api/health.service'; import { HealthCheck, HealthSnapshotMap } from '~/app/shared/models/health.interface'; import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service'; import { catchError, exhaustMap, map, takeUntil } from 'rxjs/operators'; import { EMPTY, Observable, Subject } from 'rxjs'; import { CommonModule } from '@angular/common'; -import { OverviewHealthCardComponent } from './health-card/overview-health-card.component'; + import { ComponentsModule } from '~/app/shared/components/components.module'; import { HealthIconMap } from '~/app/shared/models/overview'; @@ -25,7 +27,8 @@ interface OverviewVM { TilesModule, OverviewStorageCardComponent, OverviewHealthCardComponent, - ComponentsModule + ComponentsModule, + OverviewAlertsCardComponent ], standalone: true, templateUrl: './overview.component.html', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html index 691462af09c..ec05c116c13 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html @@ -1,7 +1,8 @@ -

Storage Overview

+

Storage Overview

- * ... + * ... * ... *

My card body content

*
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts index 048ecc572e9..31251f8a877 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts @@ -25,6 +25,15 @@ export class PrometheusAlertService { activeCriticalAlerts: number; activeWarningAlerts: number; + private totalSubject = new BehaviorSubject(0); + readonly totalAlerts$ = this.totalSubject.asObservable(); + + private criticalSubject = new BehaviorSubject(0); + readonly criticalAlerts$ = this.criticalSubject.asObservable(); + + private warningSubject = new BehaviorSubject(0); + readonly warningAlerts$ = this.warningSubject.asObservable(); + constructor( private alertFormatter: PrometheusAlertFormatter, private prometheusService: PrometheusService @@ -103,9 +112,15 @@ export class PrometheusAlertService { : result, 0 ); + + this.totalSubject.next(this.activeAlerts); + this.criticalSubject.next(this.activeCriticalAlerts); + this.warningSubject.next(this.activeWarningAlerts); + this.alerts = alerts .reverse() .sort((a, b) => a.labels.severity.localeCompare(b.labels.severity)); + this.canAlertsBeNotified = true; }