env: {
LOGIN_USER: 'admin',
LOGIN_PWD: 'admin',
- CEPH2_URL: 'https://localhost:4202/'
+ CEPH2_URL: 'https://localhost:11002/'
},
chromeWebSecurity: false,
)
return require('./cypress/plugins/index.js')(on, config);
},
- baseUrl: 'https://localhost:4200/',
+ baseUrl: 'https://localhost:11000/',
excludeSpecPattern: ['*.po.ts', '**/orchestrator/**'],
experimentalSessionAndOrigin: true,
specPattern: 'cypress/e2e/**/*-spec.{js,jsx,ts,tsx,feature}'
cy.get('[data-testid="rbd-mirroring-check"] input[type="checkbox"]').check({ force: true });
}
cy.get('cd-submit-button').click();
- this.navigateBack();
}
edit_pool_pg(name: string, new_pg: number, wait = true, mirroring = false) {
{{ vm.statusText }}
</small>
<!-- SUB TOTAL COUNTS -->
- <div class="cds-mt-6">
+ <div class="cds-mt-5">
@if (vm.badges.length) {
<div [cdsStack]="compact? 'horizontal' : 'vertical'">
@for (b of vm.badges; track b.key; let last = $last) {
private readonly authStorageService = inject(AuthStorageService);
@Input({ required: true }) vm!: HealthCardVM;
+ @Input() emptyStateText: string | null = '';
@Output() viewIncidents = new EventEmitter<void>();
@Output() viewPGStates = new EventEmitter<void>();
@Output() activeSectionChange = new EventEmitter<HealthCardTabSection | null>();
<main cdsGrid
[fullWidth]="true"
[narrow]="true"
- class="cds-mt-5 cds-mb-5 overview">
- <div cdsRow>
+ class="overview cds-mt-5">
+ <div cdsRow
+ class="cds-mb-5">
<div cdsCol
[columnNumbers]="{lg: 11}">
<cd-overview-health-card
[estimatedTimeUntilFull]="storageCard?.estimatedTimeUntilFull ?? ''"
[breakdownData]="storageCard?.breakdownData ?? []"
[isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false"
- [threshold]="storageCard?.threshold">
+ [threshold]="storageCard?.threshold"
+ [storageEmptyState]="storageEmptyState$ | async"
+ [prometheusEmptyState]="prometheusEmptyState$ | async">
</cd-overview-storage-card>
</div>
</div>
<div cdsCol
class="overview-pr-0"
[columnNumbers]="{ lg: 16 }">
- <cd-performance-card></cd-performance-card>
+ <cd-performance-card [emptyStateText]="prometheusEmptyState$ | async"></cd-performance-card>
</div>
</div>
</main>
</div>
<cd-table
[data]="health?.pgs?.states"
- [columns]="tableColumns"
+ [columns]="pgTableColumns"
size="xs">
</cd-table>
</div>
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 { provideRouter } 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 { 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';
-import { HttpClientTestingModule } from '@angular/common/http/testing';
import { OverviewStorageService } from '~/app/shared/api/storage-overview.service';
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
describe('OverviewComponent', () => {
let component: OverviewComponent;
};
const mockAuthStorageService = {
- getPermissions: jest.fn(() => ({ configOpt: { read: false } }))
+ getPermissions: jest.fn(() => ({ configOpt: { read: false } })),
+ isPwdDisplayedSource: new Subject<boolean>()
};
const mockMgrModuleService = {
getSummary: jest.fn(() => of(null))
};
+ let mockPrometheusService: {
+ isPrometheusUsable: jest.Mock;
+ };
+
beforeEach(async () => {
+ mockPrometheusService = {
+ isPrometheusUsable: jest.fn().mockReturnValue(of(true))
+ };
+
mockHealthService = { getHealthSnapshot: jest.fn() };
mockRefreshIntervalService = { intervalData$: new Subject<void>() };
};
await TestBed.configureTestingModule({
- imports: [
- OverviewComponent,
- CommonModule,
- GridModule,
- TilesModule,
- OverviewStorageCardComponent,
- OverviewHealthCardComponent,
- OverviewAlertsCardComponent,
- RouterModule,
- HttpClientTestingModule
- ],
+ imports: [OverviewComponent],
providers: [
- provideHttpClient(),
provideRouter([]),
{ provide: HealthService, useValue: mockHealthService },
{ provide: RefreshIntervalService, useValue: mockRefreshIntervalService },
{ provide: OverviewStorageService, useValue: mockOverviewStorageService },
+ { provide: PrometheusService, useValue: mockPrometheusService },
{ provide: AuthStorageService, useValue: mockAuthStorageService },
{ provide: MgrModuleService, useValue: mockMgrModuleService },
{ provide: HardwareService, useValue: mockHardwareService }
]
- }).compileComponents();
+ })
+ .overrideComponent(OverviewComponent, {
+ set: { template: '' }
+ })
+ .compileComponents();
fixture = TestBed.createComponent(OverviewComponent);
component = fixture.componentInstance;
} from '@angular/core';
import { GridModule, LayoutModule, TilesModule } from 'carbon-components-angular';
import { combineLatest, EMPTY, Observable } from 'rxjs';
-import { catchError, exhaustMap, map, shareReplay, startWith } from 'rxjs/operators';
+import { catchError, exhaustMap, map, shareReplay, startWith, switchMap } from 'rxjs/operators';
import { HealthService } from '~/app/shared/api/health.service';
import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
-import { HealthCheck, HealthSnapshotMap } from '~/app/shared/models/health.interface';
+import { HealthSnapshotMap } from '~/app/shared/models/health.interface';
import {
- ACTIVE_CLEAN_CHART_OPTIONS,
- calcActiveCleanSeverityAndReasons,
- getClusterHealth,
- getHealthChecksAndIncidents,
- getResiliencyDisplay,
+ buildHealthCardVM,
HealthCardTabSection,
HealthCardVM,
- HealthStatus,
- maxSeverity,
- safeDifference,
- SEVERITY,
- Severity,
- SEVERITY_TO_COLOR,
- SeverityIconMap,
StorageCardVM
} from '~/app/shared/models/overview';
import { DataTableModule } from '~/app/shared/datatable/datatable.module';
import { PipesModule } from '~/app/shared/pipes/pipes.module';
import { OverviewStorageService } from '~/app/shared/api/storage-overview.service';
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
const SECONDS_PER_HOUR = 3600;
const SECONDS_PER_DAY = 86400;
const TREND_DAYS = 7;
-/**
- * Mapper: HealthSnapshotMap -> HealthCardVM
- * Runs only when healthData$ emits.
- */
-function buildHealthCardVM(d: HealthSnapshotMap): HealthCardVM {
- const checksObj: Record<string, HealthCheck> = d.health?.checks ?? {};
- const clusterHealth = getClusterHealth(d.health.status as HealthStatus);
- const pgStates = d?.pgmap?.pgs_by_state ?? [];
- const totalPg = d?.pgmap?.num_pgs ?? 0;
-
- const { incidents, checks } = getHealthChecksAndIncidents(checksObj);
- const resiliencyHealth = getResiliencyDisplay(checks, pgStates);
- const {
- activeCleanPercent,
- severity: activeCleanChartSeverity,
- reasons: activeCleanChartReason
- } = calcActiveCleanSeverityAndReasons(pgStates, totalPg);
-
- // --- 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 ? SEVERITY.warn : SEVERITY.ok;
-
- // MGR
- const mgrActive = d.mgrmap?.num_active ?? 0;
- const mgrStandby = d.mgrmap?.num_standbys ?? 0;
- const mgrSev: Severity =
- mgrActive < 1 ? SEVERITY.err : mgrStandby < 1 ? SEVERITY.warn : SEVERITY.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 ? SEVERITY.err : SEVERITY.ok;
-
- // HOSTS
- const hostsTotal = d.num_hosts ?? 0;
- const hostsAvailable = (d as any)?.num_hosts_available ?? 0;
- const hostsSev: Severity = hostsAvailable < hostsTotal ? SEVERITY.warn : SEVERITY.ok;
-
- // Overall = worst of the subsystem severities.
- const overallSystemSev = maxSeverity(monSev, mgrSev, osdSev, hostsSev);
-
- return {
- fsid: d.fsid,
- overallSystemSev: SeverityIconMap[overallSystemSev],
-
- incidents,
- checks,
-
- pgs: {
- total: totalPg,
- states: pgStates,
- io: [
- { label: $localize`Client write`, value: d?.pgmap?.write_bytes_sec ?? 0 },
- { label: $localize`Client read`, value: d?.pgmap?.read_bytes_sec ?? 0 },
- { label: $localize`Recovery I/O`, value: d?.pgmap?.recovering_bytes_per_sec ?? 0 }
- ],
- activeCleanChartData: [{ group: 'value', value: activeCleanPercent }],
- activeCleanChartOptions: {
- ...ACTIVE_CLEAN_CHART_OPTIONS,
- color: { scale: { value: SEVERITY_TO_COLOR[activeCleanChartSeverity] } }
- },
- activeCleanChartReason
- },
-
- clusterHealth,
- resiliencyHealth,
-
- mon: { value: $localize`Quorum: ${monQuorum}/${monTotal}`, severity: SeverityIconMap[monSev] },
- mgr: {
- value: $localize`${mgrActive} active, ${mgrStandby} standby`,
- severity: SeverityIconMap[mgrSev]
- },
- osd: { value: $localize`${osdIn}/${osdUp} in/up`, severity: SeverityIconMap[osdSev] },
- hosts: {
- value: $localize`${hostsAvailable} / ${hostsTotal} available`,
- severity: SeverityIconMap[hostsSev]
- }
- };
-}
-
@Component({
selector: 'cd-overview',
imports: [
isHealthPanelOpen = false;
isPGStatePanelOpen = false;
activeHealthTab: HealthCardTabSection | null = null;
- tableColumns = [
+ pgTableColumns = [
{ prop: 'count', name: $localize`PGs count` },
{ prop: 'state_name', name: $localize`Status` }
];
+ hasOsd: boolean = false;
private readonly healthService = inject(HealthService);
private readonly refreshIntervalService = inject(RefreshIntervalService);
private readonly overviewStorageService = inject(OverviewStorageService);
private readonly destroyRef = inject(DestroyRef);
+ private readonly prometheusService = inject(PrometheusService);
/* HEALTH CARD DATA */
private readonly healthData$: Observable<HealthSnapshotMap> = this.refreshIntervalObs(() =>
shareReplay({ bufferSize: 1, refCount: true })
);
- /* STORAGE CARD DATA */
+ /* EMPTY STATE DATA */
+ readonly isPrometheusUsable$ = this.prometheusService
+ .isPrometheusUsable()
+ .pipe(shareReplay({ bufferSize: 1, refCount: true }));
+
+ readonly hasNoOSDs$ = this.healthData$.pipe(
+ map((data: HealthSnapshotMap) => (data?.osdmap?.num_osds ?? 0) === 0),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+
+ readonly storageEmptyState$ = this.hasNoOSDs$.pipe(startWith(false)).pipe(
+ map((hasNoOSDs) => {
+ if (hasNoOSDs) {
+ return $localize`You must have storage configured to access this capability.`;
+ }
+ return '';
+ }),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+ readonly prometheusEmptyState$ = this.isPrometheusUsable$.pipe(
+ map((isPrometheusUsable) =>
+ isPrometheusUsable
+ ? ''
+ : $localize`You must have Prometheus configured to access this capability.`
+ ),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
+
+ /* STORAGE CARD DATA */
readonly storageVm$ = this.healthData$.pipe(
map((data: HealthSnapshotMap) => ({
total: data.pgmap?.bytes_total,
shareReplay({ bufferSize: 1, refCount: true })
);
- readonly averageConsumption$ = this.refreshIntervalObs(() =>
- this.overviewStorageService.getAverageConsumption()
- ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+ readonly averageConsumption$ = this.isPrometheusUsable$.pipe(
+ switchMap((usable) =>
+ usable
+ ? this.refreshIntervalObs(() => this.overviewStorageService.getAverageConsumption())
+ : EMPTY
+ ),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
- readonly timeUntilFull$ = this.refreshIntervalObs(() =>
- this.overviewStorageService.getTimeUntilFull()
- ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+ readonly timeUntilFull$ = this.isPrometheusUsable$.pipe(
+ switchMap((usable) =>
+ usable ? this.refreshIntervalObs(() => this.overviewStorageService.getTimeUntilFull()) : EMPTY
+ ),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
- readonly breakdownRawData$ = this.refreshIntervalObs(() =>
- this.overviewStorageService.getStorageBreakdown()
- ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+ readonly breakdownRawData$ = this.isPrometheusUsable$.pipe(
+ switchMap((usable) =>
+ usable
+ ? this.refreshIntervalObs(() => this.overviewStorageService.getStorageBreakdown())
+ : EMPTY
+ ),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
- readonly capacityThresholds$ = this.refreshIntervalObs(() =>
- this.overviewStorageService.getRawCapacityThresholds()
- ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+ readonly capacityThresholds$ = this.isPrometheusUsable$.pipe(
+ switchMap((usable) =>
+ usable
+ ? this.refreshIntervalObs(() => this.overviewStorageService.getRawCapacityThresholds())
+ : EMPTY
+ ),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
// getTrendData() is already a polling stream through getRangeQueriesData()
// hence no refresh needed.
- readonly trendData$ = this.overviewStorageService
- .getTrendData(
- Math.floor(Date.now() / 1000) - TREND_DAYS * SECONDS_PER_DAY,
- Math.floor(Date.now() / 1000),
- SECONDS_PER_HOUR
- )
- .pipe(
- map((result) => {
- const values = result?.TOTAL_RAW_USED ?? [];
-
- return values.map(([ts, val]) => ({
- timestamp: new Date(ts * 1000),
- values: { Used: Number(val) }
- }));
- }),
- shareReplay({ bufferSize: 1, refCount: true })
- );
+ readonly trendData$ = this.isPrometheusUsable$.pipe(
+ switchMap((usable) =>
+ usable
+ ? this.overviewStorageService.getTrendData(
+ Math.floor(Date.now() / 1000) - TREND_DAYS * SECONDS_PER_DAY,
+ Math.floor(Date.now() / 1000),
+ SECONDS_PER_HOUR
+ )
+ : EMPTY
+ ),
+ map((result) => {
+ const values = result?.TOTAL_RAW_USED ?? [];
+
+ return values.map(([ts, val]) => ({
+ timestamp: new Date(ts * 1000),
+ values: { Used: Number(val) }
+ }));
+ }),
+ shareReplay({ bufferSize: 1, refCount: true })
+ );
readonly storageCardVm$: Observable<StorageCardVM> = combineLatest([
this.storageVm$,
i18n>Storage overview</h2>
</ng-template>
<!-- CAPACITY USAGE TEXT -->
+ @if(!storageEmptyState) {
<div class="overview-storage-card-usage-text">
@if( usedRaw !== null && totalRaw !== null && usedRawUnit && totalRawUnit) {
<h5>
</cds-skeleton-text>
}
</div>
+ }
+ @else {
+ <cd-empty-state [emptyStateText]="storageEmptyState"></cd-empty-state>
+ }
<!-- CAPACITY BREAKDOWN CHART -->
+ @if (!prometheusEmptyState && !storageEmptyState) {
@if(isBreakdownLoaded) {
<ibm-meter-chart
[options]="options"
</div>
</div>
}
+}
</cd-productive-card>
import { AreaChartComponent } from '~/app/shared/components/area-chart/area-chart.component';
import { ComponentsModule } from '~/app/shared/components/components.module';
import { BreakdownChartData, CapacityThreshold, TrendPoint } from '~/app/shared/models/overview';
+import { EmptyStateComponent } from '~/app/shared/components/empty-state/empty-state.component';
const CHART_HEIGHT = '45px';
LayoutModule,
AreaChartComponent,
ComponentsModule,
- TagModule
+ TagModule,
+ EmptyStateComponent
],
standalone: true,
templateUrl: './overview-storage-card.component.html',
private readonly formatterService = inject(FormatterService);
private readonly cdr = inject(ChangeDetectorRef);
+ @Input() storageEmptyState: string | null = '';
+ @Input() prometheusEmptyState: string | null = '';
+
@Input()
set totalCapacity(value: number) {
const [totalValue, totalUnit] = this.formatterService.formatToBinary(value, true);
import { PerformanceCardService } from './performance-card.service';
import { configureTestBed } from '~/testing/unit-test-helper';
import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { PrometheusService } from './prometheus.service';
describe('PerformanceCardService', () => {
let service: PerformanceCardService;
});
beforeEach(() => {
- TestBed.configureTestingModule({});
+ TestBed.configureTestingModule({
+ providers: [
+ {
+ provide: PrometheusService,
+ useValue: {
+ getRangeQueriesData: jest.fn()
+ }
+ }
+ ]
+ });
service = TestBed.inject(PerformanceCardService);
});
import { PrometheusService } from './prometheus.service';
import { SettingsService } from './settings.service';
import moment from 'moment';
+import { of } from 'rxjs';
+import { MgrModuleService } from './mgr-module.service';
describe('PrometheusService', () => {
let service: PrometheusService;
let httpTesting: HttpTestingController;
+ const mockMgrModuleService = {
+ list: jest.fn(() => of([])) // no modules enabled
+ };
+
configureTestBed({
- providers: [PrometheusService, SettingsService],
+ providers: [
+ PrometheusService,
+ SettingsService,
+ { provide: MgrModuleService, useValue: mockMgrModuleService }
+ ],
imports: [HttpClientTestingModule]
});
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
-import { Observable, Subject, Subscription, forkJoin, of, timer } from 'rxjs';
+import { EMPTY, Observable, Subject, Subscription, forkJoin, of, timer } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { AlertmanagerSilence } from '../models/alertmanager-silence';
PrometheusRuleGroup
} from '../models/prometheus-alerts';
import moment from 'moment';
+import { MgrModuleService } from './mgr-module.service';
export type PromethuesGaugeMetricResult = {
metric: Record<string, string>; // metric metadata
result: PromethuesGaugeMetricResult[];
};
-export const STORAGE_TYPE_WARNING =
- 'Storage type details are unavailable. Upgrade this cluster to version 9.0 or later to access them.';
+const PROMETHEUS_MODULE = 'prometheus';
@Injectable({
providedIn: 'root'
alertmanager: 'ui-api/prometheus/alertmanager-api-host',
prometheus: 'ui-api/prometheus/prometheus-api-host'
};
- private settings: { [url: string]: string } = {};
+ private settings: Record<string, string | undefined> = {};
updatedChrtData = new Subject<any>();
- constructor(private http: HttpClient) {}
+ constructor(private http: HttpClient, private mgrModuleService: MgrModuleService) {}
unsubscribe() {
if (this.timerGetPrometheusDataSub) {
return this.http.get<any>(`${this.baseURL}/prometheus_query_data`, { params });
}
- ifAlertmanagerConfigured(fn: (value?: string) => void, elseFn?: () => void): void {
- this.ifSettingConfigured(this.settingsKey.alertmanager, fn, elseFn);
- }
-
disableAlertmanagerConfig(): void {
this.disableSetting(this.settingsKey.alertmanager);
}
- ifPrometheusConfigured(fn: (value?: string) => void, elseFn?: () => void): void {
- this.ifSettingConfigured(this.settingsKey.prometheus, fn, elseFn);
- }
-
disablePrometheusConfig(): void {
this.disableSetting(this.settingsKey.prometheus);
}
+ withPrometheusEnabled<T>(
+ source$: Observable<T>,
+ fallback$: Observable<T> = EMPTY
+ ): Observable<T> {
+ return this.isPrometheusModuleEnabled().pipe(
+ switchMap((enabled) => (enabled ? source$ : fallback$)),
+ catchError(() => fallback$)
+ );
+ }
+
+ isPrometheusModuleEnabled(): Observable<boolean> {
+ return this.mgrModuleService.list().pipe(
+ map((modules) =>
+ modules.some((module) => module.name === PROMETHEUS_MODULE && module.enabled)
+ ),
+ catchError(() => of(false))
+ );
+ }
+
+ isPrometheusUsable(): Observable<boolean> {
+ return this.isPrometheusModuleEnabled().pipe(
+ switchMap((enabled) =>
+ enabled ? this.isSettingConfigured(this.settingsKey.prometheus) : of(false)
+ ),
+ catchError(() => of(false))
+ );
+ }
+
+ isAlertmanagerUsable(): Observable<boolean> {
+ return this.isPrometheusModuleEnabled().pipe(
+ switchMap((enabled) =>
+ enabled ? this.isSettingConfigured(this.settingsKey.alertmanager) : of(false)
+ ),
+ catchError(() => of(false))
+ );
+ }
+
+ ifSettingConfigured(url: string, fn: (value?: string) => void, elseFn?: () => void): void {
+ const setting = this.settings[url];
+
+ if (setting === undefined) {
+ this.http.get(url).subscribe(
+ (data: any) => {
+ this.settings[url] = this.getSettingsValue(data);
+ this.ifSettingConfigured(url, fn, elseFn);
+ },
+ (resp) => {
+ if (resp.status !== 401) {
+ this.settings[url] = '';
+ }
+ }
+ );
+ } else if (setting !== '') {
+ fn(setting);
+ } else {
+ if (elseFn) {
+ elseFn();
+ }
+ }
+ }
+
+ ifAlertmanagerConfigured(fn: (value?: string) => void, elseFn?: () => void): void {
+ this.ifSettingConfigured(this.settingsKey.alertmanager, fn, elseFn);
+ }
+
+ ifPrometheusConfigured(fn: (value?: string) => void, elseFn?: () => void): void {
+ this.ifSettingConfigured(this.settingsKey.prometheus, fn, elseFn);
+ }
+
getAlerts(clusterFilteredAlerts = false, params = {}): Observable<AlertmanagerAlert[]> {
params['cluster_filter'] = clusterFilteredAlerts;
return this.http.get<AlertmanagerAlert[]>(this.baseURL, { params });
return this.http.get<AlertmanagerNotification[]>(url);
}
- ifSettingConfigured(url: string, fn: (value?: string) => void, elseFn?: () => void): void {
- const setting = this.settings[url];
- if (setting === undefined) {
- this.http.get(url).subscribe(
- (data: any) => {
- this.settings[url] = this.getSettingsValue(data);
- this.ifSettingConfigured(url, fn, elseFn);
- },
- (resp) => {
- if (resp.status !== 401) {
- this.settings[url] = '';
- }
- }
- );
- } else if (setting !== '') {
- fn(setting);
- } else {
- if (elseFn) {
- elseFn();
- }
+ getConfiguredSetting(url: string): Observable<string | null> {
+ const cached = this.settings[url];
+
+ if (cached !== undefined) {
+ return of(cached || null);
}
+
+ return this.http.get(url).pipe(
+ map((data: any) => {
+ const value = this.getSettingsValue(data);
+ this.settings[url] = value;
+ return value || null;
+ }),
+ catchError((resp) => {
+ if (resp.status !== 401) {
+ this.settings[url] = '';
+ }
+ return of(null);
+ })
+ );
+ }
+
+ isSettingConfigured(url: string): Observable<boolean> {
+ return this.getConfiguredSetting(url).pipe(map((value) => !!value));
}
// Easiest way to stop reloading external content that can't be reached
}
getGaugeQueryData(query: string): Observable<PromqlGuageMetric> {
- let result$: Observable<PromqlGuageMetric> = of({ result: [] } as PromqlGuageMetric);
-
- this.ifPrometheusConfigured(() => {
- result$ = this.getPrometheusQueryData({ params: query }).pipe(
- map((result: PromqlGuageMetric) => result),
- catchError(() => of({ result: [] } as PromqlGuageMetric))
- );
- });
+ return this.isPrometheusUsable().pipe(
+ switchMap((usable) => {
+ if (!usable) {
+ return of({ result: [] } as PromqlGuageMetric);
+ }
- return result$;
+ return this.getPrometheusQueryData({ params: query }).pipe(
+ catchError(() => of({ result: [] } as PromqlGuageMetric))
+ );
+ })
+ );
}
formatGuageMetric(data: string): number {
allMultiClusterQueries: string[]
) {
return new Observable((observer) => {
- this.ifPrometheusConfigured(() => {
+ this.isPrometheusUsable().subscribe((usable) => {
+ if (!usable) {
+ observer.complete();
+ return;
+ }
+
if (this.timerGetPrometheusDataSub) {
this.timerGetPrometheusDataSub.unsubscribe();
}
this.timerGetPrometheusDataSub = timer(0, this.timerTime).subscribe(() => {
- let requests: any[] = [];
- let queryNames: string[] = [];
+ const requests: any[] = [];
+ const queryNames: string[] = [];
Object.entries(multiClusterQueries).forEach(([key, _value]) => {
for (const queryName in multiClusterQueries[key].queries) {
checkNan?: boolean
): Observable<Record<string, [number, string][]>> {
return timer(0, this.timerTime).pipe(
- switchMap(() => {
- this.ifPrometheusConfigured(() => {});
-
- const updatedTime = this.updateTimeStamp(selectedTime);
-
- const observables = Object.entries(queries).map(([queryName, query]) =>
- this.getPrometheusData({
- params: encodeURIComponent(query),
- start: updatedTime.start,
- end: updatedTime.end,
- step: updatedTime.step
- }).pipe(
- map((data: any) => ({
- queryName,
- values: data.result?.length ? data.result[0].values : []
- }))
- )
- );
+ switchMap(() =>
+ this.isPrometheusUsable().pipe(
+ switchMap((usable) => {
+ if (!usable) {
+ return of([] as Array<{ queryName: string; values: any[] }>);
+ }
- return forkJoin(observables) as Observable<Array<{ queryName: string; values: any[] }>>;
- }),
+ const updatedTime = this.updateTimeStamp(selectedTime);
+
+ const observables = Object.entries(queries).map(([queryName, query]) =>
+ this.getPrometheusData({
+ params: encodeURIComponent(query),
+ start: updatedTime.start,
+ end: updatedTime.end,
+ step: updatedTime.step
+ }).pipe(
+ map((data: any) => ({
+ queryName,
+ values: data.result?.length ? data.result[0].values : []
+ }))
+ )
+ );
+
+ return forkJoin(observables) as Observable<Array<{ queryName: string; values: any[] }>>;
+ })
+ )
+ ),
map((results) => {
const formattedResults: Record<string, [number, string][]> = {};
import { configureTestBed } from '~/testing/unit-test-helper';
import { OverviewStorageService } from './storage-overview.service';
+import { PrometheusService } from './prometheus.service';
+import { FormatterService } from '../services/formatter.service';
describe('OverviewStorageService', () => {
let service: OverviewStorageService;
+ const prometheusServiceMock = {
+ getRangeQueriesData: jest.fn(),
+ getPrometheusQueryData: jest.fn(),
+ getGaugeQueryData: jest.fn(),
+ formatGuageMetric: jest.fn()
+ };
+
+ const formatterServiceMock = {
+ formatToBinary: jest.fn(),
+ convertToUnit: jest.fn()
+ };
+
configureTestBed({
- imports: [HttpClientTestingModule]
+ imports: [HttpClientTestingModule],
+ providers: [
+ { provide: PrometheusService, useValue: prometheusServiceMock },
+ { provide: FormatterService, useValue: formatterServiceMock }
+ ]
});
beforeEach(() => {
- TestBed.configureTestingModule({});
- service = TestBed.inject(OverviewStorageService);
- });
-
- afterEach(() => {
jest.clearAllMocks();
+ service = TestBed.inject(OverviewStorageService);
});
it('should be created', () => {
--- /dev/null
+<div class="empty-state">
+ <img src="assets/locked.png"
+ [alt]="emptyStateText"/>
+ <span class="cds--type-label-01"
+ i18n>
+ {{ emptyStateText }}
+ </span>
+</div>
--- /dev/null
+@use '@carbon/colors';
+
+empty-state {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ gap: var(--cds-spacing-05);
+ height: 350px;
+
+ p {
+ font-size: 12px !important;
+ }
+
+ img {
+ width: 100px !important;
+ height: 100px !important;
+ }
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { EmptyStateComponent } from './empty-state.component';
+import { GridModule, LayerModule, TilesModule } from 'carbon-components-angular';
+
+describe('ProductiveCardComponent', () => {
+ let component: EmptyStateComponent;
+ let fixture: ComponentFixture<EmptyStateComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [EmptyStateComponent, GridModule, LayerModule, TilesModule]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(EmptyStateComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, Input } from '@angular/core';
+
+@Component({
+ selector: 'cd-empty-state',
+ standalone: true,
+ templateUrl: './empty-state.component.html',
+ styleUrl: './empty-state.component.scss'
+})
+export class EmptyStateComponent {
+ /* Optional: Custom empty state text, when empty state is displyed*/
+ @Input() emptyStateText: string | null = '';
+}
import { SimplebarAngularModule } from 'simplebar-angular';
import { PrometheusService } from '~/app/shared/api/prometheus.service';
-import { RbdService } from '~/app/shared/api/rbd.service';
-import { SettingsService } from '~/app/shared/api/settings.service';
import { NotificationType } from '~/app/shared/enum/notification-type.enum';
import { ExecutingTask } from '~/app/shared/models/executing-task';
import { Permissions } from '~/app/shared/models/permissions';
ClickOutsideModule
],
declarations: [NotificationsSidebarComponent],
- providers: [PrometheusService, SettingsService, SummaryService, NotificationService, RbdService]
+ providers: [
+ {
+ provide: PrometheusService,
+ useValue: {
+ setSilence: jasmine.createSpy('setSilence'),
+ expireSilence: jasmine.createSpy('expireSilence')
+ }
+ },
+ {
+ provide: PrometheusAlertService,
+ useValue: {
+ refresh: jasmine.createSpy('refresh')
+ }
+ },
+ {
+ provide: PrometheusNotificationService,
+ useValue: {
+ refresh: jasmine.createSpy('refresh')
+ }
+ }
+ ]
});
beforeEach(() => {
};
beforeEach(() => {
- spyOn(TestBed.inject(PrometheusService), 'ifAlertmanagerConfigured').and.callFake((fn) =>
- fn()
- );
-
prometheusAlertService = TestBed.inject(PrometheusAlertService);
- spyOn(prometheusAlertService, 'refresh').and.stub();
-
prometheusNotificationService = TestBed.inject(PrometheusNotificationService);
- spyOn(prometheusNotificationService, 'refresh').and.stub();
+
+ (prometheusAlertService.refresh as jasmine.Spy).calls.reset();
+ (prometheusNotificationService.refresh as jasmine.Spy).calls.reset();
});
it('should not refresh prometheus services if not allowed', () => {
i18n-headerTitle
[applyShadow]="false"
>
- @if(emptyStateKey().length === 0) {
- <ng-template #header>
- <h2 class="cds--type-heading-compact-02"
- i18n>Performance</h2>
- <div cdsStack="horizontal"
- gap="2">
- <cd-time-picker
- dropdownSize="sm"
- label="Time Span"
- (selectedTime)="loadCharts($event)"
- i18n-label
- ></cd-time-picker>
- </div>
- </ng-template>
- <div cdsGrid
- [narrow]="true"
- [condensed]="false"
- [fullWidth]="true"
- class="cds-mt-5 cds-mb-5">
- <div cdsRow
- [narrow]="true">
- <div cdsCol
- class="cds-mb-5"
- [columnNumbers]="{lg: 5, md: 8, sm: 12}">
- <cd-area-chart
- chartTitle="IOPS"
- [chartKey]="performanceTypes?.IOPS"
- [dataUnit]="metricUnitMap?.iops"
- [rawData]="chartDataSignal()?.iops"
- [decimals]="0">
- </cd-area-chart>
- </div>
- <div cdsCol
- [columnNumbers]="{lg: 5, md: 8, sm: 12}"
- class="cds-mb-5 performance-card-latency-chart">
- <cd-area-chart
- chartTitle="Latency"
- [chartKey]="performanceTypes?.Latency"
- [dataUnit]="metricUnitMap?.latency"
- [rawData]="chartDataSignal()?.latency"
- [decimals]="2">
- </cd-area-chart>
- </div>
- <div cdsCol
- class="cds-mb-5"
- [columnNumbers]="{lg: 5, md: 8, sm: 12}">
- <cd-area-chart
- chartTitle="Throughput"
- [chartKey]="performanceTypes?.Throughput"
- [dataUnit]="metricUnitMap?.throughput"
- [rawData]="chartDataSignal()?.throughput"
- [decimals]="2">
- </cd-area-chart>
- </div>
- </div>
+ <ng-template #header>
+ <h2 class="cds--type-heading-compact-02"
+ i18n>Performance</h2>
+ @if(!emptyStateText) {
+ <div cdsStack="horizontal"
+ gap="2">
+ <cd-time-picker
+ [dropdownSize]="'sm'"
+ [label]="'Time Span'"
+ (selectedTime)="loadCharts($event)"
+ ></cd-time-picker>
</div>
}
-
- @if(emptyStateKey().length > 0) {
- <div class="performance-card-empty-msg">
- <div class="performance-card-empty-msg-icon">
- <img src="assets/locked.png"
- alt="no-services-links"/>
+ </ng-template>
+ @if(emptyStateText) {
+ <cd-empty-state [emptyStateText]="emptyStateText"></cd-empty-state>
+ } @else {
+ <div cdsGrid
+ [narrow]="true"
+ [condensed]="false"
+ [fullWidth]="true"
+ class="cds-mt-5 cds-mb-5">
+ <div cdsRow
+ [narrow]="true">
+ <div cdsCol
+ class="cds-mb-5"
+ [columnNumbers]="{lg: 5, md: 8, sm: 12}">
+ <cd-area-chart
+ chartTitle="IOPS"
+ i18n-chartTitle
+ [chartKey]="performanceTypes?.IOPS"
+ [dataUnit]="metricUnitMap?.iops"
+ [rawData]="chartDataSignal()?.iops">
+ </cd-area-chart>
+ </div>
+ <div cdsCol
+ [columnNumbers]="{lg: 5, md: 8, sm: 12}"
+ class="cds-mb-5 performance-card-latency-chart">
+ <cd-area-chart
+ chartTitle="Latency"
+ i18n-chartTitle
+ [chartKey]="performanceTypes?.Latency"
+ [dataUnit]="metricUnitMap?.latency"
+ [rawData]="chartDataSignal()?.latency">
+ </cd-area-chart>
+ </div>
+ <div cdsCol
+ class="cds-mb-5"
+ [columnNumbers]="{lg: 5, md: 8, sm: 12}">
+ <cd-area-chart
+ chartTitle="Throughput"
+ i18n-chartTitle
+ [chartKey]="performanceTypes?.Throughput"
+ [dataUnit]="metricUnitMap?.throughput"
+ [rawData]="chartDataSignal()?.throughput">
+ </cd-area-chart>
</div>
- <span class="cds--type-label-01"
- i18n>
- {{ emptyStateText[emptyStateKey()] }}
- </span>
</div>
- }
+ </div>
+ }
</cd-productive-card>
</div>
-import { ComponentFixture, TestBed, fakeAsync, tick, flush } from '@angular/core/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
import { PerformanceCardComponent } from './performance-card.component';
import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { of } from 'rxjs';
+import { EMPTY, of } from 'rxjs';
import { PrometheusService } from '../../api/prometheus.service';
import { PerformanceCardService } from '../../api/performance-card.service';
-import { MgrModuleService } from '../../api/mgr-module.service';
import { PerformanceData } from '../../models/performance-data';
import { DatePipe } from '@angular/common';
import { NumberFormatterService } from '../../services/number-formatter.service';
describe('PerformanceCardComponent', () => {
let component: PerformanceCardComponent;
let fixture: ComponentFixture<PerformanceCardComponent>;
- let performanceCardService: PerformanceCardService;
- let mgrModuleService: MgrModuleService;
+ let prometheusService: PrometheusService;
const mockChartData: PerformanceData = {
iops: [{ timestamp: new Date(), values: { 'Read IOPS': 100, 'Write IOPS': 50 } }],
]
};
- const mockMgrModules = [
- { name: 'prometheus', enabled: true },
- { name: 'other', enabled: false }
- ];
-
beforeEach(async () => {
const prometheusServiceMock = {
lastHourDateObject: { start: 1000, end: 2000, step: 14 },
- ifPrometheusConfigured: jest.fn((fn) => fn())
+ withPrometheusEnabled: jest.fn((source$) => source$)
};
const performanceCardServiceMock = {
getChartData: jest.fn().mockReturnValue(of(mockChartData))
};
- const mgrModuleServiceMock = {
- list: jest.fn().mockReturnValue(of(mockMgrModules))
- };
-
const numberFormatterMock = {
formatFromTo: jest.fn().mockReturnValue('1.00'),
bytesPerSecondLabels: [
providers: [
{ provide: PrometheusService, useValue: prometheusServiceMock },
{ provide: PerformanceCardService, useValue: performanceCardServiceMock },
- { provide: MgrModuleService, useValue: mgrModuleServiceMock },
{ provide: NumberFormatterService, useValue: numberFormatterMock },
{ provide: DatePipe, useValue: datePipeMock },
{ provide: AuthStorageService, useValue: authStorageServiceMock }
fixture = TestBed.createComponent(PerformanceCardComponent);
component = fixture.componentInstance;
- performanceCardService = TestBed.inject(PerformanceCardService);
- mgrModuleService = TestBed.inject(MgrModuleService);
+ prometheusService = TestBed.inject(PrometheusService);
});
it('should create', () => {
expect(component).toBeTruthy();
});
- it('should initialize list signal from mgrModuleService', fakeAsync(() => {
- tick();
- expect(mgrModuleService.list).toHaveBeenCalled();
- expect(component.list()).toEqual(mockMgrModules);
- flush();
- }));
-
it('should call loadCharts on ngOnInit', () => {
const loadChartsSpy = jest.spyOn(component, 'loadCharts');
component.ngOnInit();
it('should load charts and update chartDataSignal', fakeAsync(() => {
const time = { start: 1000, end: 2000, step: 14 };
- component.loadCharts(time);
-
- expect(component.time).toEqual(time);
- expect(performanceCardService.getChartData).toHaveBeenCalledWith(time);
+ component.loadCharts(time);
tick();
+
expect(component.chartDataSignal()).toEqual(mockChartData);
}));
- it('should set emptyStateKey when prometheus is enabled', fakeAsync(() => {
+ it('should set emptyStateText when prometheus is enabled', fakeAsync(() => {
const time = { start: 1000, end: 2000, step: 14 };
component.loadCharts(time);
tick();
- expect(mgrModuleService.list).toHaveBeenCalled();
- expect(component.emptyStateKey()).toBe('');
+ expect(component.emptyStateText).toBe('');
}));
- it('should set emptyStateKey to prometheusDisabled when prometheus module is disabled', fakeAsync(async () => {
- const mockMgrModulesDisabled = [
- { name: 'prometheus', enabled: false },
- { name: 'other', enabled: true }
- ];
- (mgrModuleService.list as jest.Mock).mockReturnValue(of(mockMgrModulesDisabled));
-
- // Recreate component with new mock value
- fixture = TestBed.createComponent(PerformanceCardComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- tick();
+ it('should not load chart data when prometheus is disabled', fakeAsync(() => {
+ (prometheusService.withPrometheusEnabled as jest.Mock).mockReturnValue(EMPTY);
const time = { start: 1000, end: 2000, step: 14 };
component.loadCharts(time);
tick();
- expect(mgrModuleService.list).toHaveBeenCalled();
- expect(component.emptyStateKey()).toBe('prometheusDisabled');
- }));
-
- it('should handle empty mgr modules list', fakeAsync(() => {
- const mockMgrModulesEmpty: any[] = [];
- (mgrModuleService.list as jest.Mock).mockReturnValue(of(mockMgrModulesEmpty));
-
- // Recreate component with new mock value
- fixture = TestBed.createComponent(PerformanceCardComponent);
- component = fixture.componentInstance;
- // Don't call detectChanges() as it triggers ngOnInit which calls loadCharts
- // and loadCharts will crash with empty array
- tick();
-
- expect(mgrModuleService.list).toHaveBeenCalled();
- expect(component.list()).toEqual([]);
- flush();
- }));
-
- it('should set emptyStateKey to empty string when user lacks configOpt read', fakeAsync(() => {
- const auth = TestBed.inject(AuthStorageService);
- (auth.getPermissions as jest.Mock).mockReturnValue(new Permissions({}));
- fixture = TestBed.createComponent(PerformanceCardComponent);
- component = fixture.componentInstance;
-
- const time = { start: 1000, end: 2000, step: 14 };
- component.loadCharts(time);
-
- tick();
- expect(component.emptyStateKey()).toBe('');
+ expect(component.chartDataSignal()).toBeNull();
}));
it('should cleanup subscriptions on ngOnDestroy', () => {
ViewEncapsulation,
inject,
signal,
- computed
+ computed,
+ Input
} from '@angular/core';
import { Icons, IconSize } from '~/app/shared/enum/icons.enum';
import { PrometheusService } from '~/app/shared/api/prometheus.service';
import { CommonModule } from '@angular/common';
import { TimePickerComponent } from '../time-picker/time-picker.component';
import { AreaChartComponent } from '../area-chart/area-chart.component';
-import { MgrModuleService } from '../../api/mgr-module.service';
-import { toSignal } from '@angular/core/rxjs-interop';
-import { AuthStorageService } from '../../services/auth-storage.service';
+import { EmptyStateComponent } from '../empty-state/empty-state.component';
@Component({
selector: 'cd-performance-card',
AreaChartComponent,
TimePickerComponent,
LayoutModule,
- GridModule
+ GridModule,
+ EmptyStateComponent
],
encapsulation: ViewEncapsulation.None
})
export class PerformanceCardComponent implements OnInit, OnDestroy {
+ @Input() emptyStateText: string | null = '';
+
chartDataSignal = signal<PerformanceData | null>(null);
chartDataLengthSignal = computed(() => {
const data = this.chartDataSignal();
metricUnitMap = METRIC_UNIT_MAP;
icons = Icons;
iconSize = IconSize;
- emptyStateText = {
- prometheusNotAvailable: $localize`You must have prometheus configured to access this capability.`,
- storageNotAvailable: $localize`You must have storage configured to access this capability.`,
- prometheusDisabled: $localize`You must enable prometheus to access this capability.`
- };
- emptyStateKey = signal<
- 'prometheusNotAvailable' | 'storageNotAvailable' | 'prometheusDisabled' | ''
- >('prometheusNotAvailable');
private destroy$ = new Subject<void>();
private prometheusService = inject(PrometheusService);
private performanceCardService = inject(PerformanceCardService);
- private mgrModuleService = inject(MgrModuleService);
- private readonly authStorageService = inject(AuthStorageService);
time = { ...this.prometheusService.lastHourDateObject };
private chartSub?: Subscription;
- private readonly permissions = this.authStorageService.getPermissions();
-
- readonly list = this.permissions?.configOpt?.read
- ? toSignal(this.mgrModuleService.list(), { initialValue: [] })
- : toSignal(of([]), { initialValue: [] });
-
ngOnInit() {
this.loadCharts(this.time);
}
this.chartSub?.unsubscribe();
- this.chartSub = this.performanceCardService
- .getChartData(time)
+ this.chartSub = this.prometheusService
+ .withPrometheusEnabled(this.performanceCardService.getChartData(time))
.pipe(takeUntil(this.destroy$))
.subscribe((data) => {
- if (this.permissions?.configOpt?.read) {
- this.followEmptyStateMsgCheck(data);
- } else {
- this.skipEmptyStateMsgCheck(data);
- }
+ this.chartDataSignal.set(data);
});
}
- followEmptyStateMsgCheck(data: PerformanceData) {
- let enabled$ = this.list().filter((a) => a.name === 'prometheus')[0].enabled;
- this.chartDataSignal.set(data);
- if (enabled$) {
- this.emptyStateKey.set('');
- } else if (!enabled$) {
- this.emptyStateKey.set('prometheusDisabled');
- } else {
- this.emptyStateKey.set('storageNotAvailable');
- }
- }
-
- skipEmptyStateMsgCheck(data: PerformanceData) {
- this.chartDataSignal.set(data);
- this.emptyStateKey.set('');
- }
-
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
<cds-tile class="productive-card"
[ngClass]="{'productive-card--shadow': applyShadow}"
[cdsLayer]="0">
+ @if(!emptyStateText) {
<div class="productive-card-header">
<ng-container *ngTemplateOutlet="headerTemplate"></ng-container>
</div>
+ }
<section class="productive-card-section cds--type-body-compact-01"
[ngClass]="{'productive-card-section--footer': footerTemplate}">
+ @if(emptyStateText) {
+ <div class="productive-card-empty-state">
+ <img src="assets/locked.png"
+ [alt]="emptyStateText"/>
+ <span class="cds--type-label-01"
+ i18n>
+ {{ emptyStateText }}
+ </span>
+ </div>
+ }
+ @else {
<ng-content></ng-content>
+ }
</section>
- @if(!!footerTemplate) {
+ @if(!!footerTemplate && !emptyStateText) {
<footer class="productive-card-footer">
<ng-container *ngTemplateOutlet="footerTemplate"></ng-container>
</footer>
radial-gradient(120% 60% at 50% 100%, rgba(colors.$magenta-60, 0.11) 0%, transparent 70%);
box-shadow: var(--cds-ai-drop-shadow), inset 0 0 0 1px var(--cds-ai-inner-shadow);
}
+
+ &-empty-state {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ gap: var(--cds-spacing-05);
+ height: 350px;
+
+ p {
+ font-size: 12px !important;
+ }
+
+ img {
+ width: 100px !important;
+ height: 100px !important;
+ }
+ }
}
/* Optional: Applies a tinted-colored background to card */
@Input() applyShadow: boolean = false;
+ /* Optional: Custom empty state text, when empty state is displyed*/
+ @Input() emptyStateText: string | null = '';
+
/* Optional: Header action template, appears alongwith title in top-right corner */
@ContentChild('header', {
read: TemplateRef
import { ChartTabularData, GaugeChartOptions } from '@carbon/charts-angular';
-import { HealthCheck, PgStateCount } from './health.interface';
+import { HealthCheck, HealthSnapshotMap, PgStateCount } from './health.interface';
import _ from 'lodash';
// Types
return { activeCleanPercent, severity, reasons };
}
+
+/**
+ * Mapper: HealthSnapshotMap -> HealthCardVM
+ * Runs only when healthData$ emits.
+ */
+export function buildHealthCardVM(d: HealthSnapshotMap): HealthCardVM {
+ const checksObj: Record<string, HealthCheck> = d.health?.checks ?? {};
+ const clusterHealth = getClusterHealth(d.health.status as HealthStatus);
+ const pgStates = d?.pgmap?.pgs_by_state ?? [];
+ const totalPg = d?.pgmap?.num_pgs ?? 0;
+
+ const { incidents, checks } = getHealthChecksAndIncidents(checksObj);
+ const resiliencyHealth = getResiliencyDisplay(checks, pgStates);
+ const {
+ activeCleanPercent,
+ severity: activeCleanChartSeverity,
+ reasons: activeCleanChartReason
+ } = calcActiveCleanSeverityAndReasons(pgStates, totalPg);
+
+ // --- 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 ? SEVERITY.warn : SEVERITY.ok;
+
+ // MGR
+ const mgrActive = d.mgrmap?.num_active ?? 0;
+ const mgrStandby = d.mgrmap?.num_standbys ?? 0;
+ const mgrSev: Severity =
+ mgrActive < 1 ? SEVERITY.err : mgrStandby < 1 ? SEVERITY.warn : SEVERITY.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 ? SEVERITY.err : SEVERITY.ok;
+
+ // HOSTS
+ const hostsTotal = d.num_hosts ?? 0;
+ const hostsAvailable = (d as any)?.num_hosts_available ?? 0;
+ const hostsSev: Severity = hostsAvailable < hostsTotal ? SEVERITY.warn : SEVERITY.ok;
+
+ // Overall = worst of the subsystem severities.
+ const overallSystemSev = maxSeverity(monSev, mgrSev, osdSev, hostsSev);
+
+ return {
+ fsid: d.fsid,
+ overallSystemSev: SeverityIconMap[overallSystemSev],
+
+ incidents,
+ checks,
+
+ pgs: {
+ total: totalPg,
+ states: pgStates,
+ io: [
+ { label: $localize`Client write`, value: d?.pgmap?.write_bytes_sec ?? 0 },
+ { label: $localize`Client read`, value: d?.pgmap?.read_bytes_sec ?? 0 },
+ { label: $localize`Recovery I/O`, value: d?.pgmap?.recovering_bytes_per_sec ?? 0 }
+ ],
+ activeCleanChartData: [{ group: 'value', value: activeCleanPercent }],
+ activeCleanChartOptions: {
+ ...ACTIVE_CLEAN_CHART_OPTIONS,
+ color: { scale: { value: SEVERITY_TO_COLOR[activeCleanChartSeverity] } }
+ },
+ activeCleanChartReason
+ },
+
+ clusterHealth,
+ resiliencyHealth,
+
+ mon: { value: $localize`Quorum: ${monQuorum}/${monTotal}`, severity: SeverityIconMap[monSev] },
+ mgr: {
+ value: $localize`${mgrActive} active, ${mgrStandby} standby`,
+ severity: SeverityIconMap[mgrSev]
+ },
+ osd: { value: $localize`${osdIn}/${osdUp} in/up`, severity: SeverityIconMap[osdSev] },
+ hosts: {
+ value: $localize`${hostsAvailable} / ${hostsTotal} available`,
+ severity: SeverityIconMap[hostsSev]
+ }
+ };
+}
const isDisabledByStatusCode = (statusCode: number, expectedStatus: boolean, done: any) => {
service = TestBed.inject(PrometheusAlertService);
prometheusService = TestBed.inject(PrometheusService);
- spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn());
+ spyOn(prometheusService, 'isAlertmanagerUsable').and.returnValue(of(true));
spyOn(prometheusService, 'getGroupedAlerts').and.returnValue(
new Observable((observer: any) => observer.error({ status: statusCode, error: {} }))
);
spyOn(notificationService, 'show').and.stub();
prometheusService = TestBed.inject(PrometheusService);
- spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn());
+ spyOn(prometheusService, 'isAlertmanagerUsable').and.returnValue(of(true));
spyOn(prometheusService, 'getGroupedAlerts').and.callFake(() => of(alerts));
alerts = [{ alerts: [prometheus.createAlert('alert0')] }];
service = TestBed.inject(PrometheusAlertService);
prometheusService = TestBed.inject(PrometheusService);
- spyOn(prometheusService, 'ifAlertmanagerConfigured').and.callFake((fn) => fn());
+ spyOn(prometheusService, 'isAlertmanagerUsable').and.returnValue(of(true));
spyOn(prometheusService, 'getGroupedAlerts').and.callFake(() => of(alerts));
alerts = [
) {}
getGroupedAlerts(clusterFilteredAlerts = false) {
- this.prometheusService.ifAlertmanagerConfigured(() => {
+ this.prometheusService.isAlertmanagerUsable().subscribe((usable) => {
+ if (!usable) {
+ return;
+ }
+
this.prometheusService.getGroupedAlerts(clusterFilteredAlerts).subscribe(
(alerts) => this.handleAlerts(alerts),
(resp) => {