From: Afreen Misbah Date: Sun, 12 Apr 2026 19:27:44 +0000 (+0530) Subject: mgr/dashboard: Allow checks for prometheus disablement X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=bd77c67ae707516e40cf8b99fd48d5608cf90e24;p=ceph.git mgr/dashboard: Allow checks for prometheus disablement - dont fire promethues queries if promethues is disabled Signed-off-by: Afreen Misbah --- diff --git a/src/pybind/mgr/dashboard/frontend/cypress.config.ts b/src/pybind/mgr/dashboard/frontend/cypress.config.ts index 63b236078c3c..3f8f743efaf3 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress.config.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ env: { LOGIN_USER: 'admin', LOGIN_PWD: 'admin', - CEPH2_URL: 'https://localhost:4202/' + CEPH2_URL: 'https://localhost:11002/' }, chromeWebSecurity: false, @@ -55,7 +55,7 @@ export default defineConfig({ ) 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}' diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/pools/pools.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/pools/pools.po.ts index bdad7eae68d8..cd87b245d993 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/pools/pools.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/pools/pools.po.ts @@ -29,7 +29,6 @@ export class PoolPageHelper extends PageHelper { 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) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.html index 9635a0b87012..af0faf512190 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.html @@ -28,7 +28,7 @@ {{ vm.statusText }} -
+
@if (vm.badges.length) {
@for (b of vm.badges; track b.key; let last = $last) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts index 644353a63a32..8feca50aa173 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts @@ -86,6 +86,7 @@ export class OverviewHealthCardComponent { private readonly authStorageService = inject(AuthStorageService); @Input({ required: true }) vm!: HealthCardVM; + @Input() emptyStateText: string | null = ''; @Output() viewIncidents = new EventEmitter(); @Output() viewPGStates = new EventEmitter(); @Output() activeSectionChange = new EventEmitter(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html index 84bfcfc9c908..62dc9f1829ff 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html @@ -11,8 +11,9 @@
-
+ class="overview cds-mt-5"> +
+ [threshold]="storageCard?.threshold" + [storageEmptyState]="storageEmptyState$ | async" + [prometheusEmptyState]="prometheusEmptyState$ | async">
@@ -49,7 +52,7 @@
- +
@@ -106,7 +109,7 @@
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts index 1abf2c7b8f33..ce95fb1f5753 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts @@ -6,20 +6,14 @@ 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 { 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; @@ -39,7 +33,8 @@ describe('OverviewComponent', () => { }; const mockAuthStorageService = { - getPermissions: jest.fn(() => ({ configOpt: { read: false } })) + getPermissions: jest.fn(() => ({ configOpt: { read: false } })), + isPwdDisplayedSource: new Subject() }; const mockMgrModuleService = { @@ -51,7 +46,15 @@ describe('OverviewComponent', () => { 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() }; @@ -89,28 +92,22 @@ describe('OverviewComponent', () => { }; 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; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts index bb8f7119242a..9b61a099522b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts @@ -8,26 +8,15 @@ import { } 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'; @@ -40,97 +29,12 @@ import { PerformanceCardComponent } from '~/app/shared/components/performance-ca 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 = 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: [ @@ -156,15 +60,17 @@ export class OverviewComponent { 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 = this.refreshIntervalObs(() => @@ -176,8 +82,36 @@ export class OverviewComponent { 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, @@ -186,41 +120,62 @@ export class OverviewComponent { 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 = combineLatest([ this.storageVm$, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html index b6bccde8964f..a6b97997307f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html @@ -5,6 +5,7 @@ i18n>Storage overview + @if(!storageEmptyState) {
@if( usedRaw !== null && totalRaw !== null && usedRawUnit && totalRawUnit) {
@@ -54,7 +55,12 @@ }
+ } + @else { + + } + @if (!prometheusEmptyState && !storageEmptyState) { @if(isBreakdownLoaded) {
} +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts index 48089fa3cfef..6c2582ef1ebd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts @@ -19,6 +19,7 @@ import { FormatterService } from '~/app/shared/services/formatter.service'; 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'; @@ -33,7 +34,8 @@ const CHART_HEIGHT = '45px'; LayoutModule, AreaChartComponent, ComponentsModule, - TagModule + TagModule, + EmptyStateComponent ], standalone: true, templateUrl: './overview-storage-card.component.html', @@ -45,6 +47,9 @@ export class OverviewStorageCardComponent { 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); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts index 06871985247a..f39048c8f009 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts @@ -3,6 +3,7 @@ import { TestBed } from '@angular/core/testing'; 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; @@ -12,7 +13,16 @@ describe('PerformanceCardService', () => { }); beforeEach(() => { - TestBed.configureTestingModule({}); + TestBed.configureTestingModule({ + providers: [ + { + provide: PrometheusService, + useValue: { + getRangeQueriesData: jest.fn() + } + } + ] + }); service = TestBed.inject(PerformanceCardService); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts index 767d83881a55..d8267c791cf0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts @@ -6,13 +6,23 @@ import { AlertmanagerNotification } from '../models/prometheus-alerts'; 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] }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts index 355d6969f8d5..0b93d171696a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts @@ -1,7 +1,7 @@ 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'; @@ -12,6 +12,7 @@ import { PrometheusRuleGroup } from '../models/prometheus-alerts'; import moment from 'moment'; +import { MgrModuleService } from './mgr-module.service'; export type PromethuesGaugeMetricResult = { metric: Record; // metric metadata @@ -23,8 +24,7 @@ export type PromqlGuageMetric = { 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' @@ -42,10 +42,10 @@ export class PrometheusService { alertmanager: 'ui-api/prometheus/alertmanager-api-host', prometheus: 'ui-api/prometheus/prometheus-api-host' }; - private settings: { [url: string]: string } = {}; + private settings: Record = {}; updatedChrtData = new Subject(); - constructor(private http: HttpClient) {} + constructor(private http: HttpClient, private mgrModuleService: MgrModuleService) {} unsubscribe() { if (this.timerGetPrometheusDataSub) { @@ -63,22 +63,83 @@ export class PrometheusService { return this.http.get(`${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( + source$: Observable, + fallback$: Observable = EMPTY + ): Observable { + return this.isPrometheusModuleEnabled().pipe( + switchMap((enabled) => (enabled ? source$ : fallback$)), + catchError(() => fallback$) + ); + } + + isPrometheusModuleEnabled(): Observable { + return this.mgrModuleService.list().pipe( + map((modules) => + modules.some((module) => module.name === PROMETHEUS_MODULE && module.enabled) + ), + catchError(() => of(false)) + ); + } + + isPrometheusUsable(): Observable { + return this.isPrometheusModuleEnabled().pipe( + switchMap((enabled) => + enabled ? this.isSettingConfigured(this.settingsKey.prometheus) : of(false) + ), + catchError(() => of(false)) + ); + } + + isAlertmanagerUsable(): Observable { + 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 { params['cluster_filter'] = clusterFilteredAlerts; return this.http.get(this.baseURL, { params }); @@ -125,27 +186,30 @@ export class PrometheusService { return this.http.get(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 { + 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 { + return this.getConfiguredSetting(url).pipe(map((value) => !!value)); } // Easiest way to stop reloading external content that can't be reached @@ -158,16 +222,17 @@ export class PrometheusService { } getGaugeQueryData(query: string): Observable { - let result$: Observable = 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 { @@ -206,14 +271,19 @@ export class PrometheusService { 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) { @@ -284,27 +354,33 @@ export class PrometheusService { checkNan?: boolean ): Observable> { 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>; - }), + 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>; + }) + ) + ), map((results) => { const formattedResults: Record = {}; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts index d6fea71d3090..3a256176f216 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts @@ -4,21 +4,35 @@ import { of } from 'rxjs'; 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', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.html new file mode 100644 index 000000000000..8a10a70a908e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.html @@ -0,0 +1,8 @@ +
+ + + {{ emptyStateText }} + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.scss new file mode 100644 index 000000000000..28079273da46 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.scss @@ -0,0 +1,18 @@ +@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; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.spec.ts new file mode 100644 index 000000000000..91bf4017c46f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.spec.ts @@ -0,0 +1,23 @@ +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; + + 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(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.ts new file mode 100644 index 000000000000..eb96ed55f5aa --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.ts @@ -0,0 +1,12 @@ +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 = ''; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts index ff15f78111ee..a34934ab6cd9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts @@ -15,8 +15,6 @@ import { ToastrModule } from 'ngx-toastr'; 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'; @@ -49,7 +47,27 @@ describe('NotificationsSidebarComponent', () => { 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(() => { @@ -87,15 +105,11 @@ describe('NotificationsSidebarComponent', () => { }; 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', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html index 3e004b1fccc1..52b115abe968 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html @@ -4,75 +4,65 @@ i18n-headerTitle [applyShadow]="false" > - @if(emptyStateKey().length === 0) { - -

Performance

-
- -
-
-
-
-
- - -
-
- - -
-
- - -
-
+ +

Performance

+ @if(!emptyStateText) { +
+
} - - @if(emptyStateKey().length > 0) { -
-
- no-services-links + + @if(emptyStateText) { + + } @else { +
+
+
+ + +
+
+ + +
+
+ +
- - {{ emptyStateText[emptyStateKey()] }} -
- } +
+ }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts index 11841216d7bf..9490910a432e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts @@ -1,10 +1,9 @@ -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'; @@ -14,8 +13,7 @@ import { Permissions } from '../../models/permissions'; describe('PerformanceCardComponent', () => { let component: PerformanceCardComponent; let fixture: ComponentFixture; - let performanceCardService: PerformanceCardService; - let mgrModuleService: MgrModuleService; + let prometheusService: PrometheusService; const mockChartData: PerformanceData = { iops: [{ timestamp: new Date(), values: { 'Read IOPS': 100, 'Write IOPS': 50 } }], @@ -25,25 +23,16 @@ describe('PerformanceCardComponent', () => { ] }; - 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: [ @@ -74,7 +63,6 @@ describe('PerformanceCardComponent', () => { 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 } @@ -83,21 +71,13 @@ describe('PerformanceCardComponent', () => { 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(); @@ -106,73 +86,30 @@ describe('PerformanceCardComponent', () => { 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', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts index 65baa2473799..a7add9f87af8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts @@ -5,7 +5,8 @@ import { 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'; @@ -23,9 +24,7 @@ import { ProductiveCardComponent } from '../productive-card/productive-card.comp 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', @@ -39,11 +38,14 @@ import { AuthStorageService } from '../../services/auth-storage.service'; AreaChartComponent, TimePickerComponent, LayoutModule, - GridModule + GridModule, + EmptyStateComponent ], encapsulation: ViewEncapsulation.None }) export class PerformanceCardComponent implements OnInit, OnDestroy { + @Input() emptyStateText: string | null = ''; + chartDataSignal = signal(null); chartDataLengthSignal = computed(() => { const data = this.chartDataSignal(); @@ -53,14 +55,6 @@ export class PerformanceCardComponent implements OnInit, OnDestroy { 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(); @@ -87,18 +81,10 @@ export class PerformanceCardComponent implements OnInit, OnDestroy { 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); } @@ -108,35 +94,14 @@ export class PerformanceCardComponent implements OnInit, OnDestroy { 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(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html index f9e9cae8e53c..c0dd093ae768 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html @@ -1,14 +1,28 @@ + @if(!emptyStateText) {
+ }
+ @if(emptyStateText) { +
+ + + {{ emptyStateText }} + +
+ } + @else { + }
- @if(!!footerTemplate) { + @if(!!footerTemplate && !emptyStateText) {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.scss index 49596dbb1e85..589e17f59cdd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.scss @@ -40,4 +40,21 @@ 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; + } + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.ts index 24509b030c93..0b6e2a082dfc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.ts @@ -24,6 +24,9 @@ export class ProductiveCardComponent { /* 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 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts index 92d9e47e395c..dfab93e21996 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts @@ -1,5 +1,5 @@ import { ChartTabularData, GaugeChartOptions } from '@carbon/charts-angular'; -import { HealthCheck, PgStateCount } from './health.interface'; +import { HealthCheck, HealthSnapshotMap, PgStateCount } from './health.interface'; import _ from 'lodash'; // Types @@ -391,3 +391,89 @@ export function calcActiveCleanSeverityAndReasons( return { activeCleanPercent, severity, reasons }; } + +/** + * Mapper: HealthSnapshotMap -> HealthCardVM + * Runs only when healthData$ emits. + */ +export function buildHealthCardVM(d: HealthSnapshotMap): HealthCardVM { + const checksObj: Record = 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] + } + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts index bf83bd7e9c35..b2ef14a1ada5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts @@ -38,7 +38,7 @@ describe('PrometheusAlertService', () => { 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: {} })) ); @@ -115,7 +115,7 @@ describe('PrometheusAlertService', () => { 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')] }]; @@ -202,7 +202,7 @@ describe('PrometheusAlertService', () => { 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 = [ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts index 31251f8a877f..4273f8b4f95f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts @@ -40,7 +40,11 @@ export class PrometheusAlertService { ) {} getGroupedAlerts(clusterFilteredAlerts = false) { - this.prometheusService.ifAlertmanagerConfigured(() => { + this.prometheusService.isAlertmanagerUsable().subscribe((usable) => { + if (!usable) { + return; + } + this.prometheusService.getGroupedAlerts(clusterFilteredAlerts).subscribe( (alerts) => this.handleAlerts(alerts), (resp) => {