-@let storage = (storageVm$ | async);
+@let storageCard = (storageCardVm$ | async);
@let health = (healthCardVm$ | async);
<div cdsGrid
[fullWidth]="true"
class="cds-mb-5"
[columnNumbers]="{lg: 16}">
<cd-overview-storage-card
- [total]="storage?.total"
- [used]="storage?.used">
+ [totalCapacity]="storageCard?.totalCapacity"
+ [usedCapacity]="storageCard?.usedCapacity"
+ [consumptionTrendData]="storageCard?.consumptionTrendData ?? []"
+ [averageDailyConsumption]="storageCard?.averageDailyConsumption ?? ''"
+ [estimatedTimeUntilFull]="storageCard?.estimatedTimeUntilFull ?? ''"
+ [breakdownData]="storageCard?.breakdownData ?? []"
+ [isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false">
</cd-overview-storage-card>
</div>
</div>
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;
let mockHealthService: { getHealthSnapshot: jest.Mock };
let mockRefreshIntervalService: { intervalData$: Subject<void> };
+ 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 } }))
mockHealthService = { getHealthSnapshot: jest.fn() };
mockRefreshIntervalService = { intervalData$: new Subject<void>() };
+ 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,
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();
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: {
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,
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,
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,
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();
});
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')));
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();
expect(component.isHealthPanelOpen).toBe(false);
});
- // -----------------------------
- // ngOnDestroy
- // -----------------------------
it('should complete destroy$', () => {
expect(() => fixture.destroy()).not.toThrow();
});
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';
SEVERITY,
Severity,
SEVERITY_TO_COLOR,
- SeverityIconMap
+ SeverityIconMap,
+ StorageCardVM
} from '~/app/shared/models/overview';
import { OverviewStorageCardComponent } from './storage-card/overview-storage-card.component';
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
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<HealthSnapshotMap> = this.refreshIntervalObs(() =>
this.healthService.getHealthSnapshot()
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
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<StorageCardVM> = 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<T>(fn: () => Observable<T>): Observable<T> {
return this.refreshIntervalService.intervalData$.pipe(
exhaustMap(() => fn().pipe(catchError(() => EMPTY))),
<ng-template #header>
<h2 class="cds--type-heading-compact-02"
i18n>Storage Overview</h2>
- <cds-dropdown
- label="Storage type"
- class="overview-storage-card-dropdown"
- i18n-label
- size="sm"
- [disabled]="!(displayData && usedRaw && totalRaw && usedRawUnit && totalRawUnit)"
- [placeholder]="selectedStorageType"
- (selected)="onStorageTypeSelect($event)">
- <cds-dropdown-list [items]="dropdownItems"></cds-dropdown-list>
- </cds-dropdown>
</ng-template>
<!-- CAPACITY USAGE TEXT -->
<div class="overview-storage-card-usage-text">
- @if( totalRaw && usedRawUnit && totalRawUnit) {
+ @if( usedRaw !== null && totalRaw !== null && usedRawUnit && totalRawUnit) {
<h5>
<span
class="cds--type-heading-05"
- i18n>{{displayUsedRaw}}&ngsp;</span>
+ i18n>{{usedRaw}} </span>
<span
class="cds--type-body-02"
i18n>{{usedRawUnit}} of {{totalRaw}} {{totalRawUnit}} used</span>
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
>
<cd-icon type="help"></cd-icon>
</cds-tooltip>
</cds-skeleton-text>
}
</div>
- <!-- CAPACITY CHART -->
- @if(displayData) {
+ <!-- CAPACITY BREAKDOWN CHART -->
+ @if(isBreakdownLoaded) {
<ibm-meter-chart
[options]="options"
- [data]="displayData"
+ [data]="breakdownData"
class="overview-storage-card-chart"></ibm-meter-chart>
- }
- <!-- TREND CHARTS AND TOP POOLS -->
- @if(selectedStorageType === 'All' && trendData) {
+ } @else {
+ <cds-skeleton-text
+ [lines]="1"
+ [minLineWidth]="1200"
+ [maxLineWidth]="1200">
+ </cds-skeleton-text>
+}
+ <!-- DATA CONSUMPTION TREND -->
+ @if(consumptionTrendData.length) {
<div cdsRow
class="align-items-center cds-ml-2 cds-mt-6">
<div cdsCol
[chartKey]="'Consumption trend'"
[dataUnit]="'B'"
[legendEnabled]="false"
- [rawData]="trendData"
+ [rawData]="consumptionTrendData"
[subHeading]="'Shows last 7 days of storage consumption trends based on recent usage'"
[height]="'200px'"
[chartType]="'area'">
gap="4">
<div cdsStack="vertical"
gap="1">
- <span class="cds--type-heading-03">{{ timeUntilFull }}</span>
+ <span class="cds--type-heading-03">{{ estimatedTimeUntilFull }}</span>
<div class="consumption-stats-wrapper">
<cds-tooltip-definition
[autoAlign]="true"
</div>
<div cdsStack="vertical"
gap="1">
- <span class="cds--type-heading-03">{{ averageConsumption }}</span>
+ <span class="cds--type-heading-03">{{ averageDailyConsumption }}</span>
<div class="consumption-stats-wrapper">
<cds-tooltip-definition
[autoAlign]="true"
</div>
</div>
}
- @if (selectedStorageType !== 'All' && topPoolsData) {
- @if (topPoolsData) {
- <div cdsRow
- class="align-items-center">
- <div cdsCol
- [columnNumbers]="{ lg: 4, md: 8, sm: 16 }">
- <div cdsStack="vertical"
- gap="4"
- class="cds-mb-3 cds-ml-3">
- @for(metric of storageMetrics[selectedStorageType].metrics; track metric.label){
- <div class="cds-mb-5 cds-ml-3">
- <span class="cds--type-heading-05">{{metric.value}}</span>
- <span class="cds--type-heading-03 cds-ml-3">{{metric.label}}</span>
- </div>
- }
- </div>
- </div>
- <div cdsCol
- [columnNumbers]="{ lg: 6, md: 8, sm: 16 }">
- <cd-pie-chart
- [chartData]="topPoolsData"
- [data]="topPoolsData"
- [title]="'Top 5 ' + selectedStorageType + ' pools'"
- [legendPosition]="'right'"
- [showPercentage]="true"
- [height]="'250px'">
- </cd-pie-chart>
- </div>
- </div>
- }
- @else {
- <cds-skeleton-text
- [lines]="1"
- [minLineWidth]="1025"
- [maxLineWidth]="1025">
- </cds-skeleton-text>
- <cds-skeleton-text
- [lines]="1"
- [minLineWidth]="200"
- [maxLineWidth]="200">
- </cds-skeleton-text>
- }
- }
</cd-productive-card>
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<OverviewStorageCardComponent>;
- 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);
});
});
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,
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<void>();
- 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: {
}
}
};
- 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: {
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();
- }
}
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);
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 = {
}))
);
}
+
+ convertBytesToUnit(value: string, unit: string): number {
+ return this.formatter.convertToUnit(value, 'B', unit, 1);
+ }
+
+ getStorageBreakdown(): Observable<PromqlGuageMetric> {
+ 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);
+ }
}
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.`;
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+/);