--- /dev/null
+<div class="d-flex pl-1 pb-2 pt-2">
+ <div class="ms-2 me-auto">
+ <a [routerLink]="link"
+ *ngIf="link && total > 0; else noLinkTitle"
+ [ngPlural]="total"
+ i18n>
+ {{ total }}
+ <ng-template ngPluralCase="=0">{{ title }}</ng-template>
+ <ng-template ngPluralCase="=1">{{ title }}</ng-template>
+ <ng-template ngPluralCase="other">{{ title }}s</ng-template>
+ </a>
+ </div>
+
+ <ng-container [ngSwitch]="summaryType">
+ <ng-container *ngSwitchCase="'iscsi'">
+ <ng-container *ngTemplateOutlet="iscsiSummary"></ng-container>
+ </ng-container>
+ <ng-container *ngSwitchCase="'osd'">
+ <ng-container *ngTemplateOutlet="osdSummary"></ng-container>
+ </ng-container>
+ <ng-container *ngSwitchCase="'simplified'">
+ <ng-container *ngTemplateOutlet="simplifiedSummary"></ng-container>
+ </ng-container>
+ <ng-container *ngSwitchDefault>
+ <ng-container *ngTemplateOutlet="defaultSummary"></ng-container>
+ </ng-container>
+ </ng-container>
+</div>
+
+<ng-template #defaultSummary>
+ <span *ngIf="data.success || data.categoryPgAmount?.clean || (data.success === 0 && data.total === 0)">
+ <span *ngIf="data.success || (data.success === 0 && data.total === 0)">
+ {{ data.success }}
+ </span>
+ <span *ngIf="data.categoryPgAmount?.clean">
+ {{ data.categoryPgAmount?.clean }}
+ </span>
+ <i class="text-success"
+ [ngClass]="[icons.success]">
+ </i>
+ </span>
+ <span *ngIf="data.info"
+ class="ms-2">
+ <span *ngIf="data.info">
+ {{ data.info }}
+ </span>
+ <i class="text-info"
+ [ngClass]="[icons.danger]">
+ </i>
+ </span>
+ <span *ngIf="data.warn || data.categoryPgAmount?.warning"
+ class="ms-2">
+ <span *ngIf="data.warn">
+ {{ data.warn }}
+ </span>
+ <span *ngIf="data.categoryPgAmount?.warning">
+ {{ data.categoryPgAmount?.warning }}
+ </span>
+ <i class="text-warning"
+ [ngClass]="[icons.warning]">
+ </i>
+ </span>
+ <span *ngIf="data.error || data.categoryPgAmount?.unknown"
+ class="ms-2">
+ <span *ngIf="data.error">
+ {{ data.error }}
+ </span>
+ <span *ngIf="data.categoryPgAmount?.unknown">
+ {{ data.categoryPgAmount?.unknown }}
+ </span>
+ <i class="text-danger"
+ [ngClass]="[icons.danger]">
+ </i>
+ </span>
+ <span *ngIf="data.categoryPgAmount?.working"
+ class="ms-2">
+ <span *ngIf="data.categoryPgAmount?.working">
+ {{ data.categoryPgAmount?.working }}
+ </span>
+ <i class="text-warning"
+ [ngClass]="[icons.spinner, icons.spin]">
+ </i>
+ </span>
+</ng-template>
+
+<ng-template #osdSummary>
+ <span *ngIf="data.up === data.in">
+ {{ data.up }}
+ <i class="text-success"
+ [ngClass]="[icons.success]">
+ </i>
+ </span>
+ <span *ngIf="data.up !== data.in">
+ {{ data.up }}
+ <span class="fw-bold text-success">
+ up
+ </span>
+ </span>
+ <span *ngIf="data.in !== data.up"
+ class="ms-2">
+ {{ data.in }}
+ <span class="fw-bold text-success">
+ in
+ </span>
+ </span>
+ <span *ngIf="data.down"
+ class="ms-2">
+ {{ data.down }}
+ <span class="fw-bold text-danger me-2">
+ down
+ </span>
+ </span>
+ <span *ngIf="data.out"
+ class="ms-2">
+ {{ data.out }}
+ <span class="fw-bold text-danger me-2">
+ out
+ </span>
+ </span>
+ <span *ngIf="data.nearfull"
+ class="ms-2">
+ {{ data.nearfull }}
+ <span class="fw-bold text-warning me-2">
+ nearfull</span></span>
+ <span *ngIf="data.full"
+ class="ms-2">
+ {{ data.full }}
+ <span class="fw-bold text-danger">
+ full
+ </span>
+ </span>
+</ng-template>
+
+<ng-template #iscsiSummary>
+ <span>
+ {{ data.up }}
+ <i class="text-success"
+ *ngIf="data.up || data.up === 0"
+ [ngClass]="[icons.success]">
+ </i>
+ </span>
+ <span *ngIf="data.down"
+ class="ms-2">
+ {{ data.down }}
+ <i class="text-danger"
+ [ngClass]="[icons.danger]">
+ </i>
+ </span>
+</ng-template>
+
+<ng-template #simplifiedSummary>
+ <span>
+ {{ data }}
+ <i class="text-success"
+ [ngClass]="[icons.success]"></i>
+ </span>
+</ng-template>
+
+<ng-template #noLinkTitle>
+ <span *ngIf="total || total === 0"
+ [ngPlural]="total">
+ {{ total }}
+ <ng-template ngPluralCase="=0">{{ title }}</ng-template>
+ <ng-template ngPluralCase="=1">{{ title }}</ng-template>
+ <ng-template ngPluralCase="other">{{ title }}s</ng-template>
+ </span>
+</ng-template>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { CardRowComponent } from './card-row.component';
+
+describe('CardRowComponent', () => {
+ let component: CardRowComponent;
+ let fixture: ComponentFixture<CardRowComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [CardRowComponent]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(CardRowComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, Input, OnChanges } from '@angular/core';
+import { Icons } from '~/app/shared/enum/icons.enum';
+
+@Component({
+ selector: 'cd-card-row',
+ templateUrl: './card-row.component.html',
+ styleUrls: ['./card-row.component.scss']
+})
+export class CardRowComponent implements OnChanges {
+ @Input()
+ title: string;
+
+ @Input()
+ link: string;
+
+ @Input()
+ data: any;
+
+ @Input()
+ summaryType = 'default';
+
+ icons = Icons;
+ total: number;
+
+ ngOnChanges(): void {
+ if (this.data.total || this.data.total === 0) {
+ this.total = this.data.total;
+ } else if (this.summaryType === 'iscsi') {
+ this.total = this.data.up + this.data.down || 0;
+ } else {
+ this.total = this.data;
+ }
+ }
+}
import { CardComponent } from './card/card.component';
import { DashboardPieComponent } from './dashboard-pie/dashboard-pie.component';
import { DashboardComponent } from './dashboard/dashboard.component';
+import { CardRowComponent } from './card-row/card-row.component';
+import { PgSummaryPipe } from './pg-summary.pipe';
@NgModule({
imports: [
SimplebarAngularModule
],
- declarations: [DashboardComponent, CardComponent, DashboardPieComponent]
+ declarations: [
+ DashboardComponent,
+ CardComponent,
+ DashboardPieComponent,
+ DashboardPieComponent,
+ CardRowComponent,
+ PgSummaryPipe
+ ]
})
export class NewDashboardModule {}
-<div class="container-fluid">
+<div class="container-fluid"
+ *ngIf="healthData && enabledFeature$ | async as enabledFeature">
<div class="row mx-0">
<cd-card title="Details"
i18n-title
import { PrometheusService } from '~/app/shared/api/prometheus.service';
import { CssHelper } from '~/app/shared/classes/css-helper';
import { AlertmanagerAlert } from '~/app/shared/models/prometheus-alerts';
-import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { FeatureTogglesService } from '~/app/shared/services/feature-toggles.service';
import { SummaryService } from '~/app/shared/services/summary.service';
+import { SharedModule } from '~/app/shared/shared.module';
import { configureTestBed } from '~/testing/unit-test-helper';
+import { PgCategoryService } from '../../shared/pg-category.service';
+import { CardRowComponent } from '../card-row/card-row.component';
import { CardComponent } from '../card/card.component';
import { DashboardPieComponent } from '../dashboard-pie/dashboard-pie.component';
+import { PgSummaryPipe } from '../pg-summary.pipe';
import { DashboardComponent } from './dashboard.component';
export class SummaryServiceMock {
let orchestratorService: MgrModuleService;
let getHealthSpy: jasmine.Spy;
let getAlertsSpy: jasmine.Spy;
+ let fakeFeatureTogglesService: jasmine.Spy;
const healthPayload: Record<string, any> = {
health: { status: 'HEALTH_OK' },
hosts: 0,
rgw: 0,
fs_map: { filesystems: [], standbys: [] },
- iscsi_daemons: 0,
+ iscsi_daemons: 1,
client_perf: {},
scrub_status: 'Inactive',
pools: [],
df: { stats: {} },
- pg_info: { object_stats: { num_objects: 0 } }
+ pg_info: { object_stats: { num_objects: 1 } }
};
const alertsPayload: AlertmanagerAlert[] = [
};
configureTestBed({
- imports: [RouterTestingModule, HttpClientTestingModule, ToastrModule.forRoot(), PipesModule],
- declarations: [DashboardComponent, CardComponent, DashboardPieComponent],
+ imports: [RouterTestingModule, HttpClientTestingModule, ToastrModule.forRoot(), SharedModule],
+ declarations: [
+ DashboardComponent,
+ CardComponent,
+ DashboardPieComponent,
+ CardRowComponent,
+ PgSummaryPipe
+ ],
schemas: [NO_ERRORS_SCHEMA],
- providers: [{ provide: SummaryService, useClass: SummaryServiceMock }, CssHelper]
+ providers: [
+ { provide: SummaryService, useClass: SummaryServiceMock },
+ CssHelper,
+ PgCategoryService
+ ]
});
beforeEach(() => {
+ fakeFeatureTogglesService = spyOn(TestBed.inject(FeatureTogglesService), 'get').and.returnValue(
+ of({
+ rbd: true,
+ mirroring: true,
+ iscsi: true,
+ cephfs: true,
+ rgw: true
+ })
+ );
fixture = TestBed.createComponent(DashboardComponent);
component = fixture.componentInstance;
configurationService = TestBed.inject(ConfigurationService);
});
it('should render all cards', () => {
+ fixture.detectChanges();
const dashboardCards = fixture.debugElement.nativeElement.querySelectorAll('cd-card');
expect(dashboardCards.length).toBe(5);
});
expect(successNotification).toBe(null);
expect(dangerNotification).toBe(null);
});
+
+ describe('features disabled', () => {
+ beforeEach(() => {
+ fakeFeatureTogglesService.and.returnValue(
+ of({
+ rbd: false,
+ mirroring: false,
+ iscsi: false,
+ cephfs: false,
+ rgw: false
+ })
+ );
+ fixture = TestBed.createComponent(DashboardComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should not render items related to disabled features', () => {
+ fixture.detectChanges();
+
+ const iscsiCard = fixture.debugElement.query(By.css('li[id=iscsi-item]'));
+ const rgwCard = fixture.debugElement.query(By.css('li[id=rgw-item]'));
+ const mds = fixture.debugElement.query(By.css('li[id=mds-item]'));
+
+ expect(iscsiCard).toBeFalsy();
+ expect(rgwCard).toBeFalsy();
+ expect(mds).toBeFalsy();
+ });
+ });
});
-import { Component, NgZone, OnDestroy, OnInit } from '@angular/core';
+import { Component, OnDestroy, OnInit } from '@angular/core';
import _ from 'lodash';
import { Observable, Subscription } from 'rxjs';
FeatureTogglesMap$,
FeatureTogglesService
} from '~/app/shared/services/feature-toggles.service';
+import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
import { SummaryService } from '~/app/shared/services/summary.service';
@Component({
}
ngOnDestroy() {
- window.clearInterval(this.interval);
+ this.interval.unsubscribe();
+ }
+
+ getHealth() {
+ this.healthService.getMinimalHealth().subscribe((data: any) => {
+ this.healthData = data;
+ });
}
toggleAlertsWindow(type: string, isToggleButton: boolean = false) {
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { PgCategoryService } from '../shared/pg-category.service';
+import { PgSummaryPipe } from './pg-summary.pipe';
+
+describe('OsdSummaryPipe', () => {
+ let pipe: PgSummaryPipe;
+
+ configureTestBed({
+ providers: [PgSummaryPipe, PgCategoryService]
+ });
+
+ beforeEach(() => {
+ pipe = TestBed.inject(PgSummaryPipe);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('tranforms value', () => {
+ const value = {
+ statuses: {
+ 'active+clean': 241
+ },
+ pgs_per_osd: 241
+ };
+ expect(pipe.transform(value)).toEqual({
+ categoryPgAmount: {
+ clean: 241
+ },
+ total: 241
+ });
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+import _ from 'lodash';
+import { PgCategoryService } from '~/app/ceph/shared/pg-category.service';
+
+@Pipe({
+ name: 'pgSummary'
+})
+export class PgSummaryPipe implements PipeTransform {
+ constructor(private pgCategoryService: PgCategoryService) {}
+
+ transform(value: any): any {
+ const categoryPgAmount: Record<string, number> = {};
+ let total = 0;
+ _.forEach(value.statuses, (pgAmount, pgStatesText) => {
+ const categoryType = this.pgCategoryService.getTypeByStates(pgStatesText);
+ if (_.isUndefined(categoryPgAmount[categoryType])) {
+ categoryPgAmount[categoryType] = 0;
+ }
+ categoryPgAmount[categoryType] += pgAmount;
+ total += pgAmount;
+ });
+ return {
+ categoryPgAmount,
+ total
+ };
+ }
+}
analyse = 'fa fa-stethoscope', // Scrub
deepCheck = 'fa fa-cog', // Deep Scrub, Setting, Configuration
reweight = 'fa fa-balance-scale', // Reweight
+ up = 'fa fa-arrow-up', // Up
left = 'fa fa-arrow-left', // Mark out
right = 'fa fa-arrow-right', // Mark in
down = 'fa fa-arrow-down', // Mark Down
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MdsSummaryPipe } from './mds-summary.pipe';
+
+describe('MdsSummaryPipe', () => {
+ let pipe: MdsSummaryPipe;
+
+ configureTestBed({
+ providers: [MdsSummaryPipe]
+ });
+
+ beforeEach(() => {
+ pipe = TestBed.inject(MdsSummaryPipe);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms with 0 active and 2 standy', () => {
+ const payload = {
+ standbys: [{ name: 'a' }],
+ filesystems: [{ mdsmap: { info: [{ state: 'up:standby-replay' }] } }]
+ };
+
+ expect(pipe.transform(payload)).toEqual({
+ success: 0,
+ info: 2,
+ total: 2
+ });
+ });
+
+ it('transforms with 1 active and 1 standy', () => {
+ const payload = {
+ standbys: [{ name: 'b' }],
+ filesystems: [{ mdsmap: { info: [{ state: 'up:active', name: 'a' }] } }]
+ };
+ expect(pipe.transform(payload)).toEqual({
+ success: 1,
+ info: 1,
+ total: 2
+ });
+ });
+
+ it('transforms with 0 filesystems', () => {
+ const payload: Record<string, any> = {
+ standbys: [0],
+ filesystems: []
+ };
+
+ expect(pipe.transform(payload)).toEqual({
+ success: 0,
+ info: 0,
+ total: 0
+ });
+ });
+
+ it('transforms without filesystem', () => {
+ const payload = { standbys: [{ name: 'a' }] };
+
+ expect(pipe.transform(payload)).toEqual({
+ success: 0,
+ info: 1,
+ total: 1
+ });
+ });
+
+ it('transforms without value', () => {
+ expect(pipe.transform(undefined)).toEqual({
+ success: 0,
+ info: 0,
+ total: 0
+ });
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'mdsSummary'
+})
+export class MdsSummaryPipe implements PipeTransform {
+ transform(value: any): any {
+ if (!value) {
+ return {
+ success: 0,
+ info: 0,
+ total: 0
+ };
+ }
+
+ let activeCount = 0;
+ let standbyCount = 0;
+ let standbys = 0;
+ let active = 0;
+ let standbyReplay = 0;
+ _.each(value.standbys, () => {
+ standbys += 1;
+ });
+
+ if (value.standbys && !value.filesystems) {
+ standbyCount = standbys;
+ activeCount = 0;
+ } else if (value.filesystems.length === 0) {
+ activeCount = 0;
+ } else {
+ _.each(value.filesystems, (fs) => {
+ _.each(fs.mdsmap.info, (mds) => {
+ if (mds.state === 'up:standby-replay') {
+ standbyReplay += 1;
+ } else {
+ active += 1;
+ }
+ });
+ });
+
+ activeCount = active;
+ standbyCount = standbys + standbyReplay;
+ }
+ const totalCount = activeCount + standbyCount;
+ const mdsSummary = {
+ success: activeCount,
+ info: standbyCount,
+ total: totalCount
+ };
+
+ return mdsSummary;
+ }
+}
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { MgrSummaryPipe } from './mgr-summary.pipe';
+
+describe('MgrSummaryPipe', () => {
+ let pipe: MgrSummaryPipe;
+
+ configureTestBed({
+ providers: [MgrSummaryPipe]
+ });
+
+ beforeEach(() => {
+ pipe = TestBed.inject(MgrSummaryPipe);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms without value', () => {
+ expect(pipe.transform(undefined)).toEqual({
+ success: 0,
+ info: 0,
+ total: 0
+ });
+ });
+
+ it('transforms with 1 active and 2 standbys', () => {
+ const payload = {
+ active_name: 'x',
+ standbys: [{ name: 'y' }, { name: 'z' }]
+ };
+ const expected = { success: 1, info: 2, total: 3 };
+
+ expect(pipe.transform(payload)).toEqual(expected);
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'mgrSummary'
+})
+export class MgrSummaryPipe implements PipeTransform {
+ transform(value: any): any {
+ if (!value) {
+ return {
+ success: 0,
+ info: 0,
+ total: 0
+ };
+ }
+
+ let activeCount: number;
+ const activeTitleText = _.isUndefined(value.active_name)
+ ? ''
+ : `${$localize`active daemon`}: ${value.active_name}`;
+ // There is always one standbyreplay to replace active daemon, if active one is down
+ if (activeTitleText.length > 0) {
+ activeCount = 1;
+ }
+ const standbyCount = value.standbys.length;
+ const totalCount = activeCount + standbyCount;
+
+ const mgrSummary = {
+ success: activeCount,
+ info: standbyCount,
+ total: totalCount
+ };
+
+ return mgrSummary;
+ }
+}
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { OsdSummaryPipe } from './osd-summary.pipe';
+
+describe('OsdSummaryPipe', () => {
+ let pipe: OsdSummaryPipe;
+
+ configureTestBed({
+ providers: [OsdSummaryPipe]
+ });
+
+ beforeEach(() => {
+ pipe = TestBed.inject(OsdSummaryPipe);
+ });
+
+ it('create an instance', () => {
+ expect(pipe).toBeTruthy();
+ });
+
+ it('transforms without value', () => {
+ expect(pipe.transform(undefined)).toBe('');
+ });
+
+ it('transforms having 3 osd with 3 up, 3 in, 0 down, 0 out', () => {
+ const value = {
+ osds: [
+ { up: 1, in: 1, state: ['up', 'exists'] },
+ { up: 1, in: 1, state: ['up', 'exists'] },
+ { up: 1, in: 1, state: ['up', 'exists'] }
+ ]
+ };
+ expect(pipe.transform(value)).toEqual({
+ total: 3,
+ down: 0,
+ out: 0,
+ up: 3,
+ in: 3,
+ nearfull: 0,
+ full: 0
+ });
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+
+import _ from 'lodash';
+
+@Pipe({
+ name: 'osdSummary'
+})
+export class OsdSummaryPipe implements PipeTransform {
+ transform(value: any): any {
+ if (!value) {
+ return '';
+ }
+
+ let inCount = 0;
+ let upCount = 0;
+ let nearFullCount = 0;
+ let fullCount = 0;
+ _.each(value.osds, (osd) => {
+ if (osd.in) {
+ inCount++;
+ }
+ if (osd.up) {
+ upCount++;
+ }
+ if (osd.state.includes('nearfull')) {
+ nearFullCount++;
+ }
+ if (osd.state.includes('full')) {
+ fullCount++;
+ }
+ });
+
+ const downCount = value.osds.length - upCount;
+ const outCount = value.osds.length - inCount;
+ const osdSummary = {
+ total: value.osds.length,
+ down: downCount,
+ out: outCount,
+ up: upCount,
+ in: inCount,
+ nearfull: nearFullCount,
+ full: fullCount
+ };
+ return osdSummary;
+ }
+}
import { JoinPipe } from './join.pipe';
import { LogPriorityPipe } from './log-priority.pipe';
import { MapPipe } from './map.pipe';
+import { MdsSummaryPipe } from './mds-summary.pipe';
+import { MgrSummaryPipe } from './mgr-summary.pipe';
import { MillisecondsPipe } from './milliseconds.pipe';
import { NotAvailablePipe } from './not-available.pipe';
import { OrdinalPipe } from './ordinal.pipe';
+import { OsdSummaryPipe } from './osd-summary.pipe';
import { RbdConfigurationSourcePipe } from './rbd-configuration-source.pipe';
import { RelativeDatePipe } from './relative-date.pipe';
import { RoundPipe } from './round.pipe';
TruncatePipe,
SanitizeHtmlPipe,
SearchHighlightPipe,
- HealthIconPipe
+ HealthIconPipe,
+ MgrSummaryPipe,
+ MdsSummaryPipe,
+ OsdSummaryPipe
],
exports: [
ArrayPipe,
TruncatePipe,
SanitizeHtmlPipe,
SearchHighlightPipe,
- HealthIconPipe
+ HealthIconPipe,
+ MgrSummaryPipe,
+ MdsSummaryPipe,
+ OsdSummaryPipe
],
providers: [
ArrayPipe,
MapPipe,
TruncatePipe,
SanitizeHtmlPipe,
- HealthIconPipe
+ HealthIconPipe,
+ MgrSummaryPipe,
+ MdsSummaryPipe,
+ OsdSummaryPipe
]
})
export class PipesModule {}