From: Afreen Misbah Date: Thu, 2 Jul 2026 21:10:08 +0000 (+0530) Subject: mgr/dashboard: Fix hardware tab health status X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F69751%2Fhead;p=ceph.git mgr/dashboard: Fix hardware tab health status - fix overall health - add warning health - refactor Signed-off-by: Afreen Misbah --- 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 3e4bf0f5783..d171c4a6d95 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,6 +1,6 @@ @let data = data$ | async; @let hwEnabled = enabled$ | async; -@let hwSections = sections$ | async; +@let hwData = hardwareData$ | async; @let telemetryEnabled = telemetryEnabled$ | async; @let colorClass = 'overview-health-card-status--' + vm?.clusterHealth?.icon; @@ -156,7 +156,7 @@ class="cds-ml-3" [caret]="true" description="Health incidents represent Ceph health check warnings that indicate abnormal conditions requiring intervention and persist until the condition is resolved." - i8n-description + i18n-description >  | @@ -193,12 +193,12 @@ } - @if (hwEnabled && hwSections) { + @if (hwEnabled && hwData?.sections) {
-
+
-

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

+ @if (hwData?.overallSeverity && hwData.overallSeverity !== 'success') { +

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

+ } @else { +

+ All hardware components are operating normally. +

+ } - @if (hwEnabled && hwSections) { + @if (hwEnabled && hwData?.sections) {
- @for (section of hwSections; track $index; let isLast = $last) { -
- @for (row of section; track row.key) { -
- - - - {{ row.label }} - - + @for (section of hwData.sections; track $index; let isLast = $last) { +
+ @for (row of section; track row.key) { +
+ + + + {{ row.label }} + + - @if (row.error > 0) { - + @for (status of row.statusCounts; track status.icon) { + - {{ row.error }} + {{ status.count }} } - - - {{ row.ok }} -
} 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 6d1a9742c5f..0c16f9d3da2 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 @@ -72,7 +72,7 @@ &-hardware-sections { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); column-gap: var(--cds-spacing-03); width: 100%; margin-top: var(--cds-spacing-03); 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 dbfe612fde8..138d4e01ac6 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 @@ -19,14 +19,15 @@ import { HardwareNameMapping } from '~/app/shared/enum/hardware.enum'; const MOCK_HW_SUMMARY = { total: { category: { - memory: { total: 8, ok: 8, error: 0 }, - storage: { total: 4, ok: 3, error: 1 }, - processors: { total: 2, ok: 2, error: 0 }, - network: { total: 6, ok: 5, error: 1 }, - power: { total: 4, ok: 4, error: 0 }, - fans: { total: 12, ok: 10, error: 2 } + memory: { total: 8, ok: 8, warn: 0, critical: 0 }, + storage: { total: 4, ok: 2, warn: 1, critical: 1 }, + processors: { total: 2, ok: 2, warn: 0, critical: 0 }, + network: { total: 6, ok: 4, warn: 1, critical: 1 }, + power: { total: 4, ok: 4, warn: 0, critical: 0 }, + fans: { total: 12, ok: 8, warn: 2, critical: 2 }, + temperatures: { total: 3, ok: 3, warn: 0, critical: 0 } }, - total: { total: 36, ok: 32, error: 4 } + total: { total: 39, ok: 31, warn: 4, critical: 4 } }, host: { flawed: 2 } }; @@ -37,7 +38,8 @@ const EXPECTED_ICON_MAP: Record = { processors: 'chip', network: 'network1', power: 'plug', - fans: 'ibmStreamSets' + fans: 'ibmStreamSets', + temperatures: 'temperature' }; describe('OverviewHealthCardComponent', () => { @@ -104,28 +106,19 @@ describe('OverviewHealthCardComponent', () => { expect(component).toBeTruthy(); }); - describe('hardware sections', () => { - it('should emit hardware rows grouped in pairs', (done) => { - component.sections$.subscribe((sections) => { - expect(sections).not.toBeNull(); - sections!.forEach((section) => { - expect(section.length).toBeLessThanOrEqual(2); - }); - done(); - }); - }); - - it('should limit to 3 groups (6 categories max)', (done) => { - component.sections$.subscribe((sections) => { - expect(sections).not.toBeNull(); - expect(sections!.length).toBeLessThanOrEqual(3); + describe('hardware data', () => { + it('should emit 7 hardware rows in 4 sections', (done) => { + component.hardwareData$.subscribe((hwData) => { + expect(hwData).not.toBeNull(); + expect(hwData!.sections.length).toBe(4); + expect(hwData!.sections.flat().length).toBe(7); done(); }); }); - it('should map each row to correct icon from HW_ICON_MAP', (done) => { - component.sections$.subscribe((sections) => { - const rows = sections!.flat(); + it('should map each row to correct icon from HARDWARE_ICON_MAP', (done) => { + component.hardwareData$.subscribe((hwData) => { + const rows = hwData!.sections.flat(); rows.forEach((row) => { expect(row.icon).toBe(EXPECTED_ICON_MAP[row.key]); }); @@ -134,42 +127,230 @@ describe('OverviewHealthCardComponent', () => { }); it('should map each row to correct label from HardwareNameMapping', (done) => { - component.sections$.subscribe((sections) => { - const rows = sections!.flat(); + component.hardwareData$.subscribe((hwData) => { + const rows = hwData!.sections.flat(); rows.forEach((row) => { - expect(row.label).toBe( - HardwareNameMapping[row.key as keyof typeof HardwareNameMapping] - ); + expect(row.label).toBe(HardwareNameMapping[row.key as keyof typeof HardwareNameMapping]); }); done(); }); }); - it('should compute ok and error counts from summary data', (done) => { - component.sections$.subscribe((sections) => { - const rows = sections!.flat(); + it('should build statusCounts with correct ok/warn/critical counts', (done) => { + component.hardwareData$.subscribe((hwData) => { + const rows = hwData!.sections.flat(); + const memoryRow = rows.find((r) => r.key === 'memory'); - expect(memoryRow?.ok).toBe(8); - expect(memoryRow?.error).toBe(0); + expect(memoryRow?.statusCounts).toEqual([{ icon: 'success', count: 8 }]); const storageRow = rows.find((r) => r.key === 'storage'); - expect(storageRow?.ok).toBe(3); - expect(storageRow?.error).toBe(1); + expect(storageRow?.statusCounts).toEqual([ + { icon: 'error', count: 1 }, + { icon: 'warningAltFilled', count: 1 }, + { icon: 'success', count: 2 } + ]); const fansRow = rows.find((r) => r.key === 'fans'); - expect(fansRow?.ok).toBe(10); - expect(fansRow?.error).toBe(2); + expect(fansRow?.statusCounts).toEqual([ + { icon: 'error', count: 2 }, + { icon: 'warningAltFilled', count: 2 }, + { icon: 'success', count: 8 } + ]); + done(); + }); + }); + + it('should compute correct severity per row', (done) => { + component.hardwareData$.subscribe((hwData) => { + const rows = hwData!.sections.flat(); + + const memoryRow = rows.find((r) => r.key === 'memory'); + expect(memoryRow?.severity).toBe(0); + + const storageRow = rows.find((r) => r.key === 'storage'); + expect(storageRow?.severity).toBe(2); + + const powerRow = rows.find((r) => r.key === 'power'); + expect(powerRow?.severity).toBe(0); done(); }); }); - it('should include exactly memory, storage, processors, network, power, fans', (done) => { - component.sections$.subscribe((sections) => { - const keys = sections!.flat().map((r) => r.key); - expect(keys).toEqual(['memory', 'storage', 'processors', 'network', 'power', 'fans']); + it('should compute overall severity as worst across all categories', (done) => { + component.hardwareData$.subscribe((hwData) => { + expect(hwData!.overallSeverity).toBe('error'); done(); }); }); + + it('should include all 7 categories', (done) => { + component.hardwareData$.subscribe((hwData) => { + const keys = hwData!.sections.flat().map((r) => r.key); + expect(keys).toEqual([ + 'memory', + 'storage', + 'processors', + 'network', + 'power', + 'fans', + 'temperatures' + ]); + done(); + }); + }); + }); +}); + +describe('OverviewHealthCardComponent (all healthy)', () => { + let component: OverviewHealthCardComponent; + let fixture: ComponentFixture; + + const healthyMock = { + total: { + category: { + memory: { total: 4, ok: 4, warn: 0, critical: 0 }, + storage: { total: 2, ok: 2, warn: 0, critical: 0 }, + processors: { total: 1, ok: 1, warn: 0, critical: 0 }, + network: { total: 2, ok: 2, warn: 0, critical: 0 }, + power: { total: 2, ok: 2, warn: 0, critical: 0 }, + fans: { total: 4, ok: 4, warn: 0, critical: 0 }, + temperatures: { total: 3, ok: 3, warn: 0, critical: 0 } + }, + total: { total: 18, ok: 18, warn: 0, critical: 0 } + }, + host: { flawed: 0 } + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + OverviewHealthCardComponent, + CommonModule, + ProductiveCardComponent, + SkeletonModule, + ButtonModule, + RouterModule, + ComponentsModule, + LinkModule, + PipesModule + ], + providers: [ + { + provide: SummaryService, + useValue: { summaryData$: of({ version: 'ceph version 18.0.0 reef (dev)' }) } + }, + { provide: UpgradeService, useValue: { listCached: jest.fn(() => of(null)) } }, + { + provide: AuthStorageService, + useValue: { getPermissions: jest.fn(() => ({ configOpt: { read: true } })) } + }, + { + provide: MgrModuleService, + useValue: { getConfig: jest.fn(() => of({ hw_monitoring: true })) } + }, + { provide: HardwareService, useValue: { getSummary: jest.fn(() => of(healthyMock)) } }, + { provide: HealthService, useValue: { getTelemetryStatus: jest.fn(() => of(false)) } }, + provideRouter([]) + ] + }).compileComponents(); + + fixture = TestBed.createComponent(OverviewHealthCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should report overall severity as success when all healthy', (done) => { + component.hardwareData$.subscribe((hwData) => { + expect(hwData!.overallSeverity).toBe('success'); + done(); + }); + }); + + it('should have only ok statusCounts per row', (done) => { + component.hardwareData$.subscribe((hwData) => { + hwData!.sections.flat().forEach((row) => { + expect(row.statusCounts.length).toBe(1); + expect(row.statusCounts[0].icon).toBe('success'); + }); + done(); + }); + }); +}); + +describe('OverviewHealthCardComponent (warn only)', () => { + let component: OverviewHealthCardComponent; + let fixture: ComponentFixture; + + const warnMock = { + total: { + category: { + memory: { total: 4, ok: 3, warn: 1, critical: 0 }, + storage: { total: 2, ok: 2, warn: 0, critical: 0 }, + processors: { total: 1, ok: 1, warn: 0, critical: 0 }, + network: { total: 2, ok: 2, warn: 0, critical: 0 }, + power: { total: 2, ok: 2, warn: 0, critical: 0 }, + fans: { total: 4, ok: 4, warn: 0, critical: 0 }, + temperatures: { total: 3, ok: 3, warn: 0, critical: 0 } + }, + total: { total: 18, ok: 17, warn: 1, critical: 0 } + }, + host: { flawed: 1 } + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + OverviewHealthCardComponent, + CommonModule, + ProductiveCardComponent, + SkeletonModule, + ButtonModule, + RouterModule, + ComponentsModule, + LinkModule, + PipesModule + ], + providers: [ + { + provide: SummaryService, + useValue: { summaryData$: of({ version: 'ceph version 18.0.0 reef (dev)' }) } + }, + { provide: UpgradeService, useValue: { listCached: jest.fn(() => of(null)) } }, + { + provide: AuthStorageService, + useValue: { getPermissions: jest.fn(() => ({ configOpt: { read: true } })) } + }, + { + provide: MgrModuleService, + useValue: { getConfig: jest.fn(() => of({ hw_monitoring: true })) } + }, + { provide: HardwareService, useValue: { getSummary: jest.fn(() => of(warnMock)) } }, + { provide: HealthService, useValue: { getTelemetryStatus: jest.fn(() => of(false)) } }, + provideRouter([]) + ] + }).compileComponents(); + + fixture = TestBed.createComponent(OverviewHealthCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should report overall severity as warningAltFilled when only warnings', (done) => { + component.hardwareData$.subscribe((hwData) => { + expect(hwData!.overallSeverity).toBe('warningAltFilled'); + done(); + }); + }); + + it('should include warn statusCount for memory row', (done) => { + component.hardwareData$.subscribe((hwData) => { + const memoryRow = hwData!.sections.flat().find((r) => r.key === 'memory'); + expect(memoryRow?.statusCounts).toEqual([ + { icon: 'warningAltFilled', count: 1 }, + { icon: 'success', count: 3 } + ]); + done(); + }); }); }); @@ -193,18 +374,17 @@ describe('OverviewHealthCardComponent (hw disabled)', () => { providers: [ { provide: SummaryService, - useValue: { - summaryData$: of({ - version: 'ceph version 18.0.0 reef (dev)' - }) - } + useValue: { summaryData$: of({ version: 'ceph version 18.0.0 reef (dev)' }) } }, { provide: UpgradeService, useValue: { listCached: jest.fn(() => of(null)) } }, { provide: AuthStorageService, useValue: { getPermissions: jest.fn(() => ({ configOpt: { read: true } })) } }, - { provide: MgrModuleService, useValue: { getConfig: jest.fn(() => of({ hw_monitoring: false })) } }, + { + provide: MgrModuleService, + useValue: { getConfig: jest.fn(() => of({ hw_monitoring: false })) } + }, { provide: HardwareService, useValue: { getSummary: jest.fn(() => of(null)) } }, { provide: HealthService, useValue: { getTelemetryStatus: jest.fn(() => of(false)) } }, provideRouter([]) @@ -216,9 +396,9 @@ describe('OverviewHealthCardComponent (hw disabled)', () => { fixture.detectChanges(); }); - it('should emit null sections when hw monitoring is disabled', (done) => { - component.sections$.subscribe((sections) => { - expect(sections).toBeNull(); + it('should emit null hardwareData when hw monitoring is disabled', (done) => { + component.hardwareData$.subscribe((hwData) => { + expect(hwData).toBeNull(); done(); }); }); @@ -251,11 +431,7 @@ describe('OverviewHealthCardComponent (no permissions)', () => { providers: [ { provide: SummaryService, - useValue: { - summaryData$: of({ - version: 'ceph version 18.0.0 reef (dev)' - }) - } + useValue: { summaryData$: of({ version: 'ceph version 18.0.0 reef (dev)' }) } }, { provide: UpgradeService, useValue: { listCached: jest.fn(() => of(null)) } }, { 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 f3ff1cac477..bba7e29aa44 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 @@ -26,13 +26,17 @@ 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, shareReplay, startWith, switchMap } from 'rxjs/operators'; -import { HealthCardTabSection, HealthCardVM } from '~/app/shared/models/overview'; +import { + HealthCardTabSection, + HealthCardVM, + HardwareCardVM, + buildHardwareCardVM +} from '~/app/shared/models/overview'; import { HardwareService } from '~/app/shared/api/hardware.service'; import { HealthService } from '~/app/shared/api/health.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'; import { GaugeChartComponent } from '@carbon/charts-angular'; type OverviewHealthData = { @@ -47,25 +51,6 @@ interface HealthItemConfig { i18n?: boolean; } -type HwKey = keyof typeof HardwareNameMapping; - -type HwRowVM = { - key: HwKey; - label: string; - icon: string; - ok: number; - error: number; -}; - -const HW_ICON_MAP: Record = { - memory: 'dataEnrichment', - storage: 'vmdkDisk', - processors: 'chip', - network: 'network1', - power: 'plug', - fans: 'ibmStreamSets', -}; - @Component({ selector: 'cd-overview-health-card', imports: [ @@ -156,19 +141,8 @@ export class OverviewHealthCardComponent { 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], - icon: HW_ICON_MAP[key], - ok: Number(category?.[key]?.ok ?? 0), - error: Number(category?.[key]?.error ?? 0) - })); - }), + readonly hardwareData$: Observable = this.hardwareSummary$.pipe( + map((hw) => buildHardwareCardVM(hw)), shareReplay({ bufferSize: 1, refCount: true }) ); @@ -177,17 +151,4 @@ export class OverviewHealthCardComponent { catchError(() => of(false)), 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/shared/api/hardware.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.ts index 3238493ebe5..fd890f8fd50 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.ts @@ -1,5 +1,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; +import { HardwareSummary } from '../enum/hardware.enum'; @Injectable({ providedIn: 'root' @@ -9,8 +11,8 @@ export class HardwareService { constructor(private http: HttpClient) {} - getSummary(category: string[] = []): any { - return this.http.get(`${this.baseURL}/summary`, { + getSummary(category: string[] = []): Observable { + return this.http.get(`${this.baseURL}/summary`, { params: { categories: category }, headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } }); 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 b56b4da806b..24429e46f02 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 @@ -133,6 +133,7 @@ import Locked16 from '@carbon/icons/es/locked/16'; import WebServicesCluster20 from '@carbon/icons/es/web-services--cluster/20'; import WebServicesCluster32 from '@carbon/icons/es/web-services--cluster/32'; import CloudMonitoring16 from '@carbon/icons/es/cloud--monitoring/16'; +import Temperature16 from '@carbon/icons/es/temperature/16'; import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component'; import { PageHeaderComponent } from './page-header/page-header.component'; @@ -329,7 +330,8 @@ export class ComponentsModule { Locked16, WebServicesCluster20, WebServicesCluster32, - CloudMonitoring16 + CloudMonitoring16, + Temperature16 ]); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts index ae115d05001..fbc15382403 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts @@ -7,3 +7,18 @@ export enum HardwareNameMapping { fans = 'Fan module', temperatures = 'Temperature' } + +export interface HardwareHealthCount { + total: number; + ok: number; + warn: number; + critical: number; +} + +export interface HardwareSummary { + total: { + category: Record; + total: HardwareHealthCount; + }; + host: Record; +} 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 c5190555b6a..22a047d462e 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 @@ -124,7 +124,8 @@ export enum Icons { inProgress = 'in-progress', arrowDown = 'arrow--down', locked = 'locked', // Access denied, locked state - cloudMonitoring = 'cloud--monitoring' + cloudMonitoring = 'cloud--monitoring', + temperature = 'temperature' } export enum IconSize { @@ -175,7 +176,8 @@ export const ICON_TYPE = { rightArrow: 'caret--right', locked: 'locked', cloudMonitoring: 'cloud--monitoring', - trash: 'trash-can' + trash: 'trash-can', + temperature: 'temperature' } as const; export const EMPTY_STATE_IMAGE = { 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 index a35b972971c..6bc21f692be 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts @@ -1,5 +1,6 @@ import { ChartTabularData, GaugeChartOptions } from '@carbon/charts-angular'; import { HealthCheck, HealthSnapshotMap, PgStateCount } from './health.interface'; +import { HardwareNameMapping, HardwareSummary } from '../enum/hardware.enum'; import _ from 'lodash'; // Types @@ -70,6 +71,24 @@ export interface HealthCardSubStateVM { severity: string; } +export interface HardwareStatusCount { + icon: string; + count: number; +} + +export interface HardwareRowVM { + key: string; + label: string; + icon: string; + severity: Severity; + statusCounts: HardwareStatusCount[]; +} + +export interface HardwareCardVM { + sections: HardwareRowVM[][]; + overallSeverity: string; +} + export interface HealthCardVM { fsid: string; overallSystemSev: string; @@ -477,3 +496,59 @@ export function buildHealthCardVM(d: HealthSnapshotMap): HealthCardVM { } }; } + +const HARDWARE_ICON_MAP: Record = { + memory: 'dataEnrichment', + storage: 'vmdkDisk', + processors: 'chip', + network: 'network1', + power: 'plug', + fans: 'ibmStreamSets', + temperatures: 'temperature' +}; + +/** + * Mapper: Hardware summary -> HardwareCardVM + */ +export function buildHardwareCardVM(hwSummary: HardwareSummary): HardwareCardVM | null { + const category = hwSummary?.total?.category; + if (!category) return null; + + const severities: Severity[] = []; + + const rows = (Object.keys(HardwareNameMapping) as Array).map( + (key) => { + const ok = Number(category?.[key]?.ok ?? 0); + const warn = Number(category?.[key]?.warn ?? 0); + const critical = Number(category?.[key]?.critical ?? 0); + + const severity: Severity = + critical > 0 ? SEVERITY.err : warn > 0 ? SEVERITY.warn : SEVERITY.ok; + + severities.push(severity); + + const statusCounts: HardwareStatusCount[] = []; + if (critical > 0) { + statusCounts.push({ icon: SeverityIconMap[SEVERITY.err], count: critical }); + } + if (warn > 0) statusCounts.push({ icon: SeverityIconMap[SEVERITY.warn], count: warn }); + if (ok > 0) statusCounts.push({ icon: SeverityIconMap[SEVERITY.ok], count: ok }); + + return { + key, + label: HardwareNameMapping[key], + icon: HARDWARE_ICON_MAP[key], + severity, + statusCounts + }; + } + ); + + const sections = _.chunk(rows, 2); + const overallSeverity = maxSeverity(...severities); + + return { + sections, + overallSeverity: SeverityIconMap[overallSeverity] + }; +} diff --git a/src/pybind/mgr/dashboard/services/hardware.py b/src/pybind/mgr/dashboard/services/hardware.py index 04e8d022271..8df49742c0e 100644 --- a/src/pybind/mgr/dashboard/services/hardware.py +++ b/src/pybind/mgr/dashboard/services/hardware.py @@ -6,6 +6,8 @@ from ..exceptions import DashboardException from ..services.orchestrator import OrchClient STATUS_OK = 'OK' +STATUS_WARNING = 'Warning' +STATUS_CRITICAL = 'Critical' STATUS_UNKNOWN = 'Unknown' @@ -14,7 +16,7 @@ class HardwareService(object): @staticmethod def get_summary(categories: Optional[List[str]] = None, hostname: Optional[List[str]] = None): - total_count = {'total': 0, 'ok': 0, 'error': 0} + total_count = {'total': 0, 'ok': 0, 'warn': 0, 'critical': 0} output: Dict[str, Any] = { 'total': { @@ -36,13 +38,19 @@ class HardwareService(object): return status_val return STATUS_UNKNOWN - def count_ok(data: dict) -> int: - return sum( - _get_health(component) == STATUS_OK - for node in data.values() - for system in node.values() - for component in system.values() - ) + def count_by_health(data: dict) -> Dict[str, int]: + counts = {'ok': 0, 'warn': 0, 'critical': 0} + for node in data.values(): + for system in node.values(): + for component in system.values(): + health = _get_health(component) + if health == STATUS_OK: + counts['ok'] += 1 + elif health == STATUS_WARNING: + counts['warn'] += 1 + elif health == STATUS_CRITICAL: + counts['critical'] += 1 + return counts def count_total(data: dict) -> int: return sum( @@ -56,10 +64,12 @@ class HardwareService(object): orch_hardware_instance = OrchClient.instance().hardware for category in categories: data = orch_hardware_instance.common(category, hostname) + health_counts = count_by_health(data) category_total = { 'total': count_total(data), - 'ok': count_ok(data), - 'error': 0 + 'ok': health_counts['ok'], + 'warn': health_counts['warn'], + 'critical': health_counts['critical'] } for host, systems in data.items(): @@ -67,22 +77,24 @@ class HardwareService(object): if not output['host'][host]['flawed']: for system in systems.values(): if any(_get_health(comp) != STATUS_OK - for comp in system.values()): + for comp in system.values()): output['host'][host]['flawed'] = True break - category_total['error'] = max(0, category_total['total'] - category_total['ok']) output['total']['category'].setdefault(category, {}) output['total']['category'][category] = category_total total_count['total'] += category_total['total'] total_count['ok'] += category_total['ok'] - total_count['error'] += category_total['error'] + total_count['warn'] += category_total['warn'] + total_count['critical'] += category_total['critical'] output['total']['total'] = total_count - output['host']['flawed'] = sum(1 for host in output['host'] - if host != 'flawed' and output['host'][host]['flawed']) + output['host']['flawed'] = sum( + 1 for host in output['host'] + if host != 'flawed' and output['host'][host]['flawed'] + ) return output @@ -99,7 +111,9 @@ class HardwareService(object): raise DashboardException(msg=f'{categories} is not a list', component='Hardware') if not all(item in categories_list for item in categories): - raise DashboardException(msg=f'Invalid category, there is no {categories}', - component='Hardware') + raise DashboardException( + msg=f'Invalid category, there is no {categories}', + component='Hardware' + ) return categories diff --git a/src/pybind/mgr/dashboard/tests/test_hardware.py b/src/pybind/mgr/dashboard/tests/test_hardware.py index 2e67c32d11d..8fa173240e6 100644 --- a/src/pybind/mgr/dashboard/tests/test_hardware.py +++ b/src/pybind/mgr/dashboard/tests/test_hardware.py @@ -2,8 +2,8 @@ import unittest from unittest import mock from ..exceptions import DashboardException -from ..services.hardware import HardwareService, STATUS_OK, STATUS_UNKNOWN - +from ..services.hardware import STATUS_CRITICAL, STATUS_OK, STATUS_UNKNOWN, \ + STATUS_WARNING, HardwareService MOCK_HARDWARE_DATA = { 'memory': { @@ -117,11 +117,36 @@ MOCK_NON_DICT_COMPONENT = { } } +MOCK_WARNING_STATUS_DATA = { + 'host1': { + 'SystemBoard': { + 'DIMM.Socket.A1': { + 'description': 'DIMM DDR5', + 'status': {'health': 'OK'} + }, + 'DIMM.Socket.A2': { + 'description': 'DIMM DDR5', + 'status': {'health': 'Warning'} + }, + 'DIMM.Socket.A3': { + 'description': 'DIMM DDR5', + 'status': {'health': 'Critical'} + } + } + } +} + class HardwareConstantsTest(unittest.TestCase): def test_status_ok_value(self): self.assertEqual(STATUS_OK, 'OK') + def test_status_warning_value(self): + self.assertEqual(STATUS_WARNING, 'Warning') + + def test_status_critical_value(self): + self.assertEqual(STATUS_CRITICAL, 'Critical') + def test_status_unknown_value(self): self.assertEqual(STATUS_UNKNOWN, 'Unknown') @@ -154,7 +179,7 @@ class HardwareValidateCategoriesTest(unittest.TestCase): class HardwareGetSummaryTest(unittest.TestCase): def _mock_common(self, data_map): - def side_effect(category, hostname=None): + def side_effect(category, _hostname=None): return data_map.get(category, {}) return side_effect @@ -168,7 +193,8 @@ class HardwareGetSummaryTest(unittest.TestCase): cat = result['total']['category']['memory'] self.assertEqual(cat['total'], 3) self.assertEqual(cat['ok'], 3) - self.assertEqual(cat['error'], 0) + self.assertEqual(cat['warn'], 0) + self.assertEqual(cat['critical'], 0) @mock.patch('dashboard.services.hardware.OrchClient.instance') def test_dict_status_health_mixed(self, mock_instance): @@ -180,7 +206,8 @@ class HardwareGetSummaryTest(unittest.TestCase): cat = result['total']['category']['storage'] self.assertEqual(cat['total'], 2) self.assertEqual(cat['ok'], 1) - self.assertEqual(cat['error'], 1) + self.assertEqual(cat['warn'], 0) + self.assertEqual(cat['critical'], 1) @mock.patch('dashboard.services.hardware.OrchClient.instance') def test_string_status_format(self, mock_instance): @@ -193,10 +220,11 @@ class HardwareGetSummaryTest(unittest.TestCase): result = HardwareService.get_summary(categories=['memory']) cat = result['total']['category']['memory'] self.assertEqual(cat['ok'], 1) - self.assertEqual(cat['error'], 1) + self.assertEqual(cat['warn'], 0) + self.assertEqual(cat['critical'], 1) @mock.patch('dashboard.services.hardware.OrchClient.instance') - def test_missing_status_treated_as_unknown(self, mock_instance): + def test_missing_status_not_counted_as_critical(self, mock_instance): fake_client = mock.Mock() fake_client.hardware.common = self._mock_common( {'memory': MOCK_MISSING_STATUS_DATA} @@ -206,10 +234,11 @@ class HardwareGetSummaryTest(unittest.TestCase): result = HardwareService.get_summary(categories=['memory']) cat = result['total']['category']['memory'] self.assertEqual(cat['ok'], 0) - self.assertEqual(cat['error'], 1) + self.assertEqual(cat['warn'], 0) + self.assertEqual(cat['critical'], 0) @mock.patch('dashboard.services.hardware.OrchClient.instance') - def test_non_dict_component_treated_as_unknown(self, mock_instance): + def test_non_dict_component_not_counted_as_critical(self, mock_instance): fake_client = mock.Mock() fake_client.hardware.common = self._mock_common( {'memory': MOCK_NON_DICT_COMPONENT} @@ -219,7 +248,8 @@ class HardwareGetSummaryTest(unittest.TestCase): result = HardwareService.get_summary(categories=['memory']) cat = result['total']['category']['memory'] self.assertEqual(cat['ok'], 0) - self.assertEqual(cat['error'], 1) + self.assertEqual(cat['warn'], 0) + self.assertEqual(cat['critical'], 0) @mock.patch('dashboard.services.hardware.OrchClient.instance') def test_flawed_host_detected(self, mock_instance): @@ -254,7 +284,8 @@ class HardwareGetSummaryTest(unittest.TestCase): totals = result['total']['total'] self.assertEqual(totals['total'], 9) self.assertEqual(totals['ok'], 8) - self.assertEqual(totals['error'], 1) + self.assertEqual(totals['warn'], 0) + self.assertEqual(totals['critical'], 1) @mock.patch('dashboard.services.hardware.OrchClient.instance') def test_empty_data_returns_zeros(self, mock_instance): @@ -266,4 +297,20 @@ class HardwareGetSummaryTest(unittest.TestCase): cat = result['total']['category']['memory'] self.assertEqual(cat['total'], 0) self.assertEqual(cat['ok'], 0) - self.assertEqual(cat['error'], 0) + self.assertEqual(cat['warn'], 0) + self.assertEqual(cat['critical'], 0) + + @mock.patch('dashboard.services.hardware.OrchClient.instance') + def test_warning_status_counted_separately(self, mock_instance): + fake_client = mock.Mock() + fake_client.hardware.common = self._mock_common( + {'memory': MOCK_WARNING_STATUS_DATA} + ) + mock_instance.return_value = fake_client + + result = HardwareService.get_summary(categories=['memory']) + cat = result['total']['category']['memory'] + self.assertEqual(cat['total'], 3) + self.assertEqual(cat['ok'], 1) + self.assertEqual(cat['warn'], 1) + self.assertEqual(cat['critical'], 1)