From: Afreen Misbah Date: Mon, 16 Mar 2026 09:04:30 +0000 (+0530) Subject: mgr/dashboard: Fix loading states in storage overview card X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=a1c2b561376d90a456b1444b8ca88328bf41c8f9;p=ceph.git mgr/dashboard: Fix loading states in storage overview card Fixes https://tracker.ceph.com/issues/75321 Fixes https://tracker.ceph.com/issues/75299 - removes storage type - stabilizes overview card for loading data - raw capcity shown when promethues not there - multiple refresh intervals which may vcause sync issues and bugs hence moved the logic to parent - overview component - Now all queries are updated at 5 s interval except data consumption - using promethues interval. This needs more refactor hence would do in a later PR Signed-off-by: Afreen Misbah --- 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 eee4c1e3730..0a11dccba29 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 @@ -1,4 +1,4 @@ -@let storage = (storageVm$ | async); +@let storageCard = (storageCardVm$ | async); @let health = (healthCardVm$ | async);
+ [totalCapacity]="storageCard?.totalCapacity" + [usedCapacity]="storageCard?.usedCapacity" + [consumptionTrendData]="storageCard?.consumptionTrendData ?? []" + [averageDailyConsumption]="storageCard?.averageDailyConsumption ?? ''" + [estimatedTimeUntilFull]="storageCard?.estimatedTimeUntilFull ?? ''" + [breakdownData]="storageCard?.breakdownData ?? []" + [isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false">
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 70ad024e425..4eff04f6efb 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 @@ -19,6 +19,7 @@ import { HardwareService } from '~/app/shared/api/hardware.service'; import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { OverviewStorageService } from '~/app/shared/api/storage-overview.service'; describe('OverviewComponent', () => { let component: OverviewComponent; @@ -26,6 +27,14 @@ describe('OverviewComponent', () => { let mockHealthService: { getHealthSnapshot: jest.Mock }; let mockRefreshIntervalService: { intervalData$: Subject }; + let mockOverviewStorageService: { + getTrendData: jest.Mock; + getAverageConsumption: jest.Mock; + getTimeUntilFull: jest.Mock; + getStorageBreakdown: jest.Mock; + formatBytesForChart: jest.Mock; + mapStorageChartData: jest.Mock; + }; const mockAuthStorageService = { getPermissions: jest.fn(() => ({ configOpt: { read: false } })) @@ -44,6 +53,32 @@ describe('OverviewComponent', () => { mockHealthService = { getHealthSnapshot: jest.fn() }; mockRefreshIntervalService = { intervalData$: new Subject() }; + mockOverviewStorageService = { + getTrendData: jest.fn().mockReturnValue( + of({ + TOTAL_RAW_USED: [ + [0, '512'], + [60, '1024'] + ] + }) + ), + getAverageConsumption: jest.fn().mockReturnValue(of('12 GiB/day')), + getTimeUntilFull: jest.fn().mockReturnValue(of('30 days')), + getStorageBreakdown: jest.fn().mockReturnValue( + of({ + result: [ + { metric: { application: 'Block' }, value: [0, '1024'] }, + { metric: { application: 'Filesystem' }, value: [0, '2048'] } + ] + }) + ), + formatBytesForChart: jest.fn().mockReturnValue([3, 'GiB']), + mapStorageChartData: jest.fn().mockReturnValue([ + { group: 'Block', value: 1 }, + { group: 'File system', value: 2 } + ]) + }; + await TestBed.configureTestingModule({ imports: [ OverviewComponent, @@ -61,10 +96,10 @@ describe('OverviewComponent', () => { provideRouter([]), { provide: HealthService, useValue: mockHealthService }, { provide: RefreshIntervalService, useValue: mockRefreshIntervalService }, + { provide: OverviewStorageService, useValue: mockOverviewStorageService }, { provide: AuthStorageService, useValue: mockAuthStorageService }, { provide: MgrModuleService, useValue: mockMgrModuleService }, - { provide: HardwareService, useValue: mockHardwareService }, - provideRouter([]) + { provide: HardwareService, useValue: mockHardwareService } ] }).compileComponents(); @@ -79,10 +114,7 @@ describe('OverviewComponent', () => { expect(component).toBeTruthy(); }); - // ----------------------------- - // View model stream success - // ----------------------------- - it('healthCardVm$ should emit HealthCardVM with new keys', (done) => { + it('healthCardVm$ should emit HealthCardVM correctly', (done) => { const mockData: HealthSnapshotMap = { fsid: 'fsid-123', health: { @@ -92,14 +124,8 @@ describe('OverviewComponent', () => { b: { severity: 'HEALTH_ERR', summary: { message: 'B issue' } } } }, - // data resileincy pgmap: { - pgs_by_state: [ - { - state_name: 'active+clean', - count: 497 - } - ], + pgs_by_state: [{ state_name: 'active+clean', count: 497 }], num_pools: 14, bytes_used: 3236978688, bytes_total: 325343772672, @@ -108,7 +134,6 @@ describe('OverviewComponent', () => { read_bytes_sec: 0, recovering_bytes_per_sec: 0 }, - // subsystem inputs used by mapper monmap: { num_mons: 3, quorum: [0, 1, 2] } as any, mgrmap: { num_active: 1, num_standbys: 1 } as any, osdmap: { num_osds: 2, up: 2, in: 2 } as any, @@ -171,16 +196,11 @@ describe('OverviewComponent', () => { const mockData: HealthSnapshotMap = { fsid: 'fsid-999', health: { status: 'HEALTH_OK', checks: {} }, - monmap: { num_mons: 3, quorum: [0, 1, 2] } as any, // ok - mgrmap: { num_active: 0, num_standbys: 0 } as any, // err (active < 1) - osdmap: { num_osds: 2, up: 2, in: 2 } as any, // ok + monmap: { num_mons: 3, quorum: [0, 1, 2] } as any, + mgrmap: { num_active: 0, num_standbys: 0 } as any, + osdmap: { num_osds: 2, up: 2, in: 2 } as any, pgmap: { - pgs_by_state: [ - { - state_name: 'active+clean', - count: 497 - } - ], + pgs_by_state: [{ state_name: 'active+clean', count: 497 }], num_pools: 14, bytes_used: 3236978688, bytes_total: 325343772672, @@ -190,14 +210,13 @@ describe('OverviewComponent', () => { recovering_bytes_per_sec: 0 }, num_hosts: 1, - num_hosts_down: 0 // ok + num_hosts_down: 0 } as any; mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData)); const sub = component.healthCardVm$.subscribe((vm) => { - // mgr -> err, therefore overall should be err icon - expect(vm.overallSystemSev).toBe(SeverityIconMap[2]); // sev.err === 2 + expect(vm.overallSystemSev).toBe(SeverityIconMap[2]); sub.unsubscribe(); done(); }); @@ -205,9 +224,6 @@ describe('OverviewComponent', () => { mockRefreshIntervalService.intervalData$.next(); }); - // ----------------------------- - // View model stream error → EMPTY - // ----------------------------- it('healthCardVm$ should not emit if healthService throws (EMPTY)', (done) => { mockHealthService.getHealthSnapshot.mockReturnValue(throwError(() => new Error('API Error'))); @@ -225,9 +241,97 @@ describe('OverviewComponent', () => { mockRefreshIntervalService.intervalData$.complete(); }); - // ----------------------------- - // toggle health panel - // ----------------------------- + it('storageCardVm$ should emit storage view model with mapped fields', (done) => { + const mockData: HealthSnapshotMap = { + fsid: 'fsid-storage', + health: { status: 'HEALTH_OK', checks: {} }, + pgmap: { + pgs_by_state: [{ state_name: 'active+clean', count: 497 }], + num_pools: 14, + bytes_used: 3236978688, + bytes_total: 325343772672, + num_pgs: 497, + write_bytes_sec: 0, + read_bytes_sec: 0, + recovering_bytes_per_sec: 0 + }, + monmap: { num_mons: 3, quorum: [0, 1, 2] } as any, + mgrmap: { num_active: 1, num_standbys: 1 } as any, + osdmap: { num_osds: 2, up: 2, in: 2 } as any, + num_hosts: 5, + num_hosts_down: 0 + } as any; + + mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData)); + + const sub = component.storageCardVm$.subscribe((vm) => { + expect(vm.totalCapacity).toBe(325343772672); + expect(vm.usedCapacity).toBe(3236978688); + expect(vm.breakdownData).toEqual([ + { group: 'Block', value: 1 }, + { group: 'File system', value: 2 } + ]); + expect(vm.isBreakdownLoaded).toBe(true); + expect(vm.consumptionTrendData).toEqual([ + { + timestamp: new Date(0), + values: { Used: 512 } + }, + { + timestamp: new Date(60000), + values: { Used: 1024 } + } + ]); + expect(vm.averageDailyConsumption).toBe('12 GiB/day'); + expect(vm.estimatedTimeUntilFull).toBe('30 days'); + + expect(mockOverviewStorageService.formatBytesForChart).toHaveBeenCalledWith(3236978688); + expect(mockOverviewStorageService.mapStorageChartData).toHaveBeenCalled(); + + sub.unsubscribe(); + done(); + }); + + mockRefreshIntervalService.intervalData$.next(); + }); + + it('storageCardVm$ should emit safe defaults before storage side streams resolve', (done) => { + const mockData: HealthSnapshotMap = { + fsid: 'fsid-storage', + health: { status: 'HEALTH_OK', checks: {} }, + pgmap: { + pgs_by_state: [{ state_name: 'active+clean', count: 1 }], + num_pools: 1, + bytes_used: 100, + bytes_total: 1000, + num_pgs: 1, + write_bytes_sec: 0, + read_bytes_sec: 0, + recovering_bytes_per_sec: 0 + }, + monmap: { num_mons: 1, quorum: [0] } as any, + mgrmap: { num_active: 1, num_standbys: 1 } as any, + osdmap: { num_osds: 1, up: 1, in: 1 } as any, + num_hosts: 1, + num_hosts_down: 0 + } as any; + + mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData)); + mockOverviewStorageService.getStorageBreakdown.mockReturnValue(of(null)); + + const sub = component.storageCardVm$.subscribe((vm) => { + expect(vm.totalCapacity).toBe(1000); + expect(vm.usedCapacity).toBe(100); + expect(vm.breakdownData).toEqual([]); + expect(vm.isBreakdownLoaded).toBe(false); + + sub.unsubscribe(); + done(); + }); + + mockRefreshIntervalService.intervalData$.next(); + }); + it('should toggle panel open/close', () => { expect(component.isHealthPanelOpen).toBe(false); component.toggleHealthPanel(); @@ -236,9 +340,6 @@ describe('OverviewComponent', () => { expect(component.isHealthPanelOpen).toBe(false); }); - // ----------------------------- - // ngOnDestroy - // ----------------------------- it('should complete destroy$', () => { expect(() => fixture.destroy()).not.toThrow(); }); 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 e9f8cecf40f..79a18c32db3 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 @@ -7,8 +7,8 @@ import { ViewEncapsulation } from '@angular/core'; import { GridModule, LayoutModule, TilesModule } from 'carbon-components-angular'; -import { EMPTY, Observable } from 'rxjs'; -import { catchError, exhaustMap, map, shareReplay } from 'rxjs/operators'; +import { combineLatest, EMPTY, Observable } from 'rxjs'; +import { catchError, exhaustMap, map, shareReplay, startWith } from 'rxjs/operators'; import { HealthService } from '~/app/shared/api/health.service'; import { RefreshIntervalService } from '~/app/shared/services/refresh-interval.service'; @@ -27,7 +27,8 @@ import { SEVERITY, Severity, SEVERITY_TO_COLOR, - SeverityIconMap + SeverityIconMap, + StorageCardVM } from '~/app/shared/models/overview'; import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component'; @@ -38,6 +39,11 @@ import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card. import { PerformanceCardComponent } from '~/app/shared/components/performance-card/performance-card.component'; import { DataTableModule } from '~/app/shared/datatable/datatable.module'; import { PipesModule } from '~/app/shared/pipes/pipes.module'; +import { OverviewStorageService } from '~/app/shared/api/storage-overview.service'; + +const SECONDS_PER_HOUR = 3600; +const SECONDS_PER_DAY = 86400; +const TREND_DAYS = 7; /** * Mapper: HealthSnapshotMap -> HealthCardVM @@ -157,8 +163,10 @@ export class OverviewComponent { private readonly healthService = inject(HealthService); private readonly refreshIntervalService = inject(RefreshIntervalService); + private readonly overviewStorageService = inject(OverviewStorageService); private readonly destroyRef = inject(DestroyRef); + /* HEALTH CARD DATA */ private readonly healthData$: Observable = this.refreshIntervalObs(() => this.healthService.getHealthSnapshot() ).pipe(shareReplay({ bufferSize: 1, refCount: true })); @@ -168,14 +176,82 @@ export class OverviewComponent { shareReplay({ bufferSize: 1, refCount: true }) ); + /* STORAGE CARD DATA */ + readonly storageVm$ = this.healthData$.pipe( map((data: HealthSnapshotMap) => ({ - total: data.pgmap?.bytes_total ?? 0, - used: data.pgmap?.bytes_used ?? 0 + total: data.pgmap?.bytes_total, + used: data.pgmap?.bytes_used })), shareReplay({ bufferSize: 1, refCount: true }) ); + readonly averageConsumption$ = this.refreshIntervalObs(() => + this.overviewStorageService.getAverageConsumption() + ).pipe(shareReplay({ bufferSize: 1, refCount: true })); + + readonly timeUntilFull$ = this.refreshIntervalObs(() => + this.overviewStorageService.getTimeUntilFull() + ).pipe(shareReplay({ bufferSize: 1, refCount: true })); + + readonly breakdownRawData$ = this.refreshIntervalObs(() => + this.overviewStorageService.getStorageBreakdown() + ).pipe(shareReplay({ bufferSize: 1, refCount: true })); + + // getTrendData() is already a polling stream through getRangeQueriesData() + // hence no refresh needed. + readonly trendData$ = this.overviewStorageService + .getTrendData( + Math.floor(Date.now() / 1000) - TREND_DAYS * SECONDS_PER_DAY, + Math.floor(Date.now() / 1000), + SECONDS_PER_HOUR + ) + .pipe( + map((result) => { + const values = result?.TOTAL_RAW_USED ?? []; + + return values.map(([ts, val]) => ({ + timestamp: new Date(ts * 1000), + values: { Used: Number(val) } + })); + }), + shareReplay({ bufferSize: 1, refCount: true }) + ); + + readonly storageCardVm$: Observable = combineLatest([ + this.storageVm$, + this.breakdownRawData$.pipe(startWith(null)), + this.trendData$.pipe(startWith([])), + this.averageConsumption$.pipe(startWith('')), + this.timeUntilFull$.pipe(startWith('')) + ]).pipe( + map( + ([ + storage, + breakdownRawData, + consumptionTrendData, + averageDailyConsumption, + estimatedTimeUntilFull + ]) => { + const used = storage?.used ?? 0; + const [, unit] = this.overviewStorageService.formatBytesForChart(used); + + return { + totalCapacity: storage?.total, + usedCapacity: storage?.used, + breakdownData: breakdownRawData + ? this.overviewStorageService.mapStorageChartData(breakdownRawData, unit) + : [], + isBreakdownLoaded: !!breakdownRawData, + consumptionTrendData, + averageDailyConsumption, + estimatedTimeUntilFull + }; + } + ), + shareReplay({ bufferSize: 1, refCount: true }) + ); + private refreshIntervalObs(fn: () => Observable): Observable { return this.refreshIntervalService.intervalData$.pipe( exhaustMap(() => fn().pipe(catchError(() => EMPTY))), 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 38c6f3823de..c92c502e96e 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 @@ -3,24 +3,14 @@

Storage Overview

- - -
- @if( totalRaw && usedRawUnit && totalRawUnit) { + @if( usedRaw !== null && totalRaw !== null && usedRawUnit && totalRawUnit) {
{{displayUsedRaw}}&ngsp; + i18n>{{usedRaw}}  {{usedRawUnit}} of {{totalRaw}} {{totalRawUnit}} used @@ -28,7 +18,7 @@ class="cds-ml-3" [caret]="true" description="Shows raw used vs. total raw capacity. Raw capacity includes all physical storage before replication or overhead." - i8n-description + i18n-description > @@ -42,15 +32,21 @@ }
- - @if(displayData) { + + @if(isBreakdownLoaded) { - } - - @if(selectedStorageType === 'All' && trendData) { + } @else { + + +} + + @if(consumptionTrendData.length) {
@@ -71,7 +67,7 @@ gap="4">
- {{ timeUntilFull }} + {{ estimatedTimeUntilFull }}
- {{ averageConsumption }} + {{ averageDailyConsumption }}
} - @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.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts index b436e33cc1b..d233934a859 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 @@ -1,428 +1,116 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { of } from 'rxjs'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; import { OverviewStorageCardComponent } from './overview-storage-card.component'; -import { PrometheusService } from '~/app/shared/api/prometheus.service'; import { FormatterService } from '~/app/shared/services/formatter.service'; -import { HttpClientTestingModule } from '@angular/common/http/testing'; -import { DatePipe } from '@angular/common'; -describe('OverviewStorageCardComponent (Jest)', () => { +describe('OverviewStorageCardComponent', () => { let component: OverviewStorageCardComponent; let fixture: ComponentFixture; - let mockPrometheusService: { - getPrometheusQueryData: jest.Mock; - getRangeQueriesData: jest.Mock; - }; - let mockFormatterService: { formatToBinary: jest.Mock; - convertToUnit: jest.Mock; }; - const mockPrometheusResponse = { - result: [ - { - metric: { application: 'Block' }, - value: [0, '1024'] - }, - { - metric: { application: 'Filesystem' }, - value: [0, '2048'] - }, - { - metric: { application: 'Object' }, - value: [0, '0'] // should be filtered - } - ] - }; - - const mockRangePrometheusResponse = { - result: [ - { - metric: { application: 'Block' }, - values: [ - [0, '512'], - [60, '1024'] - ] - } - ] - }; beforeEach(async () => { - mockPrometheusService = { - getPrometheusQueryData: jest.fn().mockReturnValue(of(mockPrometheusResponse)), - getRangeQueriesData: jest.fn().mockReturnValue(of(mockRangePrometheusResponse)) - }; - mockFormatterService = { - formatToBinary: jest.fn().mockReturnValue([10, 'GiB']), - convertToUnit: jest.fn((value: number) => Number(value)) + formatToBinary: jest.fn().mockReturnValue([10, 'GiB']) }; await TestBed.configureTestingModule({ imports: [OverviewStorageCardComponent, HttpClientTestingModule], - providers: [ - { provide: PrometheusService, useValue: mockPrometheusService }, - { provide: FormatterService, useValue: mockFormatterService }, - DatePipe - ] + providers: [{ provide: FormatterService, useValue: mockFormatterService }] }).compileComponents(); fixture = TestBed.createComponent(OverviewStorageCardComponent); component = fixture.componentInstance; - fixture.detectChanges(); }); afterEach(() => { jest.clearAllMocks(); }); - // -------------------------------------------------- - // CREATION - // -------------------------------------------------- - it('should create', () => { + fixture.detectChanges(); expect(component).toBeTruthy(); }); - // -------------------------------------------------- - // TOTAL setter (truthy) - // -------------------------------------------------- - - it('should set total when valid value provided', () => { - component.total = 1024; + it('should set totalCapacity when valid value is provided', () => { + component.totalCapacity = 1024; expect(component.totalRaw).toBe(10); expect(component.totalRawUnit).toBe('GiB'); + expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(1024, true); }); - // -------------------------------------------------- - // TOTAL setter (falsy) - // -------------------------------------------------- - - it('should not set total when formatter returns NaN', () => { + it('should not set totalCapacity when formatter returns NaN', () => { mockFormatterService.formatToBinary.mockReturnValue([NaN, 'GiB']); - component.total = 0; + component.totalCapacity = 1024; - expect(component.totalRaw).toBeUndefined(); + expect(component.totalRaw).toBeNull(); + expect(component.totalRawUnit).toBe(''); }); - // -------------------------------------------------- - // USED setter - // -------------------------------------------------- - - it('should set used correctly', () => { - component.used = 2048; + it('should set usedCapacity when valid value is provided', () => { + component.usedCapacity = 2048; expect(component.usedRaw).toBe(10); expect(component.usedRawUnit).toBe('GiB'); + expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(2048, true); }); - // -------------------------------------------------- - // ngOnInit data load - // -------------------------------------------------- - - it('should load and filter data on init', () => { - expect(mockPrometheusService.getPrometheusQueryData).toHaveBeenCalled(); - expect(component.allData.length).toBe(3); // Object filtered (0 value) - }); - - // -------------------------------------------------- - // FILTERING - // -------------------------------------------------- - - it('should filter displayData for selected storage type', () => { - component.allData = [ - { group: 'Block', value: 10 }, - { group: 'Filesystem', value: 20 } - ]; - - component.onStorageTypeSelect({ item: { content: 'Block', selected: true } } as any); - - expect(component.displayData).toEqual([{ group: 'Block', value: 10 }]); - }); - - it('should show all data when ALL selected', () => { - component.allData = [ - { group: 'Block', value: 10 }, - { group: 'Filesystem', value: 20 } - ]; - - component.onStorageTypeSelect({ item: { content: 'All', selected: true } } as any); - - expect(component.displayData.length).toBe(2); - }); - - // -------------------------------------------------- - // DROPDOWN - // -------------------------------------------------- - - it('should update storage type from dropdown selection', () => { - component.onStorageTypeSelect({ - item: { content: 'Block', selected: true } - }); - - expect(component.selectedStorageType).toBe('Block'); - }); - - it('should auto-select single item if only one exists', () => { - component.allData = [{ group: 'Block', value: 10 }]; - - (component as any)._setDropdownItemsAndStorageType(); - - expect(component.selectedStorageType).toBe('All'); - expect(component.dropdownItems.length).toBe(2); - }); - - // -------------------------------------------------- - // DESTROY - // -------------------------------------------------- - - it('should clean up on destroy', () => { - const nextSpy = jest.spyOn((component as any).destroy$, 'next'); - const completeSpy = jest.spyOn((component as any).destroy$, 'complete'); - - component.ngOnDestroy(); - - expect(nextSpy).toHaveBeenCalled(); - expect(completeSpy).toHaveBeenCalled(); - }); - - // -------------------------------------------------- - // USED setter (falsy) - // -------------------------------------------------- - it('should not set used when formatter returns NaN', () => { + it('should not set usedCapacity when formatter returns NaN', () => { mockFormatterService.formatToBinary.mockReturnValue([NaN, 'GiB']); - component.used = 0; - - expect(component.usedRaw).toBeUndefined(); - }); - - // -------------------------------------------------- - // _getAllData - // -------------------------------------------------- - - it('should map Filesystem application to File system group', () => { - mockFormatterService.convertToUnit.mockReturnValue(5); - const data = { - result: [{ metric: { application: 'Filesystem' }, value: [0, '1024'] }] - }; - - mockPrometheusService.getPrometheusQueryData.mockReturnValue(of(data)); - fixture = TestBed.createComponent(OverviewStorageCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - expect(component.allData.some((d) => d.group === 'File system')).toBe(true); - }); - - it('should filter out entries with unknown application groups', () => { - mockFormatterService.convertToUnit.mockReturnValue(5); - const data = { - result: [ - { metric: { application: 'Unknown' }, value: [0, '1024'] }, - { metric: { application: 'Block' }, value: [0, '2048'] } - ] - }; - - mockPrometheusService.getPrometheusQueryData.mockReturnValue(of(data)); - fixture = TestBed.createComponent(OverviewStorageCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - expect(component.allData.every((d) => d.group !== 'Unknown')).toBe(true); - }); - - it('should handle empty result in _getAllData', () => { - mockPrometheusService.getPrometheusQueryData.mockReturnValue(of({ result: [] })); - fixture = TestBed.createComponent(OverviewStorageCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - expect(component.allData).toEqual([]); - }); - - it('should handle null data in _getAllData', () => { - mockPrometheusService.getPrometheusQueryData.mockReturnValue(of(null)); - fixture = TestBed.createComponent(OverviewStorageCardComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - - expect(component.allData).toEqual([]); - }); - - // -------------------------------------------------- - // _setChartData - // -------------------------------------------------- - - it('should set displayUsedRaw to usedRaw when ALL is selected', () => { - component.usedRaw = 42; - component.allData = [{ group: 'Block', value: 10 }]; - component.selectedStorageType = 'All'; - - (component as any)._setChartData(); - - expect(component.displayUsedRaw).toBe(42); - }); - - it('should set displayUsedRaw to first matching value when specific type selected', () => { - component.allData = [ - { group: 'Block', value: 15 }, - { group: 'File system', value: 25 } - ]; - component.selectedStorageType = 'Block'; - - (component as any)._setChartData(); - - expect(component.displayUsedRaw).toBe(15); - }); - - it('should set displayData to empty array when no matching type found', () => { - component.allData = [{ group: 'Block', value: 10 }]; - component.selectedStorageType = 'Object'; - - (component as any)._setChartData(); - - expect(component.displayData).toEqual([]); - }); - - // -------------------------------------------------- - // _setDropdownItemsAndStorageType - // -------------------------------------------------- - - it('should build dropdown items from allData', () => { - component.allData = [ - { group: 'Block', value: 10 }, - { group: 'File system', value: 20 } - ]; - - (component as any)._setDropdownItemsAndStorageType(); - - expect(component.dropdownItems).toEqual([ - { content: 'All' }, - { content: 'Block' }, - { content: 'File system' } - ]); - }); - - it('should set only ALL dropdown item when allData is empty', () => { - component.allData = []; - - (component as any)._setDropdownItemsAndStorageType(); - - expect(component.dropdownItems).toEqual([{ content: 'All' }]); - }); - - // -------------------------------------------------- - // onStorageTypeSelect - non-ALL types - // -------------------------------------------------- - - it('should set topPoolsData to null when ALL is selected', () => { - component.topPoolsData = [{ some: 'data' }]; - component.allData = []; - - component.onStorageTypeSelect({ item: { content: 'All', selected: true } }); + component.usedCapacity = 2048; - expect(component.topPoolsData).toBeNull(); + expect(component.usedRaw).toBeNull(); + expect(component.usedRawUnit).toBe(''); }); - it('should not call loadTopPools for ALL type', () => { - const spy = jest.spyOn(component as any, 'loadTopPools'); - component.allData = []; - - component.onStorageTypeSelect({ item: { content: 'All', selected: true } }); - - expect(spy).not.toHaveBeenCalled(); - }); - - it('should call loadTopPools when non-ALL type is selected', () => { - const spy = jest.spyOn(component as any, 'loadTopPools').mockImplementation(() => {}); - jest.spyOn(component as any, 'loadCounts').mockImplementation(() => {}); - component.allData = [{ group: 'Block', value: 10 }]; - - component.onStorageTypeSelect({ item: { content: 'Block', selected: true } }); - - expect(spy).toHaveBeenCalled(); - }); - - it('should call loadCounts when non-ALL type is selected', () => { - jest.spyOn(component as any, 'loadTopPools').mockImplementation(() => {}); - const spy = jest.spyOn(component as any, 'loadCounts').mockImplementation(() => {}); - component.allData = [{ group: 'Block', value: 10 }]; - - component.onStorageTypeSelect({ item: { content: 'Block', selected: true } }); - - expect(spy).toHaveBeenCalled(); - }); - - // -------------------------------------------------- - // ngOnInit - secondary calls - // -------------------------------------------------- - - it('should call loadTrend on init', () => { - const spy = jest.spyOn(component as any, 'loadTrend').mockImplementation(() => {}); - - component.ngOnInit(); - - expect(spy).toHaveBeenCalled(); - }); - - it('should call loadAverageConsumption on init', () => { - const spy = jest.spyOn(component as any, 'loadAverageConsumption').mockImplementation(() => {}); - - component.ngOnInit(); - - expect(spy).toHaveBeenCalled(); - }); - - it('should call loadTimeUntilFull on init', () => { - const spy = jest.spyOn(component as any, 'loadTimeUntilFull').mockImplementation(() => {}); + it('should not update chart options until both totalCapacity and usedCapacity are set', () => { + mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']); - component.ngOnInit(); + component.totalCapacity = 1024; - expect(spy).toHaveBeenCalled(); + expect(component.options.meter.proportional.total).toBeNull(); + expect(component.options.meter.proportional.unit).toBe(''); + expect(component.options.tooltip).toBeUndefined(); }); - // -------------------------------------------------- - // _setTotalAndUsed / options update - // -------------------------------------------------- - - it('should update options.meter.proportional.total when total is set', () => { - mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']); + it('should update chart options when both totalCapacity and usedCapacity are set', () => { + mockFormatterService.formatToBinary + .mockReturnValueOnce([20, 'TiB']) + .mockReturnValueOnce([5, 'TiB']); - component.total = 1024 * 1024; + component.totalCapacity = 1024; + component.usedCapacity = 512; expect(component.options.meter.proportional.total).toBe(20); + expect(component.options.meter.proportional.unit).toBe('TiB'); + expect(component.options.tooltip).toBeDefined(); + expect(typeof component.options.tooltip?.valueFormatter).toBe('function'); }); - it('should update options.meter.proportional.unit when total is set', () => { - mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']); - - component.total = 1024 * 1024; + it('should use used unit in tooltip formatter', () => { + mockFormatterService.formatToBinary + .mockReturnValueOnce([20, 'TiB']) + .mockReturnValueOnce([5, 'TiB']); - expect(component.options.meter.proportional.unit).toBe('TiB'); - }); + component.totalCapacity = 1024; + component.usedCapacity = 512; - it('should set tooltip valueFormatter when used is set', () => { - component.used = 512; + const formatter = component.options.tooltip?.valueFormatter as (value: number) => string; - expect(component.options.tooltip).toBeDefined(); - expect(typeof component.options.tooltip.valueFormatter).toBe('function'); + expect(formatter(12.3)).toBe('12.3 TiB'); }); - // -------------------------------------------------- - // storageMetrics defaults - // -------------------------------------------------- - - it('should have default storageMetrics with zero values', () => { - expect(component.storageMetrics.Block.metrics[0].value).toBe(0); - expect(component.storageMetrics.Block.metrics[1].value).toBe(0); - expect(component.storageMetrics['File system'].metrics[0].value).toBe(0); - expect(component.storageMetrics['File system'].metrics[1].value).toBe(0); - expect(component.storageMetrics.Object.metrics[0].value).toBe(0); - expect(component.storageMetrics.Object.metrics[1].value).toBe(0); + it('should keep default input values for presentational fields', () => { + expect(component.consumptionTrendData).toEqual([]); + expect(component.averageDailyConsumption).toBe(''); + expect(component.estimatedTimeUntilFull).toBe(''); + expect(component.breakdownData).toEqual([]); + expect(component.isBreakdownLoaded).toBe(false); }); }); 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 7fa3891e2bb..ffb33f9a41a 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 @@ -4,107 +4,32 @@ import { Component, inject, Input, - OnDestroy, - OnInit, ViewEncapsulation } from '@angular/core'; -import { - CheckboxModule, - DropdownModule, - GridModule, - TilesModule, - TooltipModule, - SkeletonModule, - LayoutModule -} from 'carbon-components-angular'; +import { GridModule, TooltipModule, SkeletonModule, LayoutModule } from 'carbon-components-angular'; import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component'; import { MeterChartComponent, MeterChartOptions } from '@carbon/charts-angular'; -import { - PrometheusService, - PromethuesGaugeMetricResult, - PromqlGuageMetric -} from '~/app/shared/api/prometheus.service'; import { FormatterService } from '~/app/shared/services/formatter.service'; -import { interval, Subject } from 'rxjs'; -import { startWith, switchMap, takeUntil } from 'rxjs/operators'; -import { OverviewStorageService } from '~/app/shared/api/storage-overview.service'; -import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; import { AreaChartComponent } from '~/app/shared/components/area-chart/area-chart.component'; -import { PieChartComponent } from '~/app/shared/components/pie-chart/pie-chart.component'; import { ComponentsModule } from '~/app/shared/components/components.module'; const CHART_HEIGHT = '45px'; -const REFRESH_INTERVAL_MS = 15_000; - -const StorageType = { - ALL: $localize`All`, - BLOCK: $localize`Block`, - FILE: $localize`File system`, - OBJECT: $localize`Object`, - SYSTEM_METADATA: $localize`System metadata` -}; - -type ChartData = { - group: string; - value: number; -}; - -const PROMQL_RAW_USED_BY_STORAGE_TYPE = - 'sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})'; - -const PROMQL_TOP_POOLS_BLOCK = ` - topk(5, - (ceph_pool_bytes_used * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Block"}) - / - (ceph_pool_max_avail * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Block"}) - ) -`; - -const PROMQL_TOP_POOLS_FILESYSTEM = ` - topk(5, - (ceph_pool_bytes_used * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Filesystem"}) - / - (ceph_pool_max_avail * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Filesystem"}) - ) -`; - -const PROMQL_TOP_POOLS_OBJECT = ` - topk(5, - (ceph_pool_bytes_used * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Object"}) - / - (ceph_pool_max_avail * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Object"}) - ) -`; - -const PROMQL_COUNT_BLOCK_POOLS = 'count(ceph_pool_metadata{application="Block"})'; - -const PROMQL_COUNT_RBD_IMAGES = 'count(ceph_rbd_image_metadata)'; - -const PROMQL_COUNT_FILESYSTEMS = 'count(ceph_fs_metadata)'; - -const PROMQL_COUNT_FILESYSTEM_POOLS = 'count(ceph_pool_metadata{application="Filesystem"})'; - -const TopPoolsQueryMap = { - Block: PROMQL_TOP_POOLS_BLOCK, - 'File system': PROMQL_TOP_POOLS_FILESYSTEM, - Object: PROMQL_TOP_POOLS_OBJECT +type TrendPoint = { + timestamp: Date; + values: { Used: number }; }; @Component({ selector: 'cd-overview-storage-card', imports: [ GridModule, - TilesModule, ProductiveCardComponent, MeterChartComponent, - CheckboxModule, - DropdownModule, TooltipModule, SkeletonModule, LayoutModule, AreaChartComponent, - PieChartComponent, ComponentsModule ], standalone: true, @@ -113,37 +38,39 @@ const TopPoolsQueryMap = { encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush }) -export class OverviewStorageCardComponent implements OnInit, OnDestroy { - private readonly prometheusService = inject(PrometheusService); +export class OverviewStorageCardComponent { private readonly formatterService = inject(FormatterService); - private readonly overviewStorageService = inject(OverviewStorageService); - private readonly rgw = inject(RgwBucketService); private readonly cdr = inject(ChangeDetectorRef); - private destroy$ = new Subject(); - trendData: { timestamp: Date; values: { Used: number } }[]; - totalUsed: number; @Input() - set total(value: number) { + set totalCapacity(value: number) { const [totalValue, totalUnit] = this.formatterService.formatToBinary(value, true); if (Number.isNaN(totalValue)) return; this.totalRaw = totalValue; this.totalRawUnit = totalUnit; - this._setTotalAndUsed(); + this.updateChartOptions(); } + @Input() - set used(value: number) { - this.totalUsed = value; + set usedCapacity(value: number) { const [usedValue, usedUnit] = this.formatterService.formatToBinary(value, true); if (Number.isNaN(usedValue)) return; this.usedRaw = usedValue; this.usedRawUnit = usedUnit; - this._setTotalAndUsed(); + this.updateChartOptions(); } - totalRaw: number; - usedRaw: number; - totalRawUnit: string; - usedRawUnit: string; + + @Input() consumptionTrendData: TrendPoint[] = []; + @Input() averageDailyConsumption = ''; + @Input() estimatedTimeUntilFull = ''; + @Input() breakdownData: { group: string; value: number }[] = []; + @Input() isBreakdownLoaded = false; + + totalRaw: number | null = null; + usedRaw: number | null = null; + totalRawUnit = ''; + usedRawUnit = ''; + options: MeterChartOptions = { height: CHART_HEIGHT, meter: { @@ -163,44 +90,16 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy { } } }; - allData: ChartData[] = null; - displayData: ChartData[] = null; - displayUsedRaw: number; - selectedStorageType: string = StorageType.ALL; - dropdownItems = [ - { content: StorageType.ALL }, - { content: StorageType.BLOCK }, - { content: StorageType.FILE }, - { content: StorageType.OBJECT } - ]; - topPoolsData = null; - storageMetrics = { - Block: { - metrics: [ - { label: 'block pools', value: 0 }, - { label: 'volumes', value: 0 } - ] - }, - 'File system': { - metrics: [ - { label: 'filesystems', value: 0 }, - { label: 'filesystem pools', value: 0 } - ] - }, - Object: { - metrics: [ - { label: 'buckets', value: 0 }, - { label: 'object pools', value: 0 } - ] + private updateChartOptions() { + if ( + this.totalRaw === null || + this.usedRaw === null || + !this.totalRawUnit || + !this.usedRawUnit + ) { + return; } - }; - - averageConsumption = ''; - timeUntilFull = ''; - - private _setTotalAndUsed() { - // Chart reacts to 'options' and 'data' object changes only, hence mandatory to replace whole object. this.options = { ...this.options, meter: { @@ -215,229 +114,11 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy { valueFormatter: (value) => `${value.toLocaleString()} ${this.usedRawUnit}` } }; - this._updateCard(); - } - - private _getAllData(data: PromqlGuageMetric) { - const result = data?.result ?? []; - - const chartData: ChartData[] = []; - const storageTypeValues = Object.values(StorageType); - - let assignedBytes = 0; - let nonAssignedBytes = 0; - - result.forEach((r: PromethuesGaugeMetricResult) => { - let group = r?.metric?.application; - const rawBytes = Number(r?.value?.[1]); - - if (group === 'Filesystem') { - group = StorageType.FILE; - } - - if (storageTypeValues.includes(group) && group !== StorageType.SYSTEM_METADATA) { - assignedBytes += rawBytes; - - const value = this.formatterService.convertToUnit( - rawBytes.toString(), - 'B', - this.usedRawUnit, - 1 - ); - - chartData.push({ - group, - value - }); - } else { - nonAssignedBytes += rawBytes; - } - }); - - const miscBytes = this.totalUsed - assignedBytes + nonAssignedBytes; - if (miscBytes > 0) { - chartData.push({ - group: StorageType.SYSTEM_METADATA, - value: this.formatterService.convertToUnit(miscBytes.toString(), 'B', this.totalRawUnit, 3) - }); - } - return chartData; + this.markForCheck(); } - private _setChartData() { - if (this.selectedStorageType === StorageType.ALL) { - this.displayData = this.allData; - this.displayUsedRaw = this.usedRaw; - } else { - this.displayData = this.allData?.filter( - (d: ChartData) => d.group === this.selectedStorageType - ); - this.displayUsedRaw = this.displayData?.[0]?.value; - } - } - - private _setDropdownItemsAndStorageType() { - const newData = this.allData - ?.filter((data) => data.group !== StorageType.SYSTEM_METADATA) - .map((data) => ({ content: data.group })); - if (newData.length) { - this.dropdownItems = [{ content: StorageType.ALL }, ...newData]; - } else { - this.dropdownItems = [{ content: StorageType.ALL }]; - } - } - - private _updateCard() { + private markForCheck() { this.cdr.markForCheck(); } - - private loadTrend() { - const now = Math.floor(Date.now() / 1000); - const range = { start: now - 7 * 86400, end: now, step: 3600 }; - - this.overviewStorageService - .getTrendData(range.start, range.end, range.step) - .pipe(takeUntil(this.destroy$)) - .subscribe((result) => { - const values = result?.TOTAL_RAW_USED ?? []; - this.trendData = values.map(([ts, val]) => ({ - timestamp: new Date(ts * 1000), - values: { Used: Number(val) } - })); - this.cdr.markForCheck(); - }); - } - - private loadAverageConsumption() { - this.overviewStorageService - .getAverageConsumption() - .pipe(takeUntil(this.destroy$)) - .subscribe((v) => { - this.averageConsumption = v; - this.cdr.markForCheck(); - }); - } - - private loadTimeUntilFull() { - this.overviewStorageService - .getTimeUntilFull() - .pipe(takeUntil(this.destroy$)) - .subscribe((v) => { - this.timeUntilFull = v; - this.cdr.markForCheck(); - }); - } - - private loadTopPools() { - const query = TopPoolsQueryMap[this.selectedStorageType]; - if (!query) return; - - this.overviewStorageService - .getTopPools(query) - .pipe(takeUntil(this.destroy$)) - .subscribe((data) => { - this.topPoolsData = data; - this.cdr.markForCheck(); - }); - } - - private loadCounts() { - const type = this.selectedStorageType; - - if (type === StorageType.BLOCK) { - this.overviewStorageService - .getCount(PROMQL_COUNT_BLOCK_POOLS) - .pipe(takeUntil(this.destroy$)) - .subscribe((value) => { - this.storageMetrics.Block.metrics[0].value = value; - this.cdr.markForCheck(); - }); - - this.overviewStorageService - .getCount(PROMQL_COUNT_RBD_IMAGES) - .pipe(takeUntil(this.destroy$)) - .subscribe((value) => { - this.storageMetrics.Block.metrics[1].value = value; - this.cdr.markForCheck(); - }); - } - - if (type === StorageType.FILE) { - this.overviewStorageService - .getCount(PROMQL_COUNT_FILESYSTEMS) - .pipe(takeUntil(this.destroy$)) - .subscribe((value) => { - this.storageMetrics['File system'].metrics[0].value = value; - this.cdr.markForCheck(); - }); - - this.overviewStorageService - .getCount(PROMQL_COUNT_FILESYSTEM_POOLS) - .pipe(takeUntil(this.destroy$)) - .subscribe((value) => { - this.storageMetrics['File system'].metrics[1].value = value; - this.cdr.markForCheck(); - }); - } - - if (type === StorageType.OBJECT) { - this.overviewStorageService - .getObjectCounts(this.rgw) - .pipe(takeUntil(this.destroy$)) - .subscribe((value) => { - this.storageMetrics.Object.metrics[0].value = value.buckets; - this.storageMetrics.Object.metrics[1].value = value.pools; - this.cdr.markForCheck(); - }); - } - } - - onStorageTypeSelect(event: any) { - this.selectedStorageType = event?.item?.content; - this._setChartData(); - - if (this.selectedStorageType === StorageType.ALL) { - this.loadTrend(); - this.topPoolsData = null; - } else { - this.loadTopPools(); - this.loadCounts(); - } - } - - ngOnInit() { - interval(REFRESH_INTERVAL_MS) - .pipe( - startWith(0), - switchMap(() => - this.prometheusService.getPrometheusQueryData({ - params: PROMQL_RAW_USED_BY_STORAGE_TYPE - }) - ), - takeUntil(this.destroy$) - ) - .subscribe((data: PromqlGuageMetric) => { - this.allData = this._getAllData(data); - this._setDropdownItemsAndStorageType(); - this._setChartData(); - this._updateCard(); - if (this.selectedStorageType === StorageType.ALL) { - this.loadAverageConsumption(); - this.loadTimeUntilFull(); - } else { - this.loadTopPools(); - } - - this.cdr.markForCheck(); - }); - this.loadTrend(); - this.loadAverageConsumption(); - this.loadTimeUntilFull(); - } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } 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 f10fa9eabfa..5a7cd8a0f60 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 @@ -1,9 +1,21 @@ import { Injectable, inject } from '@angular/core'; -import { PrometheusService, PromqlGuageMetric } from '~/app/shared/api/prometheus.service'; +import { + PrometheusService, + PromethuesGaugeMetricResult, + PromqlGuageMetric +} from '~/app/shared/api/prometheus.service'; import { FormatterService } from '~/app/shared/services/formatter.service'; import { map } from 'rxjs/operators'; import { forkJoin, Observable } from 'rxjs'; +const StorageType = { + BLOCK: $localize`Block`, + FILE: $localize`File system`, + OBJECT: $localize`Object` +} as const; + +const CHART_GROUP_LABELS = new Set([StorageType.BLOCK, StorageType.FILE, StorageType.OBJECT]); + @Injectable({ providedIn: 'root' }) export class OverviewStorageService { private readonly prom = inject(PrometheusService); @@ -12,6 +24,8 @@ export class OverviewStorageService { private readonly TIME_UNTIL_FULL_QUERY = `(sum(ceph_osd_stat_bytes)) / (sum(rate(ceph_osd_stat_bytes_used[7d])) * 86400)`; private readonly TOTAL_RAW_USED_QUERY = 'sum(ceph_osd_stat_bytes_used)'; private readonly OBJECT_POOLS_COUNT_QUERY = 'count(ceph_pool_metadata{application="Object"})'; + private readonly RAW_USED_BY_STORAGE_TYPE_QUERY = + 'sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})'; getTrendData(start: number, end: number, stepSec: number) { const range = { @@ -81,4 +95,33 @@ export class OverviewStorageService { })) ); } + + convertBytesToUnit(value: string, unit: string): number { + return this.formatter.convertToUnit(value, 'B', unit, 1); + } + + getStorageBreakdown(): Observable { + return this.prom.getPrometheusQueryData({ params: this.RAW_USED_BY_STORAGE_TYPE_QUERY }); + } + formatBytesForChart(value: number): [number, string] { + return this.formatter.formatToBinary(value, true); + } + + mapStorageChartData(data: PromqlGuageMetric, unit: string): { group: string; value: number }[] { + if (!unit) return []; + + const result = data?.result ?? []; + + return result + .map((r: PromethuesGaugeMetricResult) => { + const group = r?.metric?.application; + const value = r?.value?.[1]; + + return { + group: group === 'Filesystem' ? StorageType.FILE : group, + value: this.convertBytesToUnit(value, unit) + }; + }) + .filter((item) => CHART_GROUP_LABELS.has(item.group) && item.value > 0); + } } 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 73a414e3086..746b1a7905a 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 @@ -87,6 +87,16 @@ export interface HealthCardVM { hosts: HealthCardSubStateVM; } +export interface StorageCardVM { + totalCapacity: number | null; + usedCapacity: number | null; + breakdownData: { group: string; value: number }[]; + isBreakdownLoaded: boolean; + consumptionTrendData: { timestamp: Date; values: { Used: number } }[]; + averageDailyConsumption: string; + estimatedTimeUntilFull: string; +} + // Constants const WarnAndErrMessage = $localize`There are active alerts and unresolved health warnings.`; 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 91ec7a17e9f..8d8b963eacf 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 @@ -191,7 +191,7 @@ export class FormatterService { decimals: number = 1 ): string | [number, string] { const convertedString = this.format_number(num, BINARY_FACTOR, BINARY_UNITS, decimals); - const FALLBACK: [number, string] = [0, BINARY_UNITS[0]]; // when convertedString is 'N/A', '-', or 'NaN', return [0, 'B'] + const FALLBACK: [number, string] = [NaN, BINARY_UNITS[0]]; // when convertedString is 'N/A', '-', or 'NaN', return [NaN, 'B'] if (!split) return convertedString; const parts = convertedString.trim().split(/\s+/);