]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Add alerts card
authorAfreen Misbah <afreen@ibm.com>
Sun, 22 Feb 2026 10:24:41 +0000 (15:54 +0530)
committerAfreen Misbah <afreen@ibm.com>
Mon, 23 Feb 2026 08:35:11 +0000 (14:05 +0530)
Fixes https://tracker.ceph.com/issues/75066

Signed-off-by: Afreen Misbah <afreen@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts

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 (file)
index 0000000..58d0451
--- /dev/null
@@ -0,0 +1,50 @@
+<cd-productive-card>
+@if (vm$ | async; as vm) {
+  <ng-template #header>
+    <h2 class="cds--type-heading-compact-02"
+        i18n>
+      System alerts
+    </h2>
+    <button
+      cdsButton="ghost"
+      size="sm"
+      [routerLink]="['/monitoring/active-alerts']"
+      i18n
+    >
+      View all
+    </button>
+  </ng-template>
+
+  <div>
+    <span class="cds--type-heading-07">{{ vm.total }}</span>
+    <cd-icon [type]="vm.icon"></cd-icon>
+  </div>
+
+  <small
+    class="cds--type-label-01 overview-alerts-card-need-attention"
+    i18n>
+    {{ vm.statusText }}
+  </small>
+
+  <div class="cds-mt-6">
+  @if (vm.badges.length) {
+    <div class="overview-alerts-card-badges">
+      @for (b of vm.badges; track b.key; let first = $first) {
+      <span
+        class="overview-alerts-card-badge"
+        [class.overview-alerts-card-badge-with-border]="!first">
+        <cd-icon [type]="b.icon"></cd-icon>
+        <a
+          cdsLink
+          class="cds-ml-3"
+          [routerLink]="['/monitoring/active-alerts']"
+          [queryParams]="{ severity: b.key }">
+          {{ b.count }}
+        </a>
+      </span>
+      }
+    </div>
+  }
+  </div>
+}
+</cd-productive-card>
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 (file)
index 0000000..2eee4ca
--- /dev/null
@@ -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 (file)
index 0000000..5606ffc
--- /dev/null
@@ -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<number>(0);
+  private criticalSub = new BehaviorSubject<number>(0);
+  private warningSub = new BehaviorSubject<number>(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<OverviewAlertsCardComponent>;
+  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 (file)
index 0000000..07cf461
--- /dev/null
@@ -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 })
+  );
+}
index b23a6c4a11ac4d64b0e4c7dab4af315630aa31b8..007b4f5a66c0e6ab8f8039b1ce76dba5aa546ea4 100644 (file)
@@ -16,7 +16,7 @@
     <div cdsCol
          class="cds-mb-5"
          [columnNumbers]="{lg: 5}">
-      <cds-tile>Alerts card</cds-tile>
+      <cd-overview-alerts-card></cd-overview-alerts-card>
     </div>
   </div>
   <div cdsRow>
index f3512c99e150be10cf0b50b6d5a6f21158ba82c5..abcf3cdd7233f6917f88723e7e2f8eccc20ed016 100644 (file)
@@ -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([])
index 6825d45d8170e915e6d02b33ae916fb37531312f..aaa6db1a741d56479fd3e28c4bed9db40b8bbf42 100644 (file)
@@ -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',
index 691462af09c4de803b04e8157e69ddcce2446ac7..ec05c116c13929037a3f469f3fd2fa8392917e70 100644 (file)
@@ -1,7 +1,8 @@
 <cd-productive-card>
   <!-- STORAGE CARD HEADER -->
   <ng-template #header>
-    <h2 class="cds--type-heading-compact-02">Storage Overview</h2>
+    <h2 class="cds--type-heading-compact-02"
+        i18n>Storage Overview</h2>
     <cds-dropdown
       label="Storage type"
       class="overview-storage-card-dropdown"
index e7be726237fc411e666cfe13d96776e81cf62f7d..24509b030c9396160d6d530a3005749ef0b20bae 100644 (file)
@@ -8,7 +8,7 @@ import { GridModule, LayerModule, TilesModule } from 'carbon-components-angular'
  * @example
  * <cd-productive-card title="Card Title"
  *                     [applyShadow]="true">
- *   <ng-template #headerAction>...</ng-template>
+ *   <ng-template #header>...</ng-template>
  *   <ng-template #footer>...</ng-template>
  *   <p>My card body content</p>
  * </cd-productive-card>
index 048ecc572e91d1714a2967e2109071c4cbca1411..31251f8a877f5ba2098c1e8ba4053a61e3644206 100644 (file)
@@ -25,6 +25,15 @@ export class PrometheusAlertService {
   activeCriticalAlerts: number;
   activeWarningAlerts: number;
 
+  private totalSubject = new BehaviorSubject<number>(0);
+  readonly totalAlerts$ = this.totalSubject.asObservable();
+
+  private criticalSubject = new BehaviorSubject<number>(0);
+  readonly criticalAlerts$ = this.criticalSubject.asObservable();
+
+  private warningSubject = new BehaviorSubject<number>(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;
   }