[data]="displayData"
class="overview-storage-card-chart"></ibm-meter-chart>
}
+ <!-- TREND CHARTS AND TOP POOLS -->
+ @if(selectedStorageType === 'All' && trendData) {
+ <div cdsRow
+ class="align-items-center cds-ml-2 cds-mt-6">
+ <div cdsCol
+ [columnNumbers]="{ lg: 14, md: 10, sm: 16 }">
+ <cd-area-chart chartTitle="Consumption trend"
+ [chartKey]="'Consumption trend'"
+ [dataUnit]="'B'"
+ [legendEnabled]="false"
+ [rawData]="trendData"
+ [subHeading]="'Shows last 7 days of storage consumption trends based on recent usage'"
+ [height]="'200px'"
+ [chartType]="'area'">
+ </cd-area-chart>
+ </div>
+ <div cdsCol
+ [columnNumbers]="{ lg: 2, md: 8, sm: 8 }">
+ <div cdsStack="vertical"
+ gap="4">
+ <div cdsStack="vertical"
+ gap="1">
+ <span class="cds--type-heading-03">{{ timeUntilFull }}</span>
+ <div class="consumption-stats-wrapper">
+ <cds-tooltip-definition
+ [autoAlign]="true"
+ [highContrast]="true"
+ [openOnHover]="false"
+ [dropShadow]="true"
+ [caret]="true"
+ description="Based on recent average consumption. Actual time until full may vary based on changes in consumption patterns."
+ i18n-description
+ i18n>
+ Estimated days until full
+ </cds-tooltip-definition>
+ </div>
+ </div>
+ <div cdsStack="vertical"
+ gap="1">
+ <span class="cds--type-heading-03">{{ averageConsumption }}</span>
+ <div class="consumption-stats-wrapper">
+ <cds-tooltip-definition
+ [autoAlign]="true"
+ [highContrast]="true"
+ [openOnHover]="false"
+ [dropShadow]="true"
+ [caret]="true"
+ description="Based on the average daily consumption over the last 7 days"
+ i18n-description
+ i18n>
+ Average consumption
+ </cds-tooltip-definition>
+ </div>
+ </div>
+ </div>
+ </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"
[maxLineWidth]="200">
</cds-skeleton-text>
}
+ }
</cd-productive-card>
}
}
}
+
+.consumption-stats-wrapper {
+ display: inline-flex;
+ width: fit-content;
+}
+
+.consumption-stats-wrapper cds-tooltip-definition {
+ display: inline-block;
+ width: auto;
+}
import { OverviewStorageCardComponent } from './overview-storage-card.component';
import { PrometheusService } from '~/app/shared/api/prometheus.service';
import { FormatterService } from '~/app/shared/services/formatter.service';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { DatePipe } from '@angular/common';
describe('OverviewStorageCardComponent (Jest)', () => {
let component: OverviewStorageCardComponent;
let mockPrometheusService: {
getPrometheusQueryData: jest.Mock;
+ getRangeQueriesData: jest.Mock;
};
let mockFormatterService: {
]
};
+ const mockRangePrometheusResponse = {
+ result: [
+ {
+ metric: { application: 'Block' },
+ values: [
+ [0, '512'],
+ [60, '1024']
+ ]
+ }
+ ]
+ };
beforeEach(async () => {
mockPrometheusService = {
- getPrometheusQueryData: jest.fn().mockReturnValue(of(mockPrometheusResponse))
+ getPrometheusQueryData: jest.fn().mockReturnValue(of(mockPrometheusResponse)),
+ getRangeQueriesData: jest.fn().mockReturnValue(of(mockRangePrometheusResponse))
};
mockFormatterService = {
};
await TestBed.configureTestingModule({
- imports: [OverviewStorageCardComponent],
+ imports: [OverviewStorageCardComponent, HttpClientTestingModule],
providers: [
{ provide: PrometheusService, useValue: mockPrometheusService },
- { provide: FormatterService, useValue: mockFormatterService }
+ { provide: FormatterService, useValue: mockFormatterService },
+ DatePipe
]
}).compileComponents();
fixture = TestBed.createComponent(OverviewStorageCardComponent);
component = fixture.componentInstance;
- fixture.detectChanges(); // triggers ngOnInit
+ fixture.detectChanges();
});
afterEach(() => {
expect(nextSpy).toHaveBeenCalled();
expect(completeSpy).toHaveBeenCalled();
});
+
+ // --------------------------------------------------
+ // USED setter (falsy)
+ // --------------------------------------------------
+
+ it('should not set used when formatter returns NaN', () => {
+ mockFormatterService.formatToBinary.mockReturnValue([NaN, 'GiB']);
+
+ component.used = 0;
+
+ expect(component.usedRaw).toBeUndefined();
+ });
+
+ // --------------------------------------------------
+ // _getAllData
+ // --------------------------------------------------
+
+ it('should map Filesystem application to File system group', () => {
+ mockFormatterService.convertToUnit.mockReturnValue(5);
+ const data = {
+ result: [{ metric: { application: 'Filesystem' }, value: [0, '1024'] }]
+ };
+
+ mockPrometheusService.getPrometheusQueryData.mockReturnValue(of(data));
+ fixture = TestBed.createComponent(OverviewStorageCardComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(component.allData.some((d) => d.group === 'File system')).toBe(true);
+ });
+
+ it('should filter out entries with unknown application groups', () => {
+ mockFormatterService.convertToUnit.mockReturnValue(5);
+ const data = {
+ result: [
+ { metric: { application: 'Unknown' }, value: [0, '1024'] },
+ { metric: { application: 'Block' }, value: [0, '2048'] }
+ ]
+ };
+
+ mockPrometheusService.getPrometheusQueryData.mockReturnValue(of(data));
+ fixture = TestBed.createComponent(OverviewStorageCardComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(component.allData.every((d) => d.group !== 'Unknown')).toBe(true);
+ });
+
+ it('should handle empty result in _getAllData', () => {
+ mockPrometheusService.getPrometheusQueryData.mockReturnValue(of({ result: [] }));
+ fixture = TestBed.createComponent(OverviewStorageCardComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(component.allData).toEqual([]);
+ });
+
+ it('should handle null data in _getAllData', () => {
+ mockPrometheusService.getPrometheusQueryData.mockReturnValue(of(null));
+ fixture = TestBed.createComponent(OverviewStorageCardComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+
+ expect(component.allData).toEqual([]);
+ });
+
+ // --------------------------------------------------
+ // _setChartData
+ // --------------------------------------------------
+
+ it('should set displayUsedRaw to usedRaw when ALL is selected', () => {
+ component.usedRaw = 42;
+ component.allData = [{ group: 'Block', value: 10 }];
+ component.selectedStorageType = 'All';
+
+ (component as any)._setChartData();
+
+ expect(component.displayUsedRaw).toBe(42);
+ });
+
+ it('should set displayUsedRaw to first matching value when specific type selected', () => {
+ component.allData = [
+ { group: 'Block', value: 15 },
+ { group: 'File system', value: 25 }
+ ];
+ component.selectedStorageType = 'Block';
+
+ (component as any)._setChartData();
+
+ expect(component.displayUsedRaw).toBe(15);
+ });
+
+ it('should set displayData to empty array when no matching type found', () => {
+ component.allData = [{ group: 'Block', value: 10 }];
+ component.selectedStorageType = 'Object';
+
+ (component as any)._setChartData();
+
+ expect(component.displayData).toEqual([]);
+ });
+
+ // --------------------------------------------------
+ // _setDropdownItemsAndStorageType
+ // --------------------------------------------------
+
+ it('should build dropdown items from allData', () => {
+ component.allData = [
+ { group: 'Block', value: 10 },
+ { group: 'File system', value: 20 }
+ ];
+
+ (component as any)._setDropdownItemsAndStorageType();
+
+ expect(component.dropdownItems).toEqual([
+ { content: 'All' },
+ { content: 'Block' },
+ { content: 'File system' }
+ ]);
+ });
+
+ it('should set only ALL dropdown item when allData is empty', () => {
+ component.allData = [];
+
+ (component as any)._setDropdownItemsAndStorageType();
+
+ expect(component.dropdownItems).toEqual([{ content: 'All' }]);
+ });
+
+ // --------------------------------------------------
+ // onStorageTypeSelect - non-ALL types
+ // --------------------------------------------------
+
+ it('should set topPoolsData to null when ALL is selected', () => {
+ component.topPoolsData = [{ some: 'data' }];
+ component.allData = [];
+
+ component.onStorageTypeSelect({ item: { content: 'All', selected: true } });
+
+ expect(component.topPoolsData).toBeNull();
+ });
+
+ it('should not call loadTopPools for ALL type', () => {
+ const spy = jest.spyOn(component as any, 'loadTopPools');
+ component.allData = [];
+
+ component.onStorageTypeSelect({ item: { content: 'All', selected: true } });
+
+ expect(spy).not.toHaveBeenCalled();
+ });
+
+ it('should call loadTopPools when non-ALL type is selected', () => {
+ const spy = jest.spyOn(component as any, 'loadTopPools').mockImplementation(() => {});
+ jest.spyOn(component as any, 'loadCounts').mockImplementation(() => {});
+ component.allData = [{ group: 'Block', value: 10 }];
+
+ component.onStorageTypeSelect({ item: { content: 'Block', selected: true } });
+
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('should call loadCounts when non-ALL type is selected', () => {
+ jest.spyOn(component as any, 'loadTopPools').mockImplementation(() => {});
+ const spy = jest.spyOn(component as any, 'loadCounts').mockImplementation(() => {});
+ component.allData = [{ group: 'Block', value: 10 }];
+
+ component.onStorageTypeSelect({ item: { content: 'Block', selected: true } });
+
+ expect(spy).toHaveBeenCalled();
+ });
+
+ // --------------------------------------------------
+ // ngOnInit - secondary calls
+ // --------------------------------------------------
+
+ it('should call loadTrend on init', () => {
+ const spy = jest.spyOn(component as any, 'loadTrend').mockImplementation(() => {});
+
+ component.ngOnInit();
+
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('should call loadAverageConsumption on init', () => {
+ const spy = jest.spyOn(component as any, 'loadAverageConsumption').mockImplementation(() => {});
+
+ component.ngOnInit();
+
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it('should call loadTimeUntilFull on init', () => {
+ const spy = jest.spyOn(component as any, 'loadTimeUntilFull').mockImplementation(() => {});
+
+ component.ngOnInit();
+
+ expect(spy).toHaveBeenCalled();
+ });
+
+ // --------------------------------------------------
+ // _setTotalAndUsed / options update
+ // --------------------------------------------------
+
+ it('should update options.meter.proportional.total when total is set', () => {
+ mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']);
+
+ component.total = 1024 * 1024;
+
+ expect(component.options.meter.proportional.total).toBe(20);
+ });
+
+ it('should update options.meter.proportional.unit when total is set', () => {
+ mockFormatterService.formatToBinary.mockReturnValue([20, 'TiB']);
+
+ component.total = 1024 * 1024;
+
+ expect(component.options.meter.proportional.unit).toBe('TiB');
+ });
+
+ it('should set tooltip valueFormatter when used is set', () => {
+ component.used = 512;
+
+ expect(component.options.tooltip).toBeDefined();
+ expect(typeof component.options.tooltip.valueFormatter).toBe('function');
+ });
+
+ // --------------------------------------------------
+ // storageMetrics defaults
+ // --------------------------------------------------
+
+ it('should have default storageMetrics with zero values', () => {
+ expect(component.storageMetrics.Block.metrics[0].value).toBe(0);
+ expect(component.storageMetrics.Block.metrics[1].value).toBe(0);
+ expect(component.storageMetrics['File system'].metrics[0].value).toBe(0);
+ expect(component.storageMetrics['File system'].metrics[1].value).toBe(0);
+ expect(component.storageMetrics.Object.metrics[0].value).toBe(0);
+ expect(component.storageMetrics.Object.metrics[1].value).toBe(0);
+ });
});
import { FormatterService } from '~/app/shared/services/formatter.service';
import { interval, Subject } from 'rxjs';
import { startWith, switchMap, takeUntil } from 'rxjs/operators';
+import { OverviewStorageService } from '~/app/shared/api/storage-overview.service';
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import { AreaChartComponent } from '~/app/shared/components/area-chart/area-chart.component';
+import { PieChartComponent } from '~/app/shared/components/pie-chart/pie-chart.component';
const CHART_HEIGHT = '45px';
value: number;
};
-const RawUsedByStorageType =
+const PROMQL_RAW_USED_BY_STORAGE_TYPE =
'sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})';
+const PROMQL_TOP_POOLS_BLOCK = `
+ topk(5,
+ (ceph_pool_bytes_used * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Block"})
+ /
+ (ceph_pool_max_avail * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Block"})
+ )
+`;
+
+const PROMQL_TOP_POOLS_FILESYSTEM = `
+ topk(5,
+ (ceph_pool_bytes_used * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Filesystem"})
+ /
+ (ceph_pool_max_avail * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Filesystem"})
+ )
+`;
+
+const PROMQL_TOP_POOLS_OBJECT = `
+ topk(5,
+ (ceph_pool_bytes_used * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Object"})
+ /
+ (ceph_pool_max_avail * on(pool_id) group_left(name,application) ceph_pool_metadata{application="Object"})
+ )
+`;
+
+const PROMQL_COUNT_BLOCK_POOLS = 'count(ceph_pool_metadata{application="Block"})';
+
+const PROMQL_COUNT_RBD_IMAGES = 'count(ceph_rbd_image_metadata)';
+
+const PROMQL_COUNT_FILESYSTEMS = 'count(ceph_fs_metadata)';
+
+const PROMQL_COUNT_FILESYSTEM_POOLS = 'count(ceph_pool_metadata{application="Filesystem"})';
+
const chartGroupLabels = [StorageType.BLOCK, StorageType.FILE, StorageType.OBJECT];
+const TopPoolsQueryMap = {
+ Block: PROMQL_TOP_POOLS_BLOCK,
+ 'File system': PROMQL_TOP_POOLS_FILESYSTEM,
+ Object: PROMQL_TOP_POOLS_OBJECT
+};
+
@Component({
selector: 'cd-overview-storage-card',
imports: [
DropdownModule,
TooltipModule,
SkeletonModule,
- LayoutModule
+ LayoutModule,
+ AreaChartComponent,
+ PieChartComponent
],
standalone: true,
templateUrl: './overview-storage-card.component.html',
export class OverviewStorageCardComponent implements OnInit, OnDestroy {
private readonly prometheusService = inject(PrometheusService);
private readonly formatterService = inject(FormatterService);
+ private readonly overviewStorageService = inject(OverviewStorageService);
+ private readonly rgw = inject(RgwBucketService);
private readonly cdr = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
+ trendData: { timestamp: Date; values: { Used: number } }[];
@Input()
set total(value: number) {
{ 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 }
+ ]
+ }
+ };
+
+ averageConsumption = '';
+ timeUntilFull = '';
private _setTotalAndUsed() {
// Chart reacts to 'options' and 'data' object changes only, hence mandatory to replace whole object.
this.cdr.markForCheck();
}
- public onStorageTypeSelect(selected: { item: { content: string; selected: true } }) {
- this.selectedStorageType = selected?.item?.content;
+ private loadTrend() {
+ const now = Math.floor(Date.now() / 1000);
+ const range = { start: now - 7 * 86400, end: now, step: 3600 };
+
+ this.overviewStorageService
+ .getTrendData(range.start, range.end, range.step)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((result) => {
+ const values = result?.TOTAL_RAW_USED ?? [];
+ this.trendData = values.map(([ts, val]) => ({
+ timestamp: new Date(ts * 1000),
+ values: { Used: Number(val) }
+ }));
+ this.cdr.markForCheck();
+ });
+ }
+
+ private loadAverageConsumption() {
+ this.overviewStorageService
+ .getAverageConsumption()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((v) => {
+ this.averageConsumption = v;
+ this.cdr.markForCheck();
+ });
+ }
+
+ private loadTimeUntilFull() {
+ this.overviewStorageService
+ .getTimeUntilFull()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((v) => {
+ this.timeUntilFull = v;
+ this.cdr.markForCheck();
+ });
+ }
+
+ private loadTopPools() {
+ const query = TopPoolsQueryMap[this.selectedStorageType];
+ if (!query) return;
+
+ this.overviewStorageService
+ .getTopPools(query)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((data) => {
+ this.topPoolsData = data;
+ this.cdr.markForCheck();
+ });
+ }
+
+ private loadCounts() {
+ const type = this.selectedStorageType;
+
+ if (type === StorageType.BLOCK) {
+ this.overviewStorageService
+ .getCount(PROMQL_COUNT_BLOCK_POOLS)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((value) => {
+ this.storageMetrics.Block.metrics[0].value = value;
+ this.cdr.markForCheck();
+ });
+
+ this.overviewStorageService
+ .getCount(PROMQL_COUNT_RBD_IMAGES)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((value) => {
+ this.storageMetrics.Block.metrics[1].value = value;
+ this.cdr.markForCheck();
+ });
+ }
+
+ if (type === StorageType.FILE) {
+ this.overviewStorageService
+ .getCount(PROMQL_COUNT_FILESYSTEMS)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((value) => {
+ this.storageMetrics['File system'].metrics[0].value = value;
+ this.cdr.markForCheck();
+ });
+
+ this.overviewStorageService
+ .getCount(PROMQL_COUNT_FILESYSTEM_POOLS)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((value) => {
+ this.storageMetrics['File system'].metrics[1].value = value;
+ this.cdr.markForCheck();
+ });
+ }
+
+ if (type === StorageType.OBJECT) {
+ this.overviewStorageService
+ .getObjectCounts(this.rgw)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((value) => {
+ this.storageMetrics.Object.metrics[0].value = value.buckets;
+ this.storageMetrics.Object.metrics[1].value = value.pools;
+ this.cdr.markForCheck();
+ });
+ }
+ }
+
+ onStorageTypeSelect(event: any) {
+ this.selectedStorageType = event?.item?.content;
this._setChartData();
+
+ if (this.selectedStorageType === StorageType.ALL) {
+ this.loadTrend();
+ this.topPoolsData = null;
+ } else {
+ this.loadTopPools();
+ this.loadCounts();
+ }
}
ngOnInit() {
startWith(0),
switchMap(() =>
this.prometheusService.getPrometheusQueryData({
- params: RawUsedByStorageType
+ params: PROMQL_RAW_USED_BY_STORAGE_TYPE
})
),
takeUntil(this.destroy$)
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 {
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { TestBed } from '@angular/core/testing';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+import { OverviewStorageService } from './storage-overview.service';
+
+describe('OverviewStorageService', () => {
+ let service: OverviewStorageService;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(OverviewStorageService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('getTrendData', () => {
+ it('should call getRangeQueriesData with correct params', () => {
+ const promSpy = jest.spyOn(service['prom'], 'getRangeQueriesData').mockReturnValue({} as any);
+ service.getTrendData(1000, 2000, 60);
+ expect(promSpy).toHaveBeenCalledWith(
+ { start: 1000, end: 2000, step: 60 },
+ { TOTAL_RAW_USED: 'sum(ceph_pool_bytes_used)' },
+ true
+ );
+ });
+ });
+
+ describe('getAverageConsumption', () => {
+ it('should format bytes per day correctly', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '1073741824'] }] }));
+ jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['1.0', 'GiB'] as any);
+
+ service.getAverageConsumption().subscribe((result) => {
+ expect(result).toBe('1.0 GiB/day');
+ done();
+ });
+ });
+
+ it('should return 0 formatted when no result', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+ jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['0', 'B'] as any);
+
+ service.getAverageConsumption().subscribe((result) => {
+ expect(result).toBe('0 B/day');
+ done();
+ });
+ });
+
+ it('should handle null response gracefully', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)(null));
+ jest.spyOn(service['formatter'], 'formatToBinary').mockReturnValue(['0', 'B'] as any);
+
+ service.getAverageConsumption().subscribe((result) => {
+ expect(result).toBe('0 B/day');
+ done();
+ });
+ });
+ });
+
+ describe('getTimeUntilFull', () => {
+ it('should return ∞ when days is Infinity', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+
+ service.getTimeUntilFull().subscribe((result) => {
+ expect(result).toBe('∞');
+ done();
+ });
+ });
+
+ it('should return hours when days < 1', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '0.5'] }] }));
+
+ service.getTimeUntilFull().subscribe((result) => {
+ expect(result).toBe('12.0 hours');
+ done();
+ });
+ });
+
+ it('should return days when 1 <= days < 30', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '15'] }] }));
+
+ service.getTimeUntilFull().subscribe((result) => {
+ expect(result).toBe('15.0 days');
+ done();
+ });
+ });
+
+ it('should return months when days >= 30', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '60'] }] }));
+
+ service.getTimeUntilFull().subscribe((result) => {
+ expect(result).toBe('2.0 months');
+ done();
+ });
+ });
+
+ it('should return ∞ when days <= 0', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '-5'] }] }));
+
+ service.getTimeUntilFull().subscribe((result) => {
+ expect(result).toBe('∞');
+ done();
+ });
+ });
+ });
+
+ describe('getTopPools', () => {
+ it('should map pool results with name', (done) => {
+ jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
+ new (require('rxjs').of)({
+ result: [{ metric: { name: 'mypool' }, value: [null, '0.5'] }]
+ })
+ );
+
+ service.getTopPools('some_query').subscribe((result) => {
+ expect(result).toEqual([{ group: 'mypool', value: 50 }]);
+ done();
+ });
+ });
+
+ it('should fallback to pool label when name is absent', (done) => {
+ jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
+ new (require('rxjs').of)({
+ result: [{ metric: { pool: 'fallback_pool' }, value: [null, '0.25'] }]
+ })
+ );
+
+ service.getTopPools('some_query').subscribe((result) => {
+ expect(result).toEqual([{ group: 'fallback_pool', value: 25 }]);
+ done();
+ });
+ });
+
+ it('should use "unknown" when no name or pool label', (done) => {
+ jest.spyOn(service['prom'], 'getPrometheusQueryData').mockReturnValue(
+ new (require('rxjs').of)({
+ result: [{ metric: {}, value: [null, '0.1'] }]
+ })
+ );
+
+ service.getTopPools('some_query').subscribe((result) => {
+ expect(result).toEqual([{ group: 'unknown', value: 10 }]);
+ done();
+ });
+ });
+
+ it('should return empty array when result is empty', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+
+ service.getTopPools('some_query').subscribe((result) => {
+ expect(result).toEqual([]);
+ done();
+ });
+ });
+ });
+
+ describe('getCount', () => {
+ it('should return numeric count from query result', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '42'] }] }));
+
+ service.getCount('some_query').subscribe((result) => {
+ expect(result).toBe(42);
+ done();
+ });
+ });
+
+ it('should return 0 when result is empty', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [] }));
+
+ service.getCount('some_query').subscribe((result) => {
+ expect(result).toBe(0);
+ done();
+ });
+ });
+
+ it('should return 0 when response is null', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)(null));
+
+ service.getCount('some_query').subscribe((result) => {
+ expect(result).toBe(0);
+ done();
+ });
+ });
+ });
+
+ describe('getObjectCounts', () => {
+ it('should return bucket and pool counts', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '3'] }] }));
+
+ const mockRgwService = {
+ getTotalBucketsAndUsersLength: () => new (require('rxjs').of)({ buckets_count: 10 })
+ };
+
+ service.getObjectCounts(mockRgwService).subscribe((result) => {
+ expect(result).toEqual({ buckets: 10, pools: 3 });
+ done();
+ });
+ });
+
+ it('should default buckets to 0 when buckets_count is missing', (done) => {
+ jest
+ .spyOn(service['prom'], 'getPrometheusQueryData')
+ .mockReturnValue(new (require('rxjs').of)({ result: [{ value: [null, '2'] }] }));
+
+ const mockRgwService = {
+ getTotalBucketsAndUsersLength: () => new (require('rxjs').of)({})
+ };
+
+ service.getObjectCounts(mockRgwService).subscribe((result) => {
+ expect(result).toEqual({ buckets: 0, pools: 2 });
+ done();
+ });
+ });
+ });
+});
--- /dev/null
+import { Injectable, inject } from '@angular/core';
+import { PrometheusService, PromqlGuageMetric } from '~/app/shared/api/prometheus.service';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { map } from 'rxjs/operators';
+import { forkJoin, Observable } from 'rxjs';
+
+@Injectable({ providedIn: 'root' })
+export class OverviewStorageService {
+ private readonly prom = inject(PrometheusService);
+ private readonly formatter = inject(FormatterService);
+ private readonly AVG_CONSUMPTION_QUERY = 'sum(rate(ceph_pool_bytes_used[7d])) * 86400';
+ private readonly TIME_UNTIL_FULL_QUERY = `(sum(ceph_pool_max_avail)) / (sum(rate(ceph_pool_bytes_used[7d])) * 86400)`;
+ private readonly TOTAL_RAW_USED_QUERY = 'sum(ceph_pool_bytes_used)';
+ private readonly OBJECT_POOLS_COUNT_QUERY = 'count(ceph_pool_metadata{application="Object"})';
+
+ getTrendData(start: number, end: number, stepSec: number) {
+ const range = {
+ start,
+ end,
+ step: stepSec
+ };
+
+ return this.prom.getRangeQueriesData(
+ range,
+ {
+ TOTAL_RAW_USED: this.TOTAL_RAW_USED_QUERY
+ },
+ true
+ );
+ }
+
+ getAverageConsumption(): Observable<string> {
+ return this.prom.getPrometheusQueryData({ params: this.AVG_CONSUMPTION_QUERY }).pipe(
+ map((res) => {
+ const v = Number(res?.result?.[0]?.value?.[1] ?? 0);
+ const [val, unit] = this.formatter.formatToBinary(v, true);
+ return `${val} ${unit}/day`;
+ })
+ );
+ }
+
+ getTimeUntilFull(): Observable<string> {
+ return this.prom.getPrometheusQueryData({ params: this.TIME_UNTIL_FULL_QUERY }).pipe(
+ map((res) => {
+ const days = Number(res?.result?.[0]?.value?.[1] ?? Infinity);
+ if (!isFinite(days) || days <= 0) return '∞';
+
+ if (days < 1) return `${(days * 24).toFixed(1)} hours`;
+ if (days < 30) return `${days.toFixed(1)} days`;
+ return `${(days / 30).toFixed(1)} months`;
+ })
+ );
+ }
+
+ getTopPools(query: string): Observable<{ group: string; value: number }[]> {
+ return this.prom.getPrometheusQueryData({ params: query }).pipe(
+ map((data: PromqlGuageMetric) => {
+ return (data?.result ?? []).map((r) => ({
+ group: r.metric?.name || r.metric?.pool || 'unknown',
+ value: Number(r.value?.[1]) * 100
+ }));
+ })
+ );
+ }
+
+ getCount(query: string): Observable<number> {
+ return this.prom
+ .getPrometheusQueryData({ params: query })
+ .pipe(map((res: any) => Number(res?.result?.[0]?.value?.[1]) || 0));
+ }
+
+ getObjectCounts(rgwBucketService: any) {
+ return forkJoin({
+ buckets: rgwBucketService.getTotalBucketsAndUsersLength(),
+ pools: this.getCount(this.OBJECT_POOLS_COUNT_QUERY)
+ }).pipe(
+ map(({ buckets, pools }: { buckets: { buckets_count: number }; pools: number }) => ({
+ buckets: buckets?.buckets_count ?? 0,
+ pools
+ }))
+ );
+ }
+}
@if(chartData && chartOptions){
-<ibm-line-chart
+<div class="chart-container">
+ <div class="chart-header">
+ <div class="cds--type-heading-compact-02">
+ {{ chartTitle }}
+ </div>
+ @if(subHeading) {
+ <div class="cds--type-body-compact-01 chart-subheading cds-mb-3">
+ {{ subHeading }}
+ </div>
+ }
+ </div>
+ @if(chartType === 'area') {
+ <ibm-area-chart
[data]="chartData"
[options]="chartOptions">
-</ibm-line-chart>
+ </ibm-area-chart>
+ } @else {
+ <ibm-line-chart
+ [data]="chartData"
+ [options]="chartOptions">
+ </ibm-line-chart>
+ }
+</div>
}
+.chart-container {
+ display: flex;
+ flex-direction: column;
+}
+
+.chart-header {
+ margin-bottom: var(--cds-spacing-04);
+}
+
+.chart-subheading {
+ margin-top: var(--cds-spacing-02);
+ color: var(--cds-text-secondary);
+}
component.ngOnChanges({
rawData: new SimpleChange(null, mockData, false)
});
-
- expect(component.chartOptions?.title).toBe('Test Chart');
});
});
imports: [ChartsModule]
})
export class AreaChartComponent implements OnChanges {
+ @Input() chartType = 'line';
@Input() chartTitle = '';
@Input() dataUnit = '';
@Input() rawData!: ChartPoint[];
@Input() chartKey = '';
@Input() decimals = DECIMAL;
@Input() customOptions?: Partial<AreaChartOptions>;
+ @Input() legendEnabled = true;
+ @Input() subHeading = '';
+ @Input() height = '300px';
@Output() currentFormattedValues = new EventEmitter<{
key: string;
private getChartOptions(max: number, labels: string[], divisor: number): AreaChartOptions {
return {
- title: this.chartTitle,
+ legend: {
+ enabled: this.legendEnabled
+ },
axes: {
bottom: {
- title: 'Time',
mapsTo: 'date',
scaleType: ScaleTypes.TIME,
ticks: {
]
},
animations: false,
- height: '300px',
+ height: this.height,
data: {
loading: !this.chartData?.length
}
--- /dev/null
+<ibm-pie-chart
+ *ngIf="chartData && chartOptions"
+ [data]="chartData"
+ [options]="chartOptions">
+</ibm-pie-chart>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { PieChartComponent } from './pie-chart.component';
+import { ChartsModule } from '@carbon/charts-angular';
+import { CommonModule } from '@angular/common';
+import { ChangeDetectionStrategy } from '@angular/core';
+import { By } from '@angular/platform-browser';
+
+describe('PieChartComponent', () => {
+ let component: PieChartComponent;
+ let fixture: ComponentFixture<PieChartComponent>;
+
+ const mockData = [
+ { group: 'A', value: 30 },
+ { group: 'B', value: 70 }
+ ];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [PieChartComponent, ChartsModule, CommonModule]
+ })
+ // disable OnPush for test environment
+ .overrideComponent(PieChartComponent, {
+ set: { changeDetection: ChangeDetectionStrategy.Default }
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(PieChartComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should prepare chart data on ngOnChanges', () => {
+ component.data = mockData;
+
+ component.ngOnChanges({
+ data: {
+ currentValue: mockData,
+ previousValue: null,
+ firstChange: true,
+ isFirstChange: () => true
+ }
+ });
+
+ expect(component.chartData.length).toBe(2);
+ expect(component.chartData[0]).toEqual({ group: 'A', value: 30 });
+ expect(component.chartOptions).toBeDefined();
+ });
+
+ it('should set chart options correctly', () => {
+ component.data = mockData;
+ component.title = 'Test Chart';
+ component.height = '300px';
+ component.legendPosition = 'top';
+
+ component.ngOnChanges({
+ data: {
+ currentValue: mockData,
+ previousValue: null,
+ firstChange: true,
+ isFirstChange: () => true
+ }
+ });
+
+ const opts = component.chartOptions;
+
+ expect(opts.title).toBe('Test Chart');
+ expect(opts.height).toBe('300px');
+ expect(opts.legend.position).toBe('top');
+ expect(opts.pie?.labels?.enabled).toBe(false);
+ });
+
+ it('should render ibm-pie-chart when data & options exist', () => {
+ component.data = mockData;
+
+ component.ngOnChanges({
+ data: {
+ currentValue: mockData,
+ previousValue: null,
+ firstChange: true,
+ isFirstChange: () => false
+ }
+ });
+
+ fixture.detectChanges();
+
+ const chartEl = fixture.debugElement.query(By.css('ibm-pie-chart'));
+ expect(chartEl).toBeTruthy();
+
+ expect(chartEl.componentInstance.data).toEqual(component.chartData);
+ expect(chartEl.componentInstance.options).toEqual(component.chartOptions);
+ });
+
+ it('should NOT render ibm-pie-chart when no data', () => {
+ component.data = null as any;
+
+ component.ngOnChanges({
+ data: {
+ currentValue: null,
+ previousValue: mockData,
+ firstChange: false,
+ isFirstChange: () => false
+ }
+ });
+
+ fixture.detectChanges();
+
+ const chartEl = fixture.debugElement.query(By.css('ibm-pie-chart'));
+ expect(chartEl).toBeNull();
+ });
+});
--- /dev/null
+import { CommonModule } from '@angular/common';
+import { Component, Input, OnChanges, SimpleChanges, ChangeDetectionStrategy } from '@angular/core';
+import { PieChartOptions, ChartTabularData, ChartsModule } from '@carbon/charts-angular';
+
+@Component({
+ selector: 'cd-pie-chart',
+ templateUrl: './pie-chart.component.html',
+ styleUrls: ['./pie-chart.component.scss'],
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [ChartsModule, CommonModule]
+})
+export class PieChartComponent implements OnChanges {
+ @Input() data!: { group: string; value: number }[];
+ @Input() title: string = '';
+ @Input() legendPosition: 'top' | 'bottom' | 'left' | 'right' = 'bottom';
+ @Input() height: string = '280px';
+
+ chartData: ChartTabularData = [];
+ chartOptions!: PieChartOptions;
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['data'] && this.data) {
+ this.prepareData();
+ this.prepareOptions();
+ }
+ }
+
+ private prepareData(): void {
+ this.chartData = this.data.map((d) => ({
+ group: d.group,
+ value: d.value
+ }));
+ }
+
+ private prepareOptions(): void {
+ this.chartOptions = {
+ title: this.title,
+ height: this.height,
+ legend: {
+ position: this.legendPosition
+ },
+ toolbar: {
+ enabled: true
+ },
+ pie: {
+ labels: {
+ enabled: false
+ }
+ }
+ };
+ }
+}