<div class="row d-flex flex-row ps-3">
<!-- First Grid to hold Details and Inventory Card-->
<div class="col-sm-3 d-flex flex-column ps-2 details-card">
- <cd-productive-card title="Details"
- i18n-title>
+ <cd-productive-card headerTitle="Details"
+ i18n-headerTitle>
<dl>
<dt>Cluster ID</dt>
<dd>{{ detailsCardData.fsid }}</dd>
[columnNumbers]="{lg: 16}">
@if (healthData$ | async; as healthData) {
<cd-overview-storage-card
- [total]="healthData?.pgmap.bytes_total"
- [used]="healthData?.pgmap.bytes_used">
+ [total]="healthData.pgmap.bytes_total"
+ [used]="healthData.pgmap.bytes_used">
</cd-overview-storage-card>
}
</div>
styleUrl: './overview.component.scss'
})
export class OverviewComponent implements OnDestroy {
- totalCapacity: number;
- usedCapacity: number;
private destroy$ = new Subject<void>();
public healthData$: Observable<HealthSnapshotMap>;
<cds-dropdown-list [items]="dropdownItems"></cds-dropdown-list>
</cds-dropdown>
</ng-template>
- <!-- CAPACITY USAGE TEXT AND RAW CAPACITY TOGGLE-->
+ <!-- CAPACITY USAGE TEXT -->
<div class="overview-storage-card-usage-text">
- @if(usedRaw && totalRaw && usedRawUnit && totalRawUnit) {
+ @if(displayUsedRaw && totalRaw && usedRawUnit && totalRawUnit) {
<h5>
<span
class="cds--type-heading-05"
- i18n>{{usedRaw}}&ngsp;</span>
+ i18n>{{displayUsedRaw}}&ngsp;</span>
<span
class="cds--type-body-02"
i18n>{{usedRawUnit}} of {{totalRaw}} {{totalRawUnit}} used</span>
[maxLineWidth]="400">
</cds-skeleton-text>
}
- <cds-checkbox
- [checked]="isRawCapacity"
- [disabled]="!(displayData && usedRaw && totalRaw && usedRawUnit && totalRawUnit)"
- (checkedChange)="toggleRawCapacity($event)">
- <cds-tooltip-definition
- [autoAlign]="true"
- [highContrast]="true"
- [openOnHover]="false"
- [dropShadow]="true"
- title=""
- [caret]="true"
- description="Raw capacity includes all physical storage before replication or overhead."
- i18n-description
- i18n>
- Raw capacity
- </cds-tooltip-definition>
- </cds-checkbox>
</div>
<!-- CAPACITY CHART -->
@if(displayData) {
// Hiding the native chart title
&-chart {
.meter-title {
- height: 0 !important;
display: none !important;
}
.spacer {
- height: 0 !important;
display: none !important;
}
}
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
}
]
};
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
// --------------------------------------------------
{ 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', () => {
{ 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);
});
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);
});
// --------------------------------------------------
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
+ inject,
Input,
OnDestroy,
OnInit,
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];
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<void>();
+
@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) {
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: {
};
allData: ChartData[] = null;
displayData: ChartData[] = null;
+ displayUsedRaw: number;
+ selectedStorageType: string = StorageType.ALL;
dropdownItems = [
{ content: StorageType.ALL },
{ content: StorageType.BLOCK },
{ content: StorageType.OBJECT }
];
- constructor(
- private prometheusService: PrometheusService,
- private formatterService: FormatterService,
- private cdr: ChangeDetectorRef
- ) {}
-
- private destroy$ = new Subject<void>();
- private capacityType$ = new BehaviorSubject<string>(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,
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();
});
}
TearsheetComponent,
TearsheetStepComponent,
PageHeaderComponent,
- SidebarLayoutComponent,
- PageHeaderComponent
+ SidebarLayoutComponent
],
providers: [provideCharts(withDefaultRegisterables())],
exports: [
TearsheetComponent,
TearsheetStepComponent,
PageHeaderComponent,
- SidebarLayoutComponent,
- PageHeaderComponent
+ SidebarLayoutComponent
]
})
export class ComponentsModule {
&-header {
padding-inline: var(--cds-spacing-05);
+ margin: 0;
}
&-header-row {
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);
+ });
+ });
});
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'
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]);