From 1a6ebae1e33c1d556390eea86ec852525e44d690 Mon Sep 17 00:00:00 2001 From: Devika Babrekar Date: Tue, 20 Jan 2026 11:46:33 +0530 Subject: [PATCH] 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 --- .../dashboard/dashboard-v3.component.ts | 13 +- .../app/ceph/overview/overview.component.html | 4 +- .../ceph/overview/overview.component.spec.ts | 4 +- .../app/ceph/overview/overview.component.ts | 4 +- .../overview-storage-card.component.scss | 1 + .../rgw-overview-dashboard.component.ts | 21 +- .../api/performance-card.service.spec.ts | 126 ++++++++ .../shared/api/performance-card.service.ts | 98 +++++++ .../app/shared/api/prometheus.service.spec.ts | 63 ++++ .../src/app/shared/api/prometheus.service.ts | 104 +++---- .../area-chart/area-chart.component.cy.ts | 70 +++++ .../area-chart/area-chart.component.html | 6 + .../area-chart/area-chart.component.scss | 0 .../area-chart/area-chart.component.spec.ts | 270 ++++++++++++++++++ .../area-chart/area-chart.component.ts | 213 ++++++++++++++ .../performance-card.component.html | 87 ++++++ .../performance-card.component.scss | 46 +++ .../performance-card.component.spec.ts | 22 ++ .../performance-card.component.ts | 110 +++++++ .../time-picker/time-picker.component.cy.ts | 29 ++ .../time-picker/time-picker.component.html | 10 + .../time-picker/time-picker.component.scss | 17 ++ .../time-picker/time-picker.component.ts | 54 ++++ .../app/shared/enum/dashboard-promqls.enum.ts | 38 +++ .../app/shared/helpers/unit-format-utils.ts | 57 ++++ .../src/app/shared/models/area-chart-point.ts | 4 + .../src/app/shared/models/performance-data.ts | 43 +++ .../dashboard/frontend/src/assets/locked.png | Bin 0 -> 10974 bytes 28 files changed, 1444 insertions(+), 70 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.cy.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.cy.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/unit-format-utils.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/area-chart-point.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/models/performance-data.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/assets/locked.png 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 0000000000000000000000000000000000000000..e938c87471de41ee2adf2792aa7e92b3fcce004e GIT binary patch literal 10974 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 literal 0 HcmV?d00001 -- 2.47.3