From: Afreen Misbah Date: Mon, 16 Mar 2026 14:20:51 +0000 (+0530) Subject: mgr/dashboard: Add capacity thresholds X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=3465ae7e910e7c0e52c97dc11792883ab8572ed1;p=ceph.git mgr/dashboard: Add capacity thresholds Signed-off-by: Afreen Misbah --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html index 0a11dccba29..3d9b55aa093 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html @@ -32,7 +32,8 @@ [averageDailyConsumption]="storageCard?.averageDailyConsumption ?? ''" [estimatedTimeUntilFull]="storageCard?.estimatedTimeUntilFull ?? ''" [breakdownData]="storageCard?.breakdownData ?? []" - [isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false"> + [isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false" + [threshold]="storageCard?.threshold"> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts index 4eff04f6efb..1abf2c7b8f3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts @@ -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(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts index 79a18c32db3..bb8f7119242 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts @@ -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 + ) }; } ), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html index c92c502e96e..a2e9437cd31 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html @@ -1,8 +1,8 @@ - +

Storage Overview

+ i18n>Storage overview
@@ -15,13 +15,35 @@ class="cds--type-body-02" i18n>{{usedRawUnit}} of {{totalRaw}} {{totalRawUnit}} used + @if(threshold === 'high') { + +
+ +
+ High storage usage +
+ } + @else if(threshold === 'critical') { + +
+ +
+ Capacity critically low +
+ } } @else { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.scss index b690a0f42bd..0aa973adf6e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.scss @@ -29,6 +29,11 @@ display: none !important; } } + + .cds--tag--gray { + background-color: #fddc69; + color: var(--cds-text-secondary); + } } .consumption-stats-wrapper { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts index d233934a859..a3216f64dd4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts @@ -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', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts index ffb33f9a41a..48089fa3cfe 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts @@ -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: { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts index 2f28b9fc9bc..d6fea71d309 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts @@ -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 }]); + }); + }); }); 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 index 5a7cd8a0f60..a5afd945270 100644 --- 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 @@ -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 { 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((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; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.html index a6b1b21a7db..afc9f4b077e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.html @@ -1,4 +1,4 @@ + [ngClass]="!useDefault ? [type + '-icon', class] : []"> diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.ts index 0a85a40c049..d82dba62a29 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.ts @@ -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; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts index 746b1a7905a..92d9e47e395 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/overview.ts @@ -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 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts index 4309d1b17ae..89b88e6f362 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts @@ -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']); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts index 8d8b963eacf..fc8714de82a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts @@ -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,