]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Fix hardware tab health status 69751/head
authorAfreen Misbah <afreen@ibm.com>
Thu, 2 Jul 2026 21:10:08 +0000 (02:40 +0530)
committerAfreen Misbah <afreen@ibm.com>
Fri, 17 Jul 2026 09:27:59 +0000 (14:57 +0530)
- fix overall health
- add warning health
- refactor

Signed-off-by: Afreen Misbah <afreen@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/hardware.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/hardware.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts
src/pybind/mgr/dashboard/services/hardware.py
src/pybind/mgr/dashboard/tests/test_hardware.py

index 3e4bf0f5783dd5f0e56352bc9137f19534152866..d171c4a6d9558e83bc3fd5f7ac3dd104cd8304d5 100644 (file)
@@ -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;
           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
         >
           <cd-icon type="help"></cd-icon>&nbsp;|
         </cds-tooltip>
       <cds-skeleton-text [lines]="1"></cds-skeleton-text>
     }
     <!-- HARDWARE TAB -->
-    @if (hwEnabled && hwSections) {
+    @if (hwEnabled && hwData?.sections) {
       <div
         class="overview-health-card-tab"
         [class.overview-health-card-tab-selected]="activeSection === 'hardware'"
       >
-        <div class="cds-mb-1"><cd-icon [type]="vm?.overallSystemSev"></cd-icon></div>
+        <div class="cds-mb-1"><cd-icon [type]="hwData.overallSeverity"></cd-icon></div>
         <cds-tooltip-definition
           [highContrast]="true"
           [openOnHover]="true"
     <!-- HARDWARE TAB CONTENT -->
     <ng-container *ngSwitchCase="'hardware'">
       <div class="overview-health-card-tab-content">
-        <p
-          class="overview-health-card-secondary-text cds--type-body-compact-01"
-          i18n
-        >
-          Some cluster components are degraded and may require attention.
-        </p>
+        @if (hwData?.overallSeverity && hwData.overallSeverity !== 'success') {
+          <p
+            class="overview-health-card-secondary-text cds--type-body-compact-01"
+            i18n
+          >
+            Some hardware components are degraded and may require attention.
+          </p>
+        } @else {
+          <p
+            class="overview-health-card-secondary-text cds--type-body-compact-01"
+            i18n
+          >
+            All hardware components are operating normally.
+          </p>
+        }
 
-        @if (hwEnabled && hwSections) {
+        @if (hwEnabled && hwData?.sections) {
           <div class="overview-health-card-hardware-sections">
-          @for (section of hwSections; track $index; let isLast = $last) {
-            <div class="overview-health-card-hardware-section"
-                 [class.border-subtle-right]="!isLast">
-            @for (row of section; track row.key) {
-            <div class="overview-health-card-hardware-row">
-              <span class="overview-health-card-icon-and-text">
-                <cd-icon [type]="row.icon"></cd-icon>
-                <span class="cds--type-body-compact-01">
-                  {{ row.label }}
-                </span>
-              </span>
+            @for (section of hwData.sections; track $index; let isLast = $last) {
+              <div
+                class="overview-health-card-hardware-section"
+                [class.border-subtle-right]="!isLast"
+              >
+                @for (row of section; track row.key) {
+                  <div class="overview-health-card-hardware-row">
+                    <span class="overview-health-card-icon-and-text">
+                      <cd-icon [type]="row.icon"></cd-icon>
+                      <span class="cds--type-body-compact-01">
+                        {{ row.label }}
+                      </span>
+                    </span>
 
                     <span class="overview-health-card-hardware-status">
-                      @if (row.error > 0) {
-                        <cd-icon type="warningAlt"></cd-icon>
+                      @for (status of row.statusCounts; track status.icon) {
+                        <cd-icon [type]="status.icon"></cd-icon>
                         <span class="cds--type-body-compact-01">
-                          {{ row.error }}
+                          {{ status.count }}
                         </span>
                       }
-                      <cd-icon type="checkMarkOutline"></cd-icon>
-                      <span class="cds--type-body-compact-01">
-                        {{ row.ok }}
-                      </span>
                     </span>
                   </div>
                 }
index 6d1a9742c5f47d8f19b9c600795aba7b7fcc35a7..0c16f9d3da29ef4352dc6eb303ac553942724e75 100644 (file)
@@ -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);
index dbfe612fde8575fe5b26208badf437baf8b2679f..138d4e01ac6a56b84f484c05efcbc435be25d6df 100644 (file)
@@ -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<string, string> = {
   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<OverviewHealthCardComponent>;
+
+  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<OverviewHealthCardComponent>;
+
+  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)) } },
         {
index f3ff1cac477b449090aa193c841d30619853efc8..bba7e29aa44d2275b94a4d83eff6075f4911e7f9 100644 (file)
@@ -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<HwKey, string> = {
-  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<HwRowVM[] | null> = 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<HardwareCardVM | null> = 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<HwRowVM[][] | null> = 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 })
-  );
 }
index 3238493ebe5ed7b202e25a58eb9668d81d14825e..fd890f8fd50abf003238dded4198ea99ee904039 100644 (file)
@@ -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<any>(`${this.baseURL}/summary`, {
+  getSummary(category: string[] = []): Observable<HardwareSummary> {
+    return this.http.get<HardwareSummary>(`${this.baseURL}/summary`, {
       params: { categories: category },
       headers: { Accept: 'application/vnd.ceph.api.v0.1+json' }
     });
index b56b4da806b487007cc3d22d169db0bf56ac05f0..24429e46f0220ce7407346e43a1b1b21a2a426df 100644 (file)
@@ -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
     ]);
   }
 }
index ae115d05001a3b137caf0b1f994b57a54f55b23c..fbc15382403f60c931fcb5e6589d348210cb5aa4 100644 (file)
@@ -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<string, HardwareHealthCount>;
+    total: HardwareHealthCount;
+  };
+  host: Record<string, { flawed: boolean } | number>;
+}
index c5190555b6a02294f92f01a4483d3c1baeb953b4..22a047d462e1ccb1aff878b4c9f6c7a2ac61a3c9 100644 (file)
@@ -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 = {
index a35b972971c54a30df892ad9d0b7d5599932a84a..6bc21f692be3dd2deae556804192cd1419aecb3e 100644 (file)
@@ -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<string, string> = {
+  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<keyof typeof HardwareNameMapping>).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]
+  };
+}
index 04e8d022271cbd8ea6e31991a2c580262263741e..8df49742c0e71883c11deffaebe4b458afbe67b5 100644 (file)
@@ -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
index 2e67c32d11d9a2a8bbfeefe073ef119004866765..8fa173240e637e5fa00baee48eb03ab2d936b998 100644 (file)
@@ -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)