From 4628a9e9e2379ca0a56689dcc7b15502c01a7e44 Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Mon, 16 Feb 2026 19:27:24 +0530 Subject: [PATCH] mgr/dashboard: Add health check panel Fixes https://tracker.ceph.com/issues/74958 - adds helath check panel in overview dashboard - updates tests - refactors component as per modern Angular convention - using onPush CDS in Overview component - using view model pattern to aggregate data for rendering Signed-off-by: Afreen Misbah --- .../overview-health-card.component.html | 36 ++++- .../overview-health-card.component.scss | 28 ++-- .../overview-health-card.component.ts | 31 ++-- .../app/ceph/overview/overview.component.html | 45 ++++-- .../app/ceph/overview/overview.component.scss | 18 +++ .../ceph/overview/overview.component.spec.ts | 145 ++++++------------ .../app/ceph/overview/overview.component.ts | 60 ++++++-- .../shared/components/components.module.ts | 6 +- .../copy2clipboard-button.component.html | 2 +- .../components/icon/icon.component.scss | 6 +- .../side-panel/side-panel.component.html | 6 +- .../src/app/shared/enum/icons.enum.ts | 8 +- .../src/app/shared/models/overview.ts | 6 + 13 files changed, 232 insertions(+), 165 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.html index 1371bfae110..ac27f336dda 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.html @@ -1,4 +1,5 @@ -@let data = (data$ | async); +@let data=(data$ | async); +@let colorClass="overview-health-card-status--" + data?.currentHealth?.icon; @if(fsid) { @@ -33,7 +34,7 @@ @if(data?.currentHealth){

+ [ngClass]="colorClass"> {{data?.currentHealth?.title}}

@@ -47,7 +48,6 @@

Ceph version:  {{ data?.summary?.version | cephVersion }}  - @if (data?.upgrade?.versions?.length) { } + + + @if(incidents > 0) { + + + + {{incidents}} Health incidents + + + + + +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.scss index 33a495f3ed0..7b99a71631c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.scss @@ -3,7 +3,6 @@ display: flex; align-items: end; } - // CSS for status text, modifier names match icons name &-status--success { color: var(--cds-support-success); @@ -16,21 +15,20 @@ &-status--error { color: var(--cds-text-error); } -} - -// Overrides -.clipboard-btn { - padding: var(--cds-spacing-02); -} + // Overrides + .clipboard-btn { + padding: var(--cds-spacing-02); + } -.cds--btn--icon-only { - padding: var(--cds-spacing-01); -} + .cds--btn--icon-only { + padding: var(--cds-spacing-01); + } -.cds--link.cds--link--inline { - text-decoration: none; -} + .cds--link.cds--link--inline { + text-decoration: none; + } -.cds--skeleton__placeholder { - margin-bottom: var(--cds-spacing-03); + .cds--skeleton__placeholder { + margin-bottom: var(--cds-spacing-03); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts index a644300d690..412d750b7ae 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts @@ -1,11 +1,13 @@ import { ChangeDetectionStrategy, Component, + EventEmitter, inject, Input, + Output, ViewEncapsulation } from '@angular/core'; -import { SkeletonModule, ButtonModule, LinkModule } from 'carbon-components-angular'; +import { SkeletonModule, ButtonModule, LinkModule, TooltipModule } from 'carbon-components-angular'; import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component'; import { RouterModule } from '@angular/router'; import { ComponentsModule } from '~/app/shared/components/components.module'; @@ -17,6 +19,7 @@ import { PipesModule } from '~/app/shared/pipes/pipes.module'; import { UpgradeInfoInterface } from '~/app/shared/models/upgrade.interface'; import { UpgradeService } from '~/app/shared/api/upgrade.service'; import { catchError, filter, map, startWith } from 'rxjs/operators'; +import { HealthIconMap, HealthStatus } from '~/app/shared/models/overview'; type OverviewHealthData = { summary: Summary; @@ -30,23 +33,22 @@ type Health = { icon: string; }; -type HealthStatus = 'HEALTH_OK' | 'HEALTH_WARN' | 'HEALTH_ERR'; const WarnAndErrMessage = $localize`There are active alerts and unresolved health warnings.`; const HealthMap: Record = { HEALTH_OK: { message: $localize`All core services are running normally`, - icon: 'success', + icon: HealthIconMap['HEALTH_OK'], title: $localize`Healthy` }, HEALTH_WARN: { message: WarnAndErrMessage, - icon: 'warningAltFilled', + icon: HealthIconMap['HEALTH_WARN'], title: $localize`Warning` }, HEALTH_ERR: { message: WarnAndErrMessage, - icon: 'error', + icon: HealthIconMap['HEALTH_ERR'], title: $localize`Critical` } }; @@ -61,7 +63,8 @@ const HealthMap: Record = { RouterModule, ComponentsModule, LinkModule, - PipesModule + PipesModule, + TooltipModule ], standalone: true, templateUrl: './overview-health-card.component.html', @@ -70,19 +73,21 @@ const HealthMap: Record = { changeDetection: ChangeDetectionStrategy.OnPush }) export class OverviewHealthCardComponent { + private readonly summaryService = inject(SummaryService); + private readonly upgradeService = inject(UpgradeService); + @Input() fsid!: string; @Input() - set health(value: HealthStatus) { + set status(value: HealthStatus) { this.health$.next(value); } - private health$ = new ReplaySubject(1); + @Input() incidents!: number; + @Output() viewIncidents = new EventEmitter(); - private readonly summaryService = inject(SummaryService); - private readonly upgradeService = inject(UpgradeService); + private health$ = new ReplaySubject(1); readonly data$: Observable = combineLatest([ this.summaryService.summaryData$.pipe(filter((summary): summary is Summary => !!summary)), - this.upgradeService.listCached().pipe( startWith(null as UpgradeInfoInterface), catchError(() => of(null)) @@ -91,4 +96,8 @@ export class OverviewHealthCardComponent { ]).pipe( map(([summary, upgrade, health]) => ({ summary, upgrade, currentHealth: HealthMap?.[health] })) ); + + onViewIncidentsClick() { + this.viewIncidents.emit(); + } } 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 b713bd33ab9..b23a6c4a11a 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 @@ -1,17 +1,16 @@ -@let healthData = healthData$ | async; +@let vm = vm$ | async;

+@if (isHealthPanelOpen && vm?.incidentCount > 0) { + +
+ Health incidents are Ceph health checks warnings indicating conditions that require attention and remain until resolved. +
+
+ @for (check of vm?.checks; track key) { +
+
+ + + {{ check?.name }} + +
+

{{ check?.description }}

+
+ } +
+
+} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.scss index e69de29bb2d..094b0957927 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.scss @@ -0,0 +1,18 @@ +.overview { + &-check-header { + display: flex; + align-items: center; + gap: var(--cds-spacing-02); + margin-bottom: var(--cds-spacing-02); + } + + &-check-name { + color: var(--cds-text-primary); + margin-bottom: var(--cds-spacing-01); + margin-top: var(--cds-spacing-02); + } + + &-check-description { + color: var(--cds-text-secondary); + } +} 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 3f893f5254e..f3512c99e15 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,27 +10,19 @@ 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'; describe('OverviewComponent', () => { let component: OverviewComponent; let fixture: ComponentFixture; - let mockHealthService: { - getHealthSnapshot: jest.Mock; - }; - - let mockRefreshIntervalService: { - intervalData$: Subject; - }; + let mockHealthService: { getHealthSnapshot: jest.Mock }; + let mockRefreshIntervalService: { intervalData$: Subject }; beforeEach(async () => { - mockHealthService = { - getHealthSnapshot: jest.fn() - }; - - mockRefreshIntervalService = { - intervalData$: new Subject() - }; + mockHealthService = { getHealthSnapshot: jest.fn() }; + mockRefreshIntervalService = { intervalData$: new Subject() }; await TestBed.configureTestingModule({ imports: [ @@ -43,8 +35,10 @@ describe('OverviewComponent', () => { ], providers: [ provideHttpClient(), + provideHttpClientTesting(), { provide: HealthService, useValue: mockHealthService }, - { provide: RefreshIntervalService, useValue: mockRefreshIntervalService } + { provide: RefreshIntervalService, useValue: mockRefreshIntervalService }, + provideRouter([]) ] }).compileComponents(); @@ -53,49 +47,41 @@ describe('OverviewComponent', () => { fixture.detectChanges(); }); - afterEach(() => { - jest.clearAllMocks(); - }); - - // -------------------------------------------------- - // CREATION - // -------------------------------------------------- + afterEach(() => jest.clearAllMocks()); + // ----------------------------- + // Component creation + // ----------------------------- it('should create', () => { expect(component).toBeTruthy(); }); - // -------------------------------------------------- - // refreshIntervalObs - success case - // -------------------------------------------------- + // ----------------------------- + // Vie model stream success + // ----------------------------- + it('vm$ should emit transformed HealthSnapshotMap', (done) => { + const mockData: HealthSnapshotMap = { health: { checks: { a: {} } } } as any; + mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData)); - it('should call healthService when interval emits', (done) => { - const mockResponse: HealthSnapshotMap = { status: 'OK' } as any; - - mockHealthService.getHealthSnapshot.mockReturnValue(of(mockResponse)); - - component.healthData$.subscribe((data) => { - expect(data).toEqual(mockResponse); - expect(mockHealthService.getHealthSnapshot).toHaveBeenCalled(); + component.vm$.subscribe((vm) => { + expect(vm.healthData).toEqual(mockData); + expect(vm.incidentCount).toBe(1); done(); }); mockRefreshIntervalService.intervalData$.next(); }); - // -------------------------------------------------- - // refreshIntervalObs - error case (catchError → EMPTY) - // -------------------------------------------------- - - it('should return EMPTY when healthService throws error', (done) => { + // ----------------------------- + // View model stream error → EMPTY + // ----------------------------- + it('vm$ should not emit if healthService throws', (done) => { mockHealthService.getHealthSnapshot.mockReturnValue(throwError(() => new Error('API Error'))); let emitted = false; - component.healthData$.subscribe({ - next: () => { - emitted = true; - }, + component.vm$.subscribe({ + next: () => (emitted = true), complete: () => { expect(emitted).toBe(false); done(); @@ -106,73 +92,28 @@ describe('OverviewComponent', () => { mockRefreshIntervalService.intervalData$.complete(); }); - // -------------------------------------------------- - // refreshIntervalObs - exhaustMap behavior - // -------------------------------------------------- - - it('should ignore new interval emissions until previous completes', () => { - const interval$ = new Subject(); - const inner$ = new Subject(); - - const mockRefreshService = { - intervalData$: interval$ - }; - - const testComponent = new OverviewComponent( - mockHealthService as any, - mockRefreshService as any - ); - - mockHealthService.getHealthSnapshot.mockReturnValue(inner$); - - testComponent.healthData$.subscribe(); - - // First emission - interval$.next(); - - // Second emission (should be ignored) - interval$.next(); - - expect(mockHealthService.getHealthSnapshot).toHaveBeenCalledTimes(1); - - // Complete first inner observable - inner$.complete(); - - // Now it should allow another call - interval$.next(); - - expect(mockHealthService.getHealthSnapshot).toHaveBeenCalledTimes(2); + // ----------------------------- + // toggle health panel + // ----------------------------- + it('should toggle panel open/close', () => { + expect(component.isHealthPanelOpen).toBe(false); + component.togglePanel(); + expect(component.isHealthPanelOpen).toBe(true); + component.togglePanel(); + expect(component.isHealthPanelOpen).toBe(false); }); - // -------------------------------------------------- + // ----------------------------- // ngOnDestroy - // -------------------------------------------------- - - it('should complete destroy$ on destroy', () => { - const nextSpy = jest.spyOn((component as any).destroy$, 'next'); - const completeSpy = jest.spyOn((component as any).destroy$, 'complete'); + // ----------------------------- + it('should complete destroy$', () => { + const destroy$ = (component as any).destroy$; + const nextSpy = jest.spyOn(destroy$, 'next'); + const completeSpy = jest.spyOn(destroy$, 'complete'); component.ngOnDestroy(); expect(nextSpy).toHaveBeenCalled(); expect(completeSpy).toHaveBeenCalled(); }); - - // -------------------------------------------------- - // refreshIntervalObs manual test - // -------------------------------------------------- - - it('refreshIntervalObs should pipe intervalData$', (done) => { - const testFn = jest.fn().mockReturnValue(of('TEST')); - - const obs$ = component.refreshIntervalObs(testFn); - - obs$.subscribe((value) => { - expect(value).toBe('TEST'); - expect(testFn).toHaveBeenCalled(); - done(); - }); - - mockRefreshIntervalService.intervalData$.next(); - }); }); 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 d89b68fec5d..6825d45d817 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,21 @@ -import { Component, OnDestroy } from '@angular/core'; +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 { HealthService } from '~/app/shared/api/health.service'; -import { HealthSnapshotMap } from '~/app/shared/models/health.interface'; +import { HealthCheck, HealthSnapshotMap } from '~/app/shared/models/health.interface'; import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service'; -import { catchError, exhaustMap, takeUntil } from 'rxjs/operators'; +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'; + +interface OverviewVM { + healthData: HealthSnapshotMap | null; + incidentCount: number; + checks: { name: string; description: string; icon: string }[]; +} @Component({ selector: 'cd-overview', @@ -16,32 +24,52 @@ import { OverviewHealthCardComponent } from './health-card/overview-health-card. GridModule, TilesModule, OverviewStorageCardComponent, - OverviewHealthCardComponent + OverviewHealthCardComponent, + ComponentsModule ], standalone: true, templateUrl: './overview.component.html', - styleUrl: './overview.component.scss' + styleUrl: './overview.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush }) export class OverviewComponent implements OnDestroy { + isHealthPanelOpen: boolean = false; + + private readonly healthService = inject(HealthService); + private readonly refreshIntervalService = inject(RefreshIntervalService); + private destroy$ = new Subject(); - public healthData$: Observable; - - constructor( - private healthService: HealthService, - private refreshIntervalService: RefreshIntervalService - ) { - this.healthData$ = this.refreshIntervalObs(() => - this.healthService.getHealthSnapshot() - ); - } - refreshIntervalObs(fn: () => Observable): Observable { + private healthData$: Observable = this.refreshIntervalObs(() => + this.healthService.getHealthSnapshot() + ); + + public vm$: Observable = this.healthData$.pipe( + map((data: HealthSnapshotMap) => { + const checks = data?.health?.checks ?? {}; + return { + healthData: data, + incidentCount: Object.keys(checks)?.length, + checks: Object.entries(checks)?.map((check: [string, HealthCheck]) => ({ + name: check?.[0], + description: check?.[1]?.summary?.message, + icon: HealthIconMap[check?.[1]?.severity] + })) + }; + }) + ); + + private refreshIntervalObs(fn: () => Observable): Observable { return this.refreshIntervalService.intervalData$.pipe( exhaustMap(() => fn().pipe(catchError(() => EMPTY))), takeUntil(this.destroy$) ); } + togglePanel() { + this.isHealthPanelOpen = !this.isHealthPanelOpen; + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); 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 bbded48855b..27d7ce1fc2f 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 @@ -111,6 +111,8 @@ import DataCenter16 from '@carbon/icons/es/data--center/16'; import Upgrade16 from '@carbon/icons/es/upgrade/16'; import Close16 from '@carbon/icons/es/close/16'; import WarningAltFilled16 from '@carbon/icons/es/warning--alt--filled/16'; +import Help16 from '@carbon/icons/es/help/16'; +import IncidentReporter16 from '@carbon/icons/es/incident-reporter/16'; import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component'; import { PageHeaderComponent } from './page-header/page-header.component'; @@ -280,7 +282,9 @@ export class ComponentsModule { DataViewAlt16, DataCenter16, Upgrade16, - WarningAltFilled16 + WarningAltFilled16, + Help16, + IncidentReporter16 ]); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html index 426b1507480..4046a4e2a21 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/copy2clipboard-button/copy2clipboard-button.component.html @@ -9,7 +9,7 @@ @if(text) { {{text}} + class="cds--type-mono">{{text}} } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss index 28d9c1cfe18..854b18549a8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss @@ -31,8 +31,12 @@ Using `color` in css and seyting svg will fill="currentColor does not work. fill: theme.$support-caution-major !important; } +.warningAltFilled-icon { + fill: theme.$support-caution-major !important; +} + .error-icon { - fill: theme.$support-error !important; + fill: var(--cds-text-error) !important; } .info-icon { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/side-panel/side-panel.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/side-panel/side-panel.component.html index 74bc95dd5cc..0af5a25a402 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/side-panel/side-panel.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/side-panel/side-panel.component.html @@ -10,12 +10,14 @@ -
- {{ headerText }} +

{{ headerText }}

+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 318bd3c57b1..6d97392b1dd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -109,7 +109,9 @@ export enum Icons { dataViewAlt = 'data--view--alt', dataCenter = 'data--center', upgrade = 'upgrade', - warningAltFilled = 'warning--alt--filled' + warningAltFilled = 'warning--alt--filled', + help = 'help', + incidentReporter = 'incident-reporter' } export enum IconSize { @@ -137,5 +139,7 @@ export const ICON_TYPE = { dataViewAlt: 'data--view--alt', dataCenter: 'data--center', upgrade: 'upgrade', - warningAltFilled: 'warning--alt--filled' + warningAltFilled: 'warning--alt--filled', + help: 'help', + incidentReporter: 'incident-reporter' } as const; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts new file mode 100644 index 00000000000..e43fa289d7d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts @@ -0,0 +1,6 @@ +export type HealthStatus = 'HEALTH_OK' | 'HEALTH_WARN' | 'HEALTH_ERR'; +export const HealthIconMap = { + HEALTH_OK: 'success', + HEALTH_WARN: 'warningAltFilled', + HEALTH_ERR: 'error' +}; -- 2.47.3