@let data=(data$ | async);
+@let hwEnabled = (enabled$ | async);
+@let hwSections = (sections$ | async);
+
@let colorClass="overview-health-card-status--" + vm?.health?.icon;
+
+
<cd-productive-card class="overview-health-card">
- <!-- HEALTH CARD Title -->
+ <!-- ----------------------HEALTH CARD HEADER ---------------->
@if(vm?.fsid) {
<ng-template #header>
<div class="overview-health-card-header">
[maxLineWidth]="400"
[minLineWidth]="400"></cds-skeleton-text>
}
- <!-- HEALTH CARD BODY -->
+ <!------------------------- HEALTH CARD CLUSTER STATUS ------------------>
@if(vm?.health){
<p class="cds--type-heading-05 cds-mb-0"
[ngClass]="colorClass">
[lines]="1"
[maxLineWidth]="250"></cds-skeleton-text>
}
- <!-- TABS -->
+
+ <!-----------------------------------TABS -------------------------------------->
+
<div cdsStack="horizontal"
[gap]="4">
<!-- HEALTH CHECKS -->
} @else {
<cds-skeleton-text [lines]="1"></cds-skeleton-text>
}
+ <!-- HARDWARE TAB -->
+ @if(hwEnabled && hwSections) {
+ <div class="overview-health-card-tab"
+ [class.overview-health-card-tab-selected]="activeSection === 'hardware'">
+ <div class="cds-mb-1"><cd-icon
+ [type]="vm?.overallSystemSev"></cd-icon></div>
+ <cds-tooltip-definition
+ [highContrast]="true"
+ [openOnHover]="true"
+ [dropShadow]="true"
+ class="cds-ml-2"
+ [caret]="true"
+ (click)="toggleSection('hardware')"
+ description=""
+ i18n-description>
+ <span
+ class="cds-mr-1"
+ [class.cds--type-heading-compact-01]="activeSection === 'hardware'"
+ i18n>
+ Hardware
+ </span>
+ </cds-tooltip-definition>
+ </div>
+ }
</div>
<!-- TAB CONTENT -->
<div [ngSwitch]="activeSection">
+ <!-- SYSTEM TAB CONTENT -->
<ng-container *ngSwitchCase="'system'">
<div class="overview-health-card-tab-content">
<p class="overview-health-card-secondary-text cds--type-body-compact-01"
</div>
</div>
</ng-container>
+ <!-- HARDWARE TAB CONTENT -->
+ <ng-container *ngSwitchCase="'hardware'">
+ <div class="overview-health-card-tab-content">
+ <p class="overview-health-card-secondary-text cds--type-body-compact-01"
+ i18n>
+ Some cluster components are degraded and may require attention.
+ </p>
+
+ @if (hwEnabled && hwSections) {
+ <div class="overview-health-card-hardware-sections">
+ @for (section of sections; track $index) {
+ <div class="overview-health-card-hardware-section">
+ @for (row of section; track row.key) {
+ <div class="overview-health-card-hardware-row">
+ <span class="overview-health-card-icon-and-text">
+ <cd-icon [type]="row.key"></cd-icon>
+ <span class="cds--type-body-compact-01">
+ {{ row.label }}
+ </span>
+ </span>
+
+ <span class="overview-health-card-hardware-status">
+ @if (row.error > 0) {
+ <cd-icon type="warningAlt"></cd-icon>
+ <span class="cds--type-body-compact-01">
+ {{ row.error }}
+ </span>
+ }
+ <cd-icon type="checkMarkOutline"></cd-icon>
+ <span class="cds--type-body-compact-01">
+ {{ row.ok }}
+ </span>
+ </span>
+ </div>
+ }
+ </div>
+ }
+ </div>
+ } @else {
+ <cds-skeleton-placeholder></cds-skeleton-placeholder>
+ }
+ </div>
+ </ng-container>
<ng-container *ngSwitchDefault></ng-container>
</div>
max-block-size: fit-content;
}
+ &-tab-content-item-row {
+ display: flex;
+ justify-content: space-between;
+ }
+
&-icon-and-text {
display: inline-flex;
align-items: center;
gap: var(--cds-spacing-03);
}
+ &-hardware-sections {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ column-gap: var(--cds-spacing-03);
+ width: 100%;
+ margin-top: var(--cds-spacing-03);
+ padding-right: var(--cds-spacing-06);
+ box-sizing: border-box;
+ }
+
+ &-hardware-section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--cds-spacing-03);
+ min-width: 0;
+ padding-inline-end: var(--cds-spacing-03);
+ border-right: 1px solid var(--cds-border-subtle);
+ box-sizing: border-box;
+ }
+
+ &-hardware-section:last-child {
+ border-right: none;
+ padding-inline-end: 0;
+ }
+
+ &-hardware-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--cds-spacing-03);
+ min-width: 0;
+ }
+
+ &-hardware-status {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--cds-spacing-03);
+ flex-shrink: 0;
+ }
+
// Overrides
.clipboard-btn {
padding: var(--cds-spacing-02);
import { ComponentsModule } from '~/app/shared/components/components.module';
import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { HardwareService } from '~/app/shared/api/hardware.service';
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
describe('OverviewStorageCardComponent (Jest)', () => {
let component: OverviewHealthCardComponent;
listCached: jest.fn(() => of({ versions: [] }))
};
+ const mockAuthStorageService = {
+ getPermissions: jest.fn(() => ({ configOpt: { read: false } }))
+ };
+
+ const mockMgrModuleService = {
+ getConfig: jest.fn(() => of({ hw_monitoring: false }))
+ };
+
+ const mockHardwareService = {
+ getSummary: jest.fn(() => of(null))
+ };
+
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
providers: [
{ provide: SummaryService, useValue: summaryServiceMock },
{ provide: UpgradeService, useValue: upgradeServiceMock },
+ { provide: AuthStorageService, useValue: mockAuthStorageService },
+ { provide: MgrModuleService, useValue: mockMgrModuleService },
+ { provide: HardwareService, useValue: mockHardwareService },
provideRouter([])
]
}).compileComponents();
import { PipesModule } from '~/app/shared/pipes/pipes.module';
import { UpgradeInfoInterface } from '~/app/shared/models/upgrade.interface';
import { UpgradeService } from '~/app/shared/api/upgrade.service';
-import { catchError, filter, map, startWith } from 'rxjs/operators';
+import { catchError, filter, map, shareReplay, startWith, switchMap } from 'rxjs/operators';
import { HealthCardTabSection, HealthCardVM } from '~/app/shared/models/overview';
+import { HardwareService } from '~/app/shared/api/hardware.service';
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { HardwareNameMapping } from '~/app/shared/enum/hardware.enum';
type OverviewHealthData = {
summary: Summary;
- upgrade: UpgradeInfoInterface;
+ upgrade: UpgradeInfoInterface | null;
};
interface HealthItemConfig {
i18n?: boolean;
}
+type HwKey = keyof typeof HardwareNameMapping;
+
+type HwRowVM = {
+ key: HwKey;
+ label: string;
+ ok: number;
+ error: number;
+};
+
@Component({
selector: 'cd-overview-health-card',
imports: [
export class OverviewHealthCardComponent {
private readonly summaryService = inject(SummaryService);
private readonly upgradeService = inject(UpgradeService);
+ private readonly hardwareService = inject(HardwareService);
+ private readonly mgrModuleService = inject(MgrModuleService);
+ private readonly refreshIntervalService = inject(RefreshIntervalService);
+ private readonly authStorageService = inject(AuthStorageService);
@Input({ required: true }) vm!: HealthCardVM;
@Output() viewIncidents = new EventEmitter<void>();
@Output() activeSectionChange = new EventEmitter<HealthCardTabSection | null>();
activeSection: HealthCardTabSection | null = null;
+
healthItems: HealthItemConfig[] = [
{ key: 'mon', label: $localize`Monitor` },
{ key: 'mgr', label: $localize`Manager` },
readonly data$: Observable<OverviewHealthData> = combineLatest([
this.summaryService.summaryData$.pipe(filter((summary): summary is Summary => !!summary)),
this.upgradeService.listCached().pipe(
- startWith(null as UpgradeInfoInterface),
+ startWith(null as UpgradeInfoInterface | null),
catchError(() => of(null))
)
]).pipe(map(([summary, upgrade]) => ({ summary, upgrade })));
onViewIncidentsClick() {
this.viewIncidents.emit();
}
+
+ private readonly permissions = this.authStorageService.getPermissions();
+
+ readonly enabled$: Observable<boolean> = this.permissions?.configOpt?.read
+ ? this.mgrModuleService.getConfig('cephadm').pipe(
+ map((resp: any) => !!resp?.hw_monitoring),
+ catchError(() => of(false)),
+ shareReplay({ bufferSize: 1, refCount: true })
+ )
+ : of(false);
+
+ private readonly hardwareSummary$ = this.enabled$.pipe(
+ switchMap((enabled) => {
+ if (!enabled) return of(null);
+
+ return this.refreshIntervalService.intervalData$.pipe(
+ startWith(null),
+ switchMap(() => this.hardwareService.getSummary().pipe(catchError(() => of(null))))
+ );
+ }),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+
+ private readonly hardwareRows$: Observable<HwRowVM[] | null> = this.hardwareSummary$.pipe(
+ map((hw) => {
+ const category = hw?.total?.category;
+ if (!category) return null;
+
+ return (Object.keys(HardwareNameMapping) as HwKey[]).map((key) => ({
+ key,
+ label: HardwareNameMapping[key],
+ ok: Number(category?.[key]?.ok ?? 0),
+ error: Number(category?.[key]?.error ?? 0)
+ }));
+ }),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+
+ readonly sections$: Observable<HwRowVM[][] | null> = this.hardwareRows$.pipe(
+ map((rows) => {
+ if (!rows) return null;
+
+ const result: HwRowVM[][] = [];
+ for (let i = 0; i < rows.length; i += 2) {
+ result.push(rows.slice(i, i + 2));
+ }
+ return result.slice(0, 3);
+ }),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
}
import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component';
import { HealthMap, SeverityIconMap } from '~/app/shared/models/overview';
import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card.component';
+import { HardwareService } from '~/app/shared/api/hardware.service';
+import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
describe('OverviewComponent', () => {
let component: OverviewComponent;
let mockHealthService: { getHealthSnapshot: jest.Mock };
let mockRefreshIntervalService: { intervalData$: Subject<void> };
+ const mockAuthStorageService = {
+ getPermissions: jest.fn(() => ({ configOpt: { read: false } }))
+ };
+
+ const mockMgrModuleService = {
+ getConfig: jest.fn(() => of({ hw_monitoring: false }))
+ };
+
+ const mockHardwareService = {
+ getSummary: jest.fn(() => of(null))
+ };
+
beforeEach(async () => {
mockHealthService = { getHealthSnapshot: jest.fn() };
mockRefreshIntervalService = { intervalData$: new Subject<void>() };
provideRouter([]),
{ provide: HealthService, useValue: mockHealthService },
{ provide: RefreshIntervalService, useValue: mockRefreshIntervalService },
+ { provide: AuthStorageService, useValue: mockAuthStorageService },
+ { provide: MgrModuleService, useValue: mockMgrModuleService },
+ { provide: HardwareService, useValue: mockHardwareService },
provideRouter([])
]
}).compileComponents();
import WarningAltFilled16 from '@carbon/icons/es/warning--alt--filled/16';
import Help16 from '@carbon/icons/es/help/16';
import IncidentReporter16 from '@carbon/icons/es/incident-reporter/16';
+import IbmStreamSets16 from '@carbon/icons/es/ibm--streamsets/16';
+import DataEnrichment16 from '@carbon/icons/es/data-enrichment/16';
+import Network116 from '@carbon/icons/es/network--1/16';
+import Chip16 from '@carbon/icons/es/chip/16';
+import Plug16 from '@carbon/icons/es/plug/16';
+import VmdkDisk16 from '@carbon/icons/es/vmdk-disk/16';
+import WarningAlt16 from '@carbon/icons/es/warning--alt/16';
+import CheckMarkOutline16 from '@carbon/icons/es/checkmark--outline/16';
import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component';
import { PageHeaderComponent } from './page-header/page-header.component';
Upgrade16,
WarningAltFilled16,
Help16,
- IncidentReporter16
+ IncidentReporter16,
+ IbmStreamSets16,
+ DataEnrichment16,
+ Network116,
+ Chip16,
+ Plug16,
+ VmdkDisk16,
+ WarningAlt16,
+ CheckMarkOutline16
]);
}
}
.emptySearch-icon {
fill: theme.$layer-selected-disabled !important;
}
+
+.warningAlt-icon {
+ fill: theme.$support-caution-major !important;
+}
+
+.checkMarkOutline-icon {
+ fill: theme.$support-success !important;
+}
upgrade = 'upgrade',
warningAltFilled = 'warning--alt--filled',
help = 'help',
- incidentReporter = 'incident-reporter'
+ incidentReporter = 'incident-reporter',
+ ibmStreamSets = 'ibm--streamsets',
+ dataEnrichment = 'data-enrichment',
+ network1 = 'network--1',
+ chip = 'chip',
+ plug = 'plug',
+ vmdkDisk = 'vmdk-disk',
+ checkMarkOutline = 'checkmark--outline',
+ warningAlt = 'warning--alt'
}
export enum IconSize {
upgrade: 'upgrade',
warningAltFilled: 'warning--alt--filled',
help: 'help',
- incidentReporter: 'incident-reporter'
+ incidentReporter: 'incident-reporter',
+ ibmStreamSets: 'ibm--streamsets',
+ dataEnrichment: 'data-enrichment',
+ network1: 'network--1',
+ chip: 'chip',
+ plug: 'plug',
+ vmdkDisk: 'vmdk-disk',
+ warningAlt: 'warning--alt',
+ checkMarkOutline: 'checkmark--outline'
} as const;