]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add hardware tab to health card 67474/head
authorAfreen Misbah <afreen@ibm.com>
Mon, 23 Feb 2026 23:51:58 +0000 (05:21 +0530)
committerAfreen Misbah <afreen@ibm.com>
Tue, 24 Feb 2026 00:00:41 +0000 (05:30 +0530)
Fixes https://tracker.ceph.com/issues/75120

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/ceph/overview/overview.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts

index 876e1f436f8916c1968a1f802eda1b7014d3c2ae..70b5853016a1da9663e6db099e436c1393c2bc9f 100644 (file)
@@ -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;
+
+
 <cd-productive-card class="overview-health-card">
-  <!-- HEALTH CARD Title -->
+  <!-- ----------------------HEALTH CARD HEADER ---------------->
   @if(vm?.fsid) {
   <ng-template #header>
     <div class="overview-health-card-header">
@@ -35,7 +40,7 @@
     [maxLineWidth]="400"
     [minLineWidth]="400"></cds-skeleton-text>
   }
-  <!-- HEALTH CARD BODY -->
+  <!------------------------- HEALTH CARD CLUSTER STATUS ------------------>
   @if(vm?.health){
   <p class="cds--type-heading-05 cds-mb-0"
      [ngClass]="colorClass">
@@ -67,7 +72,9 @@
     [lines]="1"
     [maxLineWidth]="250"></cds-skeleton-text>
   }
-  <!-- TABS -->
+
+  <!-----------------------------------TABS -------------------------------------->
+
   <div cdsStack="horizontal"
        [gap]="4">
   <!-- HEALTH CHECKS -->
   } @else {
   <cds-skeleton-text [lines]="1"></cds-skeleton-text>
   }
+  <!-- HARDWARE TAB -->
+  @if(hwEnabled && hwSections) {
+    <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>
+      <cds-tooltip-definition
+        [highContrast]="true"
+        [openOnHover]="true"
+        [dropShadow]="true"
+        class="cds-ml-2"
+        [caret]="true"
+        (click)="toggleSection('hardware')"
+        description=""
+        i18n-description>
+        <span
+          class="cds-mr-1"
+          [class.cds--type-heading-compact-01]="activeSection === 'hardware'"
+          i18n>
+          Hardware
+        </span>
+      </cds-tooltip-definition>
+    </div>
+  }
   </div>
 
   <!-- TAB CONTENT -->
   <div  [ngSwitch]="activeSection">
+    <!-- SYSTEM TAB CONTENT -->
     <ng-container *ngSwitchCase="'system'">
       <div class="overview-health-card-tab-content">
         <p class="overview-health-card-secondary-text cds--type-body-compact-01"
         </div>
       </div>
     </ng-container>
+    <!-- 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 (hwEnabled && hwSections) {
+          <div class="overview-health-card-hardware-sections">
+          @for (section of sections; track $index) {
+            <div class="overview-health-card-hardware-section">
+            @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.key"></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>
+              <span class="cds--type-body-compact-01">
+                {{ row.error }}
+              </span>
+              }
+              <cd-icon type="checkMarkOutline"></cd-icon>
+              <span class="cds--type-body-compact-01">
+                {{ row.ok }}
+              </span>
+              </span>
+            </div>
+            }
+            </div>
+          }
+          </div>
+        } @else {
+        <cds-skeleton-placeholder></cds-skeleton-placeholder>
+        }
+      </div>
+    </ng-container>
 
     <ng-container *ngSwitchDefault></ng-container>
   </div>
index 2d6309aeaeb9c7444dd92ddfe4a6cd0b893a3147..b8b2a653534954bd1844f803091311775034704c 100644 (file)
     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);
index 308957a6b8b1fbf4acde04f45d198b7e390aa836..aa0264a4b9c717fbb1e84340f31420ecc1838dbc 100644 (file)
@@ -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();
index bf1c855b25ec223564459e27b17ec6a6f7105d18..2e5ad7e0504ce9d9775d0b2d49e5bd4da4963e81 100644 (file)
@@ -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<void>();
   @Output() activeSectionChange = new EventEmitter<HealthCardTabSection | null>();
 
   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<OverviewHealthData> = 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<boolean> = 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<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],
+        ok: Number(category?.[key]?.ok ?? 0),
+        error: Number(category?.[key]?.error ?? 0)
+      }));
+    }),
+    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 ec4e410bc3cb3dd5168afcc0a739cf5b1560816f..f368271c42b5ef4786ad32d6f361578fe3fb4c76 100644 (file)
@@ -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<void> };
 
+  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<void>() };
@@ -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();
index 27d7ce1fc2fa4f5ccc4fc3d3e2f8ba32401f0f70..45fe3a93e4aeef5d41d3365cbb3092e4e3b8df4d 100644 (file)
@@ -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
     ]);
   }
 }
index 854b18549a80d3edba24ce12eb5d00bdd09714e3..f5ad4e445e6ab5939ba9cdbd39cd6e76668e67c4 100644 (file)
@@ -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;
+}
index 965a3309b286f55db0f8818e1632c43482e41462..bf0d514f72a3a910940977b39739c8508cf50311 100644 (file)
@@ -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;