]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Generic Performace Chart - Carbon 67021/head
authorDevika Babrekar <devika.babrekar@ibm.com>
Tue, 20 Jan 2026 06:16:33 +0000 (11:46 +0530)
committerAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Tue, 24 Feb 2026 10:26:06 +0000 (15:56 +0530)
Fixes: https://tracker.ceph.com/issues/74396
Signed-off-by: Devika Babrekar <devika.babrekar@ibm.com>
fix performance charts

mgr/dashboard: Generic Performance Chart - Area Chart Integration
Fixes:https://tracker.ceph.com/issues/74396
Signed-off-by: Devika Babrekar <devika.babrekar@ibm.com>
add storage type view

mgr/dashboard:Performance Charts - alignment adjustments
fixes:https://tracker.ceph.com/issues/74396
Signed-off-by: Devika Babrekar <devika.babrekar@ibm.com>
Conflicts:
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts

28 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/overview.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-overview-dashboard/rgw-overview-dashboard.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/performance-card.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/prometheus.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.cy.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/area-chart/area-chart.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/performance-card/performance-card.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.cy.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/time-picker/time-picker.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/dashboard-promqls.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/helpers/unit-format-utils.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/area-chart-point.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/performance-data.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/assets/locked.png [new file with mode: 0644]

index 186a0d29b7ff2cbed64eeee7dfc071286ba7a0ab..2bf07cbc48d4224e9290fbe1373ba521e837be5f 100644 (file)
@@ -73,7 +73,7 @@ export class DashboardV3Component extends PrometheusListHelper implements OnInit
   alertType: string;
   alertClass = AlertClass;
 
-  queriesResults: { [key: string]: [] } = {
+  queriesResults: Record<string, [number, string][]> = {
     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']) {
index 0e912d2ef8a3476567a123c2a04836e721ea32d1..bf243f8d0143e3c9c69771d722df08bbf0d950af 100644 (file)
@@ -33,8 +33,8 @@
   <div cdsRow>
     <div cdsCol
          class="cds-mb-5"
-         [columnNumbers]="{lg: 16}">
-      <cds-tile>Performance card</cds-tile>
+         [columnNumbers]="{ lg: 16 }">
+      <cd-performance-card></cd-performance-card>
     </div>
   </div>
 </div>
index f368271c42b5ef4786ad32d6f361578fe3fb4c76..ac36e65d5ef23d338a52aafb71160887f5def81c 100644 (file)
@@ -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(),
index e2ab38b0201def1087872892df5941016860d02e..3a327ce4e2616fc780d4ad3f69113de92cf7ecc9 100644 (file)
@@ -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',
index 3eeb62aa226460fad85f73d2e5c27cea93640913..61f93c9fda13c4c065db365281c0adabf9de3bae 100644 (file)
@@ -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<string, [number, string][]> = {
     RGW_REQUEST_PER_SECOND: [],
     BANDWIDTH: [],
     AVG_GET_LATENCY: [],
@@ -64,6 +64,7 @@ export class RgwOverviewDashboardComponent implements OnInit, OnDestroy {
   multisiteSyncStatus$: Observable<any>;
   subject = new ReplaySubject<any>();
   fetchDataSub: Subscription;
+  private destroy$ = new Subject<void>();
 
   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 (file)
index 0000000..0687198
--- /dev/null
@@ -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 (file)
index 0000000..94315a9
--- /dev/null
@@ -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<PerformanceData> {
+    const queries = this.buildQueriesForStorageType(selectedStorageType);
+
+    return this.prometheusService.getRangeQueriesData(time, queries, true).pipe(
+      map((raw) => {
+        const chartData = this.convertPerformanceData(raw);
+
+        return {
+          iops: chartData.iops.length
+            ? chartData.iops
+            : [{ timestamp: new Date(), values: { 'Read IOPS': 0, 'Write IOPS': 0 } }],
+
+          latency: chartData.latency.length
+            ? chartData.latency
+            : [{ timestamp: new Date(), values: { 'Read Latency': 0, 'Write Latency': 0 } }],
+
+          throughput: chartData.throughput.length
+            ? chartData.throughput
+            : [{ timestamp: new Date(), values: { 'Read Throughput': 0, 'Write Throughput': 0 } }]
+        };
+      })
+    );
+  }
+
+  private buildQueriesForStorageType(storageType: StorageType) {
+    const queries: any = {};
+
+    const applicationFilter =
+      storageType === StorageType.All ? '' : `{application="${storageType}"}`;
+
+    Object.entries(AllStoragetypesQueries).forEach(([key, query]) => {
+      queries[key] = query.replace('{{applicationFilter}}', applicationFilter);
+    });
+
+    return queries;
+  }
+
+  convertPerformanceData(raw: any): PerformanceData {
+    return {
+      iops: this.mergeSeries(
+        this.toSeries(raw?.READIOPS || [], 'Read IOPS'),
+        this.toSeries(raw?.WRITEIOPS || [], 'Write IOPS')
+      ),
+      latency: this.mergeSeries(
+        this.toSeries(raw?.READLATENCY || [], 'Read Latency'),
+        this.toSeries(raw?.WRITELATENCY || [], 'Write Latency')
+      ),
+      throughput: this.mergeSeries(
+        this.toSeries(raw?.READCLIENTTHROUGHPUT || [], 'Read Throughput'),
+        this.toSeries(raw?.WRITECLIENTTHROUGHPUT || [], 'Write Throughput')
+      )
+    };
+  }
+
+  private toSeries(metric: [number, string][], label: string) {
+    return metric.map(([ts, val]) => ({
+      timestamp: new Date(ts * 1000),
+      values: { [label]: Number(val) }
+    }));
+  }
+
+  private mergeSeries(...series: any[]) {
+    const map = new Map<number, any>();
+
+    for (const items of series) {
+      for (const item of items) {
+        const time = item.timestamp.getTime();
+
+        if (!map.has(time)) {
+          map.set(time, {
+            timestamp: item.timestamp,
+            values: { ...item.values }
+          });
+        } else {
+          Object.assign(map.get(time).values, item.values);
+        }
+      }
+    }
+
+    return [...map.values()].sort((a, b) => a.timestamp - b.timestamp);
+  }
+}
index 0b949c46c62cac2f4c10dcdf3211651fbdde353b..767d83881a558f890ee4cb5589909c04b7e2d270 100644 (file)
@@ -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();
+    });
+  });
 });
index 9e41497446b6717e45407c1aa4ddcc04b541836d..355d6969f8d58d6b46380e5dc13e64f2a4726e59 100644 (file)
@@ -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<any>();
 
   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<string, string>,
+    checkNan?: boolean
+  ): Observable<Record<string, [number, string][]>> {
+    return timer(0, this.timerTime).pipe(
+      switchMap(() => {
+        this.ifPrometheusConfigured(() => {});
+
+        const updatedTime = this.updateTimeStamp(selectedTime);
+
+        const observables = Object.entries(queries).map(([queryName, query]) =>
+          this.getPrometheusData({
+            params: encodeURIComponent(query),
+            start: updatedTime.start,
+            end: updatedTime.end,
+            step: updatedTime.step
+          }).pipe(
+            map((data: any) => ({
+              queryName,
+              values: data.result?.length ? data.result[0].values : []
+            }))
+          )
+        );
+
+        return forkJoin(observables) as Observable<Array<{ queryName: string; values: any[] }>>;
+      }),
+      map((results) => {
+        const formattedResults: Record<string, [number, string][]> = {};
+
+        results.forEach(({ queryName, values }) => {
+          if (checkNan) {
+            values = values.map((v) => {
+              if (isNaN(parseFloat(v[1]))) {
+                v[1] = '0';
+              }
+              return v;
+            });
+          }
+
+          formattedResults[queryName] = values;
+        });
+
+        return formattedResults;
+      })
+    );
+  }
 }
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 (file)
index 0000000..2ce1769
--- /dev/null
@@ -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<string, string>;
+    }>();
+    currentFormattedValues.emit = emitSpy;
+
+    cy.mount(AreaChartComponent, {
+      componentProperties: {
+        chartTitle: 'Test Chart',
+        dataUnit: 'B/s',
+        chartKey: 'test-key',
+        rawData: mockData,
+        currentFormattedValues
+      },
+      imports: [ChartsModule],
+      providers: [NumberFormatterService],
+      schemas: [CUSTOM_ELEMENTS_SCHEMA]
+    });
+
+    cy.get('@currentFormattedValuesSpy').should('have.been.calledOnce');
+    cy.contains('Test Chart').should('exist');
+  });
+
+  it('should set correct chartOptions based on max value', () => {
+    cy.mount(AreaChartComponent, {
+      componentProperties: {
+        chartTitle: 'Test Chart',
+        dataUnit: 'B/s',
+        rawData: mockData,
+        chartKey: 'test-key'
+      },
+      imports: [ChartsModule],
+      providers: [NumberFormatterService],
+      schemas: [CUSTOM_ELEMENTS_SCHEMA]
+    }).then(({ component }) => {
+      const options = component.chartOptions;
+      expect(options?.axes?.left?.domain?.[1]).to.be.greaterThan(0);
+      expect(options?.tooltip?.enabled).to.equal(true);
+      expect(options?.axes?.bottom?.scaleType).to.equal('time');
+    });
+  });
+});
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 (file)
index 0000000..1313c56
--- /dev/null
@@ -0,0 +1,6 @@
+@if(chartData && chartOptions){
+<ibm-line-chart
+    [data]="chartData"
+    [options]="chartOptions">
+</ibm-line-chart>
+}
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..26a454b
--- /dev/null
@@ -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<AreaChartComponent>;
+  let numberFormatterService: NumberFormatterService;
+  let datePipe: DatePipe;
+
+  const mockData: ChartPoint[] = [
+    {
+      timestamp: new Date('2024-01-01T00:00:00Z'),
+      values: { read: 1024, write: 2048 }
+    },
+    {
+      timestamp: new Date('2024-01-01T00:01:00Z'),
+      values: { read: 1536, write: 4096 }
+    }
+  ];
+
+  beforeEach(async () => {
+    const numberFormatterMock = {
+      formatFromTo: jest.fn().mockReturnValue('1.00'),
+      bytesPerSecondLabels: [
+        'B/s',
+        'KiB/s',
+        'MiB/s',
+        'GiB/s',
+        'TiB/s',
+        'PiB/s',
+        'EiB/s',
+        'ZiB/s',
+        'YiB/s'
+      ],
+      bytesLabels: ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'YiB'],
+      unitlessLabels: ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
+    };
+
+    const datePipeMock = {
+      transform: jest.fn().mockReturnValue('01 Jan, 00:00:00')
+    };
+
+    await TestBed.configureTestingModule({
+      imports: [ChartsModule, AreaChartComponent],
+      providers: [
+        { provide: NumberFormatterService, useValue: numberFormatterMock },
+        { provide: DatePipe, useValue: datePipeMock }
+      ],
+      schemas: [CUSTOM_ELEMENTS_SCHEMA]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(AreaChartComponent);
+    component = fixture.componentInstance;
+    numberFormatterService = TestBed.inject(NumberFormatterService);
+    datePipe = TestBed.inject(DatePipe);
+  });
+
+  it('should mount', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should render chart data and emit formatted values', () => {
+    const emitSpy = jest.spyOn(component.currentFormattedValues, 'emit');
+
+    component.chartTitle = 'Test Chart';
+    component.dataUnit = 'B/s';
+    component.chartKey = 'test-key';
+    component.rawData = mockData;
+
+    (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('2.00 KiB/s');
+
+    fixture.detectChanges();
+
+    // Trigger ngOnChanges manually
+    component.ngOnChanges({
+      rawData: new SimpleChange(null, mockData, false)
+    });
+
+    expect(emitSpy).toHaveBeenCalledTimes(1);
+    expect(emitSpy.mock.calls[emitSpy.mock.calls.length - 1][0]).toEqual({
+      key: 'test-key',
+      values: expect.objectContaining({
+        read: expect.any(String),
+        write: expect.any(String)
+      })
+    });
+  });
+
+  it('should set correct chartOptions based on max value', () => {
+    component.chartTitle = 'Test Chart';
+    component.dataUnit = 'B/s';
+    component.rawData = mockData;
+    component.chartKey = 'test-key';
+
+    (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('4.00 KiB/s');
+
+    fixture.detectChanges();
+
+    // Trigger ngOnChanges manually
+    component.ngOnChanges({
+      rawData: new SimpleChange(null, mockData, false)
+    });
+
+    const options = component.chartOptions;
+    expect(options?.axes?.left?.domain?.[1]).toBeGreaterThan(0);
+    expect(options?.tooltip?.enabled).toBe(true);
+    expect(options?.axes?.bottom?.scaleType).toBe('time');
+  });
+
+  it('should merge custom options with default chart options', () => {
+    component.chartTitle = 'Test Chart';
+    component.dataUnit = 'B/s';
+    component.rawData = mockData;
+    component.customOptions = {
+      height: '500px',
+      animations: true
+    };
+
+    (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('4.00 KiB/s');
+
+    fixture.detectChanges();
+    component.ngOnChanges({
+      rawData: new SimpleChange(null, mockData, false)
+    });
+
+    expect(component.chartOptions?.height).toBe('500px');
+    expect(component.chartOptions?.animations).toBe(true);
+    expect(component.chartOptions?.tooltip?.enabled).toBe(true);
+  });
+
+  it('should format tooltip with custom date format', () => {
+    const testDate = new Date('2024-01-01T12:30:45Z');
+    const formattedDate = '01 Jan, 12:30:45';
+    const defaultHTML = '<div><p class="value">2024-01-01T12:30:45Z</p></div>';
+
+    (datePipe.transform as jest.Mock).mockReturnValue(formattedDate);
+
+    const result = component.formatChartTooltip(defaultHTML, [{ date: testDate }]);
+
+    expect(datePipe.transform).toHaveBeenCalledWith(testDate, 'dd MMM, HH:mm:ss');
+    expect(result).toContain(formattedDate);
+    expect(result).not.toContain('2024-01-01T12:30:45Z');
+  });
+
+  it('should return default HTML if tooltip data is empty', () => {
+    const defaultHTML = '<div><p>Default</p></div>';
+    const result = component.formatChartTooltip(defaultHTML, []);
+    expect(result).toBe(defaultHTML);
+  });
+
+  it('should transform raw data to chart data format correctly', () => {
+    component.rawData = mockData;
+
+    fixture.detectChanges();
+    component.ngOnChanges({
+      rawData: new SimpleChange(null, mockData, false)
+    });
+
+    expect(component.chartData.length).toBe(4); // 2 timestamps * 2 groups (read, write)
+    expect(component.chartData[0]).toEqual({
+      group: 'read',
+      date: mockData[0].timestamp,
+      value: 1024
+    });
+    expect(component.chartData[1]).toEqual({
+      group: 'write',
+      date: mockData[0].timestamp,
+      value: 2048
+    });
+  });
+
+  it('should not emit formatted values if rawData is empty', () => {
+    const emitSpy = jest.spyOn(component.currentFormattedValues, 'emit');
+    component.rawData = [];
+    component.chartKey = 'test-key';
+
+    fixture.detectChanges();
+    component.ngOnChanges({
+      rawData: new SimpleChange(null, [], false)
+    });
+
+    expect(emitSpy).not.toHaveBeenCalled();
+  });
+
+  it('should not emit formatted values if values have not changed', () => {
+    const emitSpy = jest.spyOn(component.currentFormattedValues, 'emit');
+    component.dataUnit = 'B/s';
+    component.chartKey = 'test-key';
+    component.rawData = mockData;
+
+    (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('2.00 KiB/s');
+
+    fixture.detectChanges();
+    component.ngOnChanges({
+      rawData: new SimpleChange(null, mockData, false)
+    });
+
+    expect(emitSpy).toHaveBeenCalledTimes(1);
+
+    // Update with same values
+    const sameData: ChartPoint[] = [
+      {
+        timestamp: new Date('2024-01-01T00:02:00Z'),
+        values: { read: 1024, write: 2048 } // Same values as first entry
+      }
+    ];
+    component.rawData = sameData;
+
+    component.ngOnChanges({
+      rawData: new SimpleChange(mockData, sameData, false)
+    });
+
+    // Should not emit again since values are the same
+    expect(emitSpy).toHaveBeenCalledTimes(2);
+  });
+
+  it('should emit formatted values when values change', () => {
+    const emitSpy = jest.spyOn(component.currentFormattedValues, 'emit');
+    component.dataUnit = 'B/s';
+    component.chartKey = 'test-key';
+    component.rawData = mockData;
+
+    (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('2.00 KiB/s');
+
+    fixture.detectChanges();
+    component.ngOnChanges({
+      rawData: new SimpleChange(null, mockData, false)
+    });
+
+    expect(emitSpy).toHaveBeenCalledTimes(1);
+
+    // Update with different values
+    const newData: ChartPoint[] = [
+      {
+        timestamp: new Date('2024-01-01T00:02:00Z'),
+        values: { read: 5120, write: 8192 } // Different values
+      }
+    ];
+    component.rawData = newData;
+
+    (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('5.00 KiB/s');
+
+    component.ngOnChanges({
+      rawData: new SimpleChange(mockData, newData, false)
+    });
+
+    // Should emit again since values changed
+    expect(emitSpy).toHaveBeenCalledTimes(2);
+  });
+
+  it('should set chart title in chart options', () => {
+    component.chartTitle = 'Test Chart';
+    component.dataUnit = 'B/s';
+    component.rawData = mockData;
+
+    (numberFormatterService.formatFromTo as jest.Mock).mockReturnValue('4.00 KiB/s');
+
+    fixture.detectChanges();
+    component.ngOnChanges({
+      rawData: new SimpleChange(null, mockData, false)
+    });
+
+    expect(component.chartOptions?.title).toBe('Test Chart');
+  });
+});
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 (file)
index 0000000..5bc0625
--- /dev/null
@@ -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<AreaChartOptions>;
+
+  @Output() currentFormattedValues = new EventEmitter<{
+    key: string;
+    values: Record<string, string>;
+  }>();
+
+  chartData: ChartTabularData = [];
+  chartOptions!: AreaChartOptions;
+
+  private chartDisplayUnit = '';
+
+  private cdr = inject(ChangeDetectorRef);
+  private numberFormatter: NumberFormatterService = inject(NumberFormatterService);
+  private datePipe = inject(DatePipe);
+  private lastEmittedRawValues?: Record<string, number>;
+
+  ngOnChanges(changes: SimpleChanges): void {
+    if (changes['rawData'] && this.rawData?.length) {
+      this.updateChart();
+    }
+  }
+
+  // Convert raw data structure to tabular format accepted by Carbon charts.
+  private transformToChartData(data: ChartPoint[]): ChartTabularData {
+    return data.flatMap(({ timestamp, values }) =>
+      Object.entries(values).map(([group, value]) => ({
+        group,
+        date: timestamp,
+        value
+      }))
+    );
+  }
+
+  // Main method to process chart input, compute scale, format labels,
+  // and merge with custom chart options if provided.
+  private updateChart(): void {
+    this.chartData = this.transformToChartData(this.rawData);
+
+    const max = Math.max(...this.chartData.map((d) => d['value']), 1);
+
+    const labels = getLabels(this.dataUnit, this.numberFormatter);
+    const divisor = getDivisor(this.dataUnit);
+    this.chartDisplayUnit = getDisplayUnit(max, this.dataUnit, labels, divisor);
+
+    this.emitCurrentFomattedValue();
+
+    // Merge base and custom chart options
+    const defaultOptions = this.getChartOptions(max, labels, divisor);
+    this.chartOptions = merge({}, defaultOptions, this.customOptions || {});
+    this.cdr.detectChanges();
+  }
+
+  private emitCurrentFomattedValue() {
+    const latestEntry = this.rawData[this.rawData.length - 1];
+    if (!latestEntry) return;
+
+    if (
+      this.lastEmittedRawValues &&
+      Object.keys(latestEntry.values).every(
+        (value) => latestEntry.values[value] === this.lastEmittedRawValues?.[value]
+      )
+    ) {
+      return;
+    }
+
+    const formattedValues: Record<string, string> = {};
+    for (const [group, value] of Object.entries(latestEntry.values)) {
+      formattedValues[group] = formatValues(
+        value ?? 0,
+        this.dataUnit,
+        this.numberFormatter,
+        this.decimals
+      );
+    }
+
+    this.currentFormattedValues.emit({
+      key: this.chartKey,
+      values: formattedValues
+    });
+
+    this.lastEmittedRawValues = { ...latestEntry.values };
+  }
+
+  private getChartOptions(max: number, labels: string[], divisor: number): AreaChartOptions {
+    return {
+      title: this.chartTitle,
+      axes: {
+        bottom: {
+          title: 'Time',
+          mapsTo: 'date',
+          scaleType: ScaleTypes.TIME,
+          ticks: {
+            number: 4,
+            rotateIfSmallerThan: 0
+          }
+        },
+        left: {
+          title: `${this.chartTitle}${this.chartDisplayUnit ? ` (${this.chartDisplayUnit})` : ''}`,
+          mapsTo: 'value',
+          scaleType: ScaleTypes.LINEAR,
+          domain: [0, max],
+          ticks: {
+            // Only return numeric part of the formatted string (exclude units)
+            formatter: (tick: number | Date): string => {
+              const raw = this.formatValueForChart(tick, labels, divisor);
+              const num = parseFloat(raw);
+              return num.toString();
+            }
+          }
+        }
+      },
+      tooltip: {
+        enabled: true,
+        showTotal: false,
+        valueFormatter: (value: number): string =>
+          (this.formatValueForChart(value, labels, divisor) || value).toString(),
+        customHTML: (data, defaultHTML) => this.formatChartTooltip(defaultHTML, data)
+      },
+      points: {
+        enabled: false
+      },
+      toolbar: {
+        enabled: false,
+        controls: [
+          {
+            type: ToolbarControlTypes.EXPORT_CSV
+          },
+          {
+            type: ToolbarControlTypes.EXPORT_PNG
+          },
+          {
+            type: ToolbarControlTypes.EXPORT_JPG
+          },
+          {
+            type: ToolbarControlTypes.SHOW_AS_DATATABLE
+          }
+        ]
+      },
+      animations: false,
+      height: '300px',
+      data: {
+        loading: !this.chartData?.length
+      }
+    };
+  }
+
+  // Custom tooltip formatter to replace default timestamp with a formatted one.
+  formatChartTooltip(defaultHTML: string, data: { date: Date }[]): string {
+    if (!data?.length) return defaultHTML;
+
+    const formattedTime = this.datePipe.transform(data[0].date, 'dd MMM, HH:mm:ss');
+    return defaultHTML.replace(
+      /<p class="value">.*?<\/p>/,
+      `<p class="value">${formattedTime}</p>`
+    );
+  }
+
+  // Uses number formatter service to convert chart value based on unit and divisor.
+  private formatValueForChart(input: number | Date, labels: string[], divisor: number): string {
+    if (typeof input !== 'number') return '';
+    return this.numberFormatter.formatFromTo(
+      input,
+      this.dataUnit,
+      this.chartDisplayUnit,
+      divisor,
+      labels,
+      this.decimals
+    );
+  }
+}
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 (file)
index 0000000..0f61a6d
--- /dev/null
@@ -0,0 +1,87 @@
+<div class="performance-card-wrapper">
+  <cd-productive-card
+    headerTitle="Performance"
+    i18n-headerTitle
+    [applyShadow]="false"
+  >
+    @if(chartDataLengthSignal() > 0) {
+    <ng-template #header>
+      <h2 class="cds--type-heading-compact-02"
+          i18n>Performance</h2>
+      <div cdsStack="horizontal"
+           gap="2">
+        <cds-dropdown
+          [placeholder]="selectedStorageType"
+          (selected)="onStorageTypeSelection($event)"
+          [label]="'Storage Type'"
+          class="overview-storage-card-dropdown"
+          [inline]="true"
+          size="sm"
+          i18n>
+          <cds-dropdown-list
+            [items]="storageTypes"
+            size="sm"></cds-dropdown-list>
+        </cds-dropdown>
+
+        <cd-time-picker
+          [dropdwonSize]="'sm'"
+          [label]="'Time Span'"
+          (selectedTime)="loadCharts($event)"
+        ></cd-time-picker>
+      </div>
+    </ng-template>
+    <div cdsGrid
+         [narrow]="true"
+         [condensed]="false"
+         [fullWidth]="true"
+         class="cds-mt-5 cds-mb-5">
+      <div cdsRow
+           [narrow]="true">
+        <div cdsCol
+             class="cds-mb-5"
+             [columnNumbers]="{lg: 5, md: 8, sm: 12}">
+          <cd-area-chart
+            chartTitle="IOPS"
+            [chartKey]="performanceTypes?.IOPS"
+            [dataUnit]="metricUnitMap?.iops"
+            [rawData]="chartDataSignal()?.iops">
+          </cd-area-chart>
+        </div>
+        <div cdsCol
+             [columnNumbers]="{lg: 5, md: 8, sm: 12}"
+             class="cds-mb-5 performance-card-latency-chart">
+          <cd-area-chart
+            chartTitle="Latency"
+            [chartKey]="performanceTypes?.Latency"
+            [dataUnit]="metricUnitMap?.latency"
+            [rawData]="chartDataSignal()?.latency">
+          </cd-area-chart>
+        </div>
+        <div cdsCol
+             class="cds-mb-5"
+             [columnNumbers]="{lg: 5, md: 8, sm: 12}">
+          <cd-area-chart
+            chartTitle="Throughput"
+            [chartKey]="performanceTypes?.Throughput"
+            [dataUnit]="metricUnitMap?.throughput"
+            [rawData]="chartDataSignal()?.throughput">
+          </cd-area-chart>
+        </div>
+      </div>
+    </div>
+    }
+
+    @if(chartDataLengthSignal() === 0) {
+    <div class="performance-card-empty-msg">
+      <div class="performance-card-empty-msg-icon">
+        <img src="assets/locked.png"
+             alt="no-services-links"/>
+      </div>
+      <span class="cds--type-label-01"
+            i18n>
+        You must have storage configured to access this capability.
+      </span>
+    </div>
+    }
+  </cd-productive-card>
+</div>
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 (file)
index 0000000..aa2bff0
--- /dev/null
@@ -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 (file)
index 0000000..a26fbae
--- /dev/null
@@ -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<PerformanceCardComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      imports: [HttpClientTestingModule, PerformanceCardComponent]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(PerformanceCardComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
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 (file)
index 0000000..7341628
--- /dev/null
@@ -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<PerformanceData | null>(null);
+  chartDataLengthSignal = computed(() => {
+    const data = this.chartDataSignal();
+    return data ? Object.keys(data).length : 0;
+  });
+  performanceTypes = PerformanceType;
+  metricUnitMap = METRIC_UNIT_MAP;
+  icons = Icons;
+  iconSize = IconSize;
+
+  private destroy$ = new Subject<void>();
+
+  storageTypes: ListItem[] = [
+    { content: 'All', value: StorageType.All, selected: true },
+    {
+      content: 'Filesystem',
+      value: StorageType.Filesystem,
+      selected: false
+    },
+    {
+      content: 'Block',
+      value: StorageType.Block,
+      selected: false
+    },
+    {
+      content: 'Object',
+      value: StorageType.Object,
+      selected: false
+    }
+  ];
+
+  selectedStorageType = StorageType.All;
+
+  private prometheusService = inject(PrometheusService);
+  private performanceCardService = inject(PerformanceCardService);
+
+  time = { ...this.prometheusService.lastHourDateObject };
+  private chartSub?: Subscription;
+
+  ngOnInit() {
+    this.loadCharts(this.time);
+  }
+
+  loadCharts(time: { start: number; end: number; step: number }) {
+    this.time = { ...time };
+
+    this.chartSub?.unsubscribe();
+
+    this.chartSub = this.performanceCardService
+      .getChartData(time, this.selectedStorageType)
+      .pipe(takeUntil(this.destroy$))
+      .subscribe((data) => {
+        this.chartDataSignal.set(data);
+      });
+  }
+
+  onStorageTypeSelection(event: any) {
+    this.selectedStorageType = event.item.value;
+    this.loadCharts(this.time);
+  }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+    this.chartSub?.unsubscribe();
+  }
+}
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 (file)
index 0000000..f777263
--- /dev/null
@@ -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 (file)
index 0000000..c1089b2
--- /dev/null
@@ -0,0 +1,10 @@
+<cds-dropdown
+  placeholder="Select time range"
+  (selected)="onTimeSelected($event)"
+  size="sm"
+  [inline]="true"
+  class="timepicker-dropdown"
+  [label]="label"
+  i18n>
+  <cds-dropdown-list [items]="timeOptions"></cds-dropdown-list>
+</cds-dropdown>
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 (file)
index 0000000..07c749f
--- /dev/null
@@ -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 (file)
index 0000000..f028adc
--- /dev/null
@@ -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
+    });
+  }
+}
index 08e054173952c79f158a5f55abe19d1ba15ffebd..dc9542ddeb7ca4b96d78cd58b7c0c4264e487198 100644 (file)
@@ -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 (file)
index 0000000..1d2935f
--- /dev/null
@@ -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 (file)
index 0000000..4f00631
--- /dev/null
@@ -0,0 +1,4 @@
+export interface ChartPoint {
+  timestamp: Date;
+  values: Record<string, number>;
+}
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 (file)
index 0000000..ef9ca54
--- /dev/null
@@ -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, string> = {
+  [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 (file)
index 0000000..e938c87
Binary files /dev/null and b/src/pybind/mgr/dashboard/frontend/src/assets/locked.png differ