alertType: string;
alertClass = AlertClass;
- queriesResults: { [key: string]: [] } = {
+ queriesResults: Record<string, [number, string][]> = {
USEDCAPACITY: [],
IPS: [],
OPS: [],
}
public getPrometheusData(selectedTime: any) {
- this.queriesResults = this.prometheusService.getRangeQueriesData(
- selectedTime,
- UtilizationCardQueries,
- this.queriesResults
- );
+ this.prometheusService
+ .getRangeQueriesData(selectedTime, UtilizationCardQueries, true)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((results) => {
+ this.queriesResults = results;
+ });
}
getCapacityQueryValues(data: PromqlGuageMetric['result']) {
<div cdsRow>
<div cdsCol
class="cds-mb-5"
- [columnNumbers]="{lg: 16}">
- <cds-tile>Performance card</cds-tile>
+ [columnNumbers]="{ lg: 16 }">
+ <cd-performance-card></cd-performance-card>
</div>
</div>
</div>
import { HardwareService } from '~/app/shared/api/hardware.service';
import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
describe('OverviewComponent', () => {
let component: OverviewComponent;
OverviewStorageCardComponent,
OverviewHealthCardComponent,
OverviewAlertsCardComponent,
- RouterModule
+ RouterModule,
+ HttpClientTestingModule
],
providers: [
provideHttpClient(),
import { ComponentsModule } from '~/app/shared/components/components.module';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card.component';
+import { PerformanceCardComponent } from '~/app/shared/components/performance-card/performance-card.component';
const sev = {
ok: 0 as Severity,
OverviewStorageCardComponent,
OverviewHealthCardComponent,
ComponentsModule,
- OverviewAlertsCardComponent
+ OverviewAlertsCardComponent,
+ PerformanceCardComponent
],
standalone: true,
templateUrl: './overview.component.html',
.cds--dropdown {
flex: 0 0 40%;
+ min-width: 130px;
}
}
import { Component, OnDestroy, OnInit } from '@angular/core';
import _ from 'lodash';
-import { Observable, ReplaySubject, Subscription, combineLatest, of } from 'rxjs';
+import { Observable, ReplaySubject, Subject, Subscription, combineLatest, of } from 'rxjs';
import { Permissions } from '~/app/shared/models/permissions';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { RgwPromqls as queries } from '~/app/shared/enum/dashboard-promqls.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service';
-import { catchError, shareReplay, switchMap, tap } from 'rxjs/operators';
+import { catchError, shareReplay, switchMap, takeUntil, tap } from 'rxjs/operators';
import { NotificationService } from '~/app/shared/services/notification.service';
import { NotificationType } from '~/app/shared/enum/notification-type.enum';
multisiteInfo: object[] = [];
ZonegroupSub: Subscription;
ZoneSUb: Subscription;
- queriesResults: { [key: string]: [] } = {
+ queriesResults: Record<string, [number, string][]> = {
RGW_REQUEST_PER_SECOND: [],
BANDWIDTH: [],
AVG_GET_LATENCY: [],
multisiteSyncStatus$: Observable<any>;
subject = new ReplaySubject<any>();
fetchDataSub: Subscription;
+ private destroy$ = new Subject<void>();
constructor(
private authStorageService: AuthStorageService,
this.ZonegroupSub?.unsubscribe();
this.ZoneSUb?.unsubscribe();
this.fetchDataSub?.unsubscribe();
+ this.destroy$.next();
+ this.destroy$.complete();
this.prometheusService?.unsubscribe();
}
getPrometheusData(selectedTime: any) {
- this.queriesResults = this.prometheusService.getRangeQueriesData(
- selectedTime,
- queries,
- this.queriesResults,
- true
- );
+ this.prometheusService
+ .getRangeQueriesData(selectedTime, queries, true)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((results) => {
+ this.queriesResults = results;
+ });
}
getSyncStatus() {
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+
+import { PerformanceCardService } from './performance-card.service';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+
+describe('PerformanceCardService', () => {
+ let service: PerformanceCardService;
+
+ configureTestBed({
+ imports: [HttpClientTestingModule]
+ });
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(PerformanceCardService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+
+ describe('convertPerformanceData', () => {
+ it('should convert raw performance data correctly', () => {
+ const raw = {
+ READIOPS: [
+ [1609459200, '100'],
+ [1609459260, '200']
+ ],
+ WRITEIOPS: [
+ [1609459200, '50'],
+ [1609459260, '75']
+ ],
+ READLATENCY: [
+ [1609459200, '1.5'],
+ [1609459260, '2.0']
+ ],
+ WRITELATENCY: [
+ [1609459200, '2.5'],
+ [1609459260, '3.0']
+ ],
+ READCLIENTTHROUGHPUT: [
+ [1609459200, '1000'],
+ [1609459260, '2000']
+ ],
+ WRITECLIENTTHROUGHPUT: [
+ [1609459200, '500'],
+ [1609459260, '750']
+ ]
+ };
+
+ const result = service.convertPerformanceData(raw);
+
+ expect(result).toBeDefined();
+ expect(result.iops).toBeDefined();
+ expect(result.latency).toBeDefined();
+ expect(result.throughput).toBeDefined();
+
+ // Check iops data
+ expect(result.iops.length).toBe(2);
+ expect(result.iops[0].values['Read IOPS']).toBe(100);
+ expect(result.iops[0].values['Write IOPS']).toBe(50);
+
+ // Check latency data
+ expect(result.latency.length).toBe(2);
+ expect(result.latency[0].values['Read Latency']).toBe(1.5);
+ expect(result.latency[0].values['Write Latency']).toBe(2.5);
+
+ // Check throughput data
+ expect(result.throughput.length).toBe(2);
+ expect(result.throughput[0].values['Read Throughput']).toBe(1000);
+ expect(result.throughput[0].values['Write Throughput']).toBe(500);
+ });
+ });
+
+ describe('toSeries', () => {
+ it('should convert metric array to series format', () => {
+ const metric: [number, string][] = [
+ [1609459200, '100'],
+ [1609459260, '200']
+ ];
+ const label = 'Test Label';
+
+ const result = (service as any).toSeries(metric, label);
+
+ expect(result.length).toBe(2);
+ expect(result[0].timestamp).toEqual(new Date(1609459200 * 1000));
+ expect(result[0].values[label]).toBe(100);
+ expect(result[1].timestamp).toEqual(new Date(1609459260 * 1000));
+ expect(result[1].values[label]).toBe(200);
+ });
+ });
+
+ describe('mergeSeries', () => {
+ it('should merge multiple series into one', () => {
+ const series1 = [
+ {
+ timestamp: new Date(1609459200000),
+ values: { 'Series 1': 100 }
+ },
+ {
+ timestamp: new Date(1609459260000),
+ values: { 'Series 1': 200 }
+ }
+ ];
+ const series2 = [
+ {
+ timestamp: new Date(1609459200000),
+ values: { 'Series 2': 50 }
+ },
+ {
+ timestamp: new Date(1609459260000),
+ values: { 'Series 2': 75 }
+ }
+ ];
+
+ const result = (service as any).mergeSeries(series1, series2);
+
+ expect(result.length).toBe(2);
+ expect(result[0].values['Series 1']).toBe(100);
+ expect(result[0].values['Series 2']).toBe(50);
+ expect(result[1].values['Series 1']).toBe(200);
+ expect(result[1].values['Series 2']).toBe(75);
+ });
+ });
+});
--- /dev/null
+import { inject, Injectable } from '@angular/core';
+import { PrometheusService } from './prometheus.service';
+import { PerformanceData, StorageType } from '../models/performance-data';
+import { AllStoragetypesQueries } from '../enum/dashboard-promqls.enum';
+import { map } from 'rxjs/operators';
+import { Observable } from 'rxjs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class PerformanceCardService {
+ private prometheusService = inject(PrometheusService);
+
+ getChartData(
+ time: { start: number; end: number; step: number },
+ selectedStorageType: StorageType
+ ): Observable<PerformanceData> {
+ const queries = this.buildQueriesForStorageType(selectedStorageType);
+
+ return this.prometheusService.getRangeQueriesData(time, queries, true).pipe(
+ map((raw) => {
+ const chartData = this.convertPerformanceData(raw);
+
+ return {
+ iops: chartData.iops.length
+ ? chartData.iops
+ : [{ timestamp: new Date(), values: { 'Read IOPS': 0, 'Write IOPS': 0 } }],
+
+ latency: chartData.latency.length
+ ? chartData.latency
+ : [{ timestamp: new Date(), values: { 'Read Latency': 0, 'Write Latency': 0 } }],
+
+ throughput: chartData.throughput.length
+ ? chartData.throughput
+ : [{ timestamp: new Date(), values: { 'Read Throughput': 0, 'Write Throughput': 0 } }]
+ };
+ })
+ );
+ }
+
+ private buildQueriesForStorageType(storageType: StorageType) {
+ const queries: any = {};
+
+ const applicationFilter =
+ storageType === StorageType.All ? '' : `{application="${storageType}"}`;
+
+ Object.entries(AllStoragetypesQueries).forEach(([key, query]) => {
+ queries[key] = query.replace('{{applicationFilter}}', applicationFilter);
+ });
+
+ return queries;
+ }
+
+ convertPerformanceData(raw: any): PerformanceData {
+ return {
+ iops: this.mergeSeries(
+ this.toSeries(raw?.READIOPS || [], 'Read IOPS'),
+ this.toSeries(raw?.WRITEIOPS || [], 'Write IOPS')
+ ),
+ latency: this.mergeSeries(
+ this.toSeries(raw?.READLATENCY || [], 'Read Latency'),
+ this.toSeries(raw?.WRITELATENCY || [], 'Write Latency')
+ ),
+ throughput: this.mergeSeries(
+ this.toSeries(raw?.READCLIENTTHROUGHPUT || [], 'Read Throughput'),
+ this.toSeries(raw?.WRITECLIENTTHROUGHPUT || [], 'Write Throughput')
+ )
+ };
+ }
+
+ private toSeries(metric: [number, string][], label: string) {
+ return metric.map(([ts, val]) => ({
+ timestamp: new Date(ts * 1000),
+ values: { [label]: Number(val) }
+ }));
+ }
+
+ private mergeSeries(...series: any[]) {
+ const map = new Map<number, any>();
+
+ for (const items of series) {
+ for (const item of items) {
+ const time = item.timestamp.getTime();
+
+ if (!map.has(time)) {
+ map.set(time, {
+ timestamp: item.timestamp,
+ values: { ...item.values }
+ });
+ } else {
+ Object.assign(map.get(time).values, item.values);
+ }
+ }
+ }
+
+ return [...map.values()].sort((a, b) => a.timestamp - b.timestamp);
+ }
+}
import { AlertmanagerNotification } from '../models/prometheus-alerts';
import { PrometheusService } from './prometheus.service';
import { SettingsService } from './settings.service';
+import moment from 'moment';
describe('PrometheusService', () => {
let service: PrometheusService;
expect(x).toBe(false);
});
});
+
+ describe('updateTimeStamp', () => {
+ it('should update timestamp correctly', () => {
+ const currentTime = moment().unix();
+ const selectedTime = {
+ start: currentTime - 3600,
+ end: currentTime,
+ step: 14
+ };
+
+ const result = (service as any).updateTimeStamp(selectedTime);
+
+ expect(result).toBeDefined();
+ expect(result.step).toBe(14);
+ expect(result.start).toBeLessThanOrEqual(currentTime);
+ expect(result.end).toBeGreaterThanOrEqual(currentTime);
+ expect(result.end - result.start).toBe(3600);
+ });
+ });
+
+ describe('getMultiClusterData', () => {
+ it('should make GET request to correct endpoint', () => {
+ const params = { params: 'test_query', start: 123456, end: 123789, step: 14 };
+ service.getMultiClusterData(params).subscribe();
+
+ const req = httpTesting.expectOne((request) => {
+ return request.url === 'api/prometheus/prometheus_query_data' && request.method === 'GET';
+ });
+ expect(req.request.params.get('params')).toBe('test_query');
+ expect(req.request.params.get('start')).toBe('123456');
+ expect(req.request.params.get('end')).toBe('123789');
+ expect(req.request.params.get('step')).toBe('14');
+ req.flush({ result: [] });
+ });
+ });
+
+ describe('getMultiClusterQueryRangeData', () => {
+ it('should make GET request to correct endpoint', () => {
+ const params = { params: 'test_query', start: 123456, end: 123789, step: 14 };
+ service.getMultiClusterQueryRangeData(params).subscribe();
+
+ const req = httpTesting.expectOne((request) => {
+ return request.url === 'api/prometheus/data' && request.method === 'GET';
+ });
+ expect(req.request.params.get('params')).toBe('test_query');
+ expect(req.request.params.get('start')).toBe('123456');
+ expect(req.request.params.get('end')).toBe('123789');
+ expect(req.request.params.get('step')).toBe('14');
+ req.flush({ result: [] });
+ });
+ });
+
+ describe('getMultiClusterQueriesData', () => {
+ beforeEach(() => {
+ spyOn(service, 'ifPrometheusConfigured').and.callFake((fn) => fn());
+ service.timerTime = 100; // Reduce timer for faster tests
+ });
+
+ afterEach(() => {
+ service.unsubscribe();
+ });
+ });
});
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
-import { Observable, Subscription, forkJoin, of, timer } from 'rxjs';
+import { Observable, Subject, Subscription, forkJoin, of, timer } from 'rxjs';
import { catchError, map, switchMap } from 'rxjs/operators';
import { AlertmanagerSilence } from '../models/alertmanager-silence';
result: PromethuesGaugeMetricResult[];
};
+export const STORAGE_TYPE_WARNING =
+ 'Storage type details are unavailable. Upgrade this cluster to version 9.0 or later to access them.';
+
@Injectable({
providedIn: 'root'
})
prometheus: 'ui-api/prometheus/prometheus-api-host'
};
private settings: { [url: string]: string } = {};
+ updatedChrtData = new Subject<any>();
constructor(private http: HttpClient) {}
return isFinite(value) ? value : null;
}
- getRangeQueriesData(selectedTime: any, queries: any, queriesResults: any, checkNan?: boolean) {
- this.ifPrometheusConfigured(() => {
- if (this.timerGetPrometheusDataSub) {
- this.timerGetPrometheusDataSub.unsubscribe();
- }
- this.timerGetPrometheusDataSub = timer(0, this.timerTime)
- .pipe(
- switchMap(() => {
- selectedTime = this.updateTimeStamp(selectedTime);
- const observables = [];
- for (const queryName in queries) {
- if (queries.hasOwnProperty(queryName)) {
- const query = queries[queryName];
- observables.push(
- this.getPrometheusData({
- params: encodeURIComponent(query),
- start: selectedTime['start'],
- end: selectedTime['end'],
- step: selectedTime['step']
- }).pipe(map((data: any) => ({ queryName, data })))
- );
- }
- }
- return forkJoin(observables);
- })
- )
- .subscribe((results: any) => {
- results.forEach(({ queryName, data }: any) => {
- if (data.result.length) {
- queriesResults[queryName] = data.result[0].values;
- } else {
- queriesResults[queryName] = [];
- }
- if (
- queriesResults[queryName] !== undefined &&
- queriesResults[queryName] !== '' &&
- checkNan
- ) {
- queriesResults[queryName].forEach((valueArray: any[]) => {
- if (isNaN(parseFloat(valueArray[1]))) {
- valueArray[1] = '0';
- }
- });
- }
- });
- });
- });
- return queriesResults;
- }
-
private updateTimeStamp(selectedTime: any): any {
let formattedDate = {};
let secondsAgo = selectedTime['end'] - selectedTime['start'];
});
});
}
+
+ getRangeQueriesData(
+ selectedTime: any,
+ queries: Record<string, string>,
+ checkNan?: boolean
+ ): Observable<Record<string, [number, string][]>> {
+ return timer(0, this.timerTime).pipe(
+ switchMap(() => {
+ this.ifPrometheusConfigured(() => {});
+
+ const updatedTime = this.updateTimeStamp(selectedTime);
+
+ const observables = Object.entries(queries).map(([queryName, query]) =>
+ this.getPrometheusData({
+ params: encodeURIComponent(query),
+ start: updatedTime.start,
+ end: updatedTime.end,
+ step: updatedTime.step
+ }).pipe(
+ map((data: any) => ({
+ queryName,
+ values: data.result?.length ? data.result[0].values : []
+ }))
+ )
+ );
+
+ return forkJoin(observables) as Observable<Array<{ queryName: string; values: any[] }>>;
+ }),
+ map((results) => {
+ const formattedResults: Record<string, [number, string][]> = {};
+
+ results.forEach(({ queryName, values }) => {
+ if (checkNan) {
+ values = values.map((v) => {
+ if (isNaN(parseFloat(v[1]))) {
+ v[1] = '0';
+ }
+ return v;
+ });
+ }
+
+ formattedResults[queryName] = values;
+ });
+
+ return formattedResults;
+ })
+ );
+ }
}
--- /dev/null
+import { AreaChartComponent } from './area-chart.component';
+import { ChartsModule } from '@carbon/charts-angular';
+import { CUSTOM_ELEMENTS_SCHEMA, EventEmitter } from '@angular/core';
+import { NumberFormatterService } from '@ceph-dashboard/shared';
+
+describe('AreaChartComponent', () => {
+ const mockData = [
+ {
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ values: { read: 1024, write: 2048 }
+ },
+ {
+ timestamp: new Date('2024-01-01T00:01:00Z'),
+ values: { read: 1536, write: 4096 }
+ }
+ ];
+
+ it('should mount', () => {
+ cy.mount(AreaChartComponent, {
+ imports: [ChartsModule],
+ providers: [NumberFormatterService],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
+ });
+ });
+
+ it('should render chart data and emit formatted values', () => {
+ const emitSpy = cy.spy().as('currentFormattedValuesSpy');
+
+ const currentFormattedValues = new EventEmitter<{
+ key: string;
+ values: Record<string, string>;
+ }>();
+ currentFormattedValues.emit = emitSpy;
+
+ cy.mount(AreaChartComponent, {
+ componentProperties: {
+ chartTitle: 'Test Chart',
+ dataUnit: 'B/s',
+ chartKey: 'test-key',
+ rawData: mockData,
+ currentFormattedValues
+ },
+ imports: [ChartsModule],
+ providers: [NumberFormatterService],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
+ });
+
+ cy.get('@currentFormattedValuesSpy').should('have.been.calledOnce');
+ cy.contains('Test Chart').should('exist');
+ });
+
+ it('should set correct chartOptions based on max value', () => {
+ cy.mount(AreaChartComponent, {
+ componentProperties: {
+ chartTitle: 'Test Chart',
+ dataUnit: 'B/s',
+ rawData: mockData,
+ chartKey: 'test-key'
+ },
+ imports: [ChartsModule],
+ providers: [NumberFormatterService],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
+ }).then(({ component }) => {
+ const options = component.chartOptions;
+ expect(options?.axes?.left?.domain?.[1]).to.be.greaterThan(0);
+ expect(options?.tooltip?.enabled).to.equal(true);
+ expect(options?.axes?.bottom?.scaleType).to.equal('time');
+ });
+ });
+});
--- /dev/null
+@if(chartData && chartOptions){
+<ibm-line-chart
+ [data]="chartData"
+ [options]="chartOptions">
+</ibm-line-chart>
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { CUSTOM_ELEMENTS_SCHEMA, SimpleChange } from '@angular/core';
+import { DatePipe } from '@angular/common';
+import { ChartsModule } from '@carbon/charts-angular';
+import { AreaChartComponent } from './area-chart.component';
+import { NumberFormatterService } from '../../services/number-formatter.service';
+import { ChartPoint } from '../../models/area-chart-point';
+
+describe('AreaChartComponent', () => {
+ let component: AreaChartComponent;
+ let fixture: ComponentFixture<AreaChartComponent>;
+ let numberFormatterService: NumberFormatterService;
+ let datePipe: DatePipe;
+
+ const mockData: ChartPoint[] = [
+ {
+ timestamp: new Date('2024-01-01T00:00:00Z'),
+ values: { read: 1024, write: 2048 }
+ },
+ {
+ timestamp: new Date('2024-01-01T00:01:00Z'),
+ values: { read: 1536, write: 4096 }
+ }
+ ];
+
+ beforeEach(async () => {
+ const numberFormatterMock = {
+ formatFromTo: jest.fn().mockReturnValue('1.00'),
+ bytesPerSecondLabels: [
+ 'B/s',
+ 'KiB/s',
+ 'MiB/s',
+ 'GiB/s',
+ 'TiB/s',
+ 'PiB/s',
+ 'EiB/s',
+ 'ZiB/s',
+ 'YiB/s'
+ ],
+ bytesLabels: ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'YiB'],
+ unitlessLabels: ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
+ };
+
+ const datePipeMock = {
+ transform: jest.fn().mockReturnValue('01 Jan, 00:00:00')
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [ChartsModule, AreaChartComponent],
+ providers: [
+ { provide: NumberFormatterService, useValue: numberFormatterMock },
+ { provide: DatePipe, useValue: datePipeMock }
+ ],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(AreaChartComponent);
+ component = fixture.componentInstance;
+ numberFormatterService = TestBed.inject(NumberFormatterService);
+ datePipe = TestBed.inject(DatePipe);
+ });
+
+ it('should mount', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should render chart data and emit formatted values', () => {
+ const emitSpy = jest.spyOn(component.currentFormattedValues, 'emit');
+
+ component.chartTitle = 'Test Chart';
+ component.dataUnit = 'B/s';
+ component.chartKey = 'test-key';
+ component.rawData = mockData;
+
+ (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('2.00 KiB/s');
+
+ fixture.detectChanges();
+
+ // Trigger ngOnChanges manually
+ component.ngOnChanges({
+ rawData: new SimpleChange(null, mockData, false)
+ });
+
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+ expect(emitSpy.mock.calls[emitSpy.mock.calls.length - 1][0]).toEqual({
+ key: 'test-key',
+ values: expect.objectContaining({
+ read: expect.any(String),
+ write: expect.any(String)
+ })
+ });
+ });
+
+ it('should set correct chartOptions based on max value', () => {
+ component.chartTitle = 'Test Chart';
+ component.dataUnit = 'B/s';
+ component.rawData = mockData;
+ component.chartKey = 'test-key';
+
+ (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('4.00 KiB/s');
+
+ fixture.detectChanges();
+
+ // Trigger ngOnChanges manually
+ component.ngOnChanges({
+ rawData: new SimpleChange(null, mockData, false)
+ });
+
+ const options = component.chartOptions;
+ expect(options?.axes?.left?.domain?.[1]).toBeGreaterThan(0);
+ expect(options?.tooltip?.enabled).toBe(true);
+ expect(options?.axes?.bottom?.scaleType).toBe('time');
+ });
+
+ it('should merge custom options with default chart options', () => {
+ component.chartTitle = 'Test Chart';
+ component.dataUnit = 'B/s';
+ component.rawData = mockData;
+ component.customOptions = {
+ height: '500px',
+ animations: true
+ };
+
+ (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('4.00 KiB/s');
+
+ fixture.detectChanges();
+ component.ngOnChanges({
+ rawData: new SimpleChange(null, mockData, false)
+ });
+
+ expect(component.chartOptions?.height).toBe('500px');
+ expect(component.chartOptions?.animations).toBe(true);
+ expect(component.chartOptions?.tooltip?.enabled).toBe(true);
+ });
+
+ it('should format tooltip with custom date format', () => {
+ const testDate = new Date('2024-01-01T12:30:45Z');
+ const formattedDate = '01 Jan, 12:30:45';
+ const defaultHTML = '<div><p class="value">2024-01-01T12:30:45Z</p></div>';
+
+ (datePipe.transform as jest.Mock).mockReturnValue(formattedDate);
+
+ const result = component.formatChartTooltip(defaultHTML, [{ date: testDate }]);
+
+ expect(datePipe.transform).toHaveBeenCalledWith(testDate, 'dd MMM, HH:mm:ss');
+ expect(result).toContain(formattedDate);
+ expect(result).not.toContain('2024-01-01T12:30:45Z');
+ });
+
+ it('should return default HTML if tooltip data is empty', () => {
+ const defaultHTML = '<div><p>Default</p></div>';
+ const result = component.formatChartTooltip(defaultHTML, []);
+ expect(result).toBe(defaultHTML);
+ });
+
+ it('should transform raw data to chart data format correctly', () => {
+ component.rawData = mockData;
+
+ fixture.detectChanges();
+ component.ngOnChanges({
+ rawData: new SimpleChange(null, mockData, false)
+ });
+
+ expect(component.chartData.length).toBe(4); // 2 timestamps * 2 groups (read, write)
+ expect(component.chartData[0]).toEqual({
+ group: 'read',
+ date: mockData[0].timestamp,
+ value: 1024
+ });
+ expect(component.chartData[1]).toEqual({
+ group: 'write',
+ date: mockData[0].timestamp,
+ value: 2048
+ });
+ });
+
+ it('should not emit formatted values if rawData is empty', () => {
+ const emitSpy = jest.spyOn(component.currentFormattedValues, 'emit');
+ component.rawData = [];
+ component.chartKey = 'test-key';
+
+ fixture.detectChanges();
+ component.ngOnChanges({
+ rawData: new SimpleChange(null, [], false)
+ });
+
+ expect(emitSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not emit formatted values if values have not changed', () => {
+ const emitSpy = jest.spyOn(component.currentFormattedValues, 'emit');
+ component.dataUnit = 'B/s';
+ component.chartKey = 'test-key';
+ component.rawData = mockData;
+
+ (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('2.00 KiB/s');
+
+ fixture.detectChanges();
+ component.ngOnChanges({
+ rawData: new SimpleChange(null, mockData, false)
+ });
+
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+
+ // Update with same values
+ const sameData: ChartPoint[] = [
+ {
+ timestamp: new Date('2024-01-01T00:02:00Z'),
+ values: { read: 1024, write: 2048 } // Same values as first entry
+ }
+ ];
+ component.rawData = sameData;
+
+ component.ngOnChanges({
+ rawData: new SimpleChange(mockData, sameData, false)
+ });
+
+ // Should not emit again since values are the same
+ expect(emitSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should emit formatted values when values change', () => {
+ const emitSpy = jest.spyOn(component.currentFormattedValues, 'emit');
+ component.dataUnit = 'B/s';
+ component.chartKey = 'test-key';
+ component.rawData = mockData;
+
+ (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('2.00 KiB/s');
+
+ fixture.detectChanges();
+ component.ngOnChanges({
+ rawData: new SimpleChange(null, mockData, false)
+ });
+
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+
+ // Update with different values
+ const newData: ChartPoint[] = [
+ {
+ timestamp: new Date('2024-01-01T00:02:00Z'),
+ values: { read: 5120, write: 8192 } // Different values
+ }
+ ];
+ component.rawData = newData;
+
+ (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('5.00 KiB/s');
+
+ component.ngOnChanges({
+ rawData: new SimpleChange(mockData, newData, false)
+ });
+
+ // Should emit again since values changed
+ expect(emitSpy).toHaveBeenCalledTimes(2);
+ });
+
+ it('should set chart title in chart options', () => {
+ component.chartTitle = 'Test Chart';
+ component.dataUnit = 'B/s';
+ component.rawData = mockData;
+
+ (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('4.00 KiB/s');
+
+ fixture.detectChanges();
+ component.ngOnChanges({
+ rawData: new SimpleChange(null, mockData, false)
+ });
+
+ expect(component.chartOptions?.title).toBe('Test Chart');
+ });
+});
--- /dev/null
+import {
+ ChangeDetectorRef,
+ Component,
+ EventEmitter,
+ inject,
+ Input,
+ OnChanges,
+ Output,
+ SimpleChanges
+} from '@angular/core';
+import {
+ AreaChartOptions,
+ ChartTabularData,
+ ToolbarControlTypes,
+ ScaleTypes,
+ ChartsModule
+} from '@carbon/charts-angular';
+import merge from 'lodash.merge';
+import { NumberFormatterService } from '../../services/number-formatter.service';
+import { DatePipe } from '@angular/common';
+import { ChartPoint } from '../../models/area-chart-point';
+import {
+ DECIMAL,
+ formatValues,
+ getDisplayUnit,
+ getDivisor,
+ getLabels
+} from '../../helpers/unit-format-utils';
+
+@Component({
+ selector: 'cd-area-chart',
+ standalone: true,
+ templateUrl: './area-chart.component.html',
+ styleUrl: './area-chart.component.scss',
+ imports: [ChartsModule]
+})
+export class AreaChartComponent implements OnChanges {
+ @Input() chartTitle = '';
+ @Input() dataUnit = '';
+ @Input() rawData!: ChartPoint[];
+ @Input() chartKey = '';
+ @Input() decimals = DECIMAL;
+ @Input() customOptions?: Partial<AreaChartOptions>;
+
+ @Output() currentFormattedValues = new EventEmitter<{
+ key: string;
+ values: Record<string, string>;
+ }>();
+
+ chartData: ChartTabularData = [];
+ chartOptions!: AreaChartOptions;
+
+ private chartDisplayUnit = '';
+
+ private cdr = inject(ChangeDetectorRef);
+ private numberFormatter: NumberFormatterService = inject(NumberFormatterService);
+ private datePipe = inject(DatePipe);
+ private lastEmittedRawValues?: Record<string, number>;
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes['rawData'] && this.rawData?.length) {
+ this.updateChart();
+ }
+ }
+
+ // Convert raw data structure to tabular format accepted by Carbon charts.
+ private transformToChartData(data: ChartPoint[]): ChartTabularData {
+ return data.flatMap(({ timestamp, values }) =>
+ Object.entries(values).map(([group, value]) => ({
+ group,
+ date: timestamp,
+ value
+ }))
+ );
+ }
+
+ // Main method to process chart input, compute scale, format labels,
+ // and merge with custom chart options if provided.
+ private updateChart(): void {
+ this.chartData = this.transformToChartData(this.rawData);
+
+ const max = Math.max(...this.chartData.map((d) => d['value']), 1);
+
+ const labels = getLabels(this.dataUnit, this.numberFormatter);
+ const divisor = getDivisor(this.dataUnit);
+ this.chartDisplayUnit = getDisplayUnit(max, this.dataUnit, labels, divisor);
+
+ this.emitCurrentFomattedValue();
+
+ // Merge base and custom chart options
+ const defaultOptions = this.getChartOptions(max, labels, divisor);
+ this.chartOptions = merge({}, defaultOptions, this.customOptions || {});
+ this.cdr.detectChanges();
+ }
+
+ private emitCurrentFomattedValue() {
+ const latestEntry = this.rawData[this.rawData.length - 1];
+ if (!latestEntry) return;
+
+ if (
+ this.lastEmittedRawValues &&
+ Object.keys(latestEntry.values).every(
+ (value) => latestEntry.values[value] === this.lastEmittedRawValues?.[value]
+ )
+ ) {
+ return;
+ }
+
+ const formattedValues: Record<string, string> = {};
+ for (const [group, value] of Object.entries(latestEntry.values)) {
+ formattedValues[group] = formatValues(
+ value ?? 0,
+ this.dataUnit,
+ this.numberFormatter,
+ this.decimals
+ );
+ }
+
+ this.currentFormattedValues.emit({
+ key: this.chartKey,
+ values: formattedValues
+ });
+
+ this.lastEmittedRawValues = { ...latestEntry.values };
+ }
+
+ private getChartOptions(max: number, labels: string[], divisor: number): AreaChartOptions {
+ return {
+ title: this.chartTitle,
+ axes: {
+ bottom: {
+ title: 'Time',
+ mapsTo: 'date',
+ scaleType: ScaleTypes.TIME,
+ ticks: {
+ number: 4,
+ rotateIfSmallerThan: 0
+ }
+ },
+ left: {
+ title: `${this.chartTitle}${this.chartDisplayUnit ? ` (${this.chartDisplayUnit})` : ''}`,
+ mapsTo: 'value',
+ scaleType: ScaleTypes.LINEAR,
+ domain: [0, max],
+ ticks: {
+ // Only return numeric part of the formatted string (exclude units)
+ formatter: (tick: number | Date): string => {
+ const raw = this.formatValueForChart(tick, labels, divisor);
+ const num = parseFloat(raw);
+ return num.toString();
+ }
+ }
+ }
+ },
+ tooltip: {
+ enabled: true,
+ showTotal: false,
+ valueFormatter: (value: number): string =>
+ (this.formatValueForChart(value, labels, divisor) || value).toString(),
+ customHTML: (data, defaultHTML) => this.formatChartTooltip(defaultHTML, data)
+ },
+ points: {
+ enabled: false
+ },
+ toolbar: {
+ enabled: false,
+ controls: [
+ {
+ type: ToolbarControlTypes.EXPORT_CSV
+ },
+ {
+ type: ToolbarControlTypes.EXPORT_PNG
+ },
+ {
+ type: ToolbarControlTypes.EXPORT_JPG
+ },
+ {
+ type: ToolbarControlTypes.SHOW_AS_DATATABLE
+ }
+ ]
+ },
+ animations: false,
+ height: '300px',
+ data: {
+ loading: !this.chartData?.length
+ }
+ };
+ }
+
+ // Custom tooltip formatter to replace default timestamp with a formatted one.
+ formatChartTooltip(defaultHTML: string, data: { date: Date }[]): string {
+ if (!data?.length) return defaultHTML;
+
+ const formattedTime = this.datePipe.transform(data[0].date, 'dd MMM, HH:mm:ss');
+ return defaultHTML.replace(
+ /<p class="value">.*?<\/p>/,
+ `<p class="value">${formattedTime}</p>`
+ );
+ }
+
+ // Uses number formatter service to convert chart value based on unit and divisor.
+ private formatValueForChart(input: number | Date, labels: string[], divisor: number): string {
+ if (typeof input !== 'number') return '';
+ return this.numberFormatter.formatFromTo(
+ input,
+ this.dataUnit,
+ this.chartDisplayUnit,
+ divisor,
+ labels,
+ this.decimals
+ );
+ }
+}
--- /dev/null
+<div class="performance-card-wrapper">
+ <cd-productive-card
+ headerTitle="Performance"
+ i18n-headerTitle
+ [applyShadow]="false"
+ >
+ @if(chartDataLengthSignal() > 0) {
+ <ng-template #header>
+ <h2 class="cds--type-heading-compact-02"
+ i18n>Performance</h2>
+ <div cdsStack="horizontal"
+ gap="2">
+ <cds-dropdown
+ [placeholder]="selectedStorageType"
+ (selected)="onStorageTypeSelection($event)"
+ [label]="'Storage Type'"
+ class="overview-storage-card-dropdown"
+ [inline]="true"
+ size="sm"
+ i18n>
+ <cds-dropdown-list
+ [items]="storageTypes"
+ size="sm"></cds-dropdown-list>
+ </cds-dropdown>
+
+ <cd-time-picker
+ [dropdwonSize]="'sm'"
+ [label]="'Time Span'"
+ (selectedTime)="loadCharts($event)"
+ ></cd-time-picker>
+ </div>
+ </ng-template>
+ <div cdsGrid
+ [narrow]="true"
+ [condensed]="false"
+ [fullWidth]="true"
+ class="cds-mt-5 cds-mb-5">
+ <div cdsRow
+ [narrow]="true">
+ <div cdsCol
+ class="cds-mb-5"
+ [columnNumbers]="{lg: 5, md: 8, sm: 12}">
+ <cd-area-chart
+ chartTitle="IOPS"
+ [chartKey]="performanceTypes?.IOPS"
+ [dataUnit]="metricUnitMap?.iops"
+ [rawData]="chartDataSignal()?.iops">
+ </cd-area-chart>
+ </div>
+ <div cdsCol
+ [columnNumbers]="{lg: 5, md: 8, sm: 12}"
+ class="cds-mb-5 performance-card-latency-chart">
+ <cd-area-chart
+ chartTitle="Latency"
+ [chartKey]="performanceTypes?.Latency"
+ [dataUnit]="metricUnitMap?.latency"
+ [rawData]="chartDataSignal()?.latency">
+ </cd-area-chart>
+ </div>
+ <div cdsCol
+ class="cds-mb-5"
+ [columnNumbers]="{lg: 5, md: 8, sm: 12}">
+ <cd-area-chart
+ chartTitle="Throughput"
+ [chartKey]="performanceTypes?.Throughput"
+ [dataUnit]="metricUnitMap?.throughput"
+ [rawData]="chartDataSignal()?.throughput">
+ </cd-area-chart>
+ </div>
+ </div>
+ </div>
+ }
+
+ @if(chartDataLengthSignal() === 0) {
+ <div class="performance-card-empty-msg">
+ <div class="performance-card-empty-msg-icon">
+ <img src="assets/locked.png"
+ alt="no-services-links"/>
+ </div>
+ <span class="cds--type-label-01"
+ i18n>
+ You must have storage configured to access this capability.
+ </span>
+ </div>
+ }
+ </cd-productive-card>
+</div>
--- /dev/null
+.performance-card-wrapper {
+ .overview-storage-card-dropdown {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+
+ .cds--label {
+ padding-right: var(--cds-spacing-03);
+ }
+
+ .cds--dropdown {
+ flex: 0 0 40%;
+ min-width: 130px;
+ }
+ }
+
+ .cds--dropdown__wrapper .cds--label {
+ white-space: nowrap;
+ }
+
+ .cds--dropdown-text {
+ white-space: nowrap;
+ }
+
+ .performance-card-empty-msg {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ gap: var(--cds-spacing-05);
+ height: 350px;
+
+ p {
+ font-size: 12px !important;
+ }
+
+ img {
+ width: 100px !important;
+ height: 100px !important;
+ }
+ }
+
+ .performance-card-latency-chart {
+ margin-right: 10px;
+ margin-left: 10px;
+ }
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { PerformanceCardComponent } from './performance-card.component';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+
+describe('PerformanceCardComponent', () => {
+ let component: PerformanceCardComponent;
+ let fixture: ComponentFixture<PerformanceCardComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [HttpClientTestingModule, PerformanceCardComponent]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(PerformanceCardComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import {
+ Component,
+ OnDestroy,
+ OnInit,
+ ViewEncapsulation,
+ inject,
+ signal,
+ computed
+} from '@angular/core';
+import { Icons, IconSize } from '~/app/shared/enum/icons.enum';
+import { PrometheusService } from '~/app/shared/api/prometheus.service';
+import {
+ METRIC_UNIT_MAP,
+ PerformanceData,
+ PerformanceType,
+ StorageType
+} from '~/app/shared/models/performance-data';
+import { PerformanceCardService } from '../../api/performance-card.service';
+import { DropdownModule, GridModule, LayoutModule, ListItem } from 'carbon-components-angular';
+import { Subject, Subscription } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+import { ProductiveCardComponent } from '../productive-card/productive-card.component';
+import { CommonModule } from '@angular/common';
+import { TimePickerComponent } from '../time-picker/time-picker.component';
+import { AreaChartComponent } from '../area-chart/area-chart.component';
+
+@Component({
+ selector: 'cd-performance-card',
+ templateUrl: './performance-card.component.html',
+ styleUrl: './performance-card.component.scss',
+ standalone: true,
+ imports: [
+ ProductiveCardComponent,
+ CommonModule,
+ DropdownModule,
+ AreaChartComponent,
+ TimePickerComponent,
+ LayoutModule,
+ GridModule
+ ],
+ encapsulation: ViewEncapsulation.None
+})
+export class PerformanceCardComponent implements OnInit, OnDestroy {
+ chartDataSignal = signal<PerformanceData | null>(null);
+ chartDataLengthSignal = computed(() => {
+ const data = this.chartDataSignal();
+ return data ? Object.keys(data).length : 0;
+ });
+ performanceTypes = PerformanceType;
+ metricUnitMap = METRIC_UNIT_MAP;
+ icons = Icons;
+ iconSize = IconSize;
+
+ private destroy$ = new Subject<void>();
+
+ storageTypes: ListItem[] = [
+ { content: 'All', value: StorageType.All, selected: true },
+ {
+ content: 'Filesystem',
+ value: StorageType.Filesystem,
+ selected: false
+ },
+ {
+ content: 'Block',
+ value: StorageType.Block,
+ selected: false
+ },
+ {
+ content: 'Object',
+ value: StorageType.Object,
+ selected: false
+ }
+ ];
+
+ selectedStorageType = StorageType.All;
+
+ private prometheusService = inject(PrometheusService);
+ private performanceCardService = inject(PerformanceCardService);
+
+ time = { ...this.prometheusService.lastHourDateObject };
+ private chartSub?: Subscription;
+
+ ngOnInit() {
+ this.loadCharts(this.time);
+ }
+
+ loadCharts(time: { start: number; end: number; step: number }) {
+ this.time = { ...time };
+
+ this.chartSub?.unsubscribe();
+
+ this.chartSub = this.performanceCardService
+ .getChartData(time, this.selectedStorageType)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((data) => {
+ this.chartDataSignal.set(data);
+ });
+ }
+
+ onStorageTypeSelection(event: any) {
+ this.selectedStorageType = event.item.value;
+ this.loadCharts(this.time);
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ this.chartSub?.unsubscribe();
+ }
+}
--- /dev/null
+import { EventEmitter } from '@angular/core';
+import { TimePickerComponent } from './time-picker.component';
+
+describe('TimePickerComponent', () => {
+ it('should mount', () => {
+ cy.mount(TimePickerComponent);
+ });
+
+ it('should emit default time range on init (Last 6 hours)', () => {
+ const selectedTimeEmitter = new EventEmitter<{ start: number; end: number; step: number }>();
+ cy.spy(selectedTimeEmitter, 'emit').as('emitSpy');
+
+ cy.mount(TimePickerComponent, {
+ componentProperties: {
+ selectedTime: selectedTimeEmitter
+ }
+ });
+
+ cy.get('@emitSpy').should('have.been.calledOnce');
+ cy.get('@emitSpy')
+ .invoke('getCall', 0)
+ .its('args.0')
+ .should((emitted) => {
+ expect(emitted.step).to.eq(14);
+ const duration = emitted.end - emitted.start;
+ expect(duration).to.eq(60 * 60);
+ });
+ });
+});
--- /dev/null
+<cds-dropdown
+ placeholder="Select time range"
+ (selected)="onTimeSelected($event)"
+ size="sm"
+ [inline]="true"
+ class="timepicker-dropdown"
+ [label]="label"
+ i18n>
+ <cds-dropdown-list [items]="timeOptions"></cds-dropdown-list>
+</cds-dropdown>
--- /dev/null
+.timepicker {
+ &-dropdown {
+ display: flex;
+ justify-content: flex-end;
+ align-items: flex-end;
+
+ .cds--label {
+ padding-right: var(--cds-spacing-03);
+ }
+
+ ::ng-deep .cds--dropdown {
+ flex: 0 0 40%;
+ width: 100% !important;
+ min-width: 170px;
+ }
+ }
+}
--- /dev/null
+import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { DropdownModule, ListItem } from 'carbon-components-angular';
+
+@Component({
+ selector: 'cd-time-picker',
+ standalone: true,
+ templateUrl: './time-picker.component.html',
+ styleUrl: './time-picker.component.scss',
+ imports: [DropdownModule]
+})
+export class TimePickerComponent implements OnInit {
+ @Output() selectedTime = new EventEmitter<{ start: number; end: number; step: number }>();
+ @Input() dropdownSize?: 'sm' | 'md' | 'lg' = 'sm';
+ @Input() label?: string = 'Time Span'; // Default to 'Last 1 hour'
+
+ private timeRanges = [
+ { label: $localize`Last 5 minutes`, minutes: 5, step: 1 },
+ { label: $localize`Last 30 minutes`, minutes: 30, step: 7 },
+ { label: $localize`Last 1 hour`, minutes: 60, step: 14 },
+ { label: $localize`Last 6 hours`, minutes: 360, step: 86 },
+ { label: $localize`Last 24 hours`, minutes: 1440, step: 345 },
+ { label: $localize`Last 7 days`, minutes: 10080, step: 2419 },
+ { label: $localize`Last 30 days`, minutes: 43200, step: 10368 }
+ ];
+
+ timeOptions: ListItem[] = this.timeRanges.map((range, index) => ({
+ content: range.label,
+ value: index,
+ selected: index === 2 // Default to 'Last 1 hour'
+ }));
+
+ ngOnInit(): void {
+ const defaultOption = this.timeOptions.find((option) => option.selected);
+ if (defaultOption) {
+ this.emitTime(defaultOption['value']);
+ }
+ }
+
+ onTimeSelected(event: object): void {
+ this.emitTime((event as { item: ListItem }).item['value']);
+ }
+
+ private emitTime(index: number): void {
+ const now = Math.floor(Date.now() / 1000);
+ const selectedRange = this.timeRanges[index];
+ const start = now - selectedRange.minutes * 60;
+
+ this.selectedTime.emit({
+ start,
+ end: now,
+ step: selectedRange.step
+ });
+ }
+}
POOL_IOPS_UTILIZATION = 'topk(5, (rate(ceph_pool_rd[1m]) + rate(ceph_pool_wr[1m])) * on(pool_id, cluster) group_left(instance, name) ceph_pool_metadata )',
POOL_THROUGHPUT_UTILIZATION = 'topk(5, (irate(ceph_pool_rd_bytes[1m]) + irate(ceph_pool_wr_bytes[1m])) * on(pool_id, cluster) group_left(instance, name) ceph_pool_metadata )'
}
+
+export const AllStoragetypesQueries = {
+ READIOPS: `
+ sum(
+ rate(ceph_pool_rd[1m])
+ * on (pool_id, cluster)
+ group_left(application)
+ ceph_pool_metadata{{applicationFilter}}
+ ) OR vector(0)
+ `,
+
+ WRITEIOPS: `
+ sum(
+ rate(ceph_pool_wr[1m])
+ * on (pool_id, cluster)
+ group_left(application)
+ ceph_pool_metadata{{applicationFilter}}
+ ) OR vector(0)
+ `,
+
+ READCLIENTTHROUGHPUT: `
+ sum(
+ rate(ceph_pool_rd_bytes[1m])
+ * on (pool_id, cluster)
+ group_left(application)
+ ceph_pool_metadata{{applicationFilter}}
+ ) OR vector(0)
+ `,
+
+ WRITECLIENTTHROUGHPUT: `
+ sum(
+ rate(ceph_pool_wr_bytes[1m])
+ * on (pool_id, cluster)
+ group_left(application)
+ ceph_pool_metadata{{applicationFilter}}
+ ) OR vector(0)
+ `
+};
--- /dev/null
+import { NumberFormatterService } from '../services/number-formatter.service';
+
+export const DECIMAL = 2;
+
+export function getLabels(unit: string, nf: NumberFormatterService): string[] {
+ switch (unit) {
+ case 'B/s':
+ return nf.bytesPerSecondLabels;
+ case 'B':
+ return nf.bytesLabels;
+ case 'ms':
+ return ['ms', 's'];
+ default:
+ return nf.unitlessLabels;
+ }
+}
+
+export function getDivisor(unit: string): number {
+ switch (unit) {
+ case 'B/s':
+ case 'B':
+ return 1024;
+ case 'ms':
+ return 1000;
+ default:
+ return 1000;
+ }
+}
+
+export function getDisplayUnit(
+ value: number,
+ baseUnit: string,
+ labels: string[],
+ divisor: number
+): string {
+ if (value <= 0) return baseUnit;
+
+ let baseIndex = labels.findIndex((label) => label === baseUnit);
+ if (baseIndex === -1) baseIndex = 0;
+
+ const step = Math.floor(Math.log(value) / Math.log(divisor));
+ const newIndex = Math.min(labels.length - 1, baseIndex + step);
+ return labels[newIndex];
+}
+
+export function formatValues(
+ value: number,
+ baseUnit: string,
+ nf: NumberFormatterService,
+ decimals = DECIMAL
+): string {
+ const labels = getLabels(baseUnit, nf);
+ const divisor = getDivisor(baseUnit);
+ const displayUnit = getDisplayUnit(value, baseUnit, labels, divisor);
+
+ return nf.formatFromTo(value, baseUnit, displayUnit, divisor, labels, decimals);
+}
--- /dev/null
+export interface ChartPoint {
+ timestamp: Date;
+ values: Record<string, number>;
+}
--- /dev/null
+import { ChartPoint } from './area-chart-point';
+
+export interface PerformanceData {
+ [PerformanceType.IOPS]: ChartPoint[];
+ [PerformanceType.Latency]: ChartPoint[];
+ [PerformanceType.Throughput]: ChartPoint[];
+}
+
+export interface TimeRange {
+ start: number;
+ end: number;
+ step: number;
+}
+
+export interface ChartSeriesEntry {
+ labels: { timestamp: string };
+ value: string;
+}
+
+export enum StorageType {
+ Filesystem = 'Filesystem',
+ Block = 'Block',
+ Object = 'Object',
+ All = 'All'
+}
+
+export enum PerformanceType {
+ IOPS = 'iops',
+ Latency = 'latency',
+ Throughput = 'throughput'
+}
+
+export enum Units {
+ IOPS = '',
+ Latency = 'ms',
+ Throughput = 'B/s'
+}
+
+export const METRIC_UNIT_MAP: Record<PerformanceType, string> = {
+ [PerformanceType.Latency]: Units.Latency,
+ [PerformanceType.Throughput]: Units.Throughput,
+ [PerformanceType.IOPS]: Units.IOPS
+};