From fa37283bbefe5c8d5324f9f17b6a82c971197149 Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Tue, 24 Feb 2026 05:21:58 +0530 Subject: [PATCH] mgr/dashboard: Add hardware tab to health card Fixes https://tracker.ceph.com/issues/75120 Signed-off-by: Afreen Misbah --- .../overview-health-card.component.html | 81 ++++++++++++++++++- .../overview-health-card.component.scss | 45 +++++++++++ .../overview-health-card.component.spec.ts | 18 +++++ .../overview-health-card.component.ts | 75 ++++++++++++++++- .../ceph/overview/overview.component.spec.ts | 18 +++++ .../shared/components/components.module.ts | 18 ++++- .../components/icon/icon.component.scss | 8 ++ .../src/app/shared/enum/icons.enum.ts | 20 ++++- 8 files changed, 274 insertions(+), 9 deletions(-) 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 876e1f436f8..70b5853016a 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,7 +1,12 @@ @let data=(data$ | async); +@let hwEnabled = (enabled$ | async); +@let hwSections = (sections$ | async); + @let colorClass="overview-health-card-status--" + vm?.health?.icon; + + - + @if(vm?.fsid) {
@@ -35,7 +40,7 @@ [maxLineWidth]="400" [minLineWidth]="400"> } - + @if(vm?.health){

@@ -67,7 +72,9 @@ [lines]="1" [maxLineWidth]="250"> } - + + +

@@ -127,10 +134,35 @@ } @else { } + + @if(hwEnabled && hwSections) { +
+
+ + + Hardware + + +
+ }
+

+ + +
+

+ Some cluster components are degraded and may require attention. +

+ + @if (hwEnabled && hwSections) { +
+ @for (section of sections; track $index) { +
+ @for (row of section; track row.key) { +
+ + + + {{ row.label }} + + + + + @if (row.error > 0) { + + + {{ row.error }} + + } + + + {{ row.ok }} + + +
+ } +
+ } +
+ } @else { + + } +
+
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 2d6309aeaeb..b8b2a653534 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 @@ -42,12 +42,57 @@ max-block-size: fit-content; } + &-tab-content-item-row { + display: flex; + justify-content: space-between; + } + &-icon-and-text { display: inline-flex; align-items: center; gap: var(--cds-spacing-03); } + &-hardware-sections { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + column-gap: var(--cds-spacing-03); + width: 100%; + margin-top: var(--cds-spacing-03); + padding-right: var(--cds-spacing-06); + box-sizing: border-box; + } + + &-hardware-section { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-03); + min-width: 0; + padding-inline-end: var(--cds-spacing-03); + border-right: 1px solid var(--cds-border-subtle); + box-sizing: border-box; + } + + &-hardware-section:last-child { + border-right: none; + padding-inline-end: 0; + } + + &-hardware-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--cds-spacing-03); + min-width: 0; + } + + &-hardware-status { + display: inline-flex; + align-items: center; + gap: var(--cds-spacing-03); + flex-shrink: 0; + } + // Overrides .clipboard-btn { padding: var(--cds-spacing-02); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts index 308957a6b8b..aa0264a4b9c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts @@ -10,6 +10,9 @@ import { SkeletonModule, ButtonModule, LinkModule } from 'carbon-components-angu import { ComponentsModule } from '~/app/shared/components/components.module'; import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component'; import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { HardwareService } from '~/app/shared/api/hardware.service'; +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; describe('OverviewStorageCardComponent (Jest)', () => { let component: OverviewHealthCardComponent; @@ -26,6 +29,18 @@ describe('OverviewStorageCardComponent (Jest)', () => { listCached: jest.fn(() => of({ versions: [] })) }; + const mockAuthStorageService = { + getPermissions: jest.fn(() => ({ configOpt: { read: false } })) + }; + + const mockMgrModuleService = { + getConfig: jest.fn(() => of({ hw_monitoring: false })) + }; + + const mockHardwareService = { + getSummary: jest.fn(() => of(null)) + }; + beforeEach(async () => { await TestBed.configureTestingModule({ imports: [ @@ -42,6 +57,9 @@ describe('OverviewStorageCardComponent (Jest)', () => { providers: [ { provide: SummaryService, useValue: summaryServiceMock }, { provide: UpgradeService, useValue: upgradeServiceMock }, + { provide: AuthStorageService, useValue: mockAuthStorageService }, + { provide: MgrModuleService, useValue: mockMgrModuleService }, + { provide: HardwareService, useValue: mockHardwareService }, provideRouter([]) ] }).compileComponents(); 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 bf1c855b25e..2e5ad7e0504 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 @@ -25,12 +25,17 @@ import { CommonModule } from '@angular/common'; 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 { catchError, filter, map, shareReplay, startWith, switchMap } from 'rxjs/operators'; import { HealthCardTabSection, HealthCardVM } from '~/app/shared/models/overview'; +import { HardwareService } from '~/app/shared/api/hardware.service'; +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { HardwareNameMapping } from '~/app/shared/enum/hardware.enum'; type OverviewHealthData = { summary: Summary; - upgrade: UpgradeInfoInterface; + upgrade: UpgradeInfoInterface | null; }; interface HealthItemConfig { @@ -40,6 +45,15 @@ interface HealthItemConfig { i18n?: boolean; } +type HwKey = keyof typeof HardwareNameMapping; + +type HwRowVM = { + key: HwKey; + label: string; + ok: number; + error: number; +}; + @Component({ selector: 'cd-overview-health-card', imports: [ @@ -64,12 +78,17 @@ interface HealthItemConfig { export class OverviewHealthCardComponent { private readonly summaryService = inject(SummaryService); private readonly upgradeService = inject(UpgradeService); + private readonly hardwareService = inject(HardwareService); + private readonly mgrModuleService = inject(MgrModuleService); + private readonly refreshIntervalService = inject(RefreshIntervalService); + private readonly authStorageService = inject(AuthStorageService); @Input({ required: true }) vm!: HealthCardVM; @Output() viewIncidents = new EventEmitter(); @Output() activeSectionChange = new EventEmitter(); activeSection: HealthCardTabSection | null = null; + healthItems: HealthItemConfig[] = [ { key: 'mon', label: $localize`Monitor` }, { key: 'mgr', label: $localize`Manager` }, @@ -85,7 +104,7 @@ export class OverviewHealthCardComponent { readonly data$: Observable = combineLatest([ this.summaryService.summaryData$.pipe(filter((summary): summary is Summary => !!summary)), this.upgradeService.listCached().pipe( - startWith(null as UpgradeInfoInterface), + startWith(null as UpgradeInfoInterface | null), catchError(() => of(null)) ) ]).pipe(map(([summary, upgrade]) => ({ summary, upgrade }))); @@ -93,4 +112,54 @@ export class OverviewHealthCardComponent { onViewIncidentsClick() { this.viewIncidents.emit(); } + + private readonly permissions = this.authStorageService.getPermissions(); + + readonly enabled$: Observable = this.permissions?.configOpt?.read + ? this.mgrModuleService.getConfig('cephadm').pipe( + map((resp: any) => !!resp?.hw_monitoring), + catchError(() => of(false)), + shareReplay({ bufferSize: 1, refCount: true }) + ) + : of(false); + + private readonly hardwareSummary$ = this.enabled$.pipe( + switchMap((enabled) => { + if (!enabled) return of(null); + + return this.refreshIntervalService.intervalData$.pipe( + startWith(null), + switchMap(() => this.hardwareService.getSummary().pipe(catchError(() => of(null)))) + ); + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + private readonly hardwareRows$: Observable = this.hardwareSummary$.pipe( + map((hw) => { + const category = hw?.total?.category; + if (!category) return null; + + return (Object.keys(HardwareNameMapping) as HwKey[]).map((key) => ({ + key, + label: HardwareNameMapping[key], + ok: Number(category?.[key]?.ok ?? 0), + error: Number(category?.[key]?.error ?? 0) + })); + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + readonly sections$: Observable = this.hardwareRows$.pipe( + map((rows) => { + if (!rows) return null; + + const result: HwRowVM[][] = []; + for (let i = 0; i < rows.length; i += 2) { + result.push(rows.slice(i, i + 2)); + } + return result.slice(0, 3); + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); } 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 ec4e410bc3c..f368271c42b 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 @@ -15,6 +15,9 @@ import { OverviewHealthCardComponent } from './health-card/overview-health-card. import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component'; import { HealthMap, SeverityIconMap } from '~/app/shared/models/overview'; import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card.component'; +import { HardwareService } from '~/app/shared/api/hardware.service'; +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; describe('OverviewComponent', () => { let component: OverviewComponent; @@ -23,6 +26,18 @@ describe('OverviewComponent', () => { let mockHealthService: { getHealthSnapshot: jest.Mock }; let mockRefreshIntervalService: { intervalData$: Subject }; + const mockAuthStorageService = { + getPermissions: jest.fn(() => ({ configOpt: { read: false } })) + }; + + const mockMgrModuleService = { + getConfig: jest.fn(() => of({ hw_monitoring: false })) + }; + + const mockHardwareService = { + getSummary: jest.fn(() => of(null)) + }; + beforeEach(async () => { mockHealthService = { getHealthSnapshot: jest.fn() }; mockRefreshIntervalService = { intervalData$: new Subject() }; @@ -43,6 +58,9 @@ describe('OverviewComponent', () => { provideRouter([]), { provide: HealthService, useValue: mockHealthService }, { provide: RefreshIntervalService, useValue: mockRefreshIntervalService }, + { provide: AuthStorageService, useValue: mockAuthStorageService }, + { provide: MgrModuleService, useValue: mockMgrModuleService }, + { provide: HardwareService, useValue: mockHardwareService }, provideRouter([]) ] }).compileComponents(); 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 27d7ce1fc2f..45fe3a93e4a 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 @@ -113,6 +113,14 @@ 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 IbmStreamSets16 from '@carbon/icons/es/ibm--streamsets/16'; +import DataEnrichment16 from '@carbon/icons/es/data-enrichment/16'; +import Network116 from '@carbon/icons/es/network--1/16'; +import Chip16 from '@carbon/icons/es/chip/16'; +import Plug16 from '@carbon/icons/es/plug/16'; +import VmdkDisk16 from '@carbon/icons/es/vmdk-disk/16'; +import WarningAlt16 from '@carbon/icons/es/warning--alt/16'; +import CheckMarkOutline16 from '@carbon/icons/es/checkmark--outline/16'; import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component'; import { PageHeaderComponent } from './page-header/page-header.component'; @@ -284,7 +292,15 @@ export class ComponentsModule { Upgrade16, WarningAltFilled16, Help16, - IncidentReporter16 + IncidentReporter16, + IbmStreamSets16, + DataEnrichment16, + Network116, + Chip16, + Plug16, + VmdkDisk16, + WarningAlt16, + CheckMarkOutline16 ]); } } 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 854b18549a8..f5ad4e445e6 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 @@ -54,3 +54,11 @@ Using `color` in css and seyting svg will fill="currentColor does not work. .emptySearch-icon { fill: theme.$layer-selected-disabled !important; } + +.warningAlt-icon { + fill: theme.$support-caution-major !important; +} + +.checkMarkOutline-icon { + fill: theme.$support-success !important; +} 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 965a3309b28..bf0d514f72a 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 @@ -113,7 +113,15 @@ export enum Icons { upgrade = 'upgrade', warningAltFilled = 'warning--alt--filled', help = 'help', - incidentReporter = 'incident-reporter' + incidentReporter = 'incident-reporter', + ibmStreamSets = 'ibm--streamsets', + dataEnrichment = 'data-enrichment', + network1 = 'network--1', + chip = 'chip', + plug = 'plug', + vmdkDisk = 'vmdk-disk', + checkMarkOutline = 'checkmark--outline', + warningAlt = 'warning--alt' } export enum IconSize { @@ -143,5 +151,13 @@ export const ICON_TYPE = { upgrade: 'upgrade', warningAltFilled: 'warning--alt--filled', help: 'help', - incidentReporter: 'incident-reporter' + incidentReporter: 'incident-reporter', + ibmStreamSets: 'ibm--streamsets', + dataEnrichment: 'data-enrichment', + network1: 'network--1', + chip: 'chip', + plug: 'plug', + vmdkDisk: 'vmdk-disk', + warningAlt: 'warning--alt', + checkMarkOutline: 'checkmark--outline' } as const; -- 2.47.3