From 512708a8e32b30b509a310d4216c779c06f2e7b5 Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Wed, 18 Feb 2026 07:38:08 +0530 Subject: [PATCH] mgr/dashboard: Removed Raw capacity toggle - removed raw capacity toggle - updated tests - added polling for promethues queries - added tests for formatter service functions Signed-off-by: Afreen Misbah --- .../dashboard/dashboard-v3.component.html | 4 +- .../app/ceph/overview/overview.component.html | 4 +- .../app/ceph/overview/overview.component.ts | 2 - .../overview-storage-card.component.html | 23 +--- .../overview-storage-card.component.scss | 2 - .../overview-storage-card.component.spec.ts | 57 ++-------- .../overview-storage-card.component.ts | 106 +++++++----------- .../shared/components/components.module.ts | 6 +- .../productive-card.component.scss | 1 + .../shared/services/formatter.service.spec.ts | 66 +++++++++++ .../app/shared/services/formatter.service.ts | 31 +++-- 11 files changed, 148 insertions(+), 154 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.html index 2e74622998d..e7504450d9e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/dashboard-v3/dashboard/dashboard-v3.component.html @@ -2,8 +2,8 @@
- +
Cluster ID
{{ detailsCardData.fsid }}
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 8c234111c98..246edfff161 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 @@ -23,8 +23,8 @@ [columnNumbers]="{lg: 16}"> @if (healthData$ | async; as healthData) { + [total]="healthData.pgmap.bytes_total" + [used]="healthData.pgmap.bytes_used"> }
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 c76dceb4e93..16f5afd02a4 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 @@ -16,8 +16,6 @@ import { CommonModule } from '@angular/common'; styleUrl: './overview.component.scss' }) export class OverviewComponent implements OnDestroy { - totalCapacity: number; - usedCapacity: number; private destroy$ = new Subject(); public healthData$: Observable; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html index eb528c88055..6400faf4dd5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.html @@ -14,13 +14,13 @@ - +
- @if(usedRaw && totalRaw && usedRawUnit && totalRawUnit) { + @if(displayUsedRaw && totalRaw && usedRawUnit && totalRawUnit) {
{{usedRaw}}&ngsp; + i18n>{{displayUsedRaw}}&ngsp; {{usedRawUnit}} of {{totalRaw}} {{totalRawUnit}} used @@ -33,23 +33,6 @@ [maxLineWidth]="400"> } - - - Raw capacity - -
@if(displayData) { 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 bd0e7a1cbed..1d8cdca8558 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 @@ -21,12 +21,10 @@ // Hiding the native chart title &-chart { .meter-title { - height: 0 !important; display: none !important; } .spacer { - height: 0 !important; display: none !important; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts index 5a8e63011f6..83ac658b151 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.spec.ts @@ -22,15 +22,15 @@ describe('OverviewStorageCardComponent (Jest)', () => { result: [ { metric: { application: 'Block' }, - value: [0, 1024] + value: [0, '1024'] }, { metric: { application: 'Filesystem' }, - value: [0, 2048] + value: [0, '2048'] }, { metric: { application: 'Object' }, - value: [0, 0] // should be filtered + value: [0, '0'] // should be filtered } ] }; @@ -103,31 +103,6 @@ describe('OverviewStorageCardComponent (Jest)', () => { expect(component.usedRaw).toBe(10); expect(component.usedRawUnit).toBe('GiB'); }); - - // -------------------------------------------------- - // TOGGLE - // -------------------------------------------------- - - it('should switch to RAW when toggled true', () => { - component.toggleRawCapacity(true); - - expect(component.isRawCapacity).toBe(true); - expect(component.selectedCapacityType).toBe('raw'); - }); - - it('should switch to USED when toggled false', () => { - component.toggleRawCapacity(false); - - expect(component.isRawCapacity).toBe(false); - expect(component.selectedCapacityType).toBe('used'); - }); - - it('should call Prometheus again when toggled', () => { - component.toggleRawCapacity(false); - - expect(mockPrometheusService.getPrometheusQueryData).toHaveBeenCalledTimes(2); - }); - // -------------------------------------------------- // ngOnInit data load // -------------------------------------------------- @@ -147,11 +122,9 @@ describe('OverviewStorageCardComponent (Jest)', () => { { group: 'Filesystem', value: 20 } ]; - component.selectedStorageType = 'Block'; - (component as any).setChartData(); + component.onStorageTypeSelect({ item: { content: 'Block', selected: true } } as any); - expect(component.displayData.length).toBe(1); - expect(component.displayData[0].group).toBe('Block'); + expect(component.displayData).toEqual([{ group: 'Block', value: 10 }]); }); it('should show all data when ALL selected', () => { @@ -160,8 +133,7 @@ describe('OverviewStorageCardComponent (Jest)', () => { { group: 'Filesystem', value: 20 } ]; - component.selectedStorageType = 'All'; - (component as any).setChartData(); + component.onStorageTypeSelect({ item: { content: 'All', selected: true } } as any); expect(component.displayData.length).toBe(2); }); @@ -181,23 +153,10 @@ describe('OverviewStorageCardComponent (Jest)', () => { it('should auto-select single item if only one exists', () => { component.allData = [{ group: 'Block', value: 10 }]; - (component as any).setDropdownItemsAndStorageType(); - - expect(component.selectedStorageType).toBe('Block'); - expect(component.dropdownItems.length).toBe(1); - }); - - it('should reset to ALL if previous selection missing', () => { - component.selectedStorageType = 'Block'; - - component.allData = [ - { group: 'Filesystem', value: 20 }, - { group: 'Object', value: 30 } - ]; - - (component as any).setDropdownItemsAndStorageType(); + (component as any)._setDropdownItemsAndStorageType(); expect(component.selectedStorageType).toBe('All'); + expect(component.dropdownItems.length).toBe(2); }); // -------------------------------------------------- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts index 4c0183ae90b..56117d8442a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/overview/storage-card/overview-storage-card.component.ts @@ -2,6 +2,7 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, + inject, Input, OnDestroy, OnInit, @@ -24,32 +25,27 @@ import { PromqlGuageMetric } from '~/app/shared/api/prometheus.service'; import { FormatterService } from '~/app/shared/services/formatter.service'; -import { BehaviorSubject, Subject } from 'rxjs'; -import { switchMap, takeUntil } from 'rxjs/operators'; +import { interval, Subject } from 'rxjs'; +import { startWith, switchMap, takeUntil } from 'rxjs/operators'; const CHART_HEIGHT = '45px'; +const REFRESH_INTERVAL_MS = 15_000; + const StorageType = { ALL: $localize`All`, BLOCK: $localize`Block`, - FILE: $localize`Filesystem`, + FILE: $localize`File system`, OBJECT: $localize`Object` }; -const CapacityType = { - RAW: 'raw', - USED: 'used' -}; - type ChartData = { group: string; value: number; }; -const Query = { - [CapacityType.RAW]: `sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})`, - [CapacityType.USED]: `sum by (application) (ceph_pool_stored * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})` -}; +const RawUsedByStorageType = + 'sum by (application) (ceph_pool_bytes_used * on(pool_id) group_left(instance, name, application) ceph_pool_metadata{application=~"(.*Block.*)|(.*Filesystem.*)|(.*Object.*)|(..*)"})'; const chartGroupLabels = [StorageType.BLOCK, StorageType.FILE, StorageType.OBJECT]; @@ -73,13 +69,18 @@ const chartGroupLabels = [StorageType.BLOCK, StorageType.FILE, StorageType.OBJEC changeDetection: ChangeDetectionStrategy.OnPush }) export class OverviewStorageCardComponent implements OnInit, OnDestroy { + private readonly prometheusService = inject(PrometheusService); + private readonly formatterService = inject(FormatterService); + private readonly cdr = inject(ChangeDetectorRef); + private destroy$ = new Subject(); + @Input() set total(value: number) { const [totalValue, totalUnit] = this.formatterService.formatToBinary(value, true); if (Number.isNaN(totalValue)) return; this.totalRaw = totalValue; this.totalRawUnit = totalUnit; - this.setTotalAndUsed(); + this._setTotalAndUsed(); } @Input() set used(value: number) { @@ -87,15 +88,12 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy { if (Number.isNaN(usedValue)) return; this.usedRaw = usedValue; this.usedRawUnit = usedUnit; - this.setTotalAndUsed(); + this._setTotalAndUsed(); } totalRaw: number; usedRaw: number; totalRawUnit: string; usedRawUnit: string; - isRawCapacity: boolean = true; - selectedStorageType: string = StorageType.ALL; - selectedCapacityType: string = CapacityType.RAW; options: MeterChartOptions = { height: CHART_HEIGHT, meter: { @@ -117,6 +115,8 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy { }; allData: ChartData[] = null; displayData: ChartData[] = null; + displayUsedRaw: number; + selectedStorageType: string = StorageType.ALL; dropdownItems = [ { content: StorageType.ALL }, { content: StorageType.BLOCK }, @@ -124,16 +124,7 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy { { content: StorageType.OBJECT } ]; - constructor( - private prometheusService: PrometheusService, - private formatterService: FormatterService, - private cdr: ChangeDetectorRef - ) {} - - private destroy$ = new Subject(); - private capacityType$ = new BehaviorSubject(CapacityType.RAW); - - private setTotalAndUsed() { + private _setTotalAndUsed() { // Chart reacts to 'options' and 'data' object changes only, hence mandatory to replace whole object. this.options = { ...this.options, @@ -149,79 +140,68 @@ export class OverviewStorageCardComponent implements OnInit, OnDestroy { valueFormatter: (value) => `${value.toLocaleString()} ${this.usedRawUnit}` } }; - this.updateCard(); + this._updateCard(); } - private getAllData(data: PromqlGuageMetric) { + private _getAllData(data: PromqlGuageMetric) { const result = data?.result ?? []; const chartData = result .map((r: PromethuesGaugeMetricResult) => { const group = r?.metric?.application; - const value = this.formatterService.convertToUnit(r?.value?.[1], 'B', this.usedRawUnit, 10); - return { group, value }; + const value = this.formatterService.convertToUnit(r?.value?.[1], 'B', this.usedRawUnit, 1); + return { group: group === 'Filesystem' ? StorageType.FILE : group, value }; }) - // Removing 0 values and legends other than Block, Filesystem, and Object. - .filter((r) => chartGroupLabels.includes(r.group) && r.value > 0); + // Removing 0 values and legends other than Block, File system, and Object. + .filter((r) => chartGroupLabels.includes(r?.group) && r?.value > 0); return chartData; } - private setChartData() { + private _setChartData() { if (this.selectedStorageType === StorageType.ALL) { this.displayData = this.allData; + this.displayUsedRaw = this.usedRaw; } else { - this.displayData = this.allData.filter( + this.displayData = this.allData?.filter( (d: ChartData) => d.group === this.selectedStorageType ); + this.displayUsedRaw = this.displayData?.[0]?.value; } } - private setDropdownItemsAndStorageType() { - const dynamicItems = this.allData.map((data) => ({ content: data.group })); - const hasExistingItem = dynamicItems.some((item) => item.content === this.selectedStorageType); - - if (dynamicItems.length === 1) { - this.dropdownItems = dynamicItems; - this.selectedStorageType = dynamicItems[0]?.content; + private _setDropdownItemsAndStorageType() { + const newData = this.allData?.map((data) => ({ content: data.group })); + if (newData.length) { + this.dropdownItems = [{ content: StorageType.ALL }, ...newData]; } else { - this.dropdownItems = [{ content: StorageType.ALL }, ...dynamicItems]; - } - // Change the current dropdown selection to 'ALL' if prev selection is absent in current data, and current data has more than one item. - if (!hasExistingItem && dynamicItems.length > 1) { - this.selectedStorageType = StorageType.ALL; + this.dropdownItems = [{ content: StorageType.ALL }]; } } - private updateCard() { + private _updateCard() { this.cdr.markForCheck(); } - public toggleRawCapacity(isChecked: boolean) { - this.isRawCapacity = isChecked; - this.selectedCapacityType = isChecked ? CapacityType.RAW : CapacityType.USED; - // Reloads Prometheus Query - this.capacityType$.next(this.selectedCapacityType); - } - public onStorageTypeSelect(selected: { item: { content: string; selected: true } }) { this.selectedStorageType = selected?.item?.content; - this.setChartData(); + this._setChartData(); } ngOnInit() { - this.capacityType$ + interval(REFRESH_INTERVAL_MS) .pipe( - switchMap((capacityType) => + startWith(0), + switchMap(() => this.prometheusService.getPrometheusQueryData({ - params: Query[capacityType] + params: RawUsedByStorageType }) ), takeUntil(this.destroy$) ) .subscribe((data: PromqlGuageMetric) => { - this.allData = this.getAllData(data); - this.setDropdownItemsAndStorageType(); - this.setChartData(); - this.updateCard(); + this.allData = this._getAllData(data); + this._setDropdownItemsAndStorageType(); + this._setChartData(); + this._updateCard(); }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 4a9f1ce5297..dd7751b289c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -208,8 +208,7 @@ import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.componen TearsheetComponent, TearsheetStepComponent, PageHeaderComponent, - SidebarLayoutComponent, - PageHeaderComponent + SidebarLayoutComponent ], providers: [provideCharts(withDefaultRegisterables())], exports: [ @@ -256,8 +255,7 @@ import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.componen TearsheetComponent, TearsheetStepComponent, PageHeaderComponent, - SidebarLayoutComponent, - PageHeaderComponent + SidebarLayoutComponent ] }) export class ComponentsModule { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.scss index d550c32f7ca..a8b5edad58b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/productive-card/productive-card.component.scss @@ -6,6 +6,7 @@ &-header { padding-inline: var(--cds-spacing-05); + margin: 0; } &-header-row { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts index c5f13d9eb6f..4309d1b17ae 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.spec.ts @@ -109,4 +109,70 @@ describe('FormatterService', () => { convertToBytesAndBack('123.5 EiB'); }); }); + + describe('formatToBinary', () => { + it('should return formatted string when split=false (default decimals=1)', () => { + expect(service.formatToBinary('0', false)).toBe('0 B'); + expect(service.formatToBinary('0.1', false)).toBe('0.1 B'); + expect(service.formatToBinary('1', false)).toBe('1 B'); + expect(service.formatToBinary('1024', false)).toBe('1 KiB'); + expect(service.formatToBinary(23.45678 * Math.pow(1024, 3), false)).toBe('23.5 GiB'); + }); + + it('should respect decimals param when split=false', () => { + expect(service.formatToBinary(23.45678 * Math.pow(1024, 3), false, 2)).toBe('23.46 GiB'); + expect(service.formatToBinary('1024', false, 3)).toBe('1 KiB'); + }); + + it('should return tuple [number, unit] when split=true', () => { + expect(service.formatToBinary('0', true)).toEqual([0, 'B']); + expect(service.formatToBinary('1024', true)).toEqual([1, 'KiB']); + expect(service.formatToBinary(23.45678 * Math.pow(1024, 3), true)).toEqual([23.5, 'GiB']); + }); + + it('should return "-" for unsupported values when split=false', () => { + expect(service.formatToBinary(undefined as any, false)).toBe('-'); + expect(service.formatToBinary(null as any, false)).toBe('-'); + expect(service.formatToBinary(service as any, false)).toBe('-'); + }); + + it('should return a safe tuple when split=true and input is unsupported', () => { + expect(service.formatToBinary(undefined as any, true)).toEqual([0, 'B']); + expect(service.formatToBinary(null as any, true)).toEqual([0, 'B']); + expect(service.formatToBinary(service as any, true)).toEqual([0, 'B']); + }); + }); + + describe('convertToUnit', () => { + it('should return 0 for empty-ish values', () => { + expect(service.convertToUnit('', 'B', 'KiB')).toBe(0); + expect(service.convertToUnit(undefined as any, 'B', 'KiB')).toBe(0); + expect(service.convertToUnit(null as any, 'B', 'KiB')).toBe(0); + }); + + it('should convert between binary units (default decimals=1)', () => { + expect(service.convertToUnit('1024', 'B', 'KiB')).toBe(1); + expect(service.convertToUnit('1', 'GiB', 'MiB')).toBe(1024); + expect(service.convertToUnit('1', 'MiB', 'KiB')).toBe(1024); + }); + + it('should respect decimals rounding', () => { + // 1000 MiB -> 0.9765625 GiB -> with 3 decimals => 0.977 + expect(service.convertToUnit('1000', 'mib', 'gib', 3)).toBe(0.977); + + // with 1 decimal => 1.0 (rounding) + expect(service.convertToUnit('1000', 'mib', 'gib', 1)).toBe(1); + }); + + it('should handle very small conversions that round to 0', () => { + expect(service.convertToUnit('0.1', 'B', 'TiB')).toBe(0); + }); + }); + + describe('convertToNumber', () => { + it('should remove commas and trim', () => { + expect(service.convertToNumber('1,024')).toBe(1024); + expect(service.convertToNumber(' 23.5 ')).toBe(23.5); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts index f112b766322..91ec7a17e9f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts @@ -3,7 +3,8 @@ import { AbstractControl, ValidationErrors } from '@angular/forms'; import _ from 'lodash'; import { isEmptyInputValue } from '../forms/cd-validators'; -const binaryUnits = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; +const BINARY_UNITS = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB']; +const BINARY_FACTOR = 1024; @Injectable({ providedIn: 'root' @@ -189,24 +190,34 @@ export class FormatterService { split: boolean = false, decimals: number = 1 ): string | [number, string] { - const conversionFactor = 1024; - const convertedString = this.format_number(num, conversionFactor, binaryUnits, decimals); - if (split) { - const [value, unit] = convertedString.split(/\s+/); - return [this.convertToNumber(value), unit]; + const convertedString = this.format_number(num, BINARY_FACTOR, BINARY_UNITS, decimals); + const FALLBACK: [number, string] = [0, BINARY_UNITS[0]]; // when convertedString is 'N/A', '-', or 'NaN', return [0, 'B'] + if (!split) return convertedString; + + const parts = convertedString.trim().split(/\s+/); + + if (parts.length < 2) { + return FALLBACK; + } + + const value = this.convertToNumber(parts[0]); + const unit = parts[1]; + + if (!Number.isFinite(value) || !unit) { + return FALLBACK; } - return convertedString; + + return [value, unit]; } convertToUnit(value: string, fromUnit: string, toUnit: string, decimals: number = 1): number { if (!value) return 0; - const conversionFactor = 1024; const convertedString = this.formatNumberFromTo( value, fromUnit, toUnit, - conversionFactor, - binaryUnits, + BINARY_FACTOR, + BINARY_UNITS, decimals ); return this.convertToNumber(convertedString.split(/\s+/)[0]); -- 2.47.3