[averageDailyConsumption]="storageCard?.averageDailyConsumption ?? ''"
[estimatedTimeUntilFull]="storageCard?.estimatedTimeUntilFull ?? ''"
[breakdownData]="storageCard?.breakdownData ?? []"
- [isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false">
+ [isBreakdownLoaded]="storageCard?.isBreakdownLoaded ?? false"
+ [threshold]="storageCard?.threshold">
</cd-overview-storage-card>
</div>
</div>
getStorageBreakdown: jest.Mock;
formatBytesForChart: jest.Mock;
mapStorageChartData: jest.Mock;
+ getThresholdStatus: jest.Mock;
+ getRawCapacityThresholds: jest.Mock;
};
const mockAuthStorageService = {
mapStorageChartData: jest.fn().mockReturnValue([
{ group: 'Block', value: 1 },
{ group: 'File system', value: 2 }
- ])
+ ]),
+ getThresholdStatus: jest.fn().mockReturnValue(null),
+ getRawCapacityThresholds: jest.fn().mockReturnValue(
+ of({
+ osdFullRatio: 0.99,
+ osdNearfullRatio: 0.85
+ })
+ )
};
await TestBed.configureTestingModule({
mockHealthService.getHealthSnapshot.mockReturnValue(of(mockData));
const sub = component.storageCardVm$.subscribe((vm) => {
+ if (!vm.isBreakdownLoaded || !vm.averageDailyConsumption || !vm.estimatedTimeUntilFull) {
+ return;
+ }
expect(vm.totalCapacity).toBe(325343772672);
expect(vm.usedCapacity).toBe(3236978688);
expect(vm.breakdownData).toEqual([
]);
expect(vm.averageDailyConsumption).toBe('12 GiB/day');
expect(vm.estimatedTimeUntilFull).toBe('30 days');
+ expect(vm.threshold).toBe(null);
expect(mockOverviewStorageService.formatBytesForChart).toHaveBeenCalledWith(3236978688);
expect(mockOverviewStorageService.mapStorageChartData).toHaveBeenCalled();
this.overviewStorageService.getStorageBreakdown()
).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+ readonly capacityThresholds$ = this.refreshIntervalObs(() =>
+ this.overviewStorageService.getRawCapacityThresholds()
+ ).pipe(shareReplay({ bufferSize: 1, refCount: true }));
+
// getTrendData() is already a polling stream through getRangeQueriesData()
// hence no refresh needed.
readonly trendData$ = this.overviewStorageService
this.breakdownRawData$.pipe(startWith(null)),
this.trendData$.pipe(startWith([])),
this.averageConsumption$.pipe(startWith('')),
- this.timeUntilFull$.pipe(startWith(''))
+ this.timeUntilFull$.pipe(startWith('')),
+ this.capacityThresholds$.pipe(startWith({ osdFullRatio: null, osdNearfullRatio: null }))
]).pipe(
map(
([
breakdownRawData,
consumptionTrendData,
averageDailyConsumption,
- estimatedTimeUntilFull
+ estimatedTimeUntilFull,
+ capacityThresholds
]) => {
+ const total = storage?.total ?? 0;
const used = storage?.used ?? 0;
const [, unit] = this.overviewStorageService.formatBytesForChart(used);
return {
- totalCapacity: storage?.total,
- usedCapacity: storage?.used,
+ totalCapacity: total,
+ usedCapacity: used,
breakdownData: breakdownRawData
- ? this.overviewStorageService.mapStorageChartData(breakdownRawData, unit)
+ ? this.overviewStorageService.mapStorageChartData(breakdownRawData, unit, used)
: [],
isBreakdownLoaded: !!breakdownRawData,
consumptionTrendData,
averageDailyConsumption,
- estimatedTimeUntilFull
+ estimatedTimeUntilFull,
+ threshold: this.overviewStorageService.getThresholdStatus(
+ total,
+ storage?.used,
+ capacityThresholds.osdNearfullRatio,
+ capacityThresholds.osdFullRatio
+ )
};
}
),
-<cd-productive-card>
+<cd-productive-card class="overview-storage-card">
<!-- STORAGE CARD HEADER -->
<ng-template #header>
<h2 class="cds--type-heading-compact-02"
- i18n>Storage Overview</h2>
+ i18n>Storage overview</h2>
</ng-template>
<!-- CAPACITY USAGE TEXT -->
<div class="overview-storage-card-usage-text">
class="cds--type-body-02"
i18n>{{usedRawUnit}} of {{totalRaw}} {{totalRawUnit}} used</span>
<cds-tooltip
- class="cds-ml-3"
+ class="cds-ml-3 cds-mr-3"
[caret]="true"
- description="Shows raw used vs. total raw capacity. Raw capacity includes all physical storage before replication or overhead."
+ description="Shows raw used and total capacity. Raw capacity includes all physical storage before replication or overhead."
i18n-description
>
<cd-icon type="help"></cd-icon>
</cds-tooltip>
+ @if(threshold === 'high') {
+ <cds-tag size="md">
+ <div cdsTagIcon>
+ <cd-icon
+ type="warningAltFilled"
+ useDefault="true"></cd-icon>
+ </div>
+ <span i18n>High storage usage</span>
+ </cds-tag>
+ }
+ @else if(threshold === 'critical') {
+ <cds-tag
+ type="red"
+ size="md">
+ <div cdsTagIcon>
+ <cd-icon
+ type="warningAltFilled"
+ useDefault="true"></cd-icon>
+ </div>
+ <span i18n>Capacity critically low</span>
+ </cds-tag>
+ }
</h5>
}
@else {
display: none !important;
}
}
+
+ .cds--tag--gray {
+ background-color: #fddc69;
+ color: var(--cds-text-secondary);
+ }
}
.consumption-stats-wrapper {
let mockFormatterService: {
formatToBinary: jest.Mock;
+ convertToUnit: jest.Mock;
};
beforeEach(async () => {
mockFormatterService = {
- formatToBinary: jest.fn().mockReturnValue([10, 'GiB'])
+ formatToBinary: jest.fn((value: number) => {
+ if (value === 1024) return [20, 'TiB'];
+ if (value === 512) return [5, 'TiB'];
+ if (value === 256) return [5, 'MiB'];
+ return [10, 'GiB'];
+ }),
+ convertToUnit: jest.fn((value: number, fromUnit: string, toUnit: string) => {
+ if (value === 20 && fromUnit === 'TiB' && toUnit === 'TiB') return 20;
+ if (value === 20 && fromUnit === 'TiB' && toUnit === 'MiB') return 20;
+ return value;
+ })
};
await TestBed.configureTestingModule({
});
afterEach(() => {
- jest.clearAllMocks();
+ jest.resetAllMocks();
});
it('should create', () => {
- fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should set totalCapacity when valid value is provided', () => {
component.totalCapacity = 1024;
- expect(component.totalRaw).toBe(10);
- expect(component.totalRawUnit).toBe('GiB');
+ expect(component.totalRaw).toBe(20);
+ expect(component.totalRawUnit).toBe('TiB');
expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(1024, true);
});
});
it('should set usedCapacity when valid value is provided', () => {
- component.usedCapacity = 2048;
+ component.usedCapacity = 512;
- expect(component.usedRaw).toBe(10);
- expect(component.usedRawUnit).toBe('GiB');
- expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(2048, true);
+ expect(component.usedRaw).toBe(5);
+ expect(component.usedRawUnit).toBe('TiB');
+ expect(mockFormatterService.formatToBinary).toHaveBeenCalledWith(512, true);
});
it('should not set usedCapacity when formatter returns NaN', () => {
mockFormatterService.formatToBinary.mockReturnValue([NaN, 'GiB']);
- component.usedCapacity = 2048;
+ component.usedCapacity = 512;
expect(component.usedRaw).toBeNull();
expect(component.usedRawUnit).toBe('');
});
it('should not update chart options until both totalCapacity and usedCapacity are set', () => {
- mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']);
-
component.totalCapacity = 1024;
expect(component.options.meter.proportional.total).toBeNull();
});
it('should update chart options when both totalCapacity and usedCapacity are set', () => {
- mockFormatterService.formatToBinary
- .mockReturnValueOnce([20, 'TiB'])
- .mockReturnValueOnce([5, 'TiB']);
-
component.totalCapacity = 1024;
component.usedCapacity = 512;
+ expect(mockFormatterService.convertToUnit).toHaveBeenCalledWith(20, 'TiB', 'TiB', 1);
expect(component.options.meter.proportional.total).toBe(20);
expect(component.options.meter.proportional.unit).toBe('TiB');
expect(component.options.tooltip).toBeDefined();
});
it('should use used unit in tooltip formatter', () => {
- mockFormatterService.formatToBinary
- .mockReturnValueOnce([20, 'TiB'])
- .mockReturnValueOnce([5, 'TiB']);
+ mockFormatterService.formatToBinary.mockImplementation((value: number) => {
+ if (value === 1024) return [20, 'TiB'];
+ if (value === 512) return [5, 'MiB'];
+ return [10, 'GiB'];
+ });
component.totalCapacity = 1024;
component.usedCapacity = 512;
const formatter = component.options.tooltip?.valueFormatter as (value: number) => string;
- expect(formatter(12.3)).toBe('12.3 TiB');
+ expect(component.usedRawUnit).toBe('MiB');
+ expect(component.options.meter.proportional.unit).toBe('MiB');
+ expect(formatter(12.3)).toBe('12.3 MiB');
});
it('should keep default input values for presentational fields', () => {
Input,
ViewEncapsulation
} from '@angular/core';
-import { GridModule, TooltipModule, SkeletonModule, LayoutModule } from 'carbon-components-angular';
+import {
+ GridModule,
+ TooltipModule,
+ SkeletonModule,
+ LayoutModule,
+ TagModule
+} from 'carbon-components-angular';
import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
import { MeterChartComponent, MeterChartOptions } from '@carbon/charts-angular';
import { FormatterService } from '~/app/shared/services/formatter.service';
import { AreaChartComponent } from '~/app/shared/components/area-chart/area-chart.component';
import { ComponentsModule } from '~/app/shared/components/components.module';
+import { BreakdownChartData, CapacityThreshold, TrendPoint } from '~/app/shared/models/overview';
const CHART_HEIGHT = '45px';
-type TrendPoint = {
- timestamp: Date;
- values: { Used: number };
-};
-
@Component({
selector: 'cd-overview-storage-card',
imports: [
SkeletonModule,
LayoutModule,
AreaChartComponent,
- ComponentsModule
+ ComponentsModule,
+ TagModule
],
standalone: true,
templateUrl: './overview-storage-card.component.html',
@Input() consumptionTrendData: TrendPoint[] = [];
@Input() averageDailyConsumption = '';
@Input() estimatedTimeUntilFull = '';
- @Input() breakdownData: { group: string; value: number }[] = [];
+ @Input() breakdownData: BreakdownChartData[] = [];
@Input() isBreakdownLoaded = false;
+ @Input() threshold: CapacityThreshold;
totalRaw: number | null = null;
usedRaw: number | null = null;
) {
return;
}
+
+ const totalInUsedUnit = this.formatterService.convertToUnit(
+ this.totalRaw,
+ this.totalRawUnit,
+ this.usedRawUnit,
+ 1
+ );
this.options = {
...this.options,
meter: {
...this.options.meter,
proportional: {
...this.options.meter.proportional,
- total: this.totalRaw,
- unit: this.totalRawUnit
+ total: totalInUsedUnit,
+ unit: this.usedRawUnit
}
},
tooltip: {
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
+import { of } from 'rxjs';
import { configureTestBed } from '~/testing/unit-test-helper';
import { OverviewStorageService } from './storage-overview.service';
service = TestBed.inject(OverviewStorageService);
});
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('getTrendData', () => {
it('should call getRangeQueriesData with correct params', () => {
const promSpy = jest.spyOn(service['prom'], 'getRangeQueriesData').mockReturnValue({} as any);
+
service.getTrendData(1000, 2000, 60);
+
expect(promSpy).toHaveBeenCalledWith(
{ start: 1000, end: 2000, step: 60 },
{ TOTAL_RAW_USED: 'sum(ceph_osd_stat_bytes_used)' },
it('should format bytes per day correctly', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '1073741824'] }] }));
+ .mockReturnValue(of({ result: [{ value: [null, '1073741824'] }] }) as any);
jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['1.0', 'GiB'] as any);
service.getAverageConsumption().subscribe((result) => {
it('should return 0 formatted when no result', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+ .mockReturnValue(of({ result: [] }) as any);
jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['0', 'B'] as any);
service.getAverageConsumption().subscribe((result) => {
});
it('should handle null response gracefully', (done) => {
- jest
- .spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)(null));
+ jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(of(null) as any);
jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['0', 'B'] as any);
service.getAverageConsumption().subscribe((result) => {
it('should return N/A when days is Infinity', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+ .mockReturnValue(of({ result: [] }) as any);
service.getTimeUntilFull().subscribe((result) => {
expect(result).toBe('N/A');
it('should return hours when days < 1', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '0.5'] }] }));
+ .mockReturnValue(of({ result: [{ value: [null, '0.5'] }] }) as any);
service.getTimeUntilFull().subscribe((result) => {
expect(result).toBe('12.0 hours');
it('should return days when 1 <= days < 30', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '15'] }] }));
+ .mockReturnValue(of({ result: [{ value: [null, '15'] }] }) as any);
service.getTimeUntilFull().subscribe((result) => {
expect(result).toBe('15.0 days');
});
});
- it('should return months when days >= 30', (done) => {
+ it('should return months when days >= 30 and < 365', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '60'] }] }));
+ .mockReturnValue(of({ result: [{ value: [null, '60'] }] }) as any);
service.getTimeUntilFull().subscribe((result) => {
expect(result).toBe('2.0 months');
});
});
+ it('should return years when days >= 365', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(of({ result: [{ value: [null, '730'] }] }) as any);
+
+ service.getTimeUntilFull().subscribe((result) => {
+ expect(result).toBe('2.0 years');
+ done();
+ });
+ });
+
it('should return N/A when days <= 0', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '-5'] }] }));
+ .mockReturnValue(of({ result: [{ value: [null, '-5'] }] }) as any);
service.getTimeUntilFull().subscribe((result) => {
expect(result).toBe('N/A');
describe('getTopPools', () => {
it('should map pool results with name', (done) => {
jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
- new (require('rxjs').of)({
+ of({
result: [{ metric: { name: 'mypool' }, value: [null, '0.5'] }]
- })
+ }) as any
);
service.getTopPools('some_query').subscribe((result) => {
it('should fallback to pool label when name is absent', (done) => {
jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
- new (require('rxjs').of)({
+ of({
result: [{ metric: { pool: 'fallback_pool' }, value: [null, '0.25'] }]
- })
+ }) as any
);
service.getTopPools('some_query').subscribe((result) => {
});
});
- it('should use "unknown" when no name or pool label', (done) => {
+ it('should use unknown when no name or pool label', (done) => {
jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
- new (require('rxjs').of)({
+ of({
result: [{ metric: {}, value: [null, '0.1'] }]
- })
+ }) as any
);
service.getTopPools('some_query').subscribe((result) => {
it('should return empty array when result is empty', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+ .mockReturnValue(of({ result: [] }) as any);
service.getTopPools('some_query').subscribe((result) => {
expect(result).toEqual([]);
it('should return numeric count from query result', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '42'] }] }));
+ .mockReturnValue(of({ result: [{ value: [null, '42'] }] }) as any);
service.getCount('some_query').subscribe((result) => {
expect(result).toBe(42);
it('should return 0 when result is empty', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+ .mockReturnValue(of({ result: [] }) as any);
service.getCount('some_query').subscribe((result) => {
expect(result).toBe(0);
});
it('should return 0 when response is null', (done) => {
- jest
- .spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)(null));
+ jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(of(null) as any);
service.getCount('some_query').subscribe((result) => {
expect(result).toBe(0);
it('should return bucket and pool counts', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '3'] }] }));
+ .mockReturnValue(of({ result: [{ value: [null, '3'] }] }) as any);
const mockRgwService = {
- getTotalBucketsAndUsersLength: () => new (require('rxjs').of)({ buckets_count: 10 })
+ getTotalBucketsAndUsersLength: () => of({ buckets_count: 10 })
};
service.getObjectCounts(mockRgwService).subscribe((result) => {
it('should default buckets to 0 when buckets_count is missing', (done) => {
jest
.spyOn(service['prom'], 'getPrometheusQueryData')
- .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '2'] }] }));
+ .mockReturnValue(of({ result: [{ value: [null, '2'] }] }) as any);
const mockRgwService = {
- getTotalBucketsAndUsersLength: () => new (require('rxjs').of)({})
+ getTotalBucketsAndUsersLength: () => of({})
};
service.getObjectCounts(mockRgwService).subscribe((result) => {
});
});
});
+
+ describe('getStorageBreakdown', () => {
+ it('should call getPrometheusQueryData with storage breakdown query', () => {
+ const promSpy = jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(of({}) as any);
+
+ service.getStorageBreakdown().subscribe();
+
+ expect(promSpy).toHaveBeenCalledWith({
+ params:
+ 'sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})'
+ });
+ });
+ });
+
+ describe('formatBytesForChart', () => {
+ it('should delegate to formatter.formatToBinary', () => {
+ const formatterSpy = jest
+ .spyOn(service['formatter'], 'formatToBinary')
+ .mockReturnValue([3, 'GiB'] as any);
+
+ const result = service.formatBytesForChart(3221225472);
+
+ expect(formatterSpy).toHaveBeenCalledWith(3221225472, true);
+ expect(result).toEqual([3, 'GiB']);
+ });
+ });
+
+ describe('convertBytesToUnit', () => {
+ it('should delegate to formatter.convertToUnit', () => {
+ const formatterSpy = jest.spyOn(service['formatter'], 'convertToUnit').mockReturnValue(12.5);
+
+ const result = service.convertBytesToUnit(13421772800, 'GiB');
+
+ expect(formatterSpy).toHaveBeenCalledWith(13421772800, 'B', 'GiB', 1);
+ expect(result).toBe(12.5);
+ });
+ });
+
+ describe('mapStorageChartData', () => {
+ it('should map Block, Filesystem, and Object groups', () => {
+ jest
+ .spyOn(service, 'convertBytesToUnit')
+ .mockImplementation((value: number) => Number(value));
+
+ const result = service.mapStorageChartData(
+ {
+ result: [
+ { metric: { application: 'Block' }, value: [0, '100'] },
+ { metric: { application: 'Filesystem' }, value: [0, '200'] },
+ { metric: { application: 'Object' }, value: [0, '300'] }
+ ]
+ } as any,
+ 'B',
+ 600
+ );
+
+ expect(result).toEqual([
+ { group: 'Block', value: 100 },
+ { group: 'File system', value: 200 },
+ { group: 'Object', value: 300 }
+ ]);
+ });
+
+ it('should add System metadata for unassigned bytes', () => {
+ jest
+ .spyOn(service, 'convertBytesToUnit')
+ .mockImplementation((value: string | number) => Number(value));
+
+ const result = service.mapStorageChartData(
+ {
+ result: [
+ { metric: { application: 'Block' }, value: [0, '100'] },
+ { metric: { application: 'Filesystem' }, value: [0, '200'] }
+ ]
+ } as any,
+ 'B',
+ 500
+ );
+
+ expect(result).toEqual([
+ { group: 'Block', value: 100 },
+ { group: 'File system', value: 200 },
+ { group: 'System metadata', value: 200 }
+ ]);
+ });
+
+ it('should treat unknown application bytes as system metadata', () => {
+ jest
+ .spyOn(service, 'convertBytesToUnit')
+ .mockImplementation((value: string | number) => Number(value));
+
+ const result = service.mapStorageChartData(
+ {
+ result: [
+ { metric: { application: 'Unknown' }, value: [0, '50'] },
+ { metric: { application: 'Block' }, value: [0, '100'] }
+ ]
+ } as any,
+ 'B',
+ 150
+ );
+
+ expect(result).toEqual([
+ { group: 'Block', value: 100 },
+ { group: 'System metadata', value: 50 }
+ ]);
+ });
+
+ it('should return empty array when unit is missing', () => {
+ const result = service.mapStorageChartData({ result: [] } as any, '', 100);
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty array when data is null', () => {
+ const result = service.mapStorageChartData(null as any, 'B', 100);
+ expect(result).toEqual([]);
+ });
+
+ it('should return empty array when totalUsedBytes is null', () => {
+ const result = service.mapStorageChartData({ result: [] } as any, 'B', null as any);
+ expect(result).toEqual([]);
+ });
+
+ it('should filter out zero-value converted entries', () => {
+ jest
+ .spyOn(service, 'convertBytesToUnit')
+ .mockImplementation((value: string | number) => Number(value));
+ const result = service.mapStorageChartData(
+ {
+ result: [
+ { metric: { application: 'mgr' }, value: [0, '50'] },
+ { metric: { application: 'Object' }, value: [0, '0'] },
+ { metric: { application: 'Block' }, value: [0, '0'] }
+ ]
+ } as any,
+ 'B',
+ 50
+ );
+
+ expect(result).toEqual([{ group: 'System metadata', value: 50 }]);
+ });
+ });
});
import { FormatterService } from '~/app/shared/services/formatter.service';
import { map } from 'rxjs/operators';
import { forkJoin, Observable } from 'rxjs';
+import { CapacityCardQueries } from '../enum/dashboard-promqls.enum';
+import { BreakdownChartData, CapacityThreshold } from '../models/overview';
const StorageType = {
BLOCK: $localize`Block`,
FILE: $localize`File system`,
- OBJECT: $localize`Object`
+ OBJECT: $localize`Object`,
+ SYSTEM_METADATA: $localize`System metadata`
} as const;
-const CHART_GROUP_LABELS = new Set([StorageType.BLOCK, StorageType.FILE, StorageType.OBJECT]);
-
@Injectable({ providedIn: 'root' })
export class OverviewStorageService {
private readonly prom = inject(PrometheusService);
private readonly OBJECT_POOLS_COUNT_QUERY = 'count(ceph_pool_metadata{application="Object"})';
private readonly RAW_USED_BY_STORAGE_TYPE_QUERY =
'sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})';
+ private readonly FULL_NEARFULL_QUERY = `{__name__=~"${CapacityCardQueries.OSD_FULL}|${CapacityCardQueries.OSD_NEARFULL}"}`;
getTrendData(start: number, end: number, stepSec: number) {
const range = {
);
}
- convertBytesToUnit(value: string, unit: string): number {
- return this.formatter.convertToUnit(value, 'B', unit, 1);
+ getRawCapacityThresholds(): Observable<{
+ osdFullRatio: number | null;
+ osdNearfullRatio: number | null;
+ }> {
+ return this.prom.getGaugeQueryData(this.FULL_NEARFULL_QUERY).pipe(
+ map((data: PromqlGuageMetric) => {
+ const result = data?.result ?? [];
+
+ const osdFull = result.find((r) => r.metric?.__name__ === CapacityCardQueries.OSD_FULL)
+ ?.value?.[1];
+ const osdNearfull = result.find(
+ (r) => r.metric?.__name__ === CapacityCardQueries.OSD_NEARFULL
+ )?.value?.[1];
+
+ return {
+ osdFullRatio: this.prom.formatGuageMetric(osdFull),
+ osdNearfullRatio: this.prom.formatGuageMetric(osdNearfull)
+ };
+ })
+ );
}
getStorageBreakdown(): Observable<PromqlGuageMetric> {
return this.prom.getPrometheusQueryData({ params: this.RAW_USED_BY_STORAGE_TYPE_QUERY });
}
+
+ getThresholdStatus(total, used, nearfull, full): CapacityThreshold {
+ if (!used || !total || !nearfull || !full) {
+ return null;
+ }
+
+ const usageRatio = used / total;
+
+ if (usageRatio >= full) return 'critical';
+ else if (usageRatio >= nearfull) return 'high';
+
+ return null;
+ }
+
+ convertBytesToUnit(value: number, unit: string): number {
+ return this.formatter.convertToUnit(value, 'B', unit, 1);
+ }
+
formatBytesForChart(value: number): [number, string] {
return this.formatter.formatToBinary(value, true);
}
- mapStorageChartData(data: PromqlGuageMetric, unit: string): { group: string; value: number }[] {
- if (!unit) return [];
+ private normalizeGroup(group: string): string {
+ if (group === 'Filesystem') return StorageType.FILE;
+ return group;
+ }
- const result = data?.result ?? [];
+ private isAStorage(group: string): boolean {
+ return (
+ group === StorageType.BLOCK || group === StorageType.FILE || group === StorageType.OBJECT
+ );
+ }
- return result
- .map((r: PromethuesGaugeMetricResult) => {
- const group = r?.metric?.application;
- const value = r?.value?.[1];
+ mapStorageChartData(
+ data: PromqlGuageMetric,
+ unit: string,
+ totalUsedBytes: number
+ ): BreakdownChartData[] {
+ if (!unit || totalUsedBytes == null || !data) return [];
- return {
- group: group === 'Filesystem' ? StorageType.FILE : group,
- value: this.convertBytesToUnit(value, unit)
- };
- })
- .filter((item) => CHART_GROUP_LABELS.has(item.group) && item.value > 0);
+ let assignedBytes = 0;
+
+ const result: PromethuesGaugeMetricResult[] = data.result ?? [];
+ const chartData = result.reduce<BreakdownChartData[]>((acc, r) => {
+ const rawBytes = Number(r?.value?.[1] ?? 0);
+ if (!rawBytes) return acc;
+
+ const group = this.normalizeGroup(r?.metric?.application);
+ const value = this.convertBytesToUnit(rawBytes, unit);
+
+ if (this.isAStorage(group) && value > 0) {
+ assignedBytes += rawBytes;
+ acc.push({ group, value });
+ }
+
+ return acc;
+ }, []);
+
+ const systemBytes = Math.max(0, totalUsedBytes - assignedBytes);
+
+ if (systemBytes > 0) {
+ chartData.push({
+ group: StorageType.SYSTEM_METADATA,
+ value: this.convertBytesToUnit(systemBytes, unit)
+ });
+ }
+
+ return chartData;
}
}
<svg [cdsIcon]="icon"
[size]="size"
- [class]="type+'-icon '+class">
+ [ngClass]="!useDefault ? [type + '-icon', class] : []">
</svg>
@Input() type!: keyof typeof ICON_TYPE;
@Input() size: IconSize = IconSize.size16;
@Input() class: string = '';
+ // No CSS class will be applied.
+ @Input() useDefault: boolean = false;
icon: string;
type SCRUBBING_STATES = typeof SCRUBBING_STATES[number];
+export type TrendPoint = {
+ timestamp: Date;
+ values: { Used: number };
+};
+
+export type BreakdownChartData = { group: string; value: number };
+
+export type CapacityThreshold = 'high' | 'critical' | null;
+
export const HealthIconMap = {
HEALTH_OK: 'success',
HEALTH_WARN: 'warningAltFilled',
export interface StorageCardVM {
totalCapacity: number | null;
usedCapacity: number | null;
- breakdownData: { group: string; value: number }[];
+ breakdownData: BreakdownChartData[];
isBreakdownLoaded: boolean;
- consumptionTrendData: { timestamp: Date; values: { Used: number } }[];
+ consumptionTrendData: TrendPoint[];
averageDailyConsumption: string;
estimatedTimeUntilFull: string;
+ threshold: CapacityThreshold;
}
// Constants
});
it('should return a safe tuple when split=true and input is unsupported', () => {
- expect(service.formatToBinary(undefined as any, true)).toEqual([0, 'B']);
- expect(service.formatToBinary(null as any, true)).toEqual([0, 'B']);
- expect(service.formatToBinary(service as any, true)).toEqual([0, 'B']);
+ expect(service.formatToBinary(undefined as any, true)).toEqual([NaN, 'B']);
+ expect(service.formatToBinary(null as any, true)).toEqual([NaN, 'B']);
+ expect(service.formatToBinary(service as any, true)).toEqual([NaN, 'B']);
});
});
return [value, unit];
}
- convertToUnit(value: string, fromUnit: string, toUnit: string, decimals: number = 1): number {
+ convertToUnit(
+ value: number | string,
+ fromUnit: string,
+ toUnit: string,
+ decimals: number = 1
+ ): number {
if (!value) return 0;
const convertedString = this.formatNumberFromTo(
value,