]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add capacity thresholds 67807/head
authorAfreen Misbah <afreen@ibm.com>
Mon, 16 Mar 2026 14:20:51 +0000 (19:50 +0530)
committerAfreen Misbah <afreen@ibm.com>
Tue, 17 Mar 2026 18:44:45 +0000 (00:14 +0530)
Signed-off-by: Afreen Misbah <afreen@ibm.com>
14 files changed:
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.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
src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts

index 0a11dccba295ccd0daff60cf61a8dbb119a0ba1c..3d9b55aa093d816884728067408b28cba7ff4b46 100644 (file)
@@ -32,7 +32,8 @@
         [averageDailyConsumption]="storageCard?.averageDailyConsumption ?? ''"
         [estimatedTimeUntilFull]="storageCard?.estimatedTimeUntilFull ?? ''"
         [breakdownData]="storageCard?.breakdownData ?? []"
-        [isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false">
+        [isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false"
+        [threshold]="storageCard?.threshold">
       </cd-overview-storage-card>
     </div>
   </div>
index 4eff04f6efbc6464621e873010a592784e0b9e2d..1abf2c7b8f33bf75a79e3c3a8fcac010afd58b4a 100644 (file)
@@ -34,6 +34,8 @@ describe('OverviewComponent', () => {
     getStorageBreakdown: jest.Mock;
     formatBytesForChart: jest.Mock;
     mapStorageChartData: jest.Mock;
+    getThresholdStatus: jest.Mock;
+    getRawCapacityThresholds: jest.Mock;
   };
 
   const mockAuthStorageService = {
@@ -76,7 +78,14 @@ describe('OverviewComponent', () => {
       mapStorageChartData: jest.fn().mockReturnValue([
         { group: 'Block', value: 1 },
         { group: 'File system', value: 2 }
-      ])
+      ]),
+      getThresholdStatus: jest.fn().mockReturnValue(null),
+      getRawCapacityThresholds: jest.fn().mockReturnValue(
+        of({
+          osdFullRatio: 0.99,
+          osdNearfullRatio: 0.85
+        })
+      )
     };
 
     await TestBed.configureTestingModule({
@@ -265,6 +274,9 @@ describe('OverviewComponent', () => {
     mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData));
 
     const sub = component.storageCardVm$.subscribe((vm) => {
+      if (!vm.isBreakdownLoaded || !vm.averageDailyConsumption || !vm.estimatedTimeUntilFull) {
+        return;
+      }
       expect(vm.totalCapacity).toBe(325343772672);
       expect(vm.usedCapacity).toBe(3236978688);
       expect(vm.breakdownData).toEqual([
@@ -284,6 +296,7 @@ describe('OverviewComponent', () => {
       ]);
       expect(vm.averageDailyConsumption).toBe('12 GiB/day');
       expect(vm.estimatedTimeUntilFull).toBe('30 days');
+      expect(vm.threshold).toBe(null);
 
       expect(mockOverviewStorageService.formatBytesForChart).toHaveBeenCalledWith(3236978688);
       expect(mockOverviewStorageService.mapStorageChartData).toHaveBeenCalled();
index 79a18c32db361ca91554f3d8bf2d26e2810e5033..bb8f7119242a088b2eb7ed2328c023eb17ca736d 100644 (file)
@@ -198,6 +198,10 @@ export class OverviewComponent {
     this.overviewStorageService.getStorageBreakdown()
   ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
 
+  readonly capacityThresholds$ = this.refreshIntervalObs(() =>
+    this.overviewStorageService.getRawCapacityThresholds()
+  ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+
   // getTrendData() is already a polling stream through getRangeQueriesData()
   // hence no refresh needed.
   readonly trendData$ = this.overviewStorageService
@@ -223,7 +227,8 @@ export class OverviewComponent {
     this.breakdownRawData$.pipe(startWith(null)),
     this.trendData$.pipe(startWith([])),
     this.averageConsumption$.pipe(startWith('')),
-    this.timeUntilFull$.pipe(startWith(''))
+    this.timeUntilFull$.pipe(startWith('')),
+    this.capacityThresholds$.pipe(startWith({ osdFullRatio: null, osdNearfullRatio: null }))
   ]).pipe(
     map(
       ([
@@ -231,21 +236,29 @@ export class OverviewComponent {
         breakdownRawData,
         consumptionTrendData,
         averageDailyConsumption,
-        estimatedTimeUntilFull
+        estimatedTimeUntilFull,
+        capacityThresholds
       ]) => {
+        const total = storage?.total ?? 0;
         const used = storage?.used ?? 0;
         const [, unit] = this.overviewStorageService.formatBytesForChart(used);
 
         return {
-          totalCapacity: storage?.total,
-          usedCapacity: storage?.used,
+          totalCapacity: total,
+          usedCapacity: used,
           breakdownData: breakdownRawData
-            ? this.overviewStorageService.mapStorageChartData(breakdownRawData, unit)
+            ? this.overviewStorageService.mapStorageChartData(breakdownRawData, unit, used)
             : [],
           isBreakdownLoaded: !!breakdownRawData,
           consumptionTrendData,
           averageDailyConsumption,
-          estimatedTimeUntilFull
+          estimatedTimeUntilFull,
+          threshold: this.overviewStorageService.getThresholdStatus(
+            total,
+            storage?.used,
+            capacityThresholds.osdNearfullRatio,
+            capacityThresholds.osdFullRatio
+          )
         };
       }
     ),
index c92c502e96e01646153a35252db0cdcfebd6f84f..a2e9437cd31b013427f0c1ae1226d918b9d94d3f 100644 (file)
@@ -1,8 +1,8 @@
-<cd-productive-card>
+<cd-productive-card class="overview-storage-card">
   <!-- STORAGE CARD HEADER -->
   <ng-template #header>
     <h2 class="cds--type-heading-compact-02"
-        i18n>Storage Overview</h2>
+        i18n>Storage overview</h2>
   </ng-template>
   <!-- CAPACITY USAGE TEXT -->
   <div class="overview-storage-card-usage-text">
         class="cds--type-body-02"
         i18n>{{usedRawUnit}} of {{totalRaw}} {{totalRawUnit}} used</span>
       <cds-tooltip
-        class="cds-ml-3"
+        class="cds-ml-3 cds-mr-3"
         [caret]="true"
-        description="Shows raw used vs. total raw capacity. Raw capacity includes all physical storage before replication or overhead."
+        description="Shows raw used and total capacity. Raw capacity includes all physical storage before replication or overhead."
         i18n-description
       >
       <cd-icon type="help"></cd-icon>
     </cds-tooltip>
+    @if(threshold === 'high') {
+    <cds-tag size="md">
+      <div cdsTagIcon>
+        <cd-icon
+          type="warningAltFilled"
+          useDefault="true"></cd-icon>
+      </div>
+      <span i18n>High storage usage</span>
+    </cds-tag>
+   }
+   @else if(threshold === 'critical') {
+    <cds-tag
+      type="red"
+      size="md">
+      <div cdsTagIcon>
+        <cd-icon
+          type="warningAltFilled"
+          useDefault="true"></cd-icon>
+      </div>
+      <span i18n>Capacity critically low</span>
+    </cds-tag>
+    }
     </h5>
     }
     @else {
index b690a0f42bd0db3854f7e563754c4a326fa9d57e..0aa973adf6e4e721023af83fe298603fe3353607 100644 (file)
       display: none !important;
     }
   }
+
+  .cds--tag--gray {
+    background-color: #fddc69;
+    color: var(--cds-text-secondary);
+  }
 }
 
 .consumption-stats-wrapper {
index d233934a8591ce3323f4372b10c4bc9c36653ee2..a3216f64dd4d6ca279cd02feda4850f6c896f121 100644 (file)
@@ -10,11 +10,22 @@ describe('OverviewStorageCardComponent', () => {
 
   let mockFormatterService: {
     formatToBinary: jest.Mock;
+    convertToUnit: jest.Mock;
   };
 
   beforeEach(async () => {
     mockFormatterService = {
-      formatToBinary: jest.fn().mockReturnValue([10, 'GiB'])
+      formatToBinary: jest.fn((value: number) => {
+        if (value === 1024) return [20, 'TiB'];
+        if (value === 512) return [5, 'TiB'];
+        if (value === 256) return [5, 'MiB'];
+        return [10, 'GiB'];
+      }),
+      convertToUnit: jest.fn((value: number, fromUnit: string, toUnit: string) => {
+        if (value === 20 && fromUnit === 'TiB' && toUnit === 'TiB') return 20;
+        if (value === 20 && fromUnit === 'TiB' && toUnit === 'MiB') return 20;
+        return value;
+      })
     };
 
     await TestBed.configureTestingModule({
@@ -27,19 +38,18 @@ describe('OverviewStorageCardComponent', () => {
   });
 
   afterEach(() => {
-    jest.clearAllMocks();
+    jest.resetAllMocks();
   });
 
   it('should create', () => {
-    fixture.detectChanges();
     expect(component).toBeTruthy();
   });
 
   it('should set totalCapacity when valid value is provided', () => {
     component.totalCapacity = 1024;
 
-    expect(component.totalRaw).toBe(10);
-    expect(component.totalRawUnit).toBe('GiB');
+    expect(component.totalRaw).toBe(20);
+    expect(component.totalRawUnit).toBe('TiB');
     expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(1024, true);
   });
 
@@ -53,25 +63,23 @@ describe('OverviewStorageCardComponent', () => {
   });
 
   it('should set usedCapacity when valid value is provided', () => {
-    component.usedCapacity = 2048;
+    component.usedCapacity = 512;
 
-    expect(component.usedRaw).toBe(10);
-    expect(component.usedRawUnit).toBe('GiB');
-    expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(2048, true);
+    expect(component.usedRaw).toBe(5);
+    expect(component.usedRawUnit).toBe('TiB');
+    expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(512, true);
   });
 
   it('should not set usedCapacity when formatter returns NaN', () => {
     mockFormatterService.formatToBinary.mockReturnValue([NaN, 'GiB']);
 
-    component.usedCapacity = 2048;
+    component.usedCapacity = 512;
 
     expect(component.usedRaw).toBeNull();
     expect(component.usedRawUnit).toBe('');
   });
 
   it('should not update chart options until both totalCapacity and usedCapacity are set', () => {
-    mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']);
-
     component.totalCapacity = 1024;
 
     expect(component.options.meter.proportional.total).toBeNull();
@@ -80,13 +88,10 @@ describe('OverviewStorageCardComponent', () => {
   });
 
   it('should update chart options when both totalCapacity and usedCapacity are set', () => {
-    mockFormatterService.formatToBinary
-      .mockReturnValueOnce([20, 'TiB'])
-      .mockReturnValueOnce([5, 'TiB']);
-
     component.totalCapacity = 1024;
     component.usedCapacity = 512;
 
+    expect(mockFormatterService.convertToUnit).toHaveBeenCalledWith(20, 'TiB', 'TiB', 1);
     expect(component.options.meter.proportional.total).toBe(20);
     expect(component.options.meter.proportional.unit).toBe('TiB');
     expect(component.options.tooltip).toBeDefined();
@@ -94,16 +99,20 @@ describe('OverviewStorageCardComponent', () => {
   });
 
   it('should use used unit in tooltip formatter', () => {
-    mockFormatterService.formatToBinary
-      .mockReturnValueOnce([20, 'TiB'])
-      .mockReturnValueOnce([5, 'TiB']);
+    mockFormatterService.formatToBinary.mockImplementation((value: number) => {
+      if (value === 1024) return [20, 'TiB'];
+      if (value === 512) return [5, 'MiB'];
+      return [10, 'GiB'];
+    });
 
     component.totalCapacity = 1024;
     component.usedCapacity = 512;
 
     const formatter = component.options.tooltip?.valueFormatter as (value: number) => string;
 
-    expect(formatter(12.3)).toBe('12.3 TiB');
+    expect(component.usedRawUnit).toBe('MiB');
+    expect(component.options.meter.proportional.unit).toBe('MiB');
+    expect(formatter(12.3)).toBe('12.3 MiB');
   });
 
   it('should keep default input values for presentational fields', () => {
index ffb33f9a41ae40ce4a3ac756b70c567f36afc075..48089fa3cfefc1fde553f6e19c4d0ab33dc1947c 100644 (file)
@@ -6,20 +6,22 @@ import {
   Input,
   ViewEncapsulation
 } from '@angular/core';
-import { GridModule, TooltipModule, SkeletonModule, LayoutModule } from 'carbon-components-angular';
+import {
+  GridModule,
+  TooltipModule,
+  SkeletonModule,
+  LayoutModule,
+  TagModule
+} from 'carbon-components-angular';
 import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
 import { MeterChartComponent, MeterChartOptions } from '@carbon/charts-angular';
 import { FormatterService } from '~/app/shared/services/formatter.service';
 import { AreaChartComponent } from '~/app/shared/components/area-chart/area-chart.component';
 import { ComponentsModule } from '~/app/shared/components/components.module';
+import { BreakdownChartData, CapacityThreshold, TrendPoint } from '~/app/shared/models/overview';
 
 const CHART_HEIGHT = '45px';
 
-type TrendPoint = {
-  timestamp: Date;
-  values: { Used: number };
-};
-
 @Component({
   selector: 'cd-overview-storage-card',
   imports: [
@@ -30,7 +32,8 @@ type TrendPoint = {
     SkeletonModule,
     LayoutModule,
     AreaChartComponent,
-    ComponentsModule
+    ComponentsModule,
+    TagModule
   ],
   standalone: true,
   templateUrl: './overview-storage-card.component.html',
@@ -63,8 +66,9 @@ export class OverviewStorageCardComponent {
   @Input() consumptionTrendData: TrendPoint[] = [];
   @Input() averageDailyConsumption = '';
   @Input() estimatedTimeUntilFull = '';
-  @Input() breakdownData: { group: string; value: number }[] = [];
+  @Input() breakdownData: BreakdownChartData[] = [];
   @Input() isBreakdownLoaded = false;
+  @Input() threshold: CapacityThreshold;
 
   totalRaw: number | null = null;
   usedRaw: number | null = null;
@@ -100,14 +104,21 @@ export class OverviewStorageCardComponent {
     ) {
       return;
     }
+
+    const totalInUsedUnit = this.formatterService.convertToUnit(
+      this.totalRaw,
+      this.totalRawUnit,
+      this.usedRawUnit,
+      1
+    );
     this.options = {
       ...this.options,
       meter: {
         ...this.options.meter,
         proportional: {
           ...this.options.meter.proportional,
-          total: this.totalRaw,
-          unit: this.totalRawUnit
+          total: totalInUsedUnit,
+          unit: this.usedRawUnit
         }
       },
       tooltip: {
index 2f28b9fc9bccce8d372c77fe3240aab3ad03165b..d6fea71d3090fcb1906122b0e0fec1a2f155d66b 100644 (file)
@@ -1,5 +1,6 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
 import { configureTestBed } from '~/testing/unit-test-helper';
 
 import { OverviewStorageService } from './storage-overview.service';
@@ -16,6 +17,10 @@ describe('OverviewStorageService', () => {
     service = TestBed.inject(OverviewStorageService);
   });
 
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
   it('should be created', () => {
     expect(service).toBeTruthy();
   });
@@ -23,7 +28,9 @@ describe('OverviewStorageService', () => {
   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_osd_stat_bytes_used)' },
@@ -36,7 +43,7 @@ describe('OverviewStorageService', () => {
     it('should format bytes per day correctly', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '1073741824'] }] }));
+        .mockReturnValue(of({ result: [{ value: [null, '1073741824'] }] }) as any);
       jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['1.0', 'GiB'] as any);
 
       service.getAverageConsumption().subscribe((result) => {
@@ -48,7 +55,7 @@ describe('OverviewStorageService', () => {
     it('should return 0 formatted when no result', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+        .mockReturnValue(of({ result: [] }) as any);
       jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['0', 'B'] as any);
 
       service.getAverageConsumption().subscribe((result) => {
@@ -58,9 +65,7 @@ describe('OverviewStorageService', () => {
     });
 
     it('should handle null response gracefully', (done) => {
-      jest
-        .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)(null));
+      jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(of(null) as any);
       jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['0', 'B'] as any);
 
       service.getAverageConsumption().subscribe((result) => {
@@ -74,7 +79,7 @@ describe('OverviewStorageService', () => {
     it('should return N/A when days is Infinity', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+        .mockReturnValue(of({ result: [] }) as any);
 
       service.getTimeUntilFull().subscribe((result) => {
         expect(result).toBe('N/A');
@@ -85,7 +90,7 @@ describe('OverviewStorageService', () => {
     it('should return hours when days < 1', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '0.5'] }] }));
+        .mockReturnValue(of({ result: [{ value: [null, '0.5'] }] }) as any);
 
       service.getTimeUntilFull().subscribe((result) => {
         expect(result).toBe('12.0 hours');
@@ -96,7 +101,7 @@ describe('OverviewStorageService', () => {
     it('should return days when 1 <= days < 30', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '15'] }] }));
+        .mockReturnValue(of({ result: [{ value: [null, '15'] }] }) as any);
 
       service.getTimeUntilFull().subscribe((result) => {
         expect(result).toBe('15.0 days');
@@ -104,10 +109,10 @@ describe('OverviewStorageService', () => {
       });
     });
 
-    it('should return months when days >= 30', (done) => {
+    it('should return months when days >= 30 and < 365', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '60'] }] }));
+        .mockReturnValue(of({ result: [{ value: [null, '60'] }] }) as any);
 
       service.getTimeUntilFull().subscribe((result) => {
         expect(result).toBe('2.0 months');
@@ -115,10 +120,21 @@ describe('OverviewStorageService', () => {
       });
     });
 
+    it('should return years when days >= 365', (done) => {
+      jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(of({ result: [{ value: [null, '730'] }] }) as any);
+
+      service.getTimeUntilFull().subscribe((result) => {
+        expect(result).toBe('2.0 years');
+        done();
+      });
+    });
+
     it('should return N/A when days <= 0', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '-5'] }] }));
+        .mockReturnValue(of({ result: [{ value: [null, '-5'] }] }) as any);
 
       service.getTimeUntilFull().subscribe((result) => {
         expect(result).toBe('N/A');
@@ -130,9 +146,9 @@ describe('OverviewStorageService', () => {
   describe('getTopPools', () => {
     it('should map pool results with name', (done) => {
       jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
-        new (require('rxjs').of)({
+        of({
           result: [{ metric: { name: 'mypool' }, value: [null, '0.5'] }]
-        })
+        }) as any
       );
 
       service.getTopPools('some_query').subscribe((result) => {
@@ -143,9 +159,9 @@ describe('OverviewStorageService', () => {
 
     it('should fallback to pool label when name is absent', (done) => {
       jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
-        new (require('rxjs').of)({
+        of({
           result: [{ metric: { pool: 'fallback_pool' }, value: [null, '0.25'] }]
-        })
+        }) as any
       );
 
       service.getTopPools('some_query').subscribe((result) => {
@@ -154,11 +170,11 @@ describe('OverviewStorageService', () => {
       });
     });
 
-    it('should use "unknown" when no name or pool label', (done) => {
+    it('should use unknown when no name or pool label', (done) => {
       jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
-        new (require('rxjs').of)({
+        of({
           result: [{ metric: {}, value: [null, '0.1'] }]
-        })
+        }) as any
       );
 
       service.getTopPools('some_query').subscribe((result) => {
@@ -170,7 +186,7 @@ describe('OverviewStorageService', () => {
     it('should return empty array when result is empty', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+        .mockReturnValue(of({ result: [] }) as any);
 
       service.getTopPools('some_query').subscribe((result) => {
         expect(result).toEqual([]);
@@ -183,7 +199,7 @@ describe('OverviewStorageService', () => {
     it('should return numeric count from query result', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '42'] }] }));
+        .mockReturnValue(of({ result: [{ value: [null, '42'] }] }) as any);
 
       service.getCount('some_query').subscribe((result) => {
         expect(result).toBe(42);
@@ -194,7 +210,7 @@ describe('OverviewStorageService', () => {
     it('should return 0 when result is empty', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+        .mockReturnValue(of({ result: [] }) as any);
 
       service.getCount('some_query').subscribe((result) => {
         expect(result).toBe(0);
@@ -203,9 +219,7 @@ describe('OverviewStorageService', () => {
     });
 
     it('should return 0 when response is null', (done) => {
-      jest
-        .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)(null));
+      jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(of(null) as any);
 
       service.getCount('some_query').subscribe((result) => {
         expect(result).toBe(0);
@@ -218,10 +232,10 @@ describe('OverviewStorageService', () => {
     it('should return bucket and pool counts', (done) => {
       jest
         .spyOn(service['prom'], 'getPrometheusQueryData')
-        .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '3'] }] }));
+        .mockReturnValue(of({ result: [{ value: [null, '3'] }] }) as any);
 
       const mockRgwService = {
-        getTotalBucketsAndUsersLength: () => new (require('rxjs').of)({ buckets_count: 10 })
+        getTotalBucketsAndUsersLength: () => of({ buckets_count: 10 })
       };
 
       service.getObjectCounts(mockRgwService).subscribe((result) => {
@@ -233,10 +247,10 @@ describe('OverviewStorageService', () => {
     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'] }] }));
+        .mockReturnValue(of({ result: [{ value: [null, '2'] }] }) as any);
 
       const mockRgwService = {
-        getTotalBucketsAndUsersLength: () => new (require('rxjs').of)({})
+        getTotalBucketsAndUsersLength: () => of({})
       };
 
       service.getObjectCounts(mockRgwService).subscribe((result) => {
@@ -245,4 +259,148 @@ describe('OverviewStorageService', () => {
       });
     });
   });
+
+  describe('getStorageBreakdown', () => {
+    it('should call getPrometheusQueryData with storage breakdown query', () => {
+      const promSpy = jest
+        .spyOn(service['prom'], 'getPrometheusQueryData')
+        .mockReturnValue(of({}) as any);
+
+      service.getStorageBreakdown().subscribe();
+
+      expect(promSpy).toHaveBeenCalledWith({
+        params:
+          'sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})'
+      });
+    });
+  });
+
+  describe('formatBytesForChart', () => {
+    it('should delegate to formatter.formatToBinary', () => {
+      const formatterSpy = jest
+        .spyOn(service['formatter'], 'formatToBinary')
+        .mockReturnValue([3, 'GiB'] as any);
+
+      const result = service.formatBytesForChart(3221225472);
+
+      expect(formatterSpy).toHaveBeenCalledWith(3221225472, true);
+      expect(result).toEqual([3, 'GiB']);
+    });
+  });
+
+  describe('convertBytesToUnit', () => {
+    it('should delegate to formatter.convertToUnit', () => {
+      const formatterSpy = jest.spyOn(service['formatter'], 'convertToUnit').mockReturnValue(12.5);
+
+      const result = service.convertBytesToUnit(13421772800, 'GiB');
+
+      expect(formatterSpy).toHaveBeenCalledWith(13421772800, 'B', 'GiB', 1);
+      expect(result).toBe(12.5);
+    });
+  });
+
+  describe('mapStorageChartData', () => {
+    it('should map Block, Filesystem, and Object groups', () => {
+      jest
+        .spyOn(service, 'convertBytesToUnit')
+        .mockImplementation((value: number) => Number(value));
+
+      const result = service.mapStorageChartData(
+        {
+          result: [
+            { metric: { application: 'Block' }, value: [0, '100'] },
+            { metric: { application: 'Filesystem' }, value: [0, '200'] },
+            { metric: { application: 'Object' }, value: [0, '300'] }
+          ]
+        } as any,
+        'B',
+        600
+      );
+
+      expect(result).toEqual([
+        { group: 'Block', value: 100 },
+        { group: 'File system', value: 200 },
+        { group: 'Object', value: 300 }
+      ]);
+    });
+
+    it('should add System metadata for unassigned bytes', () => {
+      jest
+        .spyOn(service, 'convertBytesToUnit')
+        .mockImplementation((value: string | number) => Number(value));
+
+      const result = service.mapStorageChartData(
+        {
+          result: [
+            { metric: { application: 'Block' }, value: [0, '100'] },
+            { metric: { application: 'Filesystem' }, value: [0, '200'] }
+          ]
+        } as any,
+        'B',
+        500
+      );
+
+      expect(result).toEqual([
+        { group: 'Block', value: 100 },
+        { group: 'File system', value: 200 },
+        { group: 'System metadata', value: 200 }
+      ]);
+    });
+
+    it('should treat unknown application bytes as system metadata', () => {
+      jest
+        .spyOn(service, 'convertBytesToUnit')
+        .mockImplementation((value: string | number) => Number(value));
+
+      const result = service.mapStorageChartData(
+        {
+          result: [
+            { metric: { application: 'Unknown' }, value: [0, '50'] },
+            { metric: { application: 'Block' }, value: [0, '100'] }
+          ]
+        } as any,
+        'B',
+        150
+      );
+
+      expect(result).toEqual([
+        { group: 'Block', value: 100 },
+        { group: 'System metadata', value: 50 }
+      ]);
+    });
+
+    it('should return empty array when unit is missing', () => {
+      const result = service.mapStorageChartData({ result: [] } as any, '', 100);
+      expect(result).toEqual([]);
+    });
+
+    it('should return empty array when data is null', () => {
+      const result = service.mapStorageChartData(null as any, 'B', 100);
+      expect(result).toEqual([]);
+    });
+
+    it('should return empty array when totalUsedBytes is null', () => {
+      const result = service.mapStorageChartData({ result: [] } as any, 'B', null as any);
+      expect(result).toEqual([]);
+    });
+
+    it('should filter out zero-value converted entries', () => {
+      jest
+        .spyOn(service, 'convertBytesToUnit')
+        .mockImplementation((value: string | number) => Number(value));
+      const result = service.mapStorageChartData(
+        {
+          result: [
+            { metric: { application: 'mgr' }, value: [0, '50'] },
+            { metric: { application: 'Object' }, value: [0, '0'] },
+            { metric: { application: 'Block' }, value: [0, '0'] }
+          ]
+        } as any,
+        'B',
+        50
+      );
+
+      expect(result).toEqual([{ group: 'System metadata', value: 50 }]);
+    });
+  });
 });
index 5a7cd8a0f60ae81c7410f57524adab6603ef37e8..a5afd945270873de1dff58deb75f536db43938c0 100644 (file)
@@ -7,15 +7,16 @@ import {
 import { FormatterService } from '~/app/shared/services/formatter.service';
 import { map } from 'rxjs/operators';
 import { forkJoin, Observable } from 'rxjs';
+import { CapacityCardQueries } from '../enum/dashboard-promqls.enum';
+import { BreakdownChartData, CapacityThreshold } from '../models/overview';
 
 const StorageType = {
   BLOCK: $localize`Block`,
   FILE: $localize`File system`,
-  OBJECT: $localize`Object`
+  OBJECT: $localize`Object`,
+  SYSTEM_METADATA: $localize`System metadata`
 } 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);
@@ -26,6 +27,7 @@ export class OverviewStorageService {
   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.*)|(..*)"})';
+  private readonly FULL_NEARFULL_QUERY = `{__name__=~"${CapacityCardQueries.OSD_FULL}|${CapacityCardQueries.OSD_NEARFULL}"}`;
 
   getTrendData(start: number, end: number, stepSec: number) {
     const range = {
@@ -96,32 +98,98 @@ export class OverviewStorageService {
     );
   }
 
-  convertBytesToUnit(value: string, unit: string): number {
-    return this.formatter.convertToUnit(value, 'B', unit, 1);
+  getRawCapacityThresholds(): Observable<{
+    osdFullRatio: number | null;
+    osdNearfullRatio: number | null;
+  }> {
+    return this.prom.getGaugeQueryData(this.FULL_NEARFULL_QUERY).pipe(
+      map((data: PromqlGuageMetric) => {
+        const result = data?.result ?? [];
+
+        const osdFull = result.find((r) => r.metric?.__name__ === CapacityCardQueries.OSD_FULL)
+          ?.value?.[1];
+        const osdNearfull = result.find(
+          (r) => r.metric?.__name__ === CapacityCardQueries.OSD_NEARFULL
+        )?.value?.[1];
+
+        return {
+          osdFullRatio: this.prom.formatGuageMetric(osdFull),
+          osdNearfullRatio: this.prom.formatGuageMetric(osdNearfull)
+        };
+      })
+    );
   }
 
   getStorageBreakdown(): Observable<PromqlGuageMetric> {
     return this.prom.getPrometheusQueryData({ params: this.RAW_USED_BY_STORAGE_TYPE_QUERY });
   }
+
+  getThresholdStatus(total, used, nearfull, full): CapacityThreshold {
+    if (!used || !total || !nearfull || !full) {
+      return null;
+    }
+
+    const usageRatio = used / total;
+
+    if (usageRatio >= full) return 'critical';
+    else if (usageRatio >= nearfull) return 'high';
+
+    return null;
+  }
+
+  convertBytesToUnit(value: number, unit: string): number {
+    return this.formatter.convertToUnit(value, 'B', unit, 1);
+  }
+
   formatBytesForChart(value: number): [number, string] {
     return this.formatter.formatToBinary(value, true);
   }
 
-  mapStorageChartData(data: PromqlGuageMetric, unit: string): { group: string; value: number }[] {
-    if (!unit) return [];
+  private normalizeGroup(group: string): string {
+    if (group === 'Filesystem') return StorageType.FILE;
+    return group;
+  }
 
-    const result = data?.result ?? [];
+  private isAStorage(group: string): boolean {
+    return (
+      group === StorageType.BLOCK || group === StorageType.FILE || group === StorageType.OBJECT
+    );
+  }
 
-    return result
-      .map((r: PromethuesGaugeMetricResult) => {
-        const group = r?.metric?.application;
-        const value = r?.value?.[1];
+  mapStorageChartData(
+    data: PromqlGuageMetric,
+    unit: string,
+    totalUsedBytes: number
+  ): BreakdownChartData[] {
+    if (!unit || totalUsedBytes == null || !data) return [];
 
-        return {
-          group: group === 'Filesystem' ? StorageType.FILE : group,
-          value: this.convertBytesToUnit(value, unit)
-        };
-      })
-      .filter((item) => CHART_GROUP_LABELS.has(item.group) && item.value > 0);
+    let assignedBytes = 0;
+
+    const result: PromethuesGaugeMetricResult[] = data.result ?? [];
+    const chartData = result.reduce<BreakdownChartData[]>((acc, r) => {
+      const rawBytes = Number(r?.value?.[1] ?? 0);
+      if (!rawBytes) return acc;
+
+      const group = this.normalizeGroup(r?.metric?.application);
+      const value = this.convertBytesToUnit(rawBytes, unit);
+
+      if (this.isAStorage(group) && value > 0) {
+        assignedBytes += rawBytes;
+        acc.push({ group, value });
+      }
+
+      return acc;
+    }, []);
+
+    const systemBytes = Math.max(0, totalUsedBytes - assignedBytes);
+
+    if (systemBytes > 0) {
+      chartData.push({
+        group: StorageType.SYSTEM_METADATA,
+        value: this.convertBytesToUnit(systemBytes, unit)
+      });
+    }
+
+    return chartData;
   }
 }
index a6b1b21a7db5bcd96ef15df050387a8600a9a94d..afc9f4b077e2ce84f786d38d88b5ba2208fe6663 100644 (file)
@@ -1,4 +1,4 @@
 <svg  [cdsIcon]="icon"
       [size]="size"
-      [class]="type+'-icon '+class">
+      [ngClass]="!useDefault ? [type + '-icon', class] : []">
 </svg>
index 0a85a40c0495d1215e9223e96ba4f1fd7636cb67..d82dba62a29426d1b8ec45ceff790ff8ec1d4aa7 100644 (file)
@@ -19,6 +19,8 @@ export class IconComponent implements OnInit, OnChanges {
   @Input() type!: keyof typeof ICON_TYPE;
   @Input() size: IconSize = IconSize.size16;
   @Input() class: string = '';
+  // No CSS class will be applied.
+  @Input() useDefault: boolean = false;
 
   icon: string;
 
index 746b1a7905a131ac3279adcf98429b89e5d28ee1..92d9e47e395cb6dd8487998ba21002e25f246eb0 100644 (file)
@@ -16,6 +16,15 @@ type PG_STATES = typeof PG_STATES[number];
 
 type SCRUBBING_STATES = typeof SCRUBBING_STATES[number];
 
+export type TrendPoint = {
+  timestamp: Date;
+  values: { Used: number };
+};
+
+export type BreakdownChartData = { group: string; value: number };
+
+export type CapacityThreshold = 'high' | 'critical' | null;
+
 export const HealthIconMap = {
   HEALTH_OK: 'success',
   HEALTH_WARN: 'warningAltFilled',
@@ -90,11 +99,12 @@ export interface HealthCardVM {
 export interface StorageCardVM {
   totalCapacity: number | null;
   usedCapacity: number | null;
-  breakdownData: { group: string; value: number }[];
+  breakdownData: BreakdownChartData[];
   isBreakdownLoaded: boolean;
-  consumptionTrendData: { timestamp: Date; values: { Used: number } }[];
+  consumptionTrendData: TrendPoint[];
   averageDailyConsumption: string;
   estimatedTimeUntilFull: string;
+  threshold: CapacityThreshold;
 }
 
 // Constants
index 4309d1b17aebb948788f1e2946717ce84f88b47e..89b88e6f362007fba8ca0726f16d7d88c612fc67 100644 (file)
@@ -137,9 +137,9 @@ describe('FormatterService', () => {
     });
 
     it('should return a safe tuple when split=true and input is unsupported', () => {
-      expect(service.formatToBinary(undefined as any, true)).toEqual([0, 'B']);
-      expect(service.formatToBinary(null as any, true)).toEqual([0, 'B']);
-      expect(service.formatToBinary(service as any, true)).toEqual([0, 'B']);
+      expect(service.formatToBinary(undefined as any, true)).toEqual([NaN, 'B']);
+      expect(service.formatToBinary(null as any, true)).toEqual([NaN, 'B']);
+      expect(service.formatToBinary(service as any, true)).toEqual([NaN, 'B']);
     });
   });
 
index 8d8b963eacfbbb03ca05efee2288698ee4f2ca1a..fc8714de82ab58df20b92265d4af02c138c63ba4 100644 (file)
@@ -210,7 +210,12 @@ export class FormatterService {
     return [value, unit];
   }
 
-  convertToUnit(value: string, fromUnit: string, toUnit: string, decimals: number = 1): number {
+  convertToUnit(
+    value: number | string,
+    fromUnit: string,
+    toUnit: string,
+    decimals: number = 1
+  ): number {
     if (!value) return 0;
     const convertedString = this.formatNumberFromTo(
       value,