]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add storage consumption card 67517/head
authorDevika Babrekar <devika.babrekar@ibm.com>
Tue, 20 Jan 2026 06:16:33 +0000 (11:46 +0530)
committerAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Mon, 2 Mar 2026 01:03:41 +0000 (06:33 +0530)
Fixes: https://tracker.ceph.com/issues/75181
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
14 files changed:
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.scss
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.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.ts [new file with mode: 0644]

index ec05c116c13929037a3f469f3fd2fa8392917e70..382e5b9618da3e2653dd3b49b45fdeed75ec13e0 100644 (file)
     [data]="displayData"
     class="overview-storage-card-chart"></ibm-meter-chart>
   }
+  <!-- TREND CHARTS AND TOP POOLS -->
+  @if(selectedStorageType === 'All' && trendData) {
+  <div cdsRow
+       class="align-items-center cds-ml-2 cds-mt-6">
+    <div cdsCol
+         [columnNumbers]="{ lg: 14, md: 10, sm: 16 }">
+      <cd-area-chart chartTitle="Consumption trend"
+                     [chartKey]="'Consumption trend'"
+                     [dataUnit]="'B'"
+                     [legendEnabled]="false"
+                     [rawData]="trendData"
+                     [subHeading]="'Shows last 7 days of storage consumption trends based on recent usage'"
+                     [height]="'200px'"
+                     [chartType]="'area'">
+      </cd-area-chart>
+    </div>
+    <div cdsCol
+         [columnNumbers]="{ lg: 2, md: 8, sm: 8 }">
+      <div cdsStack="vertical"
+           gap="4">
+        <div cdsStack="vertical"
+             gap="1">
+          <span class="cds--type-heading-03">{{ timeUntilFull }}</span>
+          <div class="consumption-stats-wrapper">
+            <cds-tooltip-definition
+              [autoAlign]="true"
+              [highContrast]="true"
+              [openOnHover]="false"
+              [dropShadow]="true"
+              [caret]="true"
+              description="Based on recent average consumption. Actual time until full may vary based on changes in consumption patterns."
+              i18n-description
+              i18n>
+              Estimated days until full
+            </cds-tooltip-definition>
+          </div>
+        </div>
+        <div cdsStack="vertical"
+             gap="1">
+          <span class="cds--type-heading-03">{{ averageConsumption }}</span>
+          <div class="consumption-stats-wrapper">
+            <cds-tooltip-definition
+              [autoAlign]="true"
+              [highContrast]="true"
+              [openOnHover]="false"
+              [dropShadow]="true"
+              [caret]="true"
+              description="Based on the average daily consumption over the last 7 days"
+              i18n-description
+              i18n>
+              Average consumption
+            </cds-tooltip-definition>
+          </div>
+        </div>
+      </div>
+    </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"
     [maxLineWidth]="200">
   </cds-skeleton-text>
   }
+  }
 </cd-productive-card>
index 470e591c512861bb9592062d57dca2d46d83f876..b690a0f42bd0db3854f7e563754c4a326fa9d57e 100644 (file)
     }
   }
 }
+
+.consumption-stats-wrapper {
+  display: inline-flex;
+  width: fit-content;
+}
+
+.consumption-stats-wrapper cds-tooltip-definition {
+  display: inline-block;
+  width: auto;
+}
index 83ac658b15151287e2b72d79e44feb8ef545b2b3..38c0fdbe4a2db888fa78bd8fe94c13ea731a73f6 100644 (file)
@@ -4,6 +4,8 @@ import { of } from 'rxjs';
 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)', () => {
   let component: OverviewStorageCardComponent;
@@ -11,6 +13,7 @@ describe('OverviewStorageCardComponent (Jest)', () => {
 
   let mockPrometheusService: {
     getPrometheusQueryData: jest.Mock;
+    getRangeQueriesData: jest.Mock;
   };
 
   let mockFormatterService: {
@@ -35,9 +38,21 @@ describe('OverviewStorageCardComponent (Jest)', () => {
     ]
   };
 
+  const mockRangePrometheusResponse = {
+    result: [
+      {
+        metric: { application: 'Block' },
+        values: [
+          [0, '512'],
+          [60, '1024']
+        ]
+      }
+    ]
+  };
   beforeEach(async () => {
     mockPrometheusService = {
-      getPrometheusQueryData: jest.fn().mockReturnValue(of(mockPrometheusResponse))
+      getPrometheusQueryData: jest.fn().mockReturnValue(of(mockPrometheusResponse)),
+      getRangeQueriesData: jest.fn().mockReturnValue(of(mockRangePrometheusResponse))
     };
 
     mockFormatterService = {
@@ -46,16 +61,17 @@ describe('OverviewStorageCardComponent (Jest)', () => {
     };
 
     await TestBed.configureTestingModule({
-      imports: [OverviewStorageCardComponent],
+      imports: [OverviewStorageCardComponent, HttpClientTestingModule],
       providers: [
         { provide: PrometheusService, useValue: mockPrometheusService },
-        { provide: FormatterService, useValue: mockFormatterService }
+        { provide: FormatterService, useValue: mockFormatterService },
+        DatePipe
       ]
     }).compileComponents();
 
     fixture = TestBed.createComponent(OverviewStorageCardComponent);
     component = fixture.componentInstance;
-    fixture.detectChanges(); // triggers ngOnInit
+    fixture.detectChanges();
   });
 
   afterEach(() => {
@@ -172,4 +188,241 @@ describe('OverviewStorageCardComponent (Jest)', () => {
     expect(nextSpy).toHaveBeenCalled();
     expect(completeSpy).toHaveBeenCalled();
   });
+
+  // --------------------------------------------------
+  // USED setter (falsy)
+  // --------------------------------------------------
+
+  it('should not set used 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 } });
+
+    expect(component.topPoolsData).toBeNull();
+  });
+
+  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(() => {});
+
+    component.ngOnInit();
+
+    expect(spy).toHaveBeenCalled();
+  });
+
+  // --------------------------------------------------
+  // _setTotalAndUsed / options update
+  // --------------------------------------------------
+
+  it('should update options.meter.proportional.total when total is set', () => {
+    mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']);
+
+    component.total = 1024 * 1024;
+
+    expect(component.options.meter.proportional.total).toBe(20);
+  });
+
+  it('should update options.meter.proportional.unit when total is set', () => {
+    mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']);
+
+    component.total = 1024 * 1024;
+
+    expect(component.options.meter.proportional.unit).toBe('TiB');
+  });
+
+  it('should set tooltip valueFormatter when used is set', () => {
+    component.used = 512;
+
+    expect(component.options.tooltip).toBeDefined();
+    expect(typeof component.options.tooltip.valueFormatter).toBe('function');
+  });
+
+  // --------------------------------------------------
+  // 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);
+  });
 });
index 56117d8442a02e376d0d725c18316df0c2e4f0ac..ec8599f93c6c74f0b8382e1a796a0d8ad24695c7 100644 (file)
@@ -27,6 +27,10 @@ import {
 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';
 
 const CHART_HEIGHT = '45px';
 
@@ -44,11 +48,49 @@ type ChartData = {
   value: number;
 };
 
-const RawUsedByStorageType =
+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 chartGroupLabels = [StorageType.BLOCK, StorageType.FILE, StorageType.OBJECT];
 
+const TopPoolsQueryMap = {
+  Block: PROMQL_TOP_POOLS_BLOCK,
+  'File system': PROMQL_TOP_POOLS_FILESYSTEM,
+  Object: PROMQL_TOP_POOLS_OBJECT
+};
+
 @Component({
   selector: 'cd-overview-storage-card',
   imports: [
@@ -60,7 +102,9 @@ const chartGroupLabels = [StorageType.BLOCK, StorageType.FILE, StorageType.OBJEC
     DropdownModule,
     TooltipModule,
     SkeletonModule,
-    LayoutModule
+    LayoutModule,
+    AreaChartComponent,
+    PieChartComponent
   ],
   standalone: true,
   templateUrl: './overview-storage-card.component.html',
@@ -71,8 +115,11 @@ const chartGroupLabels = [StorageType.BLOCK, StorageType.FILE, StorageType.OBJEC
 export class OverviewStorageCardComponent implements OnInit, OnDestroy {
   private readonly prometheusService = inject(PrometheusService);
   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 } }[];
 
   @Input()
   set total(value: number) {
@@ -123,6 +170,31 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy {
     { 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 }
+      ]
+    }
+  };
+
+  averageConsumption = '';
+  timeUntilFull = '';
 
   private _setTotalAndUsed() {
     // Chart reacts to 'options' and 'data' object changes only, hence mandatory to replace whole object.
@@ -181,9 +253,118 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy {
     this.cdr.markForCheck();
   }
 
-  public onStorageTypeSelect(selected: { item: { content: string; selected: true } }) {
-    this.selectedStorageType = selected?.item?.content;
+  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() {
@@ -192,7 +373,7 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy {
         startWith(0),
         switchMap(() =>
           this.prometheusService.getPrometheusQueryData({
-            params: RawUsedByStorageType
+            params: PROMQL_RAW_USED_BY_STORAGE_TYPE
           })
         ),
         takeUntil(this.destroy$)
@@ -202,7 +383,18 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy {
         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 {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts
new file mode 100644 (file)
index 0000000..19d6589
--- /dev/null
@@ -0,0 +1,248 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+import { OverviewStorageService } from './storage-overview.service';
+
+describe('OverviewStorageService', () => {
+  let service: OverviewStorageService;
+
+  configureTestBed({
+    imports: [HttpClientTestingModule]
+  });
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    service = TestBed.inject(OverviewStorageService);
+  });
+
+  it('should be created', () => {
+    expect(service).toBeTruthy();
+  });
+
+  describe('getTrendData', () => {
+    it('should call getRangeQueriesData with correct params', () => {
+      const promSpy = jest.spyOn(service['prom'], 'getRangeQueriesData').mockReturnValue({} as any);
+      service.getTrendData(1000, 2000, 60);
+      expect(promSpy).toHaveBeenCalledWith(
+        { start: 1000, end: 2000, step: 60 },
+        { TOTAL_RAW_USED: 'sum(ceph_pool_bytes_used)' },
+        true
+      );
+    });
+  });
+
+  describe('getAverageConsumption', () => {
+    it('should format bytes per day correctly', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '1073741824'] }] }));
+      jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['1.0', 'GiB'] as any);
+
+      service.getAverageConsumption().subscribe((result) => {
+        expect(result).toBe('1.0 GiB/day');
+        done();
+      });
+    });
+
+    it('should return 0 formatted when no result', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+      jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['0', 'B'] as any);
+
+      service.getAverageConsumption().subscribe((result) => {
+        expect(result).toBe('0 B/day');
+        done();
+      });
+    });
+
+    it('should handle null response gracefully', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)(null));
+      jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['0', 'B'] as any);
+
+      service.getAverageConsumption().subscribe((result) => {
+        expect(result).toBe('0 B/day');
+        done();
+      });
+    });
+  });
+
+  describe('getTimeUntilFull', () => {
+    it('should return ∞ when days is Infinity', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+
+      service.getTimeUntilFull().subscribe((result) => {
+        expect(result).toBe('∞');
+        done();
+      });
+    });
+
+    it('should return hours when days < 1', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '0.5'] }] }));
+
+      service.getTimeUntilFull().subscribe((result) => {
+        expect(result).toBe('12.0 hours');
+        done();
+      });
+    });
+
+    it('should return days when 1 <= days < 30', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '15'] }] }));
+
+      service.getTimeUntilFull().subscribe((result) => {
+        expect(result).toBe('15.0 days');
+        done();
+      });
+    });
+
+    it('should return months when days >= 30', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '60'] }] }));
+
+      service.getTimeUntilFull().subscribe((result) => {
+        expect(result).toBe('2.0 months');
+        done();
+      });
+    });
+
+    it('should return ∞ when days <= 0', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '-5'] }] }));
+
+      service.getTimeUntilFull().subscribe((result) => {
+        expect(result).toBe('∞');
+        done();
+      });
+    });
+  });
+
+  describe('getTopPools', () => {
+    it('should map pool results with name', (done) => {
+      jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
+        new (require('rxjs').of)({
+          result: [{ metric: { name: 'mypool' }, value: [null, '0.5'] }]
+        })
+      );
+
+      service.getTopPools('some_query').subscribe((result) => {
+        expect(result).toEqual([{ group: 'mypool', value: 50 }]);
+        done();
+      });
+    });
+
+    it('should fallback to pool label when name is absent', (done) => {
+      jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
+        new (require('rxjs').of)({
+          result: [{ metric: { pool: 'fallback_pool' }, value: [null, '0.25'] }]
+        })
+      );
+
+      service.getTopPools('some_query').subscribe((result) => {
+        expect(result).toEqual([{ group: 'fallback_pool', value: 25 }]);
+        done();
+      });
+    });
+
+    it('should use "unknown" when no name or pool label', (done) => {
+      jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
+        new (require('rxjs').of)({
+          result: [{ metric: {}, value: [null, '0.1'] }]
+        })
+      );
+
+      service.getTopPools('some_query').subscribe((result) => {
+        expect(result).toEqual([{ group: 'unknown', value: 10 }]);
+        done();
+      });
+    });
+
+    it('should return empty array when result is empty', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+
+      service.getTopPools('some_query').subscribe((result) => {
+        expect(result).toEqual([]);
+        done();
+      });
+    });
+  });
+
+  describe('getCount', () => {
+    it('should return numeric count from query result', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '42'] }] }));
+
+      service.getCount('some_query').subscribe((result) => {
+        expect(result).toBe(42);
+        done();
+      });
+    });
+
+    it('should return 0 when result is empty', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+
+      service.getCount('some_query').subscribe((result) => {
+        expect(result).toBe(0);
+        done();
+      });
+    });
+
+    it('should return 0 when response is null', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)(null));
+
+      service.getCount('some_query').subscribe((result) => {
+        expect(result).toBe(0);
+        done();
+      });
+    });
+  });
+
+  describe('getObjectCounts', () => {
+    it('should return bucket and pool counts', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '3'] }] }));
+
+      const mockRgwService = {
+        getTotalBucketsAndUsersLength: () => new (require('rxjs').of)({ buckets_count: 10 })
+      };
+
+      service.getObjectCounts(mockRgwService).subscribe((result) => {
+        expect(result).toEqual({ buckets: 10, pools: 3 });
+        done();
+      });
+    });
+
+    it('should default buckets to 0 when buckets_count is missing', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '2'] }] }));
+
+      const mockRgwService = {
+        getTotalBucketsAndUsersLength: () => new (require('rxjs').of)({})
+      };
+
+      service.getObjectCounts(mockRgwService).subscribe((result) => {
+        expect(result).toEqual({ buckets: 0, pools: 2 });
+        done();
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.ts
new file mode 100644 (file)
index 0000000..f68337b
--- /dev/null
@@ -0,0 +1,83 @@
+import { Injectable, inject } from '@angular/core';
+import { PrometheusService, 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';
+
+@Injectable({ providedIn: 'root' })
+export class OverviewStorageService {
+  private readonly prom = inject(PrometheusService);
+  private readonly formatter = inject(FormatterService);
+  private readonly AVG_CONSUMPTION_QUERY = 'sum(rate(ceph_pool_bytes_used[7d])) * 86400';
+  private readonly TIME_UNTIL_FULL_QUERY = `(sum(ceph_pool_max_avail)) / (sum(rate(ceph_pool_bytes_used[7d])) * 86400)`;
+  private readonly TOTAL_RAW_USED_QUERY = 'sum(ceph_pool_bytes_used)';
+  private readonly OBJECT_POOLS_COUNT_QUERY = 'count(ceph_pool_metadata{application="Object"})';
+
+  getTrendData(start: number, end: number, stepSec: number) {
+    const range = {
+      start,
+      end,
+      step: stepSec
+    };
+
+    return this.prom.getRangeQueriesData(
+      range,
+      {
+        TOTAL_RAW_USED: this.TOTAL_RAW_USED_QUERY
+      },
+      true
+    );
+  }
+
+  getAverageConsumption(): Observable<string> {
+    return this.prom.getPrometheusQueryData({ params: this.AVG_CONSUMPTION_QUERY }).pipe(
+      map((res) => {
+        const v = Number(res?.result?.[0]?.value?.[1] ?? 0);
+        const [val, unit] = this.formatter.formatToBinary(v, true);
+        return `${val} ${unit}/day`;
+      })
+    );
+  }
+
+  getTimeUntilFull(): Observable<string> {
+    return this.prom.getPrometheusQueryData({ params: this.TIME_UNTIL_FULL_QUERY }).pipe(
+      map((res) => {
+        const days = Number(res?.result?.[0]?.value?.[1] ?? Infinity);
+        if (!isFinite(days) || days <= 0) return '∞';
+
+        if (days < 1) return `${(days * 24).toFixed(1)} hours`;
+        if (days < 30) return `${days.toFixed(1)} days`;
+        return `${(days / 30).toFixed(1)} months`;
+      })
+    );
+  }
+
+  getTopPools(query: string): Observable<{ group: string; value: number }[]> {
+    return this.prom.getPrometheusQueryData({ params: query }).pipe(
+      map((data: PromqlGuageMetric) => {
+        return (data?.result ?? []).map((r) => ({
+          group: r.metric?.name || r.metric?.pool || 'unknown',
+          value: Number(r.value?.[1]) * 100
+        }));
+      })
+    );
+  }
+
+  getCount(query: string): Observable<number> {
+    return this.prom
+      .getPrometheusQueryData({ params: query })
+      .pipe(map((res: any) => Number(res?.result?.[0]?.value?.[1]) || 0));
+  }
+
+  getObjectCounts(rgwBucketService: any) {
+    return forkJoin({
+      buckets: rgwBucketService.getTotalBucketsAndUsersLength(),
+      pools: this.getCount(this.OBJECT_POOLS_COUNT_QUERY)
+    }).pipe(
+      map(({ buckets, pools }: { buckets: { buckets_count: number }; pools: number }) => ({
+        buckets: buckets?.buckets_count ?? 0,
+        pools
+      }))
+    );
+  }
+}
index 1313c5627fbdc1cc728ee679ba3b41614239e9a5..248f6a19e8dbca9b574efb0278715f10a999dddb 100644 (file)
@@ -1,6 +1,25 @@
 @if(chartData && chartOptions){
-<ibm-line-chart
+<div class="chart-container">
+  <div class="chart-header">
+    <div class="cds--type-heading-compact-02">
+      {{ chartTitle }}
+    </div>
+    @if(subHeading) {
+    <div class="cds--type-body-compact-01 chart-subheading cds-mb-3">
+      {{ subHeading }}
+    </div>
+    }
+  </div>
+  @if(chartType === 'area') {
+  <ibm-area-chart
     [data]="chartData"
     [options]="chartOptions">
-</ibm-line-chart>
+  </ibm-area-chart>
+  } @else {
+  <ibm-line-chart
+    [data]="chartData"
+    [options]="chartOptions">
+  </ibm-line-chart>
+  }
+</div>
 }
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..ed411a7d4fb2694de544253edd0dd2c90713851e 100644 (file)
@@ -0,0 +1,13 @@
+.chart-container {
+  display: flex;
+  flex-direction: column;
+}
+
+.chart-header {
+  margin-bottom: var(--cds-spacing-04);
+}
+
+.chart-subheading {
+  margin-top: var(--cds-spacing-02);
+  color: var(--cds-text-secondary);
+}
index 26a454b54f7bc18b08c5f87ef11b313a68a27a72..97c7260cf7e862b06f71542f0f12f604e40f254d 100644 (file)
@@ -264,7 +264,5 @@ describe('AreaChartComponent', () => {
     component.ngOnChanges({
       rawData: new SimpleChange(null, mockData, false)
     });
-
-    expect(component.chartOptions?.title).toBe('Test Chart');
   });
 });
index 5bc0625acf58918359b0e09f0e2f7228afd94ef0..f6308b59d46f8fa1ba84babecce281c5b6a945a6 100644 (file)
@@ -35,12 +35,16 @@ import {
   imports: [ChartsModule]
 })
 export class AreaChartComponent implements OnChanges {
+  @Input() chartType = 'line';
   @Input() chartTitle = '';
   @Input() dataUnit = '';
   @Input() rawData!: ChartPoint[];
   @Input() chartKey = '';
   @Input() decimals = DECIMAL;
   @Input() customOptions?: Partial<AreaChartOptions>;
+  @Input() legendEnabled = true;
+  @Input() subHeading = '';
+  @Input() height = '300px';
 
   @Output() currentFormattedValues = new EventEmitter<{
     key: string;
@@ -126,10 +130,11 @@ export class AreaChartComponent implements OnChanges {
 
   private getChartOptions(max: number, labels: string[], divisor: number): AreaChartOptions {
     return {
-      title: this.chartTitle,
+      legend: {
+        enabled: this.legendEnabled
+      },
       axes: {
         bottom: {
-          title: 'Time',
           mapsTo: 'date',
           scaleType: ScaleTypes.TIME,
           ticks: {
@@ -180,7 +185,7 @@ export class AreaChartComponent implements OnChanges {
         ]
       },
       animations: false,
-      height: '300px',
+      height: this.height,
       data: {
         loading: !this.chartData?.length
       }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.html
new file mode 100644 (file)
index 0000000..3013e13
--- /dev/null
@@ -0,0 +1,5 @@
+<ibm-pie-chart
+  *ngIf="chartData && chartOptions"
+  [data]="chartData"
+  [options]="chartOptions">
+</ibm-pie-chart>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.spec.ts
new file mode 100644 (file)
index 0000000..755e357
--- /dev/null
@@ -0,0 +1,113 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { PieChartComponent } from './pie-chart.component';
+import { ChartsModule } from '@carbon/charts-angular';
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy } from '@angular/core';
+import { By } from '@angular/platform-browser';
+
+describe('PieChartComponent', () => {
+  let component: PieChartComponent;
+  let fixture: ComponentFixture<PieChartComponent>;
+
+  const mockData = [
+    { group: 'A', value: 30 },
+    { group: 'B', value: 70 }
+  ];
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [PieChartComponent, ChartsModule, CommonModule]
+    })
+      // disable OnPush for test environment
+      .overrideComponent(PieChartComponent, {
+        set: { changeDetection: ChangeDetectionStrategy.Default }
+      })
+      .compileComponents();
+
+    fixture = TestBed.createComponent(PieChartComponent);
+    component = fixture.componentInstance;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should prepare chart data on ngOnChanges', () => {
+    component.data = mockData;
+
+    component.ngOnChanges({
+      data: {
+        currentValue: mockData,
+        previousValue: null,
+        firstChange: true,
+        isFirstChange: () => true
+      }
+    });
+
+    expect(component.chartData.length).toBe(2);
+    expect(component.chartData[0]).toEqual({ group: 'A', value: 30 });
+    expect(component.chartOptions).toBeDefined();
+  });
+
+  it('should set chart options correctly', () => {
+    component.data = mockData;
+    component.title = 'Test Chart';
+    component.height = '300px';
+    component.legendPosition = 'top';
+
+    component.ngOnChanges({
+      data: {
+        currentValue: mockData,
+        previousValue: null,
+        firstChange: true,
+        isFirstChange: () => true
+      }
+    });
+
+    const opts = component.chartOptions;
+
+    expect(opts.title).toBe('Test Chart');
+    expect(opts.height).toBe('300px');
+    expect(opts.legend.position).toBe('top');
+    expect(opts.pie?.labels?.enabled).toBe(false);
+  });
+
+  it('should render ibm-pie-chart when data & options exist', () => {
+    component.data = mockData;
+
+    component.ngOnChanges({
+      data: {
+        currentValue: mockData,
+        previousValue: null,
+        firstChange: true,
+        isFirstChange: () => false
+      }
+    });
+
+    fixture.detectChanges();
+
+    const chartEl = fixture.debugElement.query(By.css('ibm-pie-chart'));
+    expect(chartEl).toBeTruthy();
+
+    expect(chartEl.componentInstance.data).toEqual(component.chartData);
+    expect(chartEl.componentInstance.options).toEqual(component.chartOptions);
+  });
+
+  it('should NOT render ibm-pie-chart when no data', () => {
+    component.data = null as any;
+
+    component.ngOnChanges({
+      data: {
+        currentValue: null,
+        previousValue: mockData,
+        firstChange: false,
+        isFirstChange: () => false
+      }
+    });
+
+    fixture.detectChanges();
+
+    const chartEl = fixture.debugElement.query(By.css('ibm-pie-chart'));
+    expect(chartEl).toBeNull();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.ts
new file mode 100644 (file)
index 0000000..95f880c
--- /dev/null
@@ -0,0 +1,53 @@
+import { CommonModule } from '@angular/common';
+import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy } from '@angular/core';
+import { PieChartOptions, ChartTabularData, ChartsModule } from '@carbon/charts-angular';
+
+@Component({
+  selector: 'cd-pie-chart',
+  templateUrl: './pie-chart.component.html',
+  styleUrls: ['./pie-chart.component.scss'],
+  standalone: true,
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  imports: [ChartsModule, CommonModule]
+})
+export class PieChartComponent implements OnChanges {
+  @Input() data!: { group: string; value: number }[];
+  @Input() title: string = '';
+  @Input() legendPosition: 'top' | 'bottom' | 'left' | 'right' = 'bottom';
+  @Input() height: string = '280px';
+
+  chartData: ChartTabularData = [];
+  chartOptions!: PieChartOptions;
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes['data'] && this.data) {
+      this.prepareData();
+      this.prepareOptions();
+    }
+  }
+
+  private prepareData(): void {
+    this.chartData = this.data.map((d) => ({
+      group: d.group,
+      value: d.value
+    }));
+  }
+
+  private prepareOptions(): void {
+    this.chartOptions = {
+      title: this.title,
+      height: this.height,
+      legend: {
+        position: this.legendPosition
+      },
+      toolbar: {
+        enabled: true
+      },
+      pie: {
+        labels: {
+          enabled: false
+        }
+      }
+    };
+  }
+}