--- /dev/null
+<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>
--- /dev/null
+.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);
+ }
+}
--- /dev/null
+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);
+ });
+});
--- /dev/null
+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 })
+ );
+}
<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>
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;
GridModule,
TilesModule,
OverviewStorageCardComponent,
- OverviewHealthCardComponent
+ OverviewHealthCardComponent,
+ OverviewAlertsCardComponent,
+ RouterModule
],
providers: [
provideHttpClient(),
- provideHttpClientTesting(),
+ provideRouter([]),
{ provide: HealthService, useValue: mockHealthService },
{ provide: RefreshIntervalService, useValue: mockRefreshIntervalService },
provideRouter([])
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';
TilesModule,
OverviewStorageCardComponent,
OverviewHealthCardComponent,
- ComponentsModule
+ ComponentsModule,
+ OverviewAlertsCardComponent
],
standalone: true,
templateUrl: './overview.component.html',
<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"
* @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>
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
: 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;
}