From: Devika Babrekar Date: Tue, 20 Jan 2026 06:16:33 +0000 (+0530) Subject: mgr/dashboard: add storage consumption card X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=9550f155c0e4d5e403b3da56e610f06825dc1760;p=ceph.git mgr/dashboard: add storage consumption card Fixes: https://tracker.ceph.com/issues/75181 Signed-off-by: Aashish Sharma (cherry picked from commit c2e7fecd7226569d1a6e3ba70d2f65835038122d) --- 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 ec05c116c139..382e5b9618da 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 @@ -41,6 +41,94 @@ [data]="displayData" class="overview-storage-card-chart"> } + + @if(selectedStorageType === 'All' && trendData) { +
+
+ + +
+
+
+
+ {{ timeUntilFull }} +
+ + Estimated days until full + +
+
+
+ {{ averageConsumption }} +
+ + Average consumption + +
+
+
+
+
+ } + @if (selectedStorageType !== 'All' && topPoolsData) { + @if (topPoolsData) { +
+
+
+ @for(metric of storageMetrics[selectedStorageType].metrics; track metric.label){ +
+ {{metric.value}} + {{metric.label}} +
+ } +
+
+
+ + +
+
+ } @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 470e591c5128..b690a0f42bd0 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 @@ -30,3 +30,13 @@ } } } + +.consumption-stats-wrapper { + display: inline-flex; + width: fit-content; +} + +.consumption-stats-wrapper cds-tooltip-definition { + display: inline-block; + width: auto; +} 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 83ac658b1515..38c0fdbe4a2d 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 @@ -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); + }); }); 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 56117d8442a0..f58aa87b6150 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 @@ -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(); + 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: any = 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 index 000000000000..19d6589076e1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.spec.ts @@ -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 index 000000000000..f68337b2b631 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/storage-overview.service.ts @@ -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 { + 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 { + 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 { + 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 + })) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html index 1313c5627fbd..248f6a19e8db 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html @@ -1,6 +1,25 @@ @if(chartData && chartOptions){ - +
+
+ {{ chartTitle }} +
+ @if(subHeading) { +
+ {{ subHeading }} +
+ } +
+ @if(chartType === 'area') { + -
+ + } @else { + + + } + } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.scss index e69de29bb2d1..ed411a7d4fb2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.scss @@ -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); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts index 26a454b54f7b..97c7260cf7e8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts @@ -264,7 +264,5 @@ describe('AreaChartComponent', () => { component.ngOnChanges({ rawData: new SimpleChange(null, mockData, false) }); - - expect(component.chartOptions?.title).toBe('Test Chart'); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts index 8f94de2f21a1..b0b84bae6308 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts @@ -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; + @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 index 000000000000..3013e1307a27 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.html @@ -0,0 +1,5 @@ + + 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 index 000000000000..e69de29bb2d1 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 index 000000000000..755e3577e395 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.spec.ts @@ -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; + + 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 index 000000000000..95f880c90131 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/pie-chart/pie-chart.component.ts @@ -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 + } + } + }; + } +}