]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Allow checks for prometheus disablement
authorAfreen Misbah <afreen@ibm.com>
Sun, 12 Apr 2026 19:27:44 +0000 (00:57 +0530)
committerAfreen Misbah <afreen@ibm.com>
Tue, 12 May 2026 09:09:48 +0000 (14:39 +0530)
- dont fire promethues queries if promethues is disabled

Signed-off-by: Afreen Misbah <afreen@ibm.com>
27 files changed:
src/pybind/mgr/dashboard/frontend/cypress.config.ts
src/pybind/mgr/dashboard/frontend/cypress/e2e/pools/pools.po.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/alerts-card/overview-alerts-card.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/health-card/overview-health-card.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/empty-state/empty-state.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/notifications-sidebar/notifications-sidebar.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/prometheus-alert.service.ts

index 63b236078c3c32608b66a61e1b204c80055b906d..3f8f743efaf3c37264b0e2a9f029acb05de0f802 100644 (file)
@@ -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}'
index bdad7eae68d84be20fe9f5f0239fe158997c53f9..cd87b245d993edc3f71f3b08159ba4c20164c798 100644 (file)
@@ -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) {
index 9635a0b87012479f785da99bbc4551bc5ddcc4c2..af0faf512190b02ba418f5e20f811e8b315afa53 100644 (file)
@@ -28,7 +28,7 @@
     {{ 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) {
index 644353a63a3203403541f9d2c98c27db7a99be7e..8feca50aa1736dfbddd3b39f22ae4ec46403acef 100644 (file)
@@ -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<void>();
   @Output() viewPGStates = new EventEmitter<void>();
   @Output() activeSectionChange = new EventEmitter<HealthCardTabSection | null>();
index 84bfcfc9c908ae87646cf10e97fbd1087eacaef9..62dc9f1829ff51c5947a87dc42351ac08dfde47a 100644 (file)
@@ -11,8 +11,9 @@
 <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
@@ -41,7 +42,9 @@
         [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>
@@ -49,7 +52,7 @@
     <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>
index 1abf2c7b8f33bf75a79e3c3a8fcac010afd58b4a..ce95fb1f5753f02cef17f58e5ee95a6cc3206d85 100644 (file)
@@ -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<boolean>()
   };
 
   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<void>() };
 
@@ -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;
index bb8f7119242a088b2eb7ed2328c023eb17ca736d..9b61a099522b22eb13980879a1cff101a61a351a 100644 (file)
@@ -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<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: [
@@ -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<HealthSnapshotMap> = 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<StorageCardVM> = combineLatest([
     this.storageVm$,
index b6bccde8964f8ad75e92462acfd0df1e2110b273..a6b97997307f1cb38ade0c59c0f1d14fa37017b3 100644 (file)
@@ -5,6 +5,7 @@
         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>
index 48089fa3cfefc1fde553f6e19c4d0ab33dc1947c..6c2582ef1ebdf4fe422ae846ee9851620717d99c 100644 (file)
@@ -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);
index 06871985247a803c04dc2c0deddd981248f9f5d1..f39048c8f00973171ba972f635b7439cd2457513 100644 (file)
@@ -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);
   });
 
index 767d83881a558f890ee4cb5589909c04b7e2d270..d8267c791cf04a1ed42cb6d00a46d2eba7881279 100644 (file)
@@ -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]
   });
 
index 355d6969f8d58d6b46380e5dc13e64f2a4726e59..0b93d171696a269615cede5bf11def5552b0a539 100644 (file)
@@ -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<string, string>; // 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<string, string | undefined> = {};
   updatedChrtData = new Subject<any>();
 
-  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<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 });
@@ -125,27 +186,30 @@ export class PrometheusService {
     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
@@ -158,16 +222,17 @@ export class PrometheusService {
   }
 
   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 {
@@ -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<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][]> = {};
 
index d6fea71d3090fcb1906122b0e0fec1a2f155d66b..3a256176f21666256b792db6a26bf9ffafb712d5 100644 (file)
@@ -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 (file)
index 0000000..8a10a70
--- /dev/null
@@ -0,0 +1,8 @@
+<div class="empty-state">
+  <img src="assets/locked.png"
+       [alt]="emptyStateText"/>
+  <span class="cds--type-label-01"
+        i18n>
+    {{ emptyStateText }}
+  </span>
+</div>
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 (file)
index 0000000..2807927
--- /dev/null
@@ -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 (file)
index 0000000..91bf401
--- /dev/null
@@ -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<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();
+  });
+});
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 (file)
index 0000000..eb96ed5
--- /dev/null
@@ -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 = '';
+}
index ff15f78111ee2bc0f90c3bce3c1aebcff74975de..a34934ab6cd976c2dcc48987cba350d909352f49 100644 (file)
@@ -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', () => {
index 3e004b1fccc111875113e1d88d7c92dd3afad47f..52b115abe968ceb7be6c2121079df31d63225f2d 100644 (file)
@@ -4,75 +4,65 @@
     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>
index 11841216d7bf7e41722b0e6ae9e403b693a79c8d..9490910a432e8f70c72d10b801bce5c5b5feaadb 100644 (file)
@@ -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<PerformanceCardComponent>;
-  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', () => {
index 65baa2473799291d8883d263125cbd537739bb5a..a7add9f87af8606e7189c830fba0fb99f27a6b37 100644 (file)
@@ -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<PerformanceData | null>(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<void>();
 
@@ -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();
index f9e9cae8e53cc651144eff4069fe3cd457411c23..c0dd093ae768a7c88b5ec761234a56cf2f11350b 100644 (file)
@@ -1,14 +1,28 @@
 <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>
index 49596dbb1e8587c9cd68b2afc1fe4cc2569d2c0c..589e17f59cddcdbe400239b7c7ad9c8da0052390 100644 (file)
       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;
+    }
+  }
 }
index 24509b030c9396160d6d530a3005749ef0b20bae..0b6e2a082dfcbb72d8ded48c18b148c7d1edf0ff 100644 (file)
@@ -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
index 92d9e47e395cb6dd8487998ba21002e25f246eb0..dfab93e21996e1f00f8bbe02d114653b0f293f45 100644 (file)
@@ -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<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]
+    }
+  };
+}
index bf83bd7e9c35de186d0769380c028025b299ae89..b2ef14a1ada5ba392c3716a0b6770f3ed31c102e 100644 (file)
@@ -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 = [
index 31251f8a877f5ba2098c1e8ba4053a61e3644206..4273f8b4f95f090c8f3568ac2c3d55e2a4ef6964 100644 (file)
@@ -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) => {