'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'),
'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')
})
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):
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
@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> |
+ </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>
&-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);
.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);
+ }
}
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',
ComponentsModule,
LinkModule,
PipesModule,
- TooltipModule
+ TooltipModule,
+ TabsModule,
+ LayoutModule
],
standalone: true,
templateUrl: './overview-health-card.component.html',
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();
-@let vm = vm$ | async;
+@let storage = (storageVm$ | async);
+@let health = (healthCardVm$ | async);
<div cdsGrid
[fullWidth]="true"
class="cds-mt-5 cds-mb-5">
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"
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>
</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()">
<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>
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', () => {
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();
});
// -----------------------------
// 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);
// 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();
});
});
-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({
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();
- }
}
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;
+}
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];
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;
}
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,
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:
- num_rgw_gateways
- num_iscsi_gateways
- num_hosts
+ - num_hosts_available
type: object
application/vnd.ceph.api.v1.0+json:
schema:
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: