--- /dev/null
+@let data = (data$ | async);
+<cd-productive-card>
+ <!-- HEALTH CARD Title -->
+ @if(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>
+ </h2>
+ <cd-copy-2-clipboard-button
+ size="sm"
+ source="fsid"></cd-copy-2-clipboard-button>
+ </div>
+ <cds-icon-button
+ type="button"
+ kind="ghost"
+ size="sm"
+ description="Check logs"
+ i18n-description
+ [routerLink]="['/logs']">
+ <cd-icon type="dataViewAlt"></cd-icon>
+ </cds-icon-button>
+ </ng-template>
+ } @else {
+ <cds-skeleton-text
+ [lines]="1"
+ [maxLineWidth]="400"
+ [minLineWidth]="400"></cds-skeleton-text>
+ }
+ <!-- HEALTH CARD BODY -->
+ @if(data?.currentHealth){
+ <p class="cds--type-heading-05 cds-mb-0"
+ [ngClass]="'overview-health-card-status--' + data?.currentHealth?.icon">
+ {{data?.currentHealth?.title}}
+ <cd-icon [type]="data?.currentHealth?.icon"></cd-icon>
+ </p>
+ <p class="cds--type-label-01">{{data?.currentHealth?.message}}</p>
+ } @else {
+ <cds-skeleton-placeholder></cds-skeleton-placeholder>
+ }
+
+ @if(data?.summary?.version) {
+ <!-- CEPH VERSION -->
+ <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
+ [inline]="true"
+ i18n>
+ Upgrade available
+ <cd-icon type="upgrade"></cd-icon>
+ </a>
+ }
+ </p>
+ } @else {
+ <cds-skeleton-text
+ [lines]="1"
+ [maxLineWidth]="250"></cds-skeleton-text>
+ }
+</cd-productive-card>
--- /dev/null
+.overview-health-card {
+ &-header {
+ display: flex;
+ align-items: end;
+ }
+
+ // CSS for status text, modifier names match icons name
+ &-status--success {
+ color: var(--cds-support-success);
+ }
+
+ &-status--warningAltFilled {
+ color: var(--cds-support-caution-major);
+ }
+
+ &-status--error {
+ color: var(--cds-text-error);
+ }
+}
+
+// Overrides
+.clipboard-btn {
+ padding: var(--cds-spacing-02);
+}
+
+.cds--btn--icon-only {
+ padding: var(--cds-spacing-01);
+}
+
+.cds--link.cds--link--inline {
+ text-decoration: none;
+}
+
+.cds--skeleton__placeholder {
+ margin-bottom: var(--cds-spacing-03);
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
+
+import { OverviewHealthCardComponent } from './overview-health-card.component';
+import { SummaryService } from '~/app/shared/services/summary.service';
+import { UpgradeService } from '~/app/shared/api/upgrade.service';
+import { provideRouter, RouterModule } from '@angular/router';
+import { CommonModule } from '@angular/common';
+import { SkeletonModule, ButtonModule, LinkModule } from 'carbon-components-angular';
+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';
+
+describe('OverviewStorageCardComponent (Jest)', () => {
+ let component: OverviewHealthCardComponent;
+ let fixture: ComponentFixture<OverviewHealthCardComponent>;
+
+ const summaryServiceMock = {
+ summaryData$: of({
+ version:
+ 'ceph version 13.1.0-419-g251e2515b5 (251e2515b563856349498c6caf34e7a282f62937) nautilus (dev)'
+ })
+ };
+
+ const upgradeServiceMock = {
+ listCached: jest.fn(() => of({ versions: [] }))
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [
+ OverviewHealthCardComponent,
+ CommonModule,
+ ProductiveCardComponent,
+ SkeletonModule,
+ ButtonModule,
+ RouterModule,
+ ComponentsModule,
+ LinkModule,
+ PipesModule
+ ],
+ providers: [
+ { provide: SummaryService, useValue: summaryServiceMock },
+ { provide: UpgradeService, useValue: upgradeServiceMock },
+ provideRouter([])
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(OverviewHealthCardComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import {
+ ChangeDetectionStrategy,
+ Component,
+ inject,
+ Input,
+ ViewEncapsulation
+} from '@angular/core';
+import { SkeletonModule, ButtonModule, LinkModule } 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 { 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';
+
+type OverviewHealthData = {
+ summary: Summary;
+ upgrade: UpgradeInfoInterface;
+ currentHealth: Health;
+};
+
+type Health = {
+ message: string;
+ title: string;
+ 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',
+ title: $localize`Healthy`
+ },
+ HEALTH_WARN: {
+ message: WarnAndErrMessage,
+ icon: 'warningAltFilled',
+ title: $localize`Warning`
+ },
+ HEALTH_ERR: {
+ message: WarnAndErrMessage,
+ icon: 'error',
+ title: $localize`Critical`
+ }
+};
+
+@Component({
+ selector: 'cd-overview-health-card',
+ imports: [
+ CommonModule,
+ ProductiveCardComponent,
+ SkeletonModule,
+ ButtonModule,
+ RouterModule,
+ ComponentsModule,
+ LinkModule,
+ PipesModule
+ ],
+ standalone: true,
+ templateUrl: './overview-health-card.component.html',
+ styleUrl: './overview-health-card.component.scss',
+ encapsulation: ViewEncapsulation.None,
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class OverviewHealthCardComponent {
+ @Input() fsid!: string;
+ @Input()
+ set health(value: HealthStatus) {
+ this.health$.next(value);
+ }
+ private health$ = new ReplaySubject<HealthStatus>(1);
+
+ private readonly summaryService = inject(SummaryService);
+ private readonly upgradeService = inject(UpgradeService);
+
+ 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] }))
+ );
+}
+@let healthData = healthData$ | async;
<div cdsGrid
[narrow]="true"
[condensed]="false"
<div cdsCol
class="cds-mb-5"
[columnNumbers]="{lg: 11}">
- <cds-tile>Health card</cds-tile>
+ <cd-overview-health-card
+ [fsid]="healthData?.fsid"
+ [health]="healthData?.health?.status">
+ </cd-overview-health-card>
</div>
<div cdsCol
class="cds-mb-5"
<div cdsCol
class="cds-mb-5"
[columnNumbers]="{lg: 16}">
- @if (healthData$ | async; as healthData) {
<cd-overview-storage-card
- [total]="healthData.pgmap.bytes_total"
- [used]="healthData.pgmap.bytes_used">
+ [total]="healthData?.pgmap.bytes_total"
+ [used]="healthData?.pgmap.bytes_used">
</cd-overview-storage-card>
- }
</div>
</div>
<div cdsRow>
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 { 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';
-describe('OverviewComponent (Jest)', () => {
+describe('OverviewComponent', () => {
let component: OverviewComponent;
let fixture: ComponentFixture<OverviewComponent>;
};
await TestBed.configureTestingModule({
- imports: [OverviewComponent],
+ imports: [
+ OverviewComponent,
+ CommonModule,
+ GridModule,
+ TilesModule,
+ OverviewStorageCardComponent,
+ OverviewHealthCardComponent
+ ],
providers: [
+ provideHttpClient(),
{ provide: HealthService, useValue: mockHealthService },
{ provide: RefreshIntervalService, useValue: mockRefreshIntervalService }
]
import { catchError, exhaustMap, takeUntil } from 'rxjs/operators';
import { EMPTY, Observable, Subject } from 'rxjs';
import { CommonModule } from '@angular/common';
+import { OverviewHealthCardComponent } from './health-card/overview-health-card.component';
@Component({
selector: 'cd-overview',
- imports: [GridModule, TilesModule, OverviewStorageCardComponent, CommonModule],
+ imports: [
+ CommonModule,
+ GridModule,
+ TilesModule,
+ OverviewStorageCardComponent,
+ OverviewHealthCardComponent
+ ],
standalone: true,
templateUrl: './overview.component.html',
styleUrl: './overview.component.scss'
-<cd-productive-card
- headerTitle="Storage overview"
- i18n-headerTitle>
- <!-- STORAGE CARD HEADER DROPDOWN -->
- <ng-template #headerAction>
+<cd-productive-card>
+ <!-- STORAGE CARD HEADER -->
+ <ng-template #header>
+ <h2 class="cds--type-heading-compact-02">Storage Overview</h2>
<cds-dropdown
label="Storage type"
class="overview-storage-card-dropdown"
import InformationFilledIcon from '@carbon/icons/es/information--filled/16';
import WarningFilledIcon from '@carbon/icons/es/warning--filled/16';
import NotificationFilledIcon from '@carbon/icons/es/notification--filled/16';
-import { Close16 } from '@carbon/icons';
+import DataViewAlt16 from '@carbon/icons/es/data--view--alt/16';
+import DataCenter16 from '@carbon/icons/es/data--center/16';
+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 { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component';
import { PageHeaderComponent } from './page-header/page-header.component';
import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.component';
InformationFilledIcon,
WarningFilledIcon,
NotificationFilledIcon,
- Close16
+ Close16,
+ DataViewAlt16,
+ DataCenter16,
+ Upgrade16,
+ WarningAltFilled16
]);
}
}
@if(text) {
<span data-toggle="tooltip"
[title]="text"
- class="cds--type-mono">{{text}}</span>
+ ngClass="cds--type-mono">{{text}}</span>
}
<cd-icon type="copy"></cd-icon>
</button>
<cds-tile class="productive-card"
[ngClass]="{'productive-card--shadow': applyShadow}"
[cdsLayer]="0">
- <header
- cdsGrid
- class="productive-card-header">
- <div cdsRow
- class="productive-card-header-row">
- <div cdsCol
- [columnNumbers]="{sm: headerActionTemplate ? 12 : 16, md: headerActionTemplate ? 12 : 16, lg: headerActionTemplate ? 12 : 16}">
- <h2 class="cds--type-heading-compact-02">{{headerTitle}}</h2>
- </div>
- @if(!!headerActionTemplate) {
- <div cdsCol
- [columnNumbers]="{sm: 4, md: 4, lg: 4}"
- class="productive-card-header-actions">
- <ng-container *ngTemplateOutlet="headerActionTemplate"></ng-container>
- </div>
- }
- </div>
+ <header class="productive-card-header">
+ <ng-container *ngTemplateOutlet="headerTemplate"></ng-container>
</header>
<section class="productive-card-section cds--type-body-compact-01"
[ngClass]="{'productive-card-section--footer': footerTemplate}">
padding: 0;
&-header {
- padding-inline: var(--cds-spacing-05);
margin: 0;
- }
-
- &-header-row {
padding: var(--cds-spacing-05);
- }
-
- &-header-actions {
- padding-right: 0;
+ display: flex;
+ justify-content: space-between;
+ align-items: end;
}
&-section {
styleUrl: './productive-card.component.scss'
})
export class ProductiveCardComponent {
- /* Card Title */
- @Input() headerTitle!: string;
-
/* Optional: Applies a tinted-colored background to card */
@Input() applyShadow: boolean = false;
/* Optional: Header action template, appears alongwith title in top-right corner */
- @ContentChild('headerAction', {
+ @ContentChild('header', {
read: TemplateRef
})
- headerActionTemplate?: TemplateRef<any>;
+ headerTemplate?: TemplateRef<any>;
/* Optional: Footer template , otherwise no footer will be used for card.*/
@ContentChild('footer', {
error = 'error--filled',
notificationOff = 'notification--off',
notificationNew = 'notification--new',
- emptySearch = 'search'
+ emptySearch = 'search',
+ dataViewAlt = 'data--view--alt',
+ dataCenter = 'data--center',
+ upgrade = 'upgrade',
+ warningAltFilled = 'warning--alt--filled'
}
export enum IconSize {
success: 'success',
warning: 'warning',
add: 'add',
- emptySearch: 'emptySearch'
+ emptySearch: 'emptySearch',
+ dataViewAlt: 'data--view--alt',
+ dataCenter: 'data--center',
+ upgrade: 'upgrade',
+ warningAltFilled: 'warning--alt--filled'
} as const;
--- /dev/null
+import { VERSION_PREFIX } from '~/app/shared/constants/app.constants';
+import { CephVersionPipe } from './ceph-version.pipe';
+
+describe('CephVersionPipe', () => {
+ const pipe = new CephVersionPipe();
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('extracts version correctly', () => {
+ const value = `${VERSION_PREFIX} 20.3.0-5182-g70be2125 (70be21257b5dac58119850e36211f267cc8b541a) tentacle (dev - RelWithDebInfo)`;
+ expect(pipe.transform(value)).toBe('20.3.0-5182-g70be2125 tentacle (dev - RelWithDebInfo)');
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+import { VERSION_PREFIX } from '~/app/shared/constants/app.constants';
+
+@Pipe({
+ name: 'cephVersion',
+ standalone: false
+})
+export class CephVersionPipe implements PipeTransform {
+ transform(value: string = ''): string {
+ // Expect "ceph version 13.1.0-419-g251e2515b5
+ // (251e2515b563856349498c6caf34e7a282f62937) nautilus (dev)"
+ if (value) {
+ const version = value.replace(`${VERSION_PREFIX} `, '').split(' ');
+ return version[0] + ' ' + version.slice(2, version.length).join(' ');
+ }
+
+ return value;
+ }
+}
import { PipeFunctionPipe } from './pipe-function.pipe';
import { DimlessBinaryPerMinutePipe } from './dimless-binary-per-minute.pipe';
import { RedirectLinkResolverPipe } from './redirect-link-resolver.pipe';
+import { CephVersionPipe } from './ceph-version.pipe';
@NgModule({
imports: [CommonModule],
MbpersecondPipe,
PipeFunctionPipe,
DimlessBinaryPerMinutePipe,
- RedirectLinkResolverPipe
+ RedirectLinkResolverPipe,
+ CephVersionPipe
],
exports: [
ArrayPipe,
MbpersecondPipe,
PipeFunctionPipe,
DimlessBinaryPerMinutePipe,
- RedirectLinkResolverPipe
+ RedirectLinkResolverPipe,
+ CephVersionPipe
],
providers: [
ArrayPipe,
OctalToHumanReadablePipe,
MbpersecondPipe,
DimlessBinaryPerMinutePipe,
- RedirectLinkResolverPipe
+ RedirectLinkResolverPipe,
+ CephVersionPipe
]
})
export class PipesModule {}
margin-bottom: layout.$spacing-03;
}
+.cds-mb-4 {
+ margin-bottom: layout.$spacing-04;
+}
+
.cds-mb-5 {
margin-bottom: layout.$spacing-05;
}