]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Add systems tab to health card
authorAfreen Misbah <afreen@ibm.com>
Fri, 13 Feb 2026 23:14:46 +0000 (04:44 +0530)
committerAfreen Misbah <afreen@ibm.com>
Mon, 23 Feb 2026 16:17:49 +0000 (21:47 +0530)
Fixes https://tracker.ceph.com/issues/75065

Signed-off-by: Afreen Misbah <afreen@ibm.com>
12 files changed:
src/pybind/mgr/dashboard/controllers/health.py
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.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss
src/pybind/mgr/dashboard/frontend/src/styles/themes/_content.scss
src/pybind/mgr/dashboard/openapi.yaml

index 866a656afc78e63394e55605a0a3cc84da71a7e8..5d3257b61dd07d215d71bf0e32e957ee3df830c7 100644 (file)
@@ -127,7 +127,8 @@ HEALTH_SNAPSHOT_SCHEMA = ({
         'mutes': ([str], 'List of muted check names')
     }, 'Cluster health overview'),
     'monmap': ({
-        'num_mons': (int, 'Number of monitors')
+        'num_mons': (int, 'Number of monitors'),
+        'quorum': ([int], 'List of monitors in quorum')
     }, 'Monitor map details'),
     'osdmap': ({
         'in': (int, 'Number of OSDs in'),
@@ -157,7 +158,8 @@ HEALTH_SNAPSHOT_SCHEMA = ({
         'up': (int, 'Count of iSCSI gateways running'),
         'down': (int, 'Count of iSCSI gateways not running')
     }, 'Iscsi gateways status'),
-    'num_hosts': (int, 'Count of hosts')
+    'num_hosts': (int, 'Count of hosts'),
+    'num_hosts_available': (int, 'Count of available hosts')
 })
 
 
@@ -389,6 +391,7 @@ class Health(BaseController):
         if self._has_permissions(Permission.READ, Scope.MONITOR):
             summary['monmap'] = {
                 'num_mons': data.get('monmap', {}).get('num_mons'),
+                'quorum': data.get('monmap', {}).get('quorum')
             }
 
         if self._has_permissions(Permission.READ, Scope.OSD):
@@ -449,6 +452,12 @@ class Health(BaseController):
             summary['num_iscsi_gateways'] = self.health_minimal.iscsi_daemons()
 
         if self._has_permissions(Permission.READ, Scope.HOSTS):
-            summary['num_hosts'] = len(get_hosts())
+            hosts = get_hosts()
+            summary['num_hosts'] = len(hosts)
+            available_hosts = [
+                h for h in hosts
+                if h.get("status") == "Available"
+            ]
+            summary['num_hosts_available'] = len(available_hosts)
 
         return summary
index ac27f336ddafe19236dd5f56de93ad9984309528..7230aa877ec966ba8b9efa8af39704b5ad65d4eb 100644 (file)
@@ -1,19 +1,23 @@
 @let data=(data$ | async);
-@let colorClass="overview-health-card-status--" + data?.currentHealth?.icon;
-<cd-productive-card>
+@let colorClass="overview-health-card-status--" + vm?.health?.icon;
+<cd-productive-card class="overview-health-card">
   <!-- HEALTH CARD Title -->
-  @if(fsid) {
+  @if(vm?.fsid) {
   <ng-template #header>
     <div class="overview-health-card-header">
-      <div class="cds-mb-4 cds-mr-3"><cd-icon type="dataCenter"></cd-icon></div>
-      <h2
-        class="cds--type-heading-compact-02"
-        id="fsid">
-        <span>{{fsid}}</span>
+      <div class="cds-mb-4 cds-mr-3">
+        <cd-icon type="dataCenter"></cd-icon>
+      </div>
+      <h2 class="cds--type-heading-compact-02"
+          id="fsid">
+        <span class="cds-mr-2">{{vm?.fsid}}</span>
       </h2>
       <cd-copy-2-clipboard-button
         size="sm"
-        source="fsid"></cd-copy-2-clipboard-button>
+        title="Copy cluster fsid"
+        i18n-title
+        source="fsid">
+      </cd-copy-2-clipboard-button>
     </div>
     <cds-icon-button
       type="button"
     [minLineWidth]="400"></cds-skeleton-text>
   }
   <!-- HEALTH CARD BODY -->
-  @if(data?.currentHealth){
+  @if(vm?.health){
   <p class="cds--type-heading-05 cds-mb-0"
      [ngClass]="colorClass">
-    {{data?.currentHealth?.title}}
-    <cd-icon [type]="data?.currentHealth?.icon"></cd-icon>
+    {{vm?.health?.title}}
+    <cd-icon [type]="vm?.health?.icon"></cd-icon>
   </p>
-  <p class="cds--type-label-01">{{data?.currentHealth?.message}}</p>
+  <p class="cds--type-label-01 overview-health-card-secondary-text">{{vm?.health?.message}}</p>
   } @else {
   <cds-skeleton-placeholder></cds-skeleton-placeholder>
   }
     [lines]="1"
     [maxLineWidth]="250"></cds-skeleton-text>
   }
-  <!-- ------------------------------------------- -->
+  <!-- TABS -->
+  <div cdsStack="horizontal"
+       [gap]="4">
   <!-- HEALTH CHECKS -->
-   @if(incidents > 0) {
-  <cd-icon
-    type="incidentReporter"
-    [ngClass]="colorClass"></cd-icon>
-  <cds-tooltip-definition
-    [highContrast]="true"
-    [openOnHover]="true"
-    [dropShadow]="true"
-    [caret]="true"
-    (click)="onViewIncidentsClick()"
-    description="Click to view health incidents"
-    i18n-description>
-    <span
-      class="cds--type-heading-compact-01"
-      [ngClass]="colorClass"
-      i18n>
-      {{incidents}} Health incidents
-    </span>
-  </cds-tooltip-definition>
-  <cds-tooltip
-    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
-  >
-  <cd-icon type="help"></cd-icon>
-</cds-tooltip>
-}
+  @if(vm?.incidents > 0) {
+  <div>
+    <cd-icon
+      type="incidentReporter"
+      [ngClass]="colorClass"></cd-icon>
+    <cds-tooltip-definition
+      [highContrast]="true"
+      [openOnHover]="true"
+      [dropShadow]="true"
+      [caret]="true"
+      (click)="onViewIncidentsClick()"
+      description="Click to view health incidents"
+      i18n-description>
+      <span
+        class="cds--type-heading-compact-01"
+        [ngClass]="colorClass"
+        i18n>
+        {{vm?.incidents}} Health incidents
+      </span>
+    </cds-tooltip-definition>
+    <cds-tooltip
+      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
+    >
+      <cd-icon type="help"></cd-icon>&nbsp;|
+    </cds-tooltip>
+  </div>
+  }
+  <!-- SYSTEM TAB -->
+  @if(vm?.overallSystemSev) {
+    <div [ngClass]="{'overview-health-card-tab-selected': activeSection === 'system'}">
+      <cd-icon
+        [type]="vm?.overallSystemSev"></cd-icon>
+      <cds-tooltip-definition
+        [highContrast]="true"
+        [openOnHover]="true"
+        [dropShadow]="true"
+        class="cds-ml-2"
+        [caret]="true"
+        (click)="toggleSection('system')"
+        description="Click to view health incidents"
+        i18n-description>
+        <span
+          i18n>
+          Systems
+        </span>
+      </cds-tooltip-definition>
+    </div>
+  } @else {
+  <cds-skeleton-text [lines]="1"></cds-skeleton-text>
+  }
+  </div>
+
+  <!-- TAB CONTENT -->
+  <div  [ngSwitch]="activeSection">
+    <ng-container *ngSwitchCase="'system'">
+      <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>
+        <div cdsStack="horizontal"
+             [gap]="8">
+          <div class="overview-health-card-tab-content-item cds-pr-8">
+            <span>
+              <cd-icon [type]="vm?.mon?.severity"></cd-icon>
+              <span class="cds--type-body-compact-01 cds-ml-2">Monitor</span>
+            </span>
+            <p class="cds--type-label-01 cds-mt-3 overview-health-card-secondary-text">Quorum: {{vm?.mon?.value}}</p>
+          </div>
+          <div class="overview-health-card-tab-content-item cds-pr-8">
+            <span>
+              <cd-icon [type]="vm?.mgr?.severity"></cd-icon>
+              <span class="cds--type-body-compact-01 cds-ml-2">Manager</span>
+            </span>
+            <p class="cds--type-label-01 cds-mt-3 overview-health-card-secondary-text">{{vm?.mgr?.value}}</p>
+          </div>
+          <div class="overview-health-card-tab-content-item cds-pr-8">
+            <span>
+              <cd-icon [type]="vm?.osd?.severity"></cd-icon>
+              <span class="cds--type-body-compact-01 cds-ml-2"
+                    i18n>OSD</span>
+            </span>
+            <p class="cds--type-label-01 cds-mt-3 overview-health-card-secondary-text">{{vm?.osd?.value}}</p>
+          </div>
+          <div>
+            <span>
+              <cd-icon [type]="vm?.hosts?.severity"></cd-icon>
+              <span class="cds--type-body-compact-01 cds-ml-2">Nodes</span>
+            </span>
+            <p class="cds--type-label-01 cds-mt-3 overview-health-card-secondary-text">{{vm?.hosts?.value}}</p>
+          </div>
+        </div>
+      </div>
+    </ng-container>
+
+    <ng-container *ngSwitchDefault></ng-container>
+  </div>
 </cd-productive-card>
index 7b99a71631cae75a5209738b01f3fe95bd1aa713..b1d754f658daff9ac64a1d1a659f65290f6b91d9 100644 (file)
   &-status--error {
     color: var(--cds-text-error);
   }
+
+  &-secondary-text {
+    color: var(--cds-text-secondary);
+  }
+
+  &-tab-selected {
+    border-block-end: 2px solid var(--cds-border-interactive) !important;
+
+    .cds--definition-term {
+      color: var(--cds-text-primary) !important;
+      border-block-end: 0 !important;
+    }
+  }
+
+  &-tab-content {
+    padding: var(--cds-spacing-04) 0;
+  }
+
+  &-tab-content-item {
+    border-right: 1px solid var(--cds-border-subtle);
+  }
+
   // Overrides
   .clipboard-btn {
     padding: var(--cds-spacing-02);
@@ -31,4 +53,9 @@
   .cds--skeleton__placeholder {
     margin-bottom: var(--cds-spacing-03);
   }
+
+  .cds--definition-term {
+    color: var(--cds-link-primary);
+    border-block-end: 1px dotted var(--cds-link-primary);
+  }
 }
index 412d750b7ae385b50fb64b89f1ce024ed9c56048..6f4c74fa652f9e4a1fb8f32f6244e507e23e9964 100644 (file)
@@ -7,51 +7,33 @@ import {
   Output,
   ViewEncapsulation
 } from '@angular/core';
-import { SkeletonModule, ButtonModule, LinkModule, TooltipModule } from 'carbon-components-angular';
+import {
+  SkeletonModule,
+  ButtonModule,
+  LinkModule,
+  TooltipModule,
+  TabsModule,
+  LayoutModule
+} from 'carbon-components-angular';
 import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
 import { RouterModule } from '@angular/router';
 import { ComponentsModule } from '~/app/shared/components/components.module';
 import { SummaryService } from '~/app/shared/services/summary.service';
 import { Summary } from '~/app/shared/models/summary.model';
-import { combineLatest, Observable, of, ReplaySubject } from 'rxjs';
+import { combineLatest, Observable, of } from 'rxjs';
 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 { HealthIconMap, HealthStatus } from '~/app/shared/models/overview';
+import { HealthCardVM } from '~/app/shared/models/overview';
 
 type OverviewHealthData = {
   summary: Summary;
   upgrade: UpgradeInfoInterface;
-  currentHealth: Health;
-};
-
-type Health = {
-  message: string;
-  title: string;
-  icon: string;
 };
 
-const WarnAndErrMessage = $localize`There are active alerts and unresolved health warnings.`;
-
-const HealthMap: Record<HealthStatus, Health> = {
-  HEALTH_OK: {
-    message: $localize`All core services are running normally`,
-    icon: HealthIconMap['HEALTH_OK'],
-    title: $localize`Healthy`
-  },
-  HEALTH_WARN: {
-    message: WarnAndErrMessage,
-    icon: HealthIconMap['HEALTH_WARN'],
-    title: $localize`Warning`
-  },
-  HEALTH_ERR: {
-    message: WarnAndErrMessage,
-    icon: HealthIconMap['HEALTH_ERR'],
-    title: $localize`Critical`
-  }
-};
+type TabSection = 'system' | 'hardware' | 'resiliency';
 
 @Component({
   selector: 'cd-overview-health-card',
@@ -64,7 +46,9 @@ const HealthMap: Record<HealthStatus, Health> = {
     ComponentsModule,
     LinkModule,
     PipesModule,
-    TooltipModule
+    TooltipModule,
+    TabsModule,
+    LayoutModule
   ],
   standalone: true,
   templateUrl: './overview-health-card.component.html',
@@ -76,26 +60,22 @@ export class OverviewHealthCardComponent {
   private readonly summaryService = inject(SummaryService);
   private readonly upgradeService = inject(UpgradeService);
 
-  @Input() fsid!: string;
-  @Input()
-  set status(value: HealthStatus) {
-    this.health$.next(value);
-  }
-  @Input() incidents!: number;
+  @Input({ required: true }) vm!: HealthCardVM;
   @Output() viewIncidents = new EventEmitter<void>();
 
-  private health$ = new ReplaySubject<HealthStatus>(1);
+  activeSection: TabSection | null = null;
+
+  toggleSection(section: TabSection) {
+    this.activeSection = this.activeSection === section ? null : section;
+  }
 
   readonly data$: Observable<OverviewHealthData> = combineLatest([
     this.summaryService.summaryData$.pipe(filter((summary): summary is Summary => !!summary)),
     this.upgradeService.listCached().pipe(
       startWith(null as UpgradeInfoInterface),
       catchError(() => of(null))
-    ),
-    this.health$
-  ]).pipe(
-    map(([summary, upgrade, health]) => ({ summary, upgrade, currentHealth: HealthMap?.[health] }))
-  );
+    )
+  ]).pipe(map(([summary, upgrade]) => ({ summary, upgrade })));
 
   onViewIncidentsClick() {
     this.viewIncidents.emit();
index 007b4f5a66c0e6ab8f8039b1ce76dba5aa546ea4..aa45167850843dd1ebb23c79e0c66a0191e75671 100644 (file)
@@ -1,4 +1,5 @@
-@let vm = vm$ | async;
+@let storage = (storageVm$ | async);
+@let health = (healthCardVm$ | async);
 <div cdsGrid
      [fullWidth]="true"
      class="cds-mt-5 cds-mb-5">
@@ -7,11 +8,9 @@
          class="cds-mb-5"
          [columnNumbers]="{lg: 11}">
       <cd-overview-health-card
-        [fsid]="vm?.healthData?.fsid"
-        [status]="vm?.healthData?.health?.status"
-        [incidents]="vm?.incidentCount"
-        (viewIncidents)="togglePanel()">
-      </cd-overview-health-card>
+          [vm]="health"
+          (viewIncidents)="togglePanel()"
+        ></cd-overview-health-card>
     </div>
     <div cdsCol
          class="cds-mb-5"
@@ -24,8 +23,8 @@
          class="cds-mb-5"
          [columnNumbers]="{lg: 16}">
       <cd-overview-storage-card
-        [total]="vm?.healthData?.pgmap.bytes_total"
-        [used]="vm?.healthData?.pgmap.bytes_used">
+        [total]="storage?.total"
+        [used]="storage?.used">
       </cd-overview-storage-card>
     </div>
   </div>
@@ -37,9 +36,9 @@
     </div>
   </div>
 </div>
-@if (isHealthPanelOpen && vm?.incidentCount > 0) {
+@if (isHealthPanelOpen && health?.incidents > 0) {
   <cd-side-panel
-    [headerText]="'Health incidents ('+ vm?.incidentCount +')'"
+    [headerText]="'Health incidents ('+ health?.incidents +')'"
     [expanded]="isHealthPanelOpen"
     size="md"
     (closed)="togglePanel()">
@@ -48,7 +47,7 @@
       <span>Health incidents are Ceph health checks warnings indicating conditions that require attention and remain until resolved.</span>
     </div>
     <div class="panel-content">
-      @for (check of vm?.checks; track key) {
+      @for (check of health?.checks; track key) {
       <div>
         <div class="overview-check-header">
           <cd-icon [type]="check?.icon"></cd-icon>
index abcf3cdd7233f6917f88723e7e2f8eccc20ed016..b6b0c4d0c2d942f4929f038174c4b31e337f93c7 100644 (file)
@@ -5,12 +5,15 @@ import { OverviewComponent } from './overview.component';
 import { HealthService } from '~/app/shared/api/health.service';
 import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
 import { HealthSnapshotMap } from '~/app/shared/models/health.interface';
+
 import { provideHttpClient } from '@angular/common/http';
+import { provideRouter, RouterModule } from '@angular/router';
+
 import { CommonModule } from '@angular/common';
 import { GridModule, TilesModule } from 'carbon-components-angular';
 import { OverviewHealthCardComponent } from './health-card/overview-health-card.component';
 import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component';
-import { provideRouter, RouterModule } from '@angular/router';
+import { HealthMap, SeverityIconMap } from '~/app/shared/models/overview';
 import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card.component';
 
 describe('OverviewComponent', () => {
@@ -51,23 +54,99 @@ describe('OverviewComponent', () => {
 
   afterEach(() => jest.clearAllMocks());
 
-  // -----------------------------
-  // Component creation
-  // -----------------------------
   it('should create', () => {
     expect(component).toBeTruthy();
   });
 
   // -----------------------------
-  // Vie model stream success
+  // View model stream success
   // -----------------------------
-  it('vm$ should emit transformed HealthSnapshotMap', (done) => {
-    const mockData: HealthSnapshotMap = { health: { checks: { a: {} } } } as any;
+  it('healthCardVm$ should emit HealthCardVM with new keys', (done) => {
+    const mockData: HealthSnapshotMap = {
+      fsid: 'fsid-123',
+      health: {
+        status: 'HEALTH_OK',
+        checks: {
+          a: { severity: 'HEALTH_WARN', summary: { message: 'A issue' } },
+          b: { severity: 'HEALTH_ERR', summary: { message: 'B issue' } }
+        }
+      },
+      // subsystem inputs used by mapper
+      monmap: { num_mons: 3, quorum: [0, 1, 2] } as any,
+      mgrmap: { num_active: 1, num_standbys: 1 } as any,
+      osdmap: { num_osds: 2, up: 2, in: 2 } as any,
+      num_hosts: 5,
+      num_hosts_down: 1
+    } as any;
+
+    mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData));
+
+    const sub = component.healthCardVm$.subscribe((vm) => {
+      expect(vm.fsid).toBe('fsid-123');
+      expect(vm.incidents).toBe(2);
+
+      expect(vm.checks).toHaveLength(2);
+      expect(vm.checks[0]).toEqual(
+        expect.objectContaining({
+          name: 'a',
+          description: 'A issue'
+        })
+      );
+      expect(vm.checks[0].icon).toEqual(expect.any(String));
+
+      expect(vm.health).toEqual(HealthMap['HEALTH_OK']);
+
+      expect(vm.mon).toEqual(
+        expect.objectContaining({
+          value: '3/3',
+          severity: expect.any(String)
+        })
+      );
+      expect(vm.mgr).toEqual(
+        expect.objectContaining({
+          value: '1 active, 1 standby',
+          severity: expect.any(String)
+        })
+      );
+      expect(vm.osd).toEqual(
+        expect.objectContaining({
+          value: '2/2 in/up',
+          severity: expect.any(String)
+        })
+      );
+      expect(vm.hosts).toEqual(
+        expect.objectContaining({
+          value: '1 offline, 4 available',
+          severity: expect.any(String)
+        })
+      );
+
+      expect(vm.overallSystemSev).toEqual(expect.any(String));
+
+      sub.unsubscribe();
+      done();
+    });
+
+    mockRefreshIntervalService.intervalData$.next();
+  });
+
+  it('healthCardVm$ should compute overallSystemSev as worst subsystem severity', (done) => {
+    const mockData: HealthSnapshotMap = {
+      fsid: 'fsid-999',
+      health: { status: 'HEALTH_OK', checks: {} },
+      monmap: { num_mons: 3, quorum: [0, 1, 2] } as any, // ok
+      mgrmap: { num_active: 0, num_standbys: 0 } as any, // err (active < 1)
+      osdmap: { num_osds: 2, up: 2, in: 2 } as any, // ok
+      num_hosts: 1,
+      num_hosts_down: 0 // ok
+    } as any;
+
     mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData));
 
-    component.vm$.subscribe((vm) => {
-      expect(vm.healthData).toEqual(mockData);
-      expect(vm.incidentCount).toBe(1);
+    const sub = component.healthCardVm$.subscribe((vm) => {
+      // mgr -> err, therefore overall should be err icon
+      expect(vm.overallSystemSev).toBe(SeverityIconMap[2]); // sev.err === 2
+      sub.unsubscribe();
       done();
     });
 
@@ -77,12 +156,12 @@ describe('OverviewComponent', () => {
   // -----------------------------
   // View model stream error → EMPTY
   // -----------------------------
-  it('vm$ should not emit if healthService throws', (done) => {
+  it('healthCardVm$ should not emit if healthService throws (EMPTY)', (done) => {
     mockHealthService.getHealthSnapshot.mockReturnValue(throwError(() => new Error('API Error')));
 
     let emitted = false;
 
-    component.vm$.subscribe({
+    component.healthCardVm$.subscribe({
       next: () => (emitted = true),
       complete: () => {
         expect(emitted).toBe(false);
@@ -109,13 +188,9 @@ describe('OverviewComponent', () => {
   // ngOnDestroy
   // -----------------------------
   it('should complete destroy$', () => {
-    const destroy$ = (component as any).destroy$;
-    const nextSpy = jest.spyOn(destroy$, 'next');
-    const completeSpy = jest.spyOn(destroy$, 'complete');
-
-    component.ngOnDestroy();
-
-    expect(nextSpy).toHaveBeenCalled();
-    expect(completeSpy).toHaveBeenCalled();
+    // NOTE: your component now uses DestroyRef + takeUntilDestroyed,
+    // so there is no (component as any).destroy$ anymore.
+    // The simplest test here is to just ensure it can be destroyed without error.
+    expect(() => fixture.destroy()).not.toThrow();
   });
 });
index aaa6db1a741d56479fd3e28c4bed9db40b8bbf42..35b381dcbdff1e522317f969024f135e7c15490e 100644 (file)
-import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy, Component, DestroyRef, inject } from '@angular/core';
 import { GridModule, TilesModule } from 'carbon-components-angular';
-import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component';
-import { OverviewHealthCardComponent } from './health-card/overview-health-card.component';
-import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card.component';
+import { EMPTY, Observable } from 'rxjs';
+import { catchError, exhaustMap, map, shareReplay } from 'rxjs/operators';
+
 import { HealthService } from '~/app/shared/api/health.service';
-import { HealthCheck, HealthSnapshotMap } from '~/app/shared/models/health.interface';
 import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
-import { catchError, exhaustMap, map, takeUntil } from 'rxjs/operators';
-import { EMPTY, Observable, Subject } from 'rxjs';
-import { CommonModule } from '@angular/common';
+import { HealthCheck, HealthSnapshotMap } from '~/app/shared/models/health.interface';
+import {
+  HealthCardCheckVM,
+  HealthCardVM,
+  HealthDisplayVM,
+  HealthIconMap,
+  HealthMap,
+  HealthStatus,
+  Severity,
+  SeverityIconMap
+} from '~/app/shared/models/overview';
 
+import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component';
+import { OverviewHealthCardComponent } from './health-card/overview-health-card.component';
 import { ComponentsModule } from '~/app/shared/components/components.module';
-import { HealthIconMap } from '~/app/shared/models/overview';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card.component';
+
+const sev = {
+  ok: 0 as Severity,
+  warn: 1 as Severity,
+  err: 2 as Severity
+} as const;
+
+const maxSeverity = (...values: Severity[]): Severity => Math.max(...values) as Severity;
+
+function buildHealthDisplay(status: HealthStatus): HealthDisplayVM {
+  return HealthMap[status] ?? HealthMap['HEALTH_OK'];
+}
+
+function safeDifference(a: number, b: number): number | null {
+  return a != null && b != null ? a - b : null;
+}
+
+/**
+ * Mapper: HealthSnapshotMap -> HealthCardVM
+ * Runs only when healthData$ emits.
+ */
+export function buildHealthCardVM(d: HealthSnapshotMap): HealthCardVM {
+  const checksObj: Record<string, HealthCheck> = d.health?.checks ?? {};
+  const healthDisplay = buildHealthDisplay(d.health.status as HealthStatus);
 
-interface OverviewVM {
-  healthData: HealthSnapshotMap | null;
-  incidentCount: number;
-  checks: { name: string; description: string; icon: string }[];
+  // --- Health panel ---
+
+  // Count incidents
+  let incidents = 0;
+  const checks: HealthCardCheckVM[] = [];
+
+  for (const [name, check] of Object.entries(checksObj)) {
+    incidents++;
+    checks.push({
+      name,
+      description: check?.summary?.message ?? '',
+      icon: HealthIconMap[check?.severity] ?? ''
+    });
+  }
+
+  // --- System sub-states ---
+
+  // MON
+  const monTotal = d.monmap?.num_mons ?? 0;
+  const monQuorum = (d.monmap as any)?.quorum?.length ?? 0;
+  const monSev: Severity = monQuorum < monTotal ? sev.warn : sev.ok;
+
+  // MGR
+  const mgrActive = d.mgrmap?.num_active ?? 0;
+  const mgrStandby = d.mgrmap?.num_standbys ?? 0;
+  const mgrSev: Severity = mgrActive < 1 ? sev.err : mgrStandby < 1 ? sev.warn : sev.ok;
+
+  // OSD
+  const osdUp = (d.osdmap as any)?.up ?? 0;
+  const osdIn = (d.osdmap as any)?.in ?? 0;
+  const osdTotal = (d.osdmap as any)?.num_osds ?? 0;
+  const osdDown = safeDifference(osdTotal, osdUp);
+  const osdOut = safeDifference(osdTotal, osdIn);
+  const osdSev: Severity = osdDown > 0 || osdOut > 0 ? sev.err : sev.ok;
+
+  // HOSTS
+  const hostsTotal = d.num_hosts ?? 0;
+  const hostsAvailable = (d as any)?.num_hosts_available ?? 0;
+  const hostsSev: Severity = hostsAvailable < hostsTotal ? sev.warn : sev.ok;
+
+  // Overall = worst of the subsystem severities.
+  const overallSystemSev = maxSeverity(monSev, mgrSev, osdSev, hostsSev);
+
+  return {
+    fsid: d.fsid,
+    overallSystemSev: SeverityIconMap[overallSystemSev],
+
+    incidents,
+    checks,
+
+    health: healthDisplay,
+
+    mon: { value: `${monQuorum}/${monTotal}`, severity: SeverityIconMap[monSev] },
+    mgr: { value: `${mgrActive} active, ${mgrStandby} standby`, severity: SeverityIconMap[mgrSev] },
+    osd: { value: `${osdUp}/${osdTotal} in/up`, severity: SeverityIconMap[osdSev] },
+    hosts: {
+      value: `${hostsAvailable} / ${hostsTotal} available`,
+      severity: SeverityIconMap[hostsSev]
+    }
+  };
 }
 
 @Component({
@@ -35,46 +126,38 @@ interface OverviewVM {
   styleUrl: './overview.component.scss',
   changeDetection: ChangeDetectionStrategy.OnPush
 })
-export class OverviewComponent implements OnDestroy {
-  isHealthPanelOpen: boolean = false;
+export class OverviewComponent {
+  isHealthPanelOpen = false;
 
   private readonly healthService = inject(HealthService);
   private readonly refreshIntervalService = inject(RefreshIntervalService);
+  private readonly destroyRef = inject(DestroyRef);
 
-  private destroy$ = new Subject<void>();
-
-  private healthData$: Observable<HealthSnapshotMap> = this.refreshIntervalObs(() =>
+  private readonly healthData$: Observable<HealthSnapshotMap> = this.refreshIntervalObs(() =>
     this.healthService.getHealthSnapshot()
   );
 
-  public vm$: Observable<OverviewVM> = this.healthData$.pipe(
-    map((data: HealthSnapshotMap) => {
-      const checks = data?.health?.checks ?? {};
-      return {
-        healthData: data,
-        incidentCount: Object.keys(checks)?.length,
-        checks: Object.entries(checks)?.map((check: [string, HealthCheck]) => ({
-          name: check?.[0],
-          description: check?.[1]?.summary?.message,
-          icon: HealthIconMap[check?.[1]?.severity]
-        }))
-      };
-    })
+  readonly healthCardVm$: Observable<HealthCardVM> = this.healthData$.pipe(
+    map(buildHealthCardVM),
+    shareReplay({ bufferSize: 1, refCount: true })
+  );
+
+  readonly storageVm$ = this.healthData$.pipe(
+    map((data) => ({
+      total: data.pgmap?.bytes_total ?? 0,
+      used: data.pgmap?.bytes_used ?? 0
+    })),
+    shareReplay({ bufferSize: 1, refCount: true })
   );
 
   private refreshIntervalObs<T>(fn: () => Observable<T>): Observable<T> {
     return this.refreshIntervalService.intervalData$.pipe(
       exhaustMap(() => fn().pipe(catchError(() => EMPTY))),
-      takeUntil(this.destroy$)
+      takeUntilDestroyed(this.destroyRef)
     );
   }
 
-  togglePanel() {
+  togglePanel(): void {
     this.isHealthPanelOpen = !this.isHealthPanelOpen;
   }
-
-  ngOnDestroy() {
-    this.destroy$.next();
-    this.destroy$.complete();
-  }
 }
index e43fa289d7d7488a32d45bfb150221edc9801517..73212636396775ca0824a39176654a69a36ce992 100644 (file)
@@ -1,6 +1,74 @@
 export type HealthStatus = 'HEALTH_OK' | 'HEALTH_WARN' | 'HEALTH_ERR';
+
 export const HealthIconMap = {
   HEALTH_OK: 'success',
   HEALTH_WARN: 'warningAltFilled',
   HEALTH_ERR: 'error'
 };
+
+export const SeverityIconMap = {
+  0: 'success',
+  1: 'warningAltFilled',
+  2: 'error'
+};
+
+/** 0 ok, 1 warn, 2 err */
+export type Severity = 0 | 1 | 2;
+
+export type Health = {
+  message: string;
+  title: string;
+  icon: string;
+};
+
+const WarnAndErrMessage = $localize`There are active alerts and unresolved health warnings.`;
+
+export const HealthMap: Record<HealthStatus, Health> = {
+  HEALTH_OK: {
+    message: $localize`All core services are running normally`,
+    icon: HealthIconMap['HEALTH_OK'],
+    title: $localize`Healthy`
+  },
+  HEALTH_WARN: {
+    message: WarnAndErrMessage,
+    icon: HealthIconMap['HEALTH_WARN'],
+    title: $localize`Warning`
+  },
+  HEALTH_ERR: {
+    message: WarnAndErrMessage,
+    icon: HealthIconMap['HEALTH_ERR'],
+    title: $localize`Critical`
+  }
+};
+
+export interface HealthDisplayVM {
+  title: string;
+  message: string;
+  icon: string;
+}
+
+export interface HealthCardCheckVM {
+  name: string;
+  description: string;
+  icon: string;
+}
+
+export interface HealthCardSubStateVM {
+  value: string;
+  severity: string;
+}
+
+export interface HealthCardVM {
+  fsid: string;
+  overallSystemSev: string;
+
+  incidents: number;
+  checks: HealthCardCheckVM[];
+
+  health: HealthDisplayVM;
+
+  mon: HealthCardSubStateVM;
+  mgr: HealthCardSubStateVM;
+  osd: HealthCardSubStateVM;
+  hosts: HealthCardSubStateVM;
+}
index 228f54d37a3bb710862fde7f8efaf4d8fe596116..e28f2f766b0a48edda6861af836f2177a68bc138 100644 (file)
@@ -45,7 +45,8 @@ export class DocService {
       trademarks: `${domainCeph}/en/trademarks/`,
       'dashboard-landing-page-status': `${domain}mgr/dashboard/#dashboard-landing-page-status`,
       'dashboard-landing-page-performance': `${domain}mgr/dashboard/#dashboard-landing-page-performance`,
-      'dashboard-landing-page-capacity': `${domain}mgr/dashboard/#dashboard-landing-page-capacity`
+      'dashboard-landing-page-capacity': `${domain}mgr/dashboard/#dashboard-landing-page-capacity`,
+      'dashboard-side-panel': `${domain}/rados/operations/health-checks/`
     };
 
     return sections[section];
index 6f59c43b99d7a87c0e19bbf907bae8f0fcfe1475..f82e8b37e72fef2367a62455b3a38b6ef3842de9 100644 (file)
   padding-top: layout.$spacing-03;
 }
 
+.cds-pr-8 {
+  padding-right: layout.$spacing-08;
+}
+
 // MARGINS
 .cds-m-0 {
   margin: 0;
   margin-top: layout.$spacing-06;
 }
 
+.cds-ml-2 {
+  margin-left: layout.$spacing-02;
+}
+
 .cds-ml-3 {
   margin-left: layout.$spacing-03;
 }
   margin-left: layout.$spacing-05;
 }
 
+.cds-mr-2 {
+  margin-right: layout.$spacing-02;
+}
+
 .cds-mr-3 {
   margin-right: layout.$spacing-03;
 }
index fcf3dd4aebba8c2228a9dc24a931ea5edbc3b77c..57bc6a998d578a9bfd4753ae169ea56e8ffc127f 100644 (file)
@@ -31,7 +31,6 @@ $content-theme: map-merge(
     layer-01: vv.$light,
     layer-hover-01: colors.$gray-20,
     text-primary: vv.$dark,
-    text-secondary: vv.$dark,
     text-disabled: vv.$gray-500,
     icon-secondary: vv.$gray-800,
     field-01: colors.$gray-10,
index 5ae0b103b0a2c06a99489edc369bdc617bc5011e..0b5948cc47540866b242efcfe8188c88e1db5ac1 100755 (executable)
@@ -8193,12 +8193,21 @@ paths:
                       num_mons:
                         description: Number of monitors
                         type: integer
+                      quorum:
+                        description: List of monitors in quorum
+                        items:
+                          type: integer
+                        type: array
                     required: &id054
                     - num_mons
+                    - quorum
                     type: object
                   num_hosts:
                     description: Count of hosts
                     type: integer
+                  num_hosts_available:
+                    description: Count of available hosts
+                    type: integer
                   num_iscsi_gateways:
                     description: Iscsi gateways status
                     properties:
@@ -8280,6 +8289,7 @@ paths:
                 - num_rgw_gateways
                 - num_iscsi_gateways
                 - num_hosts
+                - num_hosts_available
                 type: object
             application/vnd.ceph.api.v1.0+json:
               schema:
@@ -8355,11 +8365,19 @@ paths:
                       num_mons:
                         description: Number of monitors
                         type: integer
+                      quorum:
+                        description: List of monitors in quorum
+                        items:
+                          type: integer
+                        type: array
                     required: *id054
                     type: object
                   num_hosts:
                     description: Count of hosts
                     type: integer
+                  num_hosts_available:
+                    description: Count of available hosts
+                    type: integer
                   num_iscsi_gateways:
                     description: Iscsi gateways status
                     properties: