-@let data = (data$ | async);
+@let data=(data$ | async);
+@let colorClass="overview-health-card-status--" + data?.currentHealth?.icon;
<cd-productive-card>
<!-- HEALTH CARD Title -->
@if(fsid) {
<!-- HEALTH CARD BODY -->
@if(data?.currentHealth){
<p class="cds--type-heading-05 cds-mb-0"
- [ngClass]="'overview-health-card-status--' + data?.currentHealth?.icon">
+ [ngClass]="colorClass">
{{data?.currentHealth?.title}}
<cd-icon [type]="data?.currentHealth?.icon"></cd-icon>
</p>
<p class="cds--type-label-02">
<span i18n>Ceph version: </span>
<span class="cds--type-heading-compact-01">{{ data?.summary?.version | cephVersion }}</span>
- <!-- UPGRADE AVAILABLE -->
@if (data?.upgrade?.versions?.length) {
<a [routerLink]="['/upgrade']"
cdsLink
[lines]="1"
[maxLineWidth]="250"></cds-skeleton-text>
}
+ <!-- ------------------------------------------- -->
+ <!-- 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>
+}
</cd-productive-card>
display: flex;
align-items: end;
}
-
// CSS for status text, modifier names match icons name
&-status--success {
color: var(--cds-support-success);
&-status--error {
color: var(--cds-text-error);
}
-}
-
-// Overrides
-.clipboard-btn {
- padding: var(--cds-spacing-02);
-}
+ // Overrides
+ .clipboard-btn {
+ padding: var(--cds-spacing-02);
+ }
-.cds--btn--icon-only {
- padding: var(--cds-spacing-01);
-}
+ .cds--btn--icon-only {
+ padding: var(--cds-spacing-01);
+ }
-.cds--link.cds--link--inline {
- text-decoration: none;
-}
+ .cds--link.cds--link--inline {
+ text-decoration: none;
+ }
-.cds--skeleton__placeholder {
- margin-bottom: var(--cds-spacing-03);
+ .cds--skeleton__placeholder {
+ margin-bottom: var(--cds-spacing-03);
+ }
}
import {
ChangeDetectionStrategy,
Component,
+ EventEmitter,
inject,
Input,
+ Output,
ViewEncapsulation
} from '@angular/core';
-import { SkeletonModule, ButtonModule, LinkModule } from 'carbon-components-angular';
+import { SkeletonModule, ButtonModule, LinkModule, TooltipModule } 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 { 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';
type OverviewHealthData = {
summary: Summary;
icon: string;
};
-type HealthStatus = 'HEALTH_OK' | 'HEALTH_WARN' | 'HEALTH_ERR';
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: 'success',
+ icon: HealthIconMap['HEALTH_OK'],
title: $localize`Healthy`
},
HEALTH_WARN: {
message: WarnAndErrMessage,
- icon: 'warningAltFilled',
+ icon: HealthIconMap['HEALTH_WARN'],
title: $localize`Warning`
},
HEALTH_ERR: {
message: WarnAndErrMessage,
- icon: 'error',
+ icon: HealthIconMap['HEALTH_ERR'],
title: $localize`Critical`
}
};
RouterModule,
ComponentsModule,
LinkModule,
- PipesModule
+ PipesModule,
+ TooltipModule
],
standalone: true,
templateUrl: './overview-health-card.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class OverviewHealthCardComponent {
+ private readonly summaryService = inject(SummaryService);
+ private readonly upgradeService = inject(UpgradeService);
+
@Input() fsid!: string;
@Input()
- set health(value: HealthStatus) {
+ set status(value: HealthStatus) {
this.health$.next(value);
}
- private health$ = new ReplaySubject<HealthStatus>(1);
+ @Input() incidents!: number;
+ @Output() viewIncidents = new EventEmitter<void>();
- private readonly summaryService = inject(SummaryService);
- private readonly upgradeService = inject(UpgradeService);
+ private health$ = new ReplaySubject<HealthStatus>(1);
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))
]).pipe(
map(([summary, upgrade, health]) => ({ summary, upgrade, currentHealth: HealthMap?.[health] }))
);
+
+ onViewIncidentsClick() {
+ this.viewIncidents.emit();
+ }
}
-@let healthData = healthData$ | async;
+@let vm = vm$ | async;
<div cdsGrid
- [narrow]="true"
- [condensed]="false"
[fullWidth]="true"
class="cds-mt-5 cds-mb-5">
- <div cdsRow
- [narrow]="true">
+ <div cdsRow>
<div cdsCol
class="cds-mb-5"
[columnNumbers]="{lg: 11}">
<cd-overview-health-card
- [fsid]="healthData?.fsid"
- [health]="healthData?.health?.status">
+ [fsid]="vm?.healthData?.fsid"
+ [status]="vm?.healthData?.health?.status"
+ [incidents]="vm?.incidentCount"
+ (viewIncidents)="togglePanel()">
</cd-overview-health-card>
</div>
<div cdsCol
<cds-tile>Alerts card</cds-tile>
</div>
</div>
- <div cdsRow
- [narrow]="true">
+ <div cdsRow>
<div cdsCol
class="cds-mb-5"
[columnNumbers]="{lg: 16}">
<cd-overview-storage-card
- [total]="healthData?.pgmap.bytes_total"
- [used]="healthData?.pgmap.bytes_used">
+ [total]="vm?.healthData?.pgmap.bytes_total"
+ [used]="vm?.healthData?.pgmap.bytes_used">
</cd-overview-storage-card>
</div>
</div>
</div>
</div>
</div>
+@if (isHealthPanelOpen && vm?.incidentCount > 0) {
+ <cd-side-panel
+ [headerText]="'Health incidents ('+ vm?.incidentCount +')'"
+ [expanded]="isHealthPanelOpen"
+ size="md"
+ (closed)="togglePanel()">
+ <div panel-header-description
+ class="cds--type-body-01">
+ <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) {
+ <div>
+ <div class="overview-check-header">
+ <cd-icon [type]="check?.icon"></cd-icon>
+ <span class="cds--type-body-compact-01 overview-check-name">
+ {{ check?.name }}
+ </span>
+ </div>
+ <p class="cds--type-body-compact-01 overview-check-description">{{ check?.description }}</p>
+ </div>
+ }
+ </div>
+ </cd-side-panel>
+}
+.overview {
+ &-check-header {
+ display: flex;
+ align-items: center;
+ gap: var(--cds-spacing-02);
+ margin-bottom: var(--cds-spacing-02);
+ }
+
+ &-check-name {
+ color: var(--cds-text-primary);
+ margin-bottom: var(--cds-spacing-01);
+ margin-top: var(--cds-spacing-02);
+ }
+
+ &-check-description {
+ color: var(--cds-text-secondary);
+ }
+}
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 { provideHttpClientTesting } from '@angular/common/http/testing';
+import { provideRouter } from '@angular/router';
describe('OverviewComponent', () => {
let component: OverviewComponent;
let fixture: ComponentFixture<OverviewComponent>;
- let mockHealthService: {
- getHealthSnapshot: jest.Mock;
- };
-
- let mockRefreshIntervalService: {
- intervalData$: Subject<void>;
- };
+ let mockHealthService: { getHealthSnapshot: jest.Mock };
+ let mockRefreshIntervalService: { intervalData$: Subject<void> };
beforeEach(async () => {
- mockHealthService = {
- getHealthSnapshot: jest.fn()
- };
-
- mockRefreshIntervalService = {
- intervalData$: new Subject<void>()
- };
+ mockHealthService = { getHealthSnapshot: jest.fn() };
+ mockRefreshIntervalService = { intervalData$: new Subject<void>() };
await TestBed.configureTestingModule({
imports: [
],
providers: [
provideHttpClient(),
+ provideHttpClientTesting(),
{ provide: HealthService, useValue: mockHealthService },
- { provide: RefreshIntervalService, useValue: mockRefreshIntervalService }
+ { provide: RefreshIntervalService, useValue: mockRefreshIntervalService },
+ provideRouter([])
]
}).compileComponents();
fixture.detectChanges();
});
- afterEach(() => {
- jest.clearAllMocks();
- });
-
- // --------------------------------------------------
- // CREATION
- // --------------------------------------------------
+ afterEach(() => jest.clearAllMocks());
+ // -----------------------------
+ // Component creation
+ // -----------------------------
it('should create', () => {
expect(component).toBeTruthy();
});
- // --------------------------------------------------
- // refreshIntervalObs - success case
- // --------------------------------------------------
+ // -----------------------------
+ // Vie model stream success
+ // -----------------------------
+ it('vm$ should emit transformed HealthSnapshotMap', (done) => {
+ const mockData: HealthSnapshotMap = { health: { checks: { a: {} } } } as any;
+ mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData));
- it('should call healthService when interval emits', (done) => {
- const mockResponse: HealthSnapshotMap = { status: 'OK' } as any;
-
- mockHealthService.getHealthSnapshot.mockReturnValue(of(mockResponse));
-
- component.healthData$.subscribe((data) => {
- expect(data).toEqual(mockResponse);
- expect(mockHealthService.getHealthSnapshot).toHaveBeenCalled();
+ component.vm$.subscribe((vm) => {
+ expect(vm.healthData).toEqual(mockData);
+ expect(vm.incidentCount).toBe(1);
done();
});
mockRefreshIntervalService.intervalData$.next();
});
- // --------------------------------------------------
- // refreshIntervalObs - error case (catchError → EMPTY)
- // --------------------------------------------------
-
- it('should return EMPTY when healthService throws error', (done) => {
+ // -----------------------------
+ // View model stream error → EMPTY
+ // -----------------------------
+ it('vm$ should not emit if healthService throws', (done) => {
mockHealthService.getHealthSnapshot.mockReturnValue(throwError(() => new Error('API Error')));
let emitted = false;
- component.healthData$.subscribe({
- next: () => {
- emitted = true;
- },
+ component.vm$.subscribe({
+ next: () => (emitted = true),
complete: () => {
expect(emitted).toBe(false);
done();
mockRefreshIntervalService.intervalData$.complete();
});
- // --------------------------------------------------
- // refreshIntervalObs - exhaustMap behavior
- // --------------------------------------------------
-
- it('should ignore new interval emissions until previous completes', () => {
- const interval$ = new Subject<void>();
- const inner$ = new Subject<any>();
-
- const mockRefreshService = {
- intervalData$: interval$
- };
-
- const testComponent = new OverviewComponent(
- mockHealthService as any,
- mockRefreshService as any
- );
-
- mockHealthService.getHealthSnapshot.mockReturnValue(inner$);
-
- testComponent.healthData$.subscribe();
-
- // First emission
- interval$.next();
-
- // Second emission (should be ignored)
- interval$.next();
-
- expect(mockHealthService.getHealthSnapshot).toHaveBeenCalledTimes(1);
-
- // Complete first inner observable
- inner$.complete();
-
- // Now it should allow another call
- interval$.next();
-
- expect(mockHealthService.getHealthSnapshot).toHaveBeenCalledTimes(2);
+ // -----------------------------
+ // toggle health panel
+ // -----------------------------
+ it('should toggle panel open/close', () => {
+ expect(component.isHealthPanelOpen).toBe(false);
+ component.togglePanel();
+ expect(component.isHealthPanelOpen).toBe(true);
+ component.togglePanel();
+ expect(component.isHealthPanelOpen).toBe(false);
});
- // --------------------------------------------------
+ // -----------------------------
// ngOnDestroy
- // --------------------------------------------------
-
- it('should complete destroy$ on destroy', () => {
- const nextSpy = jest.spyOn((component as any).destroy$, 'next');
- const completeSpy = jest.spyOn((component as any).destroy$, 'complete');
+ // -----------------------------
+ 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();
});
-
- // --------------------------------------------------
- // refreshIntervalObs manual test
- // --------------------------------------------------
-
- it('refreshIntervalObs should pipe intervalData$', (done) => {
- const testFn = jest.fn().mockReturnValue(of('TEST'));
-
- const obs$ = component.refreshIntervalObs(testFn);
-
- obs$.subscribe((value) => {
- expect(value).toBe('TEST');
- expect(testFn).toHaveBeenCalled();
- done();
- });
-
- mockRefreshIntervalService.intervalData$.next();
- });
});
-import { Component, OnDestroy } from '@angular/core';
+import { ChangeDetectionStrategy, Component, inject, OnDestroy } from '@angular/core';
import { GridModule, TilesModule } from 'carbon-components-angular';
import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component';
import { HealthService } from '~/app/shared/api/health.service';
-import { HealthSnapshotMap } from '~/app/shared/models/health.interface';
+import { HealthCheck, HealthSnapshotMap } from '~/app/shared/models/health.interface';
import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
-import { catchError, exhaustMap, takeUntil } from 'rxjs/operators';
+import { catchError, exhaustMap, map, takeUntil } from 'rxjs/operators';
import { EMPTY, Observable, Subject } from 'rxjs';
import { CommonModule } from '@angular/common';
import { OverviewHealthCardComponent } from './health-card/overview-health-card.component';
+import { ComponentsModule } from '~/app/shared/components/components.module';
+import { HealthIconMap } from '~/app/shared/models/overview';
+
+interface OverviewVM {
+ healthData: HealthSnapshotMap | null;
+ incidentCount: number;
+ checks: { name: string; description: string; icon: string }[];
+}
@Component({
selector: 'cd-overview',
GridModule,
TilesModule,
OverviewStorageCardComponent,
- OverviewHealthCardComponent
+ OverviewHealthCardComponent,
+ ComponentsModule
],
standalone: true,
templateUrl: './overview.component.html',
- styleUrl: './overview.component.scss'
+ styleUrl: './overview.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush
})
export class OverviewComponent implements OnDestroy {
+ isHealthPanelOpen: boolean = false;
+
+ private readonly healthService = inject(HealthService);
+ private readonly refreshIntervalService = inject(RefreshIntervalService);
+
private destroy$ = new Subject<void>();
- public healthData$: Observable<HealthSnapshotMap>;
-
- constructor(
- private healthService: HealthService,
- private refreshIntervalService: RefreshIntervalService
- ) {
- this.healthData$ = this.refreshIntervalObs<HealthSnapshotMap>(() =>
- this.healthService.getHealthSnapshot()
- );
- }
- refreshIntervalObs<T>(fn: () => Observable<T>): Observable<T> {
+ private 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]
+ }))
+ };
+ })
+ );
+
+ private refreshIntervalObs<T>(fn: () => Observable<T>): Observable<T> {
return this.refreshIntervalService.intervalData$.pipe(
exhaustMap(() => fn().pipe(catchError(() => EMPTY))),
takeUntil(this.destroy$)
);
}
+ togglePanel() {
+ this.isHealthPanelOpen = !this.isHealthPanelOpen;
+ }
+
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
import Upgrade16 from '@carbon/icons/es/upgrade/16';
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 { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component';
import { PageHeaderComponent } from './page-header/page-header.component';
DataViewAlt16,
DataCenter16,
Upgrade16,
- WarningAltFilled16
+ WarningAltFilled16,
+ Help16,
+ IncidentReporter16
]);
}
}
@if(text) {
<span data-toggle="tooltip"
[title]="text"
- ngClass="cds--type-mono">{{text}}</span>
+ class="cds--type-mono">{{text}}</span>
}
<cd-icon type="copy"></cd-icon>
</button>
fill: theme.$support-caution-major !important;
}
+.warningAltFilled-icon {
+ fill: theme.$support-caution-major !important;
+}
+
.error-icon {
- fill: theme.$support-error !important;
+ fill: var(--cds-text-error) !important;
}
.info-icon {
<cds-icon-button kind="ghost"
class="float-end"
title="Close"
+ i18n-title
(click)="close()">
<svg cdsIcon="close"></svg>
</cds-icon-button>
- <div class="panel-header cds--type-heading-03"
+ <div class="panel-header"
*ngIf="headerText">
- {{ headerText }}
+ <p class="cds--type-heading-03">{{ headerText }}</p>
+ <ng-content select="[panel-header-description]"></ng-content>
</div>
</div>
<ng-content select=".panel-content"></ng-content>
dataViewAlt = 'data--view--alt',
dataCenter = 'data--center',
upgrade = 'upgrade',
- warningAltFilled = 'warning--alt--filled'
+ warningAltFilled = 'warning--alt--filled',
+ help = 'help',
+ incidentReporter = 'incident-reporter'
}
export enum IconSize {
dataViewAlt: 'data--view--alt',
dataCenter: 'data--center',
upgrade: 'upgrade',
- warningAltFilled: 'warning--alt--filled'
+ warningAltFilled: 'warning--alt--filled',
+ help: 'help',
+ incidentReporter: 'incident-reporter'
} as const;
--- /dev/null
+export type HealthStatus = 'HEALTH_OK' | 'HEALTH_WARN' | 'HEALTH_ERR';
+export const HealthIconMap = {
+ HEALTH_OK: 'success',
+ HEALTH_WARN: 'warningAltFilled',
+ HEALTH_ERR: 'error'
+};