From: Devika Babrekar Date: Tue, 20 Jan 2026 06:16:33 +0000 (+0530) Subject: mgr/dashboard: Generic Performace Chart - Carbon X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F67021%2Fhead;p=ceph.git mgr/dashboard: Generic Performace Chart - Carbon Fixes: https://tracker.ceph.com/issues/74396 Signed-off-by: Devika Babrekar fix performance charts mgr/dashboard: Generic Performance Chart - Area Chart Integration Fixes:https://tracker.ceph.com/issues/74396 Signed-off-by: Devika Babrekar add storage type view mgr/dashboard:Performance Charts - alignment adjustments fixes:https://tracker.ceph.com/issues/74396 Signed-off-by: Devika Babrekar Conflicts: src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.ts index 186a0d29b7f..2bf07cbc48d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.ts @@ -73,7 +73,7 @@ export class DashboardV3Component extends PrometheusListHelper implements OnInit alertType: string; alertClass = AlertClass; - queriesResults: { [key: string]: [] } = { + queriesResults: Record = { USEDCAPACITY: [], IPS: [], OPS: [], @@ -185,11 +185,12 @@ export class DashboardV3Component extends PrometheusListHelper implements OnInit } 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']) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html index 0e912d2ef8a..bf243f8d014 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html @@ -33,8 +33,8 @@
- Performance card + [columnNumbers]="{ lg: 16 }"> +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts index f368271c42b..ac36e65d5ef 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts @@ -18,6 +18,7 @@ import { OverviewAlertsCardComponent } from './alerts-card/overview-alerts-card. 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; @@ -51,7 +52,8 @@ describe('OverviewComponent', () => { OverviewStorageCardComponent, OverviewHealthCardComponent, OverviewAlertsCardComponent, - RouterModule + RouterModule, + HttpClientTestingModule ], providers: [ provideHttpClient(), diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts index e2ab38b0201..3a327ce4e26 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts @@ -24,6 +24,7 @@ import { OverviewHealthCardComponent } from './health-card/overview-health-card. 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, @@ -123,7 +124,8 @@ export function buildHealthCardVM(d: HealthSnapshotMap): HealthCardVM { OverviewStorageCardComponent, OverviewHealthCardComponent, ComponentsModule, - OverviewAlertsCardComponent + OverviewAlertsCardComponent, + PerformanceCardComponent ], standalone: true, templateUrl: './overview.component.html', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.scss index 1d8cdca8558..470e591c512 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.scss @@ -10,6 +10,7 @@ .cds--dropdown { flex: 0 0 40%; + min-width: 130px; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-overview-dashboard/rgw-overview-dashboard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-overview-dashboard/rgw-overview-dashboard.component.ts index 3eeb62aa226..61f93c9fda1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-overview-dashboard/rgw-overview-dashboard.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-overview-dashboard/rgw-overview-dashboard.component.ts @@ -1,7 +1,7 @@ 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'; @@ -16,7 +16,7 @@ import { PrometheusService } from '~/app/shared/api/prometheus.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'; @@ -45,7 +45,7 @@ export class RgwOverviewDashboardComponent implements OnInit, OnDestroy { multisiteInfo: object[] = []; ZonegroupSub: Subscription; ZoneSUb: Subscription; - queriesResults: { [key: string]: [] } = { + queriesResults: Record = { RGW_REQUEST_PER_SECOND: [], BANDWIDTH: [], AVG_GET_LATENCY: [], @@ -64,6 +64,7 @@ export class RgwOverviewDashboardComponent implements OnInit, OnDestroy { multisiteSyncStatus$: Observable; subject = new ReplaySubject(); fetchDataSub: Subscription; + private destroy$ = new Subject(); constructor( private authStorageService: AuthStorageService, @@ -146,16 +147,18 @@ export class RgwOverviewDashboardComponent implements OnInit, OnDestroy { 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() { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts new file mode 100644 index 00000000000..06871985247 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts @@ -0,0 +1,126 @@ +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); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts new file mode 100644 index 00000000000..94315a9227c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts @@ -0,0 +1,98 @@ +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 { + 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(); + + 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); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts index 0b949c46c62..767d83881a5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts @@ -5,6 +5,7 @@ import { configureTestBed } from '~/testing/unit-test-helper'; 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; @@ -244,4 +245,66 @@ describe('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(); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts index 9e41497446b..355d6969f8d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts @@ -1,7 +1,7 @@ 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'; @@ -23,6 +23,9 @@ export type PromqlGuageMetric = { 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' }) @@ -40,6 +43,7 @@ export class PrometheusService { prometheus: 'ui-api/prometheus/prometheus-api-host' }; private settings: { [url: string]: string } = {}; + updatedChrtData = new Subject(); constructor(private http: HttpClient) {} @@ -172,56 +176,6 @@ export class PrometheusService { 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']; @@ -323,4 +277,52 @@ export class PrometheusService { }); }); } + + getRangeQueriesData( + selectedTime: any, + queries: Record, + checkNan?: boolean + ): Observable> { + 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>; + }), + map((results) => { + const formattedResults: Record = {}; + + 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; + }) + ); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.cy.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.cy.ts new file mode 100644 index 00000000000..2ce17691c52 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.cy.ts @@ -0,0 +1,70 @@ +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; + }>(); + 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'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html new file mode 100644 index 00000000000..1313c5627fb --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html @@ -0,0 +1,6 @@ +@if(chartData && chartOptions){ + + +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts new file mode 100644 index 00000000000..26a454b54f7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts @@ -0,0 +1,270 @@ +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; + 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 = '

2024-01-01T12:30:45Z

'; + + (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 = '

Default

'; + 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'); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts new file mode 100644 index 00000000000..5bc0625acf5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts @@ -0,0 +1,213 @@ +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; + + @Output() currentFormattedValues = new EventEmitter<{ + key: string; + values: Record; + }>(); + + chartData: ChartTabularData = []; + chartOptions!: AreaChartOptions; + + private chartDisplayUnit = ''; + + private cdr = inject(ChangeDetectorRef); + private numberFormatter: NumberFormatterService = inject(NumberFormatterService); + private datePipe = inject(DatePipe); + private lastEmittedRawValues?: Record; + + 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 = {}; + 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>/, + `

${formattedTime}

` + ); + } + + // 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 + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html new file mode 100644 index 00000000000..0f61a6dc3a6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html @@ -0,0 +1,87 @@ +
+ + @if(chartDataLengthSignal() > 0) { + +

Performance

+
+ + + + + +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ } + + @if(chartDataLengthSignal() === 0) { +
+
+ no-services-links +
+ + You must have storage configured to access this capability. + +
+ } +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.scss new file mode 100644 index 00000000000..aa2bff0b1a2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.scss @@ -0,0 +1,46 @@ +.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; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts new file mode 100644 index 00000000000..a26fbae9824 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts @@ -0,0 +1,22 @@ +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; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HttpClientTestingModule, PerformanceCardComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(PerformanceCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts new file mode 100644 index 00000000000..73416288096 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts @@ -0,0 +1,110 @@ +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(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(); + + 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(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.cy.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.cy.ts new file mode 100644 index 00000000000..f77726370a4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.cy.ts @@ -0,0 +1,29 @@ +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); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.html new file mode 100644 index 00000000000..c1089b2cb8c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.html @@ -0,0 +1,10 @@ + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.scss new file mode 100644 index 00000000000..07c749f4447 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.scss @@ -0,0 +1,17 @@ +.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; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.ts new file mode 100644 index 00000000000..f028adc2ac3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.ts @@ -0,0 +1,54 @@ +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 + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts index 08e05417395..dc9542ddeb7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts @@ -56,3 +56,41 @@ export enum MultiClusterPromqlsForPoolUtilization { 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) + ` +}; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/unit-format-utils.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/unit-format-utils.ts new file mode 100644 index 00000000000..1d2935f6520 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/unit-format-utils.ts @@ -0,0 +1,57 @@ +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); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/area-chart-point.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/area-chart-point.ts new file mode 100644 index 00000000000..4f00631670a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/area-chart-point.ts @@ -0,0 +1,4 @@ +export interface ChartPoint { + timestamp: Date; + values: Record; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/performance-data.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/performance-data.ts new file mode 100644 index 00000000000..ef9ca54d101 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/performance-data.ts @@ -0,0 +1,43 @@ +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.Latency]: Units.Latency, + [PerformanceType.Throughput]: Units.Throughput, + [PerformanceType.IOPS]: Units.IOPS +}; diff --git a/src/pybind/mgr/dashboard/frontend/src/assets/locked.png b/src/pybind/mgr/dashboard/frontend/src/assets/locked.png new file mode 100644 index 00000000000..e938c87471d Binary files /dev/null and b/src/pybind/mgr/dashboard/frontend/src/assets/locked.png differ