From 07e9ab519ce1512aa2985c30fd0651698b7daa8e Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Sat, 14 Feb 2026 04:44:46 +0530 Subject: [PATCH] mgr/dashboard: Add systems tab to health card Fixes https://tracker.ceph.com/issues/75065 Signed-off-by: Afreen Misbah --- .../mgr/dashboard/controllers/health.py | 15 +- .../overview-health-card.component.html | 160 +++++++++++++----- .../overview-health-card.component.scss | 27 +++ .../overview-health-card.component.ts | 64 +++---- .../app/ceph/overview/overview.component.html | 21 ++- .../ceph/overview/overview.component.spec.ts | 115 ++++++++++--- .../app/ceph/overview/overview.component.ts | 159 ++++++++++++----- .../src/app/shared/models/overview.ts | 68 ++++++++ .../src/app/shared/services/doc.service.ts | 3 +- .../src/styles/ceph-custom/_spacings.scss | 12 ++ .../frontend/src/styles/themes/_content.scss | 1 - src/pybind/mgr/dashboard/openapi.yaml | 18 ++ 12 files changed, 505 insertions(+), 158 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/health.py b/src/pybind/mgr/dashboard/controllers/health.py index 866a656afc7..5d3257b61dd 100644 --- a/src/pybind/mgr/dashboard/controllers/health.py +++ b/src/pybind/mgr/dashboard/controllers/health.py @@ -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 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 ac27f336dda..7230aa877ec 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,19 +1,23 @@ @let data=(data$ | async); -@let colorClass="overview-health-card-status--" + data?.currentHealth?.icon; - +@let colorClass="overview-health-card-status--" + vm?.health?.icon; + - @if(fsid) { + @if(vm?.fsid) {
-
-

- {{fsid}} +
+ +
+

+ {{vm?.fsid}}

+ title="Copy cluster fsid" + i18n-title + source="fsid"> +
} - @if(data?.currentHealth){ + @if(vm?.health){

- {{data?.currentHealth?.title}} - + {{vm?.health?.title}} +

-

{{data?.currentHealth?.message}}

+

{{vm?.health?.message}}

} @else { } @@ -63,34 +67,106 @@ [lines]="1" [maxLineWidth]="250"> } - + +
- @if(incidents > 0) { - - - - {{incidents}} Health incidents - - - - - -} + @if(vm?.incidents > 0) { +
+ + + + {{vm?.incidents}} Health incidents + + + +  | + +
+ } + + @if(vm?.overallSystemSev) { +
+ + + + Systems + + +
+ } @else { + + } +
+ + +
+ +
+

Some cluster components are degraded and may require attention.

+
+
+ + + Monitor + +

Quorum: {{vm?.mon?.value}}

+
+
+ + + Manager + +

{{vm?.mgr?.value}}

+
+
+ + + OSD + +

{{vm?.osd?.value}}

+
+
+ + + Nodes + +

{{vm?.hosts?.value}}

+
+
+
+
+ + +
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 7b99a71631c..b1d754f658d 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 @@ -15,6 +15,28 @@ &-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); + } } 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 412d750b7ae..6f4c74fa652 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 @@ -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 = { - 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 = { 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(); - private health$ = new ReplaySubject(1); + activeSection: TabSection | null = null; + + toggleSection(section: TabSection) { + this.activeSection = this.activeSection === section ? null : section; + } readonly data$: Observable = 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(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html index 007b4f5a66c..aa451678508 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html @@ -1,4 +1,5 @@ -@let vm = vm$ | async; +@let storage = (storageVm$ | async); +@let health = (healthCardVm$ | async);
@@ -7,11 +8,9 @@ class="cds-mb-5" [columnNumbers]="{lg: 11}"> - + [vm]="health" + (viewIncidents)="togglePanel()" + >
+ [total]="storage?.total" + [used]="storage?.used">
@@ -37,9 +36,9 @@ -@if (isHealthPanelOpen && vm?.incidentCount > 0) { +@if (isHealthPanelOpen && health?.incidents > 0) { @@ -48,7 +47,7 @@ Health incidents are Ceph health checks warnings indicating conditions that require attention and remain until resolved.
- @for (check of vm?.checks; track key) { + @for (check of health?.checks; track key) {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts index abcf3cdd723..b6b0c4d0c2d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts @@ -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(); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts index aaa6db1a741..35b381dcbdf 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts @@ -1,22 +1,113 @@ -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 = 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(); - - private healthData$: Observable = this.refreshIntervalObs(() => + private readonly healthData$: Observable = this.refreshIntervalObs(() => this.healthService.getHealthSnapshot() ); - public vm$: Observable = 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 = 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(fn: () => Observable): Observable { 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(); - } } 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 e43fa289d7d..73212636396 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,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 = { + 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; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts index 228f54d37a3..e28f2f766b0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/doc.service.ts @@ -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]; diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss index 6f59c43b99d..f82e8b37e72 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss @@ -13,6 +13,10 @@ padding-top: layout.$spacing-03; } +.cds-pr-8 { + padding-right: layout.$spacing-08; +} + // MARGINS .cds-m-0 { margin: 0; @@ -58,6 +62,10 @@ margin-top: layout.$spacing-06; } +.cds-ml-2 { + margin-left: layout.$spacing-02; +} + .cds-ml-3 { margin-left: layout.$spacing-03; } @@ -66,6 +74,10 @@ margin-left: layout.$spacing-05; } +.cds-mr-2 { + margin-right: layout.$spacing-02; +} + .cds-mr-3 { margin-right: layout.$spacing-03; } diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/themes/_content.scss b/src/pybind/mgr/dashboard/frontend/src/styles/themes/_content.scss index fcf3dd4aebb..57bc6a998d5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/themes/_content.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/themes/_content.scss @@ -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, diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 5ae0b103b0a..0b5948cc475 100755 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -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: -- 2.47.3