]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Fix loading states in storage overview card
authorAfreen Misbah <afreen@ibm.com>
Mon, 16 Mar 2026 09:04:30 +0000 (14:34 +0530)
committerAfreen Misbah <afreen@ibm.com>
Tue, 17 Mar 2026 03:18:43 +0000 (08:48 +0530)
Fixes https://tracker.ceph.com/issues/75321
Fixes https://tracker.ceph.com/issues/75299

- removes storage type
- stabilizes overview card for loading data
- raw capcity shown when promethues not there
- multiple refresh intervals which may vcause sync issues and bugs hence moved the logic to parent - overview component
- Now all queries are updated at 5 s interval except data consumption - using promethues interval. This needs more refactor hence would do in a later PR

Signed-off-by: Afreen Misbah <afreen@ibm.com>
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.spec.ts
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/storage-overview.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts

index eee4c1e37305aaddccbf69479d5567f3ec1aa8ba..0a11dccba295ccd0daff60cf61a8dbb119a0ba1c 100644 (file)
@@ -1,4 +1,4 @@
-@let storage = (storageVm$ | async);
+@let storageCard = (storageCardVm$ | async);
 @let health = (healthCardVm$ | async);
 <div cdsGrid
      [fullWidth]="true"
          class="cds-mb-5"
          [columnNumbers]="{lg: 16}">
       <cd-overview-storage-card
-        [total]="storage?.total"
-        [used]="storage?.used">
+        [totalCapacity]="storageCard?.totalCapacity"
+        [usedCapacity]="storageCard?.usedCapacity"
+        [consumptionTrendData]="storageCard?.consumptionTrendData ?? []"
+        [averageDailyConsumption]="storageCard?.averageDailyConsumption ?? ''"
+        [estimatedTimeUntilFull]="storageCard?.estimatedTimeUntilFull ?? ''"
+        [breakdownData]="storageCard?.breakdownData ?? []"
+        [isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false">
       </cd-overview-storage-card>
     </div>
   </div>
index 70ad024e4253f09152176a7875f9309d0b944fe8..4eff04f6efbc6464621e873010a592784e0b9e2d 100644 (file)
@@ -19,6 +19,7 @@ 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';
 
 describe('OverviewComponent', () => {
   let component: OverviewComponent;
@@ -26,6 +27,14 @@ describe('OverviewComponent', () => {
 
   let mockHealthService: { getHealthSnapshot: jest.Mock };
   let mockRefreshIntervalService: { intervalData$: Subject<void> };
+  let mockOverviewStorageService: {
+    getTrendData: jest.Mock;
+    getAverageConsumption: jest.Mock;
+    getTimeUntilFull: jest.Mock;
+    getStorageBreakdown: jest.Mock;
+    formatBytesForChart: jest.Mock;
+    mapStorageChartData: jest.Mock;
+  };
 
   const mockAuthStorageService = {
     getPermissions: jest.fn(() => ({ configOpt: { read: false } }))
@@ -44,6 +53,32 @@ describe('OverviewComponent', () => {
     mockHealthService = { getHealthSnapshot: jest.fn() };
     mockRefreshIntervalService = { intervalData$: new Subject<void>() };
 
+    mockOverviewStorageService = {
+      getTrendData: jest.fn().mockReturnValue(
+        of({
+          TOTAL_RAW_USED: [
+            [0, '512'],
+            [60, '1024']
+          ]
+        })
+      ),
+      getAverageConsumption: jest.fn().mockReturnValue(of('12 GiB/day')),
+      getTimeUntilFull: jest.fn().mockReturnValue(of('30 days')),
+      getStorageBreakdown: jest.fn().mockReturnValue(
+        of({
+          result: [
+            { metric: { application: 'Block' }, value: [0, '1024'] },
+            { metric: { application: 'Filesystem' }, value: [0, '2048'] }
+          ]
+        })
+      ),
+      formatBytesForChart: jest.fn().mockReturnValue([3, 'GiB']),
+      mapStorageChartData: jest.fn().mockReturnValue([
+        { group: 'Block', value: 1 },
+        { group: 'File system', value: 2 }
+      ])
+    };
+
     await TestBed.configureTestingModule({
       imports: [
         OverviewComponent,
@@ -61,10 +96,10 @@ describe('OverviewComponent', () => {
         provideRouter([]),
         { provide: HealthService, useValue: mockHealthService },
         { provide: RefreshIntervalService, useValue: mockRefreshIntervalService },
+        { provide: OverviewStorageService, useValue: mockOverviewStorageService },
         { provide: AuthStorageService, useValue: mockAuthStorageService },
         { provide: MgrModuleService, useValue: mockMgrModuleService },
-        { provide: HardwareService, useValue: mockHardwareService },
-        provideRouter([])
+        { provide: HardwareService, useValue: mockHardwareService }
       ]
     }).compileComponents();
 
@@ -79,10 +114,7 @@ describe('OverviewComponent', () => {
     expect(component).toBeTruthy();
   });
 
-  // -----------------------------
-  // View model stream success
-  // -----------------------------
-  it('healthCardVm$ should emit HealthCardVM with new keys', (done) => {
+  it('healthCardVm$ should emit HealthCardVM correctly', (done) => {
     const mockData: HealthSnapshotMap = {
       fsid: 'fsid-123',
       health: {
@@ -92,14 +124,8 @@ describe('OverviewComponent', () => {
           b: { severity: 'HEALTH_ERR', summary: { message: 'B issue' } }
         }
       },
-      // data resileincy
       pgmap: {
-        pgs_by_state: [
-          {
-            state_name: 'active+clean',
-            count: 497
-          }
-        ],
+        pgs_by_state: [{ state_name: 'active+clean', count: 497 }],
         num_pools: 14,
         bytes_used: 3236978688,
         bytes_total: 325343772672,
@@ -108,7 +134,6 @@ describe('OverviewComponent', () => {
         read_bytes_sec: 0,
         recovering_bytes_per_sec: 0
       },
-      // subsystem inputs used by mapper
       monmap: { num_mons: 3, quorum: [0, 1, 2] } as any,
       mgrmap: { num_active: 1, num_standbys: 1 } as any,
       osdmap: { num_osds: 2, up: 2, in: 2 } as any,
@@ -171,16 +196,11 @@ describe('OverviewComponent', () => {
     const mockData: HealthSnapshotMap = {
       fsid: 'fsid-999',
       health: { status: 'HEALTH_OK', checks: {} },
-      monmap: { num_mons: 3, quorum: [0, 1, 2] } as any, // ok
-      mgrmap: { num_active: 0, num_standbys: 0 } as any, // err (active < 1)
-      osdmap: { num_osds: 2, up: 2, in: 2 } as any, // ok
+      monmap: { num_mons: 3, quorum: [0, 1, 2] } as any,
+      mgrmap: { num_active: 0, num_standbys: 0 } as any,
+      osdmap: { num_osds: 2, up: 2, in: 2 } as any,
       pgmap: {
-        pgs_by_state: [
-          {
-            state_name: 'active+clean',
-            count: 497
-          }
-        ],
+        pgs_by_state: [{ state_name: 'active+clean', count: 497 }],
         num_pools: 14,
         bytes_used: 3236978688,
         bytes_total: 325343772672,
@@ -190,14 +210,13 @@ describe('OverviewComponent', () => {
         recovering_bytes_per_sec: 0
       },
       num_hosts: 1,
-      num_hosts_down: 0 // ok
+      num_hosts_down: 0
     } as any;
 
     mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData));
 
     const sub = component.healthCardVm$.subscribe((vm) => {
-      // mgr -> err, therefore overall should be err icon
-      expect(vm.overallSystemSev).toBe(SeverityIconMap[2]); // sev.err === 2
+      expect(vm.overallSystemSev).toBe(SeverityIconMap[2]);
       sub.unsubscribe();
       done();
     });
@@ -205,9 +224,6 @@ describe('OverviewComponent', () => {
     mockRefreshIntervalService.intervalData$.next();
   });
 
-  // -----------------------------
-  // View model stream error → EMPTY
-  // -----------------------------
   it('healthCardVm$ should not emit if healthService throws (EMPTY)', (done) => {
     mockHealthService.getHealthSnapshot.mockReturnValue(throwError(() => new Error('API Error')));
 
@@ -225,9 +241,97 @@ describe('OverviewComponent', () => {
     mockRefreshIntervalService.intervalData$.complete();
   });
 
-  // -----------------------------
-  // toggle health panel
-  // -----------------------------
+  it('storageCardVm$ should emit storage view model with mapped fields', (done) => {
+    const mockData: HealthSnapshotMap = {
+      fsid: 'fsid-storage',
+      health: { status: 'HEALTH_OK', checks: {} },
+      pgmap: {
+        pgs_by_state: [{ state_name: 'active+clean', count: 497 }],
+        num_pools: 14,
+        bytes_used: 3236978688,
+        bytes_total: 325343772672,
+        num_pgs: 497,
+        write_bytes_sec: 0,
+        read_bytes_sec: 0,
+        recovering_bytes_per_sec: 0
+      },
+      monmap: { num_mons: 3, quorum: [0, 1, 2] } as any,
+      mgrmap: { num_active: 1, num_standbys: 1 } as any,
+      osdmap: { num_osds: 2, up: 2, in: 2 } as any,
+      num_hosts: 5,
+      num_hosts_down: 0
+    } as any;
+
+    mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData));
+
+    const sub = component.storageCardVm$.subscribe((vm) => {
+      expect(vm.totalCapacity).toBe(325343772672);
+      expect(vm.usedCapacity).toBe(3236978688);
+      expect(vm.breakdownData).toEqual([
+        { group: 'Block', value: 1 },
+        { group: 'File system', value: 2 }
+      ]);
+      expect(vm.isBreakdownLoaded).toBe(true);
+      expect(vm.consumptionTrendData).toEqual([
+        {
+          timestamp: new Date(0),
+          values: { Used: 512 }
+        },
+        {
+          timestamp: new Date(60000),
+          values: { Used: 1024 }
+        }
+      ]);
+      expect(vm.averageDailyConsumption).toBe('12 GiB/day');
+      expect(vm.estimatedTimeUntilFull).toBe('30 days');
+
+      expect(mockOverviewStorageService.formatBytesForChart).toHaveBeenCalledWith(3236978688);
+      expect(mockOverviewStorageService.mapStorageChartData).toHaveBeenCalled();
+
+      sub.unsubscribe();
+      done();
+    });
+
+    mockRefreshIntervalService.intervalData$.next();
+  });
+
+  it('storageCardVm$ should emit safe defaults before storage side streams resolve', (done) => {
+    const mockData: HealthSnapshotMap = {
+      fsid: 'fsid-storage',
+      health: { status: 'HEALTH_OK', checks: {} },
+      pgmap: {
+        pgs_by_state: [{ state_name: 'active+clean', count: 1 }],
+        num_pools: 1,
+        bytes_used: 100,
+        bytes_total: 1000,
+        num_pgs: 1,
+        write_bytes_sec: 0,
+        read_bytes_sec: 0,
+        recovering_bytes_per_sec: 0
+      },
+      monmap: { num_mons: 1, quorum: [0] } as any,
+      mgrmap: { num_active: 1, num_standbys: 1 } as any,
+      osdmap: { num_osds: 1, up: 1, in: 1 } as any,
+      num_hosts: 1,
+      num_hosts_down: 0
+    } as any;
+
+    mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData));
+    mockOverviewStorageService.getStorageBreakdown.mockReturnValue(of(null));
+
+    const sub = component.storageCardVm$.subscribe((vm) => {
+      expect(vm.totalCapacity).toBe(1000);
+      expect(vm.usedCapacity).toBe(100);
+      expect(vm.breakdownData).toEqual([]);
+      expect(vm.isBreakdownLoaded).toBe(false);
+
+      sub.unsubscribe();
+      done();
+    });
+
+    mockRefreshIntervalService.intervalData$.next();
+  });
+
   it('should toggle panel open/close', () => {
     expect(component.isHealthPanelOpen).toBe(false);
     component.toggleHealthPanel();
@@ -236,9 +340,6 @@ describe('OverviewComponent', () => {
     expect(component.isHealthPanelOpen).toBe(false);
   });
 
-  // -----------------------------
-  // ngOnDestroy
-  // -----------------------------
   it('should complete destroy$', () => {
     expect(() => fixture.destroy()).not.toThrow();
   });
index e9f8cecf40fa961066f90f5ac3736dbe7ac743f9..79a18c32db361ca91554f3d8bf2d26e2810e5033 100644 (file)
@@ -7,8 +7,8 @@ import {
   ViewEncapsulation
 } from '@angular/core';
 import { GridModule, LayoutModule, TilesModule } from 'carbon-components-angular';
-import { EMPTY, Observable } from 'rxjs';
-import { catchError, exhaustMap, map, shareReplay } from 'rxjs/operators';
+import { combineLatest, EMPTY, Observable } from 'rxjs';
+import { catchError, exhaustMap, map, shareReplay, startWith } from 'rxjs/operators';
 
 import { HealthService } from '~/app/shared/api/health.service';
 import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service';
@@ -27,7 +27,8 @@ import {
   SEVERITY,
   Severity,
   SEVERITY_TO_COLOR,
-  SeverityIconMap
+  SeverityIconMap,
+  StorageCardVM
 } from '~/app/shared/models/overview';
 
 import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component';
@@ -38,6 +39,11 @@ import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card.
 import { PerformanceCardComponent } from '~/app/shared/components/performance-card/performance-card.component';
 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';
+
+const SECONDS_PER_HOUR = 3600;
+const SECONDS_PER_DAY = 86400;
+const TREND_DAYS = 7;
 
 /**
  * Mapper: HealthSnapshotMap -> HealthCardVM
@@ -157,8 +163,10 @@ export class OverviewComponent {
 
   private readonly healthService = inject(HealthService);
   private readonly refreshIntervalService = inject(RefreshIntervalService);
+  private readonly overviewStorageService = inject(OverviewStorageService);
   private readonly destroyRef = inject(DestroyRef);
 
+  /* HEALTH CARD DATA */
   private readonly healthData$: Observable<HealthSnapshotMap> = this.refreshIntervalObs(() =>
     this.healthService.getHealthSnapshot()
   ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
@@ -168,14 +176,82 @@ export class OverviewComponent {
     shareReplay({ bufferSize: 1, refCount: true })
   );
 
+  /* STORAGE CARD DATA */
+
   readonly storageVm$ = this.healthData$.pipe(
     map((data: HealthSnapshotMap) => ({
-      total: data.pgmap?.bytes_total ?? 0,
-      used: data.pgmap?.bytes_used ?? 0
+      total: data.pgmap?.bytes_total,
+      used: data.pgmap?.bytes_used
     })),
     shareReplay({ bufferSize: 1, refCount: true })
   );
 
+  readonly averageConsumption$ = this.refreshIntervalObs(() =>
+    this.overviewStorageService.getAverageConsumption()
+  ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+
+  readonly timeUntilFull$ = this.refreshIntervalObs(() =>
+    this.overviewStorageService.getTimeUntilFull()
+  ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+
+  readonly breakdownRawData$ = this.refreshIntervalObs(() =>
+    this.overviewStorageService.getStorageBreakdown()
+  ).pipe(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 storageCardVm$: Observable<StorageCardVM> = combineLatest([
+    this.storageVm$,
+    this.breakdownRawData$.pipe(startWith(null)),
+    this.trendData$.pipe(startWith([])),
+    this.averageConsumption$.pipe(startWith('')),
+    this.timeUntilFull$.pipe(startWith(''))
+  ]).pipe(
+    map(
+      ([
+        storage,
+        breakdownRawData,
+        consumptionTrendData,
+        averageDailyConsumption,
+        estimatedTimeUntilFull
+      ]) => {
+        const used = storage?.used ?? 0;
+        const [, unit] = this.overviewStorageService.formatBytesForChart(used);
+
+        return {
+          totalCapacity: storage?.total,
+          usedCapacity: storage?.used,
+          breakdownData: breakdownRawData
+            ? this.overviewStorageService.mapStorageChartData(breakdownRawData, unit)
+            : [],
+          isBreakdownLoaded: !!breakdownRawData,
+          consumptionTrendData,
+          averageDailyConsumption,
+          estimatedTimeUntilFull
+        };
+      }
+    ),
+    shareReplay({ bufferSize: 1, refCount: true })
+  );
+
   private refreshIntervalObs<T>(fn: () => Observable<T>): Observable<T> {
     return this.refreshIntervalService.intervalData$.pipe(
       exhaustMap(() => fn().pipe(catchError(() => EMPTY))),
index 38c6f3823dee3f7cb58956915c5337891fd1dff2..c92c502e96e01646153a35252db0cdcfebd6f84f 100644 (file)
@@ -3,24 +3,14 @@
   <ng-template #header>
     <h2 class="cds--type-heading-compact-02"
         i18n>Storage Overview</h2>
-    <cds-dropdown
-      label="Storage type"
-      class="overview-storage-card-dropdown"
-      i18n-label
-      size="sm"
-      [disabled]="!(displayData && usedRaw && totalRaw && usedRawUnit && totalRawUnit)"
-      [placeholder]="selectedStorageType"
-      (selected)="onStorageTypeSelect($event)">
-      <cds-dropdown-list [items]="dropdownItems"></cds-dropdown-list>
-    </cds-dropdown>
   </ng-template>
   <!-- CAPACITY USAGE TEXT -->
   <div class="overview-storage-card-usage-text">
-    @if( totalRaw && usedRawUnit && totalRawUnit) {
+    @if( usedRaw !== null && totalRaw !== null && usedRawUnit && totalRawUnit) {
     <h5>
       <span
         class="cds--type-heading-05"
-        i18n>{{displayUsedRaw}}&ngsp;</span>
+        i18n>{{usedRaw}}&nbsp;</span>
       <span
         class="cds--type-body-02"
         i18n>{{usedRawUnit}} of {{totalRaw}} {{totalRawUnit}} used</span>
@@ -28,7 +18,7 @@
         class="cds-ml-3"
         [caret]="true"
         description="Shows raw used vs. total raw capacity. Raw capacity includes all physical storage before replication or overhead."
-        i8n-description
+        i18n-description
       >
       <cd-icon type="help"></cd-icon>
     </cds-tooltip>
     </cds-skeleton-text>
     }
   </div>
-  <!-- CAPACITY CHART -->
-  @if(displayData) {
+  <!-- CAPACITY BREAKDOWN CHART -->
+  @if(isBreakdownLoaded) {
   <ibm-meter-chart
     [options]="options"
-    [data]="displayData"
+    [data]="breakdownData"
     class="overview-storage-card-chart"></ibm-meter-chart>
-  }
-  <!-- TREND CHARTS AND TOP POOLS -->
-  @if(selectedStorageType === 'All' && trendData) {
+  } @else {
+  <cds-skeleton-text
+    [lines]="1"
+    [minLineWidth]="1200"
+    [maxLineWidth]="1200">
+  </cds-skeleton-text>
+}
+  <!-- DATA CONSUMPTION TREND -->
+  @if(consumptionTrendData.length) {
   <div cdsRow
        class="align-items-center cds-ml-2 cds-mt-6">
     <div cdsCol
@@ -59,7 +55,7 @@
                      [chartKey]="'Consumption trend'"
                      [dataUnit]="'B'"
                      [legendEnabled]="false"
-                     [rawData]="trendData"
+                     [rawData]="consumptionTrendData"
                      [subHeading]="'Shows last 7 days of storage consumption trends based on recent usage'"
                      [height]="'200px'"
                      [chartType]="'area'">
@@ -71,7 +67,7 @@
            gap="4">
         <div cdsStack="vertical"
              gap="1">
-          <span class="cds--type-heading-03">{{ timeUntilFull }}</span>
+          <span class="cds--type-heading-03">{{ estimatedTimeUntilFull }}</span>
           <div class="consumption-stats-wrapper">
             <cds-tooltip-definition
               [autoAlign]="true"
@@ -88,7 +84,7 @@
         </div>
         <div cdsStack="vertical"
              gap="1">
-          <span class="cds--type-heading-03">{{ averageConsumption }}</span>
+          <span class="cds--type-heading-03">{{ averageDailyConsumption }}</span>
           <div class="consumption-stats-wrapper">
             <cds-tooltip-definition
               [autoAlign]="true"
     </div>
   </div>
   }
-  @if (selectedStorageType !== 'All' && topPoolsData) {
-  @if (topPoolsData) {
-  <div cdsRow
-       class="align-items-center">
-    <div cdsCol
-         [columnNumbers]="{ lg: 4, md: 8, sm: 16 }">
-      <div cdsStack="vertical"
-           gap="4"
-           class="cds-mb-3 cds-ml-3">
-        @for(metric of storageMetrics[selectedStorageType].metrics; track metric.label){
-        <div class="cds-mb-5 cds-ml-3">
-          <span class="cds--type-heading-05">{{metric.value}}</span>
-          <span class="cds--type-heading-03 cds-ml-3">{{metric.label}}</span>
-        </div>
-        }
-      </div>
-    </div>
-    <div cdsCol
-         [columnNumbers]="{ lg: 6, md: 8, sm: 16 }">
-      <cd-pie-chart
-        [chartData]="topPoolsData"
-        [data]="topPoolsData"
-        [title]="'Top 5 ' + selectedStorageType + ' pools'"
-        [legendPosition]="'right'"
-        [showPercentage]="true"
-        [height]="'250px'">
-      </cd-pie-chart>
-    </div>
-  </div>
-  }
-  @else {
-  <cds-skeleton-text
-    [lines]="1"
-    [minLineWidth]="1025"
-    [maxLineWidth]="1025">
-  </cds-skeleton-text>
-  <cds-skeleton-text
-    [lines]="1"
-    [minLineWidth]="200"
-    [maxLineWidth]="200">
-  </cds-skeleton-text>
-  }
-  }
 </cd-productive-card>
index b436e33cc1bf17cf5494f7e0478001e1083eb1f0..d233934a8591ce3323f4372b10c4bc9c36653ee2 100644 (file)
 import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { of } from 'rxjs';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
 
 import { OverviewStorageCardComponent } from './overview-storage-card.component';
-import { PrometheusService } from '~/app/shared/api/prometheus.service';
 import { FormatterService } from '~/app/shared/services/formatter.service';
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { DatePipe } from '@angular/common';
 
-describe('OverviewStorageCardComponent (Jest)', () => {
+describe('OverviewStorageCardComponent', () => {
   let component: OverviewStorageCardComponent;
   let fixture: ComponentFixture<OverviewStorageCardComponent>;
 
-  let mockPrometheusService: {
-    getPrometheusQueryData: jest.Mock;
-    getRangeQueriesData: jest.Mock;
-  };
-
   let mockFormatterService: {
     formatToBinary: jest.Mock;
-    convertToUnit: jest.Mock;
   };
 
-  const mockPrometheusResponse = {
-    result: [
-      {
-        metric: { application: 'Block' },
-        value: [0, '1024']
-      },
-      {
-        metric: { application: 'Filesystem' },
-        value: [0, '2048']
-      },
-      {
-        metric: { application: 'Object' },
-        value: [0, '0'] // should be filtered
-      }
-    ]
-  };
-
-  const mockRangePrometheusResponse = {
-    result: [
-      {
-        metric: { application: 'Block' },
-        values: [
-          [0, '512'],
-          [60, '1024']
-        ]
-      }
-    ]
-  };
   beforeEach(async () => {
-    mockPrometheusService = {
-      getPrometheusQueryData: jest.fn().mockReturnValue(of(mockPrometheusResponse)),
-      getRangeQueriesData: jest.fn().mockReturnValue(of(mockRangePrometheusResponse))
-    };
-
     mockFormatterService = {
-      formatToBinary: jest.fn().mockReturnValue([10, 'GiB']),
-      convertToUnit: jest.fn((value: number) => Number(value))
+      formatToBinary: jest.fn().mockReturnValue([10, 'GiB'])
     };
 
     await TestBed.configureTestingModule({
       imports: [OverviewStorageCardComponent, HttpClientTestingModule],
-      providers: [
-        { provide: PrometheusService, useValue: mockPrometheusService },
-        { provide: FormatterService, useValue: mockFormatterService },
-        DatePipe
-      ]
+      providers: [{ provide: FormatterService, useValue: mockFormatterService }]
     }).compileComponents();
 
     fixture = TestBed.createComponent(OverviewStorageCardComponent);
     component = fixture.componentInstance;
-    fixture.detectChanges();
   });
 
   afterEach(() => {
     jest.clearAllMocks();
   });
 
-  // --------------------------------------------------
-  // CREATION
-  // --------------------------------------------------
-
   it('should create', () => {
+    fixture.detectChanges();
     expect(component).toBeTruthy();
   });
 
-  // --------------------------------------------------
-  // TOTAL setter (truthy)
-  // --------------------------------------------------
-
-  it('should set total when valid value provided', () => {
-    component.total = 1024;
+  it('should set totalCapacity when valid value is provided', () => {
+    component.totalCapacity = 1024;
 
     expect(component.totalRaw).toBe(10);
     expect(component.totalRawUnit).toBe('GiB');
+    expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(1024, true);
   });
 
-  // --------------------------------------------------
-  // TOTAL setter (falsy)
-  // --------------------------------------------------
-
-  it('should not set total when formatter returns NaN', () => {
+  it('should not set totalCapacity when formatter returns NaN', () => {
     mockFormatterService.formatToBinary.mockReturnValue([NaN, 'GiB']);
 
-    component.total = 0;
+    component.totalCapacity = 1024;
 
-    expect(component.totalRaw).toBeUndefined();
+    expect(component.totalRaw).toBeNull();
+    expect(component.totalRawUnit).toBe('');
   });
 
-  // --------------------------------------------------
-  // USED setter
-  // --------------------------------------------------
-
-  it('should set used correctly', () => {
-    component.used = 2048;
+  it('should set usedCapacity when valid value is provided', () => {
+    component.usedCapacity = 2048;
 
     expect(component.usedRaw).toBe(10);
     expect(component.usedRawUnit).toBe('GiB');
+    expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(2048, true);
   });
-  // --------------------------------------------------
-  // ngOnInit data load
-  // --------------------------------------------------
-
-  it('should load and filter data on init', () => {
-    expect(mockPrometheusService.getPrometheusQueryData).toHaveBeenCalled();
-    expect(component.allData.length).toBe(3); // Object filtered (0 value)
-  });
-
-  // --------------------------------------------------
-  // FILTERING
-  // --------------------------------------------------
-
-  it('should filter displayData for selected storage type', () => {
-    component.allData = [
-      { group: 'Block', value: 10 },
-      { group: 'Filesystem', value: 20 }
-    ];
-
-    component.onStorageTypeSelect({ item: { content: 'Block', selected: true } } as any);
-
-    expect(component.displayData).toEqual([{ group: 'Block', value: 10 }]);
-  });
-
-  it('should show all data when ALL selected', () => {
-    component.allData = [
-      { group: 'Block', value: 10 },
-      { group: 'Filesystem', value: 20 }
-    ];
-
-    component.onStorageTypeSelect({ item: { content: 'All', selected: true } } as any);
-
-    expect(component.displayData.length).toBe(2);
-  });
-
-  // --------------------------------------------------
-  // DROPDOWN
-  // --------------------------------------------------
-
-  it('should update storage type from dropdown selection', () => {
-    component.onStorageTypeSelect({
-      item: { content: 'Block', selected: true }
-    });
-
-    expect(component.selectedStorageType).toBe('Block');
-  });
-
-  it('should auto-select single item if only one exists', () => {
-    component.allData = [{ group: 'Block', value: 10 }];
-
-    (component as any)._setDropdownItemsAndStorageType();
-
-    expect(component.selectedStorageType).toBe('All');
-    expect(component.dropdownItems.length).toBe(2);
-  });
-
-  // --------------------------------------------------
-  // DESTROY
-  // --------------------------------------------------
-
-  it('should clean up on destroy', () => {
-    const nextSpy = jest.spyOn((component as any).destroy$, 'next');
-    const completeSpy = jest.spyOn((component as any).destroy$, 'complete');
-
-    component.ngOnDestroy();
-
-    expect(nextSpy).toHaveBeenCalled();
-    expect(completeSpy).toHaveBeenCalled();
-  });
-
-  // --------------------------------------------------
-  // USED setter (falsy)
-  // --------------------------------------------------
 
-  it('should not set used when formatter returns NaN', () => {
+  it('should not set usedCapacity when formatter returns NaN', () => {
     mockFormatterService.formatToBinary.mockReturnValue([NaN, 'GiB']);
 
-    component.used = 0;
-
-    expect(component.usedRaw).toBeUndefined();
-  });
-
-  // --------------------------------------------------
-  // _getAllData
-  // --------------------------------------------------
-
-  it('should map Filesystem application to File system group', () => {
-    mockFormatterService.convertToUnit.mockReturnValue(5);
-    const data = {
-      result: [{ metric: { application: 'Filesystem' }, value: [0, '1024'] }]
-    };
-
-    mockPrometheusService.getPrometheusQueryData.mockReturnValue(of(data));
-    fixture = TestBed.createComponent(OverviewStorageCardComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-
-    expect(component.allData.some((d) => d.group === 'File system')).toBe(true);
-  });
-
-  it('should filter out entries with unknown application groups', () => {
-    mockFormatterService.convertToUnit.mockReturnValue(5);
-    const data = {
-      result: [
-        { metric: { application: 'Unknown' }, value: [0, '1024'] },
-        { metric: { application: 'Block' }, value: [0, '2048'] }
-      ]
-    };
-
-    mockPrometheusService.getPrometheusQueryData.mockReturnValue(of(data));
-    fixture = TestBed.createComponent(OverviewStorageCardComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-
-    expect(component.allData.every((d) => d.group !== 'Unknown')).toBe(true);
-  });
-
-  it('should handle empty result in _getAllData', () => {
-    mockPrometheusService.getPrometheusQueryData.mockReturnValue(of({ result: [] }));
-    fixture = TestBed.createComponent(OverviewStorageCardComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-
-    expect(component.allData).toEqual([]);
-  });
-
-  it('should handle null data in _getAllData', () => {
-    mockPrometheusService.getPrometheusQueryData.mockReturnValue(of(null));
-    fixture = TestBed.createComponent(OverviewStorageCardComponent);
-    component = fixture.componentInstance;
-    fixture.detectChanges();
-
-    expect(component.allData).toEqual([]);
-  });
-
-  // --------------------------------------------------
-  // _setChartData
-  // --------------------------------------------------
-
-  it('should set displayUsedRaw to usedRaw when ALL is selected', () => {
-    component.usedRaw = 42;
-    component.allData = [{ group: 'Block', value: 10 }];
-    component.selectedStorageType = 'All';
-
-    (component as any)._setChartData();
-
-    expect(component.displayUsedRaw).toBe(42);
-  });
-
-  it('should set displayUsedRaw to first matching value when specific type selected', () => {
-    component.allData = [
-      { group: 'Block', value: 15 },
-      { group: 'File system', value: 25 }
-    ];
-    component.selectedStorageType = 'Block';
-
-    (component as any)._setChartData();
-
-    expect(component.displayUsedRaw).toBe(15);
-  });
-
-  it('should set displayData to empty array when no matching type found', () => {
-    component.allData = [{ group: 'Block', value: 10 }];
-    component.selectedStorageType = 'Object';
-
-    (component as any)._setChartData();
-
-    expect(component.displayData).toEqual([]);
-  });
-
-  // --------------------------------------------------
-  // _setDropdownItemsAndStorageType
-  // --------------------------------------------------
-
-  it('should build dropdown items from allData', () => {
-    component.allData = [
-      { group: 'Block', value: 10 },
-      { group: 'File system', value: 20 }
-    ];
-
-    (component as any)._setDropdownItemsAndStorageType();
-
-    expect(component.dropdownItems).toEqual([
-      { content: 'All' },
-      { content: 'Block' },
-      { content: 'File system' }
-    ]);
-  });
-
-  it('should set only ALL dropdown item when allData is empty', () => {
-    component.allData = [];
-
-    (component as any)._setDropdownItemsAndStorageType();
-
-    expect(component.dropdownItems).toEqual([{ content: 'All' }]);
-  });
-
-  // --------------------------------------------------
-  // onStorageTypeSelect - non-ALL types
-  // --------------------------------------------------
-
-  it('should set topPoolsData to null when ALL is selected', () => {
-    component.topPoolsData = [{ some: 'data' }];
-    component.allData = [];
-
-    component.onStorageTypeSelect({ item: { content: 'All', selected: true } });
+    component.usedCapacity = 2048;
 
-    expect(component.topPoolsData).toBeNull();
+    expect(component.usedRaw).toBeNull();
+    expect(component.usedRawUnit).toBe('');
   });
 
-  it('should not call loadTopPools for ALL type', () => {
-    const spy = jest.spyOn(component as any, 'loadTopPools');
-    component.allData = [];
-
-    component.onStorageTypeSelect({ item: { content: 'All', selected: true } });
-
-    expect(spy).not.toHaveBeenCalled();
-  });
-
-  it('should call loadTopPools when non-ALL type is selected', () => {
-    const spy = jest.spyOn(component as any, 'loadTopPools').mockImplementation(() => {});
-    jest.spyOn(component as any, 'loadCounts').mockImplementation(() => {});
-    component.allData = [{ group: 'Block', value: 10 }];
-
-    component.onStorageTypeSelect({ item: { content: 'Block', selected: true } });
-
-    expect(spy).toHaveBeenCalled();
-  });
-
-  it('should call loadCounts when non-ALL type is selected', () => {
-    jest.spyOn(component as any, 'loadTopPools').mockImplementation(() => {});
-    const spy = jest.spyOn(component as any, 'loadCounts').mockImplementation(() => {});
-    component.allData = [{ group: 'Block', value: 10 }];
-
-    component.onStorageTypeSelect({ item: { content: 'Block', selected: true } });
-
-    expect(spy).toHaveBeenCalled();
-  });
-
-  // --------------------------------------------------
-  // ngOnInit - secondary calls
-  // --------------------------------------------------
-
-  it('should call loadTrend on init', () => {
-    const spy = jest.spyOn(component as any, 'loadTrend').mockImplementation(() => {});
-
-    component.ngOnInit();
-
-    expect(spy).toHaveBeenCalled();
-  });
-
-  it('should call loadAverageConsumption on init', () => {
-    const spy = jest.spyOn(component as any, 'loadAverageConsumption').mockImplementation(() => {});
-
-    component.ngOnInit();
-
-    expect(spy).toHaveBeenCalled();
-  });
-
-  it('should call loadTimeUntilFull on init', () => {
-    const spy = jest.spyOn(component as any, 'loadTimeUntilFull').mockImplementation(() => {});
+  it('should not update chart options until both totalCapacity and usedCapacity are set', () => {
+    mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']);
 
-    component.ngOnInit();
+    component.totalCapacity = 1024;
 
-    expect(spy).toHaveBeenCalled();
+    expect(component.options.meter.proportional.total).toBeNull();
+    expect(component.options.meter.proportional.unit).toBe('');
+    expect(component.options.tooltip).toBeUndefined();
   });
 
-  // --------------------------------------------------
-  // _setTotalAndUsed / options update
-  // --------------------------------------------------
-
-  it('should update options.meter.proportional.total when total is set', () => {
-    mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']);
+  it('should update chart options when both totalCapacity and usedCapacity are set', () => {
+    mockFormatterService.formatToBinary
+      .mockReturnValueOnce([20, 'TiB'])
+      .mockReturnValueOnce([5, 'TiB']);
 
-    component.total = 1024 * 1024;
+    component.totalCapacity = 1024;
+    component.usedCapacity = 512;
 
     expect(component.options.meter.proportional.total).toBe(20);
+    expect(component.options.meter.proportional.unit).toBe('TiB');
+    expect(component.options.tooltip).toBeDefined();
+    expect(typeof component.options.tooltip?.valueFormatter).toBe('function');
   });
 
-  it('should update options.meter.proportional.unit when total is set', () => {
-    mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']);
-
-    component.total = 1024 * 1024;
+  it('should use used unit in tooltip formatter', () => {
+    mockFormatterService.formatToBinary
+      .mockReturnValueOnce([20, 'TiB'])
+      .mockReturnValueOnce([5, 'TiB']);
 
-    expect(component.options.meter.proportional.unit).toBe('TiB');
-  });
+    component.totalCapacity = 1024;
+    component.usedCapacity = 512;
 
-  it('should set tooltip valueFormatter when used is set', () => {
-    component.used = 512;
+    const formatter = component.options.tooltip?.valueFormatter as (value: number) => string;
 
-    expect(component.options.tooltip).toBeDefined();
-    expect(typeof component.options.tooltip.valueFormatter).toBe('function');
+    expect(formatter(12.3)).toBe('12.3 TiB');
   });
 
-  // --------------------------------------------------
-  // storageMetrics defaults
-  // --------------------------------------------------
-
-  it('should have default storageMetrics with zero values', () => {
-    expect(component.storageMetrics.Block.metrics[0].value).toBe(0);
-    expect(component.storageMetrics.Block.metrics[1].value).toBe(0);
-    expect(component.storageMetrics['File system'].metrics[0].value).toBe(0);
-    expect(component.storageMetrics['File system'].metrics[1].value).toBe(0);
-    expect(component.storageMetrics.Object.metrics[0].value).toBe(0);
-    expect(component.storageMetrics.Object.metrics[1].value).toBe(0);
+  it('should keep default input values for presentational fields', () => {
+    expect(component.consumptionTrendData).toEqual([]);
+    expect(component.averageDailyConsumption).toBe('');
+    expect(component.estimatedTimeUntilFull).toBe('');
+    expect(component.breakdownData).toEqual([]);
+    expect(component.isBreakdownLoaded).toBe(false);
   });
 });
index 7fa3891e2bb659128fb8b6851063ab115c2db0c6..ffb33f9a41ae40ce4a3ac756b70c567f36afc075 100644 (file)
@@ -4,107 +4,32 @@ import {
   Component,
   inject,
   Input,
-  OnDestroy,
-  OnInit,
   ViewEncapsulation
 } from '@angular/core';
-import {
-  CheckboxModule,
-  DropdownModule,
-  GridModule,
-  TilesModule,
-  TooltipModule,
-  SkeletonModule,
-  LayoutModule
-} from 'carbon-components-angular';
+import { GridModule, TooltipModule, SkeletonModule, LayoutModule } from 'carbon-components-angular';
 import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
 import { MeterChartComponent, MeterChartOptions } from '@carbon/charts-angular';
-import {
-  PrometheusService,
-  PromethuesGaugeMetricResult,
-  PromqlGuageMetric
-} from '~/app/shared/api/prometheus.service';
 import { FormatterService } from '~/app/shared/services/formatter.service';
-import { interval, Subject } from 'rxjs';
-import { startWith, switchMap, takeUntil } from 'rxjs/operators';
-import { OverviewStorageService } from '~/app/shared/api/storage-overview.service';
-import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
 import { AreaChartComponent } from '~/app/shared/components/area-chart/area-chart.component';
-import { PieChartComponent } from '~/app/shared/components/pie-chart/pie-chart.component';
 import { ComponentsModule } from '~/app/shared/components/components.module';
 
 const CHART_HEIGHT = '45px';
 
-const REFRESH_INTERVAL_MS = 15_000;
-
-const StorageType = {
-  ALL: $localize`All`,
-  BLOCK: $localize`Block`,
-  FILE: $localize`File system`,
-  OBJECT: $localize`Object`,
-  SYSTEM_METADATA: $localize`System metadata`
-};
-
-type ChartData = {
-  group: string;
-  value: number;
-};
-
-const PROMQL_RAW_USED_BY_STORAGE_TYPE =
-  'sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})';
-
-const PROMQL_TOP_POOLS_BLOCK = `
-  topk(5,
-    (ceph_pool_bytes_used * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Block"})
-    /
-    (ceph_pool_max_avail * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Block"})
-  )
-`;
-
-const PROMQL_TOP_POOLS_FILESYSTEM = `
-  topk(5,
-    (ceph_pool_bytes_used * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Filesystem"})
-    /
-    (ceph_pool_max_avail * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Filesystem"})
-  )
-`;
-
-const PROMQL_TOP_POOLS_OBJECT = `
-  topk(5,
-    (ceph_pool_bytes_used * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Object"})
-    /
-    (ceph_pool_max_avail * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Object"})
-  )
-`;
-
-const PROMQL_COUNT_BLOCK_POOLS = 'count(ceph_pool_metadata{application="Block"})';
-
-const PROMQL_COUNT_RBD_IMAGES = 'count(ceph_rbd_image_metadata)';
-
-const PROMQL_COUNT_FILESYSTEMS = 'count(ceph_fs_metadata)';
-
-const PROMQL_COUNT_FILESYSTEM_POOLS = 'count(ceph_pool_metadata{application="Filesystem"})';
-
-const TopPoolsQueryMap = {
-  Block: PROMQL_TOP_POOLS_BLOCK,
-  'File system': PROMQL_TOP_POOLS_FILESYSTEM,
-  Object: PROMQL_TOP_POOLS_OBJECT
+type TrendPoint = {
+  timestamp: Date;
+  values: { Used: number };
 };
 
 @Component({
   selector: 'cd-overview-storage-card',
   imports: [
     GridModule,
-    TilesModule,
     ProductiveCardComponent,
     MeterChartComponent,
-    CheckboxModule,
-    DropdownModule,
     TooltipModule,
     SkeletonModule,
     LayoutModule,
     AreaChartComponent,
-    PieChartComponent,
     ComponentsModule
   ],
   standalone: true,
@@ -113,37 +38,39 @@ const TopPoolsQueryMap = {
   encapsulation: ViewEncapsulation.None,
   changeDetection: ChangeDetectionStrategy.OnPush
 })
-export class OverviewStorageCardComponent implements OnInit, OnDestroy {
-  private readonly prometheusService = inject(PrometheusService);
+export class OverviewStorageCardComponent {
   private readonly formatterService = inject(FormatterService);
-  private readonly overviewStorageService = inject(OverviewStorageService);
-  private readonly rgw = inject(RgwBucketService);
   private readonly cdr = inject(ChangeDetectorRef);
-  private destroy$ = new Subject<void>();
-  trendData: { timestamp: Date; values: { Used: number } }[];
-  totalUsed: number;
 
   @Input()
-  set total(value: number) {
+  set totalCapacity(value: number) {
     const [totalValue, totalUnit] = this.formatterService.formatToBinary(value, true);
     if (Number.isNaN(totalValue)) return;
     this.totalRaw = totalValue;
     this.totalRawUnit = totalUnit;
-    this._setTotalAndUsed();
+    this.updateChartOptions();
   }
+
   @Input()
-  set used(value: number) {
-    this.totalUsed = value;
+  set usedCapacity(value: number) {
     const [usedValue, usedUnit] = this.formatterService.formatToBinary(value, true);
     if (Number.isNaN(usedValue)) return;
     this.usedRaw = usedValue;
     this.usedRawUnit = usedUnit;
-    this._setTotalAndUsed();
+    this.updateChartOptions();
   }
-  totalRaw: number;
-  usedRaw: number;
-  totalRawUnit: string;
-  usedRawUnit: string;
+
+  @Input() consumptionTrendData: TrendPoint[] = [];
+  @Input() averageDailyConsumption = '';
+  @Input() estimatedTimeUntilFull = '';
+  @Input() breakdownData: { group: string; value: number }[] = [];
+  @Input() isBreakdownLoaded = false;
+
+  totalRaw: number | null = null;
+  usedRaw: number | null = null;
+  totalRawUnit = '';
+  usedRawUnit = '';
+
   options: MeterChartOptions = {
     height: CHART_HEIGHT,
     meter: {
@@ -163,44 +90,16 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy {
       }
     }
   };
-  allData: ChartData[] = null;
-  displayData: ChartData[] = null;
-  displayUsedRaw: number;
-  selectedStorageType: string = StorageType.ALL;
-  dropdownItems = [
-    { content: StorageType.ALL },
-    { content: StorageType.BLOCK },
-    { content: StorageType.FILE },
-    { content: StorageType.OBJECT }
-  ];
-  topPoolsData = null;
 
-  storageMetrics = {
-    Block: {
-      metrics: [
-        { label: 'block pools', value: 0 },
-        { label: 'volumes', value: 0 }
-      ]
-    },
-    'File system': {
-      metrics: [
-        { label: 'filesystems', value: 0 },
-        { label: 'filesystem pools', value: 0 }
-      ]
-    },
-    Object: {
-      metrics: [
-        { label: 'buckets', value: 0 },
-        { label: 'object pools', value: 0 }
-      ]
+  private updateChartOptions() {
+    if (
+      this.totalRaw === null ||
+      this.usedRaw === null ||
+      !this.totalRawUnit ||
+      !this.usedRawUnit
+    ) {
+      return;
     }
-  };
-
-  averageConsumption = '';
-  timeUntilFull = '';
-
-  private _setTotalAndUsed() {
-    // Chart reacts to 'options' and 'data' object changes only, hence mandatory to replace whole object.
     this.options = {
       ...this.options,
       meter: {
@@ -215,229 +114,11 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy {
         valueFormatter: (value) => `${value.toLocaleString()} ${this.usedRawUnit}`
       }
     };
-    this._updateCard();
-  }
-
-  private _getAllData(data: PromqlGuageMetric) {
-    const result = data?.result ?? [];
-
-    const chartData: ChartData[] = [];
-    const storageTypeValues = Object.values(StorageType);
-
-    let assignedBytes = 0;
-    let nonAssignedBytes = 0;
-
-    result.forEach((r: PromethuesGaugeMetricResult) => {
-      let group = r?.metric?.application;
-      const rawBytes = Number(r?.value?.[1]);
-
-      if (group === 'Filesystem') {
-        group = StorageType.FILE;
-      }
-
-      if (storageTypeValues.includes(group) && group !== StorageType.SYSTEM_METADATA) {
-        assignedBytes += rawBytes;
-
-        const value = this.formatterService.convertToUnit(
-          rawBytes.toString(),
-          'B',
-          this.usedRawUnit,
-          1
-        );
-
-        chartData.push({
-          group,
-          value
-        });
-      } else {
-        nonAssignedBytes += rawBytes;
-      }
-    });
-
-    const miscBytes = this.totalUsed - assignedBytes + nonAssignedBytes;
-    if (miscBytes > 0) {
-      chartData.push({
-        group: StorageType.SYSTEM_METADATA,
-        value: this.formatterService.convertToUnit(miscBytes.toString(), 'B', this.totalRawUnit, 3)
-      });
-    }
 
-    return chartData;
+    this.markForCheck();
   }
 
-  private _setChartData() {
-    if (this.selectedStorageType === StorageType.ALL) {
-      this.displayData = this.allData;
-      this.displayUsedRaw = this.usedRaw;
-    } else {
-      this.displayData = this.allData?.filter(
-        (d: ChartData) => d.group === this.selectedStorageType
-      );
-      this.displayUsedRaw = this.displayData?.[0]?.value;
-    }
-  }
-
-  private _setDropdownItemsAndStorageType() {
-    const newData = this.allData
-      ?.filter((data) => data.group !== StorageType.SYSTEM_METADATA)
-      .map((data) => ({ content: data.group }));
-    if (newData.length) {
-      this.dropdownItems = [{ content: StorageType.ALL }, ...newData];
-    } else {
-      this.dropdownItems = [{ content: StorageType.ALL }];
-    }
-  }
-
-  private _updateCard() {
+  private markForCheck() {
     this.cdr.markForCheck();
   }
-
-  private loadTrend() {
-    const now = Math.floor(Date.now() / 1000);
-    const range = { start: now - 7 * 86400, end: now, step: 3600 };
-
-    this.overviewStorageService
-      .getTrendData(range.start, range.end, range.step)
-      .pipe(takeUntil(this.destroy$))
-      .subscribe((result) => {
-        const values = result?.TOTAL_RAW_USED ?? [];
-        this.trendData = values.map(([ts, val]) => ({
-          timestamp: new Date(ts * 1000),
-          values: { Used: Number(val) }
-        }));
-        this.cdr.markForCheck();
-      });
-  }
-
-  private loadAverageConsumption() {
-    this.overviewStorageService
-      .getAverageConsumption()
-      .pipe(takeUntil(this.destroy$))
-      .subscribe((v) => {
-        this.averageConsumption = v;
-        this.cdr.markForCheck();
-      });
-  }
-
-  private loadTimeUntilFull() {
-    this.overviewStorageService
-      .getTimeUntilFull()
-      .pipe(takeUntil(this.destroy$))
-      .subscribe((v) => {
-        this.timeUntilFull = v;
-        this.cdr.markForCheck();
-      });
-  }
-
-  private loadTopPools() {
-    const query = TopPoolsQueryMap[this.selectedStorageType];
-    if (!query) return;
-
-    this.overviewStorageService
-      .getTopPools(query)
-      .pipe(takeUntil(this.destroy$))
-      .subscribe((data) => {
-        this.topPoolsData = data;
-        this.cdr.markForCheck();
-      });
-  }
-
-  private loadCounts() {
-    const type = this.selectedStorageType;
-
-    if (type === StorageType.BLOCK) {
-      this.overviewStorageService
-        .getCount(PROMQL_COUNT_BLOCK_POOLS)
-        .pipe(takeUntil(this.destroy$))
-        .subscribe((value) => {
-          this.storageMetrics.Block.metrics[0].value = value;
-          this.cdr.markForCheck();
-        });
-
-      this.overviewStorageService
-        .getCount(PROMQL_COUNT_RBD_IMAGES)
-        .pipe(takeUntil(this.destroy$))
-        .subscribe((value) => {
-          this.storageMetrics.Block.metrics[1].value = value;
-          this.cdr.markForCheck();
-        });
-    }
-
-    if (type === StorageType.FILE) {
-      this.overviewStorageService
-        .getCount(PROMQL_COUNT_FILESYSTEMS)
-        .pipe(takeUntil(this.destroy$))
-        .subscribe((value) => {
-          this.storageMetrics['File system'].metrics[0].value = value;
-          this.cdr.markForCheck();
-        });
-
-      this.overviewStorageService
-        .getCount(PROMQL_COUNT_FILESYSTEM_POOLS)
-        .pipe(takeUntil(this.destroy$))
-        .subscribe((value) => {
-          this.storageMetrics['File system'].metrics[1].value = value;
-          this.cdr.markForCheck();
-        });
-    }
-
-    if (type === StorageType.OBJECT) {
-      this.overviewStorageService
-        .getObjectCounts(this.rgw)
-        .pipe(takeUntil(this.destroy$))
-        .subscribe((value) => {
-          this.storageMetrics.Object.metrics[0].value = value.buckets;
-          this.storageMetrics.Object.metrics[1].value = value.pools;
-          this.cdr.markForCheck();
-        });
-    }
-  }
-
-  onStorageTypeSelect(event: any) {
-    this.selectedStorageType = event?.item?.content;
-    this._setChartData();
-
-    if (this.selectedStorageType === StorageType.ALL) {
-      this.loadTrend();
-      this.topPoolsData = null;
-    } else {
-      this.loadTopPools();
-      this.loadCounts();
-    }
-  }
-
-  ngOnInit() {
-    interval(REFRESH_INTERVAL_MS)
-      .pipe(
-        startWith(0),
-        switchMap(() =>
-          this.prometheusService.getPrometheusQueryData({
-            params: PROMQL_RAW_USED_BY_STORAGE_TYPE
-          })
-        ),
-        takeUntil(this.destroy$)
-      )
-      .subscribe((data: PromqlGuageMetric) => {
-        this.allData = this._getAllData(data);
-        this._setDropdownItemsAndStorageType();
-        this._setChartData();
-        this._updateCard();
-        if (this.selectedStorageType === StorageType.ALL) {
-          this.loadAverageConsumption();
-          this.loadTimeUntilFull();
-        } else {
-          this.loadTopPools();
-        }
-
-        this.cdr.markForCheck();
-      });
-    this.loadTrend();
-    this.loadAverageConsumption();
-    this.loadTimeUntilFull();
-  }
-
-  ngOnDestroy(): void {
-    this.destroy$.next();
-    this.destroy$.complete();
-  }
 }
index f10fa9eabfaa7116d6ec516058b2a32f2ec53526..5a7cd8a0f60ae81c7410f57524adab6603ef37e8 100644 (file)
@@ -1,9 +1,21 @@
 import { Injectable, inject } from '@angular/core';
-import { PrometheusService, PromqlGuageMetric } from '~/app/shared/api/prometheus.service';
+import {
+  PrometheusService,
+  PromethuesGaugeMetricResult,
+  PromqlGuageMetric
+} from '~/app/shared/api/prometheus.service';
 import { FormatterService } from '~/app/shared/services/formatter.service';
 import { map } from 'rxjs/operators';
 import { forkJoin, Observable } from 'rxjs';
 
+const StorageType = {
+  BLOCK: $localize`Block`,
+  FILE: $localize`File system`,
+  OBJECT: $localize`Object`
+} as const;
+
+const CHART_GROUP_LABELS = new Set([StorageType.BLOCK, StorageType.FILE, StorageType.OBJECT]);
+
 @Injectable({ providedIn: 'root' })
 export class OverviewStorageService {
   private readonly prom = inject(PrometheusService);
@@ -12,6 +24,8 @@ export class OverviewStorageService {
   private readonly TIME_UNTIL_FULL_QUERY = `(sum(ceph_osd_stat_bytes)) / (sum(rate(ceph_osd_stat_bytes_used[7d])) * 86400)`;
   private readonly TOTAL_RAW_USED_QUERY = 'sum(ceph_osd_stat_bytes_used)';
   private readonly OBJECT_POOLS_COUNT_QUERY = 'count(ceph_pool_metadata{application="Object"})';
+  private readonly RAW_USED_BY_STORAGE_TYPE_QUERY =
+    'sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})';
 
   getTrendData(start: number, end: number, stepSec: number) {
     const range = {
@@ -81,4 +95,33 @@ export class OverviewStorageService {
       }))
     );
   }
+
+  convertBytesToUnit(value: string, unit: string): number {
+    return this.formatter.convertToUnit(value, 'B', unit, 1);
+  }
+
+  getStorageBreakdown(): Observable<PromqlGuageMetric> {
+    return this.prom.getPrometheusQueryData({ params: this.RAW_USED_BY_STORAGE_TYPE_QUERY });
+  }
+  formatBytesForChart(value: number): [number, string] {
+    return this.formatter.formatToBinary(value, true);
+  }
+
+  mapStorageChartData(data: PromqlGuageMetric, unit: string): { group: string; value: number }[] {
+    if (!unit) return [];
+
+    const result = data?.result ?? [];
+
+    return result
+      .map((r: PromethuesGaugeMetricResult) => {
+        const group = r?.metric?.application;
+        const value = r?.value?.[1];
+
+        return {
+          group: group === 'Filesystem' ? StorageType.FILE : group,
+          value: this.convertBytesToUnit(value, unit)
+        };
+      })
+      .filter((item) => CHART_GROUP_LABELS.has(item.group) && item.value > 0);
+  }
 }
index 73a414e3086ff99b319860576ea82e74cc72713e..746b1a7905a131ac3279adcf98429b89e5d28ee1 100644 (file)
@@ -87,6 +87,16 @@ export interface HealthCardVM {
   hosts: HealthCardSubStateVM;
 }
 
+export interface StorageCardVM {
+  totalCapacity: number | null;
+  usedCapacity: number | null;
+  breakdownData: { group: string; value: number }[];
+  isBreakdownLoaded: boolean;
+  consumptionTrendData: { timestamp: Date; values: { Used: number } }[];
+  averageDailyConsumption: string;
+  estimatedTimeUntilFull: string;
+}
+
 // Constants
 
 const WarnAndErrMessage = $localize`There are active alerts and unresolved health warnings.`;
index 91ec7a17e9f63a85a2a2e3d1a0083774975c1f85..8d8b963eacfbbb03ca05efee2288698ee4f2ca1a 100644 (file)
@@ -191,7 +191,7 @@ export class FormatterService {
     decimals: number = 1
   ): string | [number, string] {
     const convertedString = this.format_number(num, BINARY_FACTOR, BINARY_UNITS, decimals);
-    const FALLBACK: [number, string] = [0, BINARY_UNITS[0]]; // when convertedString is 'N/A', '-', or 'NaN', return [0, 'B']
+    const FALLBACK: [number, string] = [NaN, BINARY_UNITS[0]]; // when convertedString is 'N/A', '-', or 'NaN', return [NaN, 'B']
     if (!split) return convertedString;
 
     const parts = convertedString.trim().split(/\s+/);