Expose shared storage growth deltas on the storage page

This commit is contained in:
rcourtman 2026-04-20 23:22:29 +01:00
parent 4711d11163
commit 92f4e2c0a9
22 changed files with 802 additions and 194 deletions

View file

@ -646,6 +646,13 @@ aggregate health-state summary row. Per the Extension Points constraint, this
surface must continue to stay outside the shared hover-synchronization dialect;
new additions to `RecoverySummary.tsx` must not introduce row/group/chart hover
wiring without a separate governed product decision.
`frontend-modern/src/components/Storage/useStorageSummaryCharts.ts` now owns
the reusable polling/caching state for storage summary history, while
`frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts`
keeps pool-growth label/tone formatting inside the shared feature presentation
layer. The storage page must keep reusing those shared owners instead of
rebuilding storage-history timers or byte-delta formatting inside row
components.
The frontend already has several guardrail tests. The next step is to keep
turning repeated local patterns into explicit shared primitives with hard usage

View file

@ -57,6 +57,8 @@ querying, and the operator-facing storage health presentation layer.
30. `frontend-modern/src/utils/textPresentation.ts`
31. `frontend-modern/src/components/Storage/StorageSummary.tsx`
32. `frontend-modern/src/utils/storageSummaryCache.ts`
33. `frontend-modern/src/components/Storage/useStorageSummaryCharts.ts`
34. `frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts`
## Shared Boundaries
@ -441,6 +443,11 @@ querying, and the operator-facing storage health presentation layer.
storage detail charts must all address history through the canonical
unified-resource metrics-target IDs, and the storage page must reuse the
shared sticky summary primitive instead of a storage-local scroll wrapper.
Storage-page pool growth readouts belong to that same contract: the table
may derive per-pool used-capacity deltas from the shared
`/api/storage-charts` summary payload, but it must not fan out row-local
`/api/metrics-store/history` calls, invent a second storage-history cache,
or drift onto storage-page-only metric identifiers.
Dashboard storage trends belong to that same owned summary contract: the
dashboard may derive a 24-hour storage capacity delta from
`/api/charts/storage-summary`, but it must not rebuild storage summary
@ -549,6 +556,11 @@ now surface `poolsDegraded` and `disksFailing` health indicators alongside
pool/disk counts. `RecoverySummary.tsx` gains an aggregate health-state summary
row. These additions project from existing websocket pool/disk state; they must
not introduce new API polling or widen the storage-fetch boundary.
That same owned summary path now also runs through
`useStorageSummaryCharts.ts`: the storage page owns one page-scoped summary
range and one shared storage-summary history fetch, and both the sticky
summary cards and per-pool growth column reuse that payload instead of
forking separate row-local history reads or duplicate polling loops.
This subsystem now sits under the dedicated storage and recovery lane so the
operator-facing storage page, recovery timeline, and recovery-point persistence

View file

@ -13,6 +13,13 @@ const Storage: Component = () => {
const {
kioskMode,
reconnect,
summaryTimeRange,
setSummaryTimeRange,
storageGrowthBySeriesId,
storageGrowthColumnLabel,
storageSummaryData,
storageSummaryLoaded,
storageSummaryFetchFailed,
selectedNodeId,
setSelectedNodeId,
view,
@ -85,6 +92,11 @@ const Storage: Component = () => {
selectedNodeId={selectedNodeId}
nodeOptions={nodeOptions}
physicalDisks={physicalDisks}
summaryTimeRange={summaryTimeRange}
setSummaryTimeRange={setSummaryTimeRange}
storageSummaryData={storageSummaryData}
storageSummaryLoaded={storageSummaryLoaded}
storageSummaryFetchFailed={storageSummaryFetchFailed}
hoveredResourceId={hoveredStorageResourceId}
hoveredGroupScope={hoveredSummaryStorageGroupScope}
focusedResourceId={focusedStorageResourceId}
@ -143,6 +155,8 @@ const Storage: Component = () => {
toggleGroup={toggleGroup}
expandedPoolId={expandedPoolId}
setExpandedPoolId={setExpandedPoolId}
storageGrowthBySeriesId={storageGrowthBySeriesId}
storageGrowthColumnLabel={storageGrowthColumnLabel}
nodeOnlineByLabel={nodeOnlineByLabel}
highlightedRecordId={highlightedRecordId}
getRecordAlertState={getRecordAlertState}

View file

@ -6,6 +6,7 @@ import StoragePoolsTable from '@/components/Storage/StoragePoolsTable';
import {
STORAGE_CONTENT_CARD_BODY_CLASS,
} from '@/features/storageBackups/storagePagePresentation';
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
import type { Resource } from '@/types/resource';
import type { StorageGroupKey, StorageGroupedRecords } from './useStorageModel';
import type { StorageAlertRowState } from '@/features/storageBackups/storageAlertState';
@ -25,6 +26,8 @@ type StorageContentCardProps = {
toggleGroup: (key: string) => void;
expandedPoolId: () => string | null;
setExpandedPoolId: (value: string | ((current: string | null) => string | null) | null) => void;
storageGrowthBySeriesId: () => Map<string, StorageCapacityDeltaPresentation>;
storageGrowthColumnLabel: () => string;
nodeOnlineByLabel: () => Map<string, boolean>;
highlightedRecordId: () => string | null;
getRecordAlertState: (recordId: string) => StorageAlertRowState;
@ -88,6 +91,8 @@ export const StorageContentCard: Component<StorageContentCardProps> = (props) =>
toggleGroup={props.toggleGroup}
expandedPoolId={props.expandedPoolId()}
setExpandedPoolId={props.setExpandedPoolId}
storageGrowthBySeriesId={props.storageGrowthBySeriesId()}
storageGrowthColumnLabel={props.storageGrowthColumnLabel()}
physicalDisks={props.physicalDisks()}
nodeOnlineByLabel={props.nodeOnlineByLabel()}
highlightedRecordId={props.highlightedRecordId()}

View file

@ -1,5 +1,7 @@
import { Component } from 'solid-js';
import StorageSummary from '@/components/Storage/StorageSummary';
import type { StorageSummaryChartsResponse } from '@/api/charts';
import type { SummaryTimeRange } from '@/components/shared/summaryTimeRange';
import type { Resource } from '@/types/resource';
import type { StorageRecord } from '@/features/storageBackups/models';
import type { SummarySeriesGroupScope } from '@/components/shared/summaryCardInteraction';
@ -12,6 +14,11 @@ type StoragePageSummaryProps = {
selectedNodeId: () => string;
nodeOptions: () => StoragePageNodeOption[];
physicalDisks: () => Resource[];
summaryTimeRange: () => SummaryTimeRange;
setSummaryTimeRange: (range: SummaryTimeRange) => void;
storageSummaryData: () => StorageSummaryChartsResponse | null;
storageSummaryLoaded: () => boolean;
storageSummaryFetchFailed: () => boolean;
hoveredResourceId: () => string | null;
hoveredGroupScope: () => SummarySeriesGroupScope | null;
focusedResourceId: () => string | null;
@ -24,8 +31,6 @@ type StoragePageSummaryProps = {
export const StoragePageSummary: Component<StoragePageSummaryProps> = (props) => {
const {
summaryTimeRange,
setSummaryTimeRange,
poolCount,
diskCount,
poolsDegraded,
@ -43,9 +48,11 @@ export const StoragePageSummary: Component<StoragePageSummaryProps> = (props) =>
diskCount={diskCount()}
poolsDegraded={poolsDegraded()}
disksFailing={disksFailing()}
timeRange={summaryTimeRange()}
onTimeRangeChange={setSummaryTimeRange}
nodeId={props.selectedNodeId()}
data={props.storageSummaryData()}
loaded={props.storageSummaryLoaded()}
fetchFailed={props.storageSummaryFetchFailed()}
timeRange={props.summaryTimeRange()}
onTimeRangeChange={props.setSummaryTimeRange}
hoveredResourceId={props.hoveredResourceId()}
hoveredGroupScope={props.hoveredGroupScope()}
focusedResourceId={props.focusedResourceId()}

View file

@ -6,6 +6,8 @@ import {
} from '@/features/storageBackups/rowPresentation';
import {
buildStoragePoolRowModel,
STORAGE_POOL_ROW_GROWTH_CELL_CLASS,
STORAGE_POOL_ROW_GROWTH_TEXT_CLASS,
getStoragePoolImpactTextClass,
STORAGE_POOL_ROW_CLASS,
STORAGE_POOL_ROW_EXPANDED_CLASS,
@ -28,6 +30,7 @@ import {
STORAGE_POOL_ROW_USAGE_CELL_CLASS,
STORAGE_POOL_ROW_USAGE_WRAP_CLASS,
} from '@/features/storageBackups/storagePoolRowPresentation';
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
import type { Resource } from '@/types/resource';
import type { SummaryGroupMemberInteractionState } from '@/components/shared/summaryCardInteraction';
import { EnhancedStorageBar } from './EnhancedStorageBar';
@ -40,6 +43,7 @@ import { SummaryRowActionButton } from '@/components/shared/SummaryRowActionButt
interface StoragePoolRowProps {
record: StorageRecord;
growthDelta?: StorageCapacityDeltaPresentation | null;
summarySeriesId: string;
expanded: boolean;
summaryHighlighted?: boolean;
@ -57,7 +61,7 @@ interface StoragePoolRowProps {
}
export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
const row = createMemo(() => buildStoragePoolRowModel(props.record));
const row = createMemo(() => buildStoragePoolRowModel(props.record, props.growthDelta ?? null));
const detailControlsId = createMemo(() =>
buildSummaryDisclosureControlsId(props.summarySeriesId),
);
@ -147,6 +151,15 @@ export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
</Show>
</td>
<td class={STORAGE_POOL_ROW_GROWTH_CELL_CLASS}>
<span
class={`${STORAGE_POOL_ROW_GROWTH_TEXT_CLASS} ${row().capacityDeltaToneClass}`}
title={row().capacityDeltaTitle}
>
{row().capacityDeltaLabel}
</span>
</td>
<td class={STORAGE_POOL_ROW_IMPACT_CELL_CLASS}>
<span
class={getStoragePoolImpactTextClass(row().compactImpact)}

View file

@ -1,6 +1,7 @@
import { Component, For, Index, Show } from 'solid-js';
import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/shared/Table';
import {
getStoragePoolTableColumns,
getStorageEmptyStateMessage,
getStorageLoadingMessage,
STORAGE_POOLS_BODY_CLASS,
@ -9,9 +10,9 @@ import {
STORAGE_POOLS_LOADING_STATE_CLASS,
STORAGE_POOLS_SCROLL_WRAP_CLASS,
STORAGE_POOLS_TABLE_CLASS,
STORAGE_POOL_TABLE_COLUMNS,
} from '@/features/storageBackups/storagePagePresentation';
import { resolveStorageRecordMetricResourceId } from '@/features/storageBackups/storageMetricsIdentity';
import type { StorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
import type { StorageAlertRowState } from '@/features/storageBackups/storageAlertState';
import type { Resource } from '@/types/resource';
import { StorageGroupRow } from './StorageGroupRow';
@ -29,6 +30,8 @@ type StoragePoolsTableProps = {
toggleGroup: (key: string) => void;
expandedPoolId: string | null;
setExpandedPoolId: (value: string | null | ((current: string | null) => string | null)) => void;
storageGrowthBySeriesId: Map<string, StorageCapacityDeltaPresentation>;
storageGrowthColumnLabel: string;
physicalDisks: Resource[];
nodeOnlineByLabel: Map<string, boolean>;
highlightedRecordId: string | null;
@ -70,7 +73,7 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
<Table class={STORAGE_POOLS_TABLE_CLASS}>
<TableHeader>
<TableRow class={STORAGE_POOLS_HEADER_ROW_CLASS}>
<For each={STORAGE_POOL_TABLE_COLUMNS}>
<For each={getStoragePoolTableColumns(props.storageGrowthColumnLabel)}>
{(column) => <TableHead class={column.className}>{column.label}</TableHead>}
</For>
</TableRow>
@ -83,17 +86,17 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
{(() => {
const groupSummaryScope = buildStorageSummaryGroupScope(group, props.groupBy);
return (
<StorageGroupRow
group={group}
groupBy={props.groupBy}
expanded={group.expanded}
onToggle={() => props.toggleGroup(group.key)}
summaryGroupScope={groupSummaryScope}
summaryActive={props.activeSummaryGroupScope?.id === groupSummaryScope?.id}
summaryFocused={props.focusedSummaryGroupId === groupSummaryScope?.id}
onFocusChange={props.onGroupFocusChange}
onHoverChange={props.onGroupHoverChange}
/>
<StorageGroupRow
group={group}
groupBy={props.groupBy}
expanded={group.expanded}
onToggle={() => props.toggleGroup(group.key)}
summaryGroupScope={groupSummaryScope}
summaryActive={props.activeSummaryGroupScope?.id === groupSummaryScope?.id}
summaryFocused={props.focusedSummaryGroupId === groupSummaryScope?.id}
onFocusChange={props.onGroupFocusChange}
onHoverChange={props.onGroupHoverChange}
/>
);
})()}
</Show>
@ -105,6 +108,11 @@ export const StoragePoolsTable: Component<StoragePoolsTableProps> = (props) => {
return (
<StoragePoolRow
record={record()}
growthDelta={
props.storageGrowthBySeriesId.get(
resolveStorageRecordMetricResourceId(record()),
) ?? null
}
summarySeriesId={resolveStorageRecordMetricResourceId(record())}
expanded={rowModel().expanded}
summaryHighlighted={

View file

@ -1,4 +1,4 @@
import { Component, Show, createEffect, createMemo, createSignal, onCleanup } from 'solid-js';
import { Component, Show, createEffect, createMemo, createSignal } from 'solid-js';
import { InteractiveSparkline } from '@/components/shared/InteractiveSparkline';
import type { InteractiveSparklineSeries } from '@/components/shared/InteractiveSparkline';
import {
@ -21,14 +21,7 @@ import {
type SummaryTimeRange,
} from '@/components/shared/summaryTimeRange';
import { formatBytes } from '@/utils/format';
import { eventBus } from '@/stores/events';
import { getChartSeriesColor } from '@/utils/chartSeriesPresentation';
import {
fetchStorageSummaryAndCache,
readStorageSummaryCache,
} from '@/utils/storageSummaryCache';
const POLL_INTERVAL_MS = 30_000;
// ---------------------------------------------------------------------------
// Props
@ -39,9 +32,11 @@ interface StorageSummaryProps {
diskCount: number;
poolsDegraded?: number;
disksFailing?: number;
data: StorageSummaryChartsResponse | null;
loaded: boolean;
fetchFailed: boolean;
timeRange: SummaryTimeRange;
onTimeRangeChange?: (range: SummaryTimeRange) => void;
nodeId?: string;
hoveredResourceId?: string | null;
hoveredGroupScope?: SummarySeriesGroupScope | null;
focusedResourceId?: string | null;
@ -57,9 +52,6 @@ interface StorageSummaryProps {
// ---------------------------------------------------------------------------
const StorageSummary: Component<StorageSummaryProps> = (props) => {
const [data, setData] = createSignal<StorageSummaryChartsResponse | null>(null);
const [loaded, setLoaded] = createSignal(false);
const [fetchFailed, setFetchFailed] = createSignal(false);
const [localChartHoverSync, setLocalChartHoverSync] = createSignal<SummaryChartHoverSync | null>(
null,
);
@ -71,132 +63,12 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
props.onChartHoverSyncChange?.(value);
};
// Track org switches so the effect re-runs when the org changes.
const [orgVersion, setOrgVersion] = createSignal(0);
const unsubscribeOrgSwitch = eventBus.on('org_switched', () => {
setOrgVersion((v) => v + 1);
});
let activeFetchController: AbortController | null = null;
let activeFetchRequest = 0;
let refreshTimer: ReturnType<typeof setInterval> | undefined;
const selectedRange = createMemo<TimeRange>(() => (props.timeRange as TimeRange) || '1h');
const selectedNodeId = createMemo(() => {
const id = props.nodeId?.trim();
return id && id !== 'all' ? id : undefined;
});
const awaitAbortable = <T,>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
if (signal.aborted) {
return Promise.reject(new DOMException('Aborted', 'AbortError'));
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(new DOMException('Aborted', 'AbortError'));
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener('abort', onAbort);
reject(error);
},
);
});
};
// Fetch data with race-condition prevention via request ID
const fetchData = async (options?: { prioritize?: boolean }) => {
const prioritize = options?.prioritize === true;
if (activeFetchController && !prioritize) return;
if (activeFetchController && prioritize) {
activeFetchController.abort();
}
const requestedRange = selectedRange();
const requestedNodeId = selectedNodeId();
const controller = new AbortController();
const requestId = ++activeFetchRequest;
activeFetchController = controller;
try {
const response = await awaitAbortable(
fetchStorageSummaryAndCache(requestedRange, {
caller: 'StorageSummary',
nodeId: requestedNodeId,
}),
controller.signal,
);
if (requestId !== activeFetchRequest) return; // stale response
setData(response);
setFetchFailed(false);
} catch (err: unknown) {
if (err instanceof DOMException && err.name === 'AbortError') return;
if (requestId !== activeFetchRequest) return; // stale error
setFetchFailed(true);
// Fall back to cache
const cached = readStorageSummaryCache(requestedRange, requestedNodeId);
if (cached) setData(cached);
} finally {
if (activeFetchController === controller) {
activeFetchController = null;
}
if (requestId === activeFetchRequest) {
setLoaded(true);
}
}
};
// Initial load + range/org/node change
createEffect(() => {
const range = selectedRange();
const nodeId = selectedNodeId();
const _org = orgVersion(); // subscribe to org switches
void _org;
// Clear stale timer on scope change
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
// Try cache first for instant display
const cached = readStorageSummaryCache(range, nodeId);
if (cached) {
setData(cached);
setLoaded(true);
} else {
setData(null);
setLoaded(false);
}
setFetchFailed(false);
// Start polling
refreshTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
void fetchData({ prioritize: true });
onCleanup(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
});
});
onCleanup(() => {
activeFetchController?.abort();
unsubscribeOrgSwitch();
});
// ---------------------------------------------------------------------------
// Series builders
// ---------------------------------------------------------------------------
const allPoolUsageSeries = createMemo((): InteractiveSparklineSeries[] => {
const d = data();
const d = props.data;
if (!d?.pools) return [];
const entries = Object.entries(d.pools);
return entries
@ -210,7 +82,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
});
const allPoolUsedSeries = createMemo((): InteractiveSparklineSeries[] => {
const d = data();
const d = props.data;
if (!d?.pools) return [];
const entries = Object.entries(d.pools);
return entries
@ -224,7 +96,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
});
const allPoolAvailSeries = createMemo((): InteractiveSparklineSeries[] => {
const d = data();
const d = props.data;
if (!d?.pools) return [];
const entries = Object.entries(d.pools);
return entries
@ -238,7 +110,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
});
const allDiskTempSeries = createMemo((): InteractiveSparklineSeries[] => {
const d = data();
const d = props.data;
if (!d?.disks) return [];
const entries = Object.entries(d.disks);
return entries
@ -292,7 +164,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
const hasPoolAvail = () => poolAvailSeries().length > 0;
const emptyLabel = () => {
if (fetchFailed()) return 'Trend data unavailable';
if (props.fetchFailed) return 'Trend data unavailable';
if (summaryFocus.activeGroupScope()) return 'No group history yet';
return 'No history yet';
};
@ -399,7 +271,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
label="Pool Usage"
secondaryLabel={focusedLabel(poolUsageSeries())}
headerValue={renderSyncedReadout(poolUsageSyncedReadout())}
loaded={loaded()}
loaded={props.loaded}
hasData={hasPoolUsage()}
emptyMessage={emptyLabel()}
interactionState={summaryFocus.interactionStateFor(poolUsageSeries())}
@ -423,7 +295,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
label="Disk Temperature"
secondaryLabel={focusedLabel(diskTempSeries())}
headerValue={renderSyncedReadout(diskTempSyncedReadout())}
loaded={loaded()}
loaded={props.loaded}
hasData={hasDiskTemp()}
emptyMessage={emptyLabel()}
interactionState={summaryFocus.interactionStateFor(diskTempSeries())}
@ -449,7 +321,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
label="Used Capacity"
secondaryLabel={focusedLabel(poolUsedSeries())}
headerValue={renderSyncedReadout(poolUsedSyncedReadout())}
loaded={loaded()}
loaded={props.loaded}
hasData={hasPoolUsed()}
emptyMessage={emptyLabel()}
interactionState={summaryFocus.interactionStateFor(poolUsedSeries())}
@ -475,7 +347,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
label="Available Space"
secondaryLabel={focusedLabel(poolAvailSeries())}
headerValue={renderSyncedReadout(poolAvailSyncedReadout())}
loaded={loaded()}
loaded={props.loaded}
hasData={hasPoolAvail()}
emptyMessage={emptyLabel()}
interactionState={summaryFocus.interactionStateFor(poolAvailSeries())}

View file

@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/te
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ChartsAPI } from '@/api/charts';
import StorageSummary from '@/components/Storage/StorageSummary';
import storageSummarySource from '@/components/Storage/StorageSummary.tsx?raw';
import storageSummarySource from '@/components/Storage/useStorageSummaryCharts.ts?raw';
import storageSummaryCacheSource from '@/utils/storageSummaryCache.ts?raw';
import type { Alert } from '@/types/api';
import type { Resource, ResourceType } from '@/types/resource';
@ -394,6 +394,71 @@ describe('Storage', () => {
expect(screen.queryByText('Local-LVM-PVE2')).not.toBeInTheDocument();
});
it('renders per-pool growth from the shared storage summary history contract', async () => {
const storageSummarySpy = vi.spyOn(ChartsAPI, 'getStorageSummaryCharts').mockResolvedValue({
pools: {
'pool:alpha': {
name: 'Alpha-Store',
usage: [
{ timestamp: Date.now() - 86_400_000, value: 40 },
{ timestamp: Date.now(), value: 44 },
],
used: [
{ timestamp: Date.now() - 86_400_000, value: 100 * 1024 * 1024 * 1024 },
{ timestamp: Date.now(), value: 140 * 1024 * 1024 * 1024 },
],
avail: [
{ timestamp: Date.now() - 86_400_000, value: 150 * 1024 * 1024 * 1024 },
{ timestamp: Date.now(), value: 110 * 1024 * 1024 * 1024 },
],
},
'pool:beta': {
name: 'Beta-Store',
usage: [
{ timestamp: Date.now() - 86_400_000, value: 72 },
{ timestamp: Date.now(), value: 68 },
],
used: [
{ timestamp: Date.now() - 86_400_000, value: 220 * 1024 * 1024 * 1024 },
{ timestamp: Date.now(), value: 200 * 1024 * 1024 * 1024 },
],
avail: [
{ timestamp: Date.now() - 86_400_000, value: 80 * 1024 * 1024 * 1024 },
{ timestamp: Date.now(), value: 100 * 1024 * 1024 * 1024 },
],
},
},
disks: {},
stats: {
oldestDataTimestamp: Date.now() - 86_400_000,
},
});
hookResources = [
buildStorageResource('storage-display-alpha', 'Alpha-Store', 'pve1', {
metricsTarget: {
resourceType: 'storage',
resourceId: 'pool:alpha',
},
}),
buildStorageResource('storage-display-beta', 'Beta-Store', 'pve2', {
metricsTarget: {
resourceType: 'storage',
resourceId: 'pool:beta',
},
}),
];
render(() => <Storage />);
expect(await screen.findByText('Growth (24h)')).toBeInTheDocument();
expect(await screen.findByText(/\+40(?:\.0)? GB/)).toBeInTheDocument();
expect(screen.getByText(/-20(?:\.0)? GB/)).toBeInTheDocument();
expect(storageSummarySpy).toHaveBeenCalledWith('24h', undefined, { nodeId: undefined });
storageSummarySpy.mockRestore();
});
it('routes hovered pool rows into the shared summary highlight contract and keeps the summary sticky', async () => {
const storageSummarySpy = vi.spyOn(ChartsAPI, 'getStorageSummaryCharts').mockResolvedValue({
pools: {
@ -1178,6 +1243,46 @@ describe('Storage', () => {
<StorageSummary
poolCount={2}
diskCount={0}
data={{
pools: {
'pool:alpha': {
name: 'Alpha-Store',
usage: [
{ timestamp: Date.now() - 60_000, value: 45 },
{ timestamp: Date.now(), value: 47 },
],
used: [
{ timestamp: Date.now() - 60_000, value: 450 },
{ timestamp: Date.now(), value: 470 },
],
avail: [
{ timestamp: Date.now() - 60_000, value: 550 },
{ timestamp: Date.now(), value: 530 },
],
},
'pool:beta': {
name: 'Beta-Store',
usage: [
{ timestamp: Date.now() - 60_000, value: 68 },
{ timestamp: Date.now(), value: 70 },
],
used: [
{ timestamp: Date.now() - 60_000, value: 680 },
{ timestamp: Date.now(), value: 700 },
],
avail: [
{ timestamp: Date.now() - 60_000, value: 320 },
{ timestamp: Date.now(), value: 300 },
],
},
},
disks: {},
stats: {
oldestDataTimestamp: Date.now() - 60_000,
},
}}
loaded
fetchFailed={false}
timeRange="1h"
chartHoverSync={{
sourceKey: 'pool-usage',
@ -1721,7 +1826,7 @@ describe('Storage', () => {
it('routes storage summary fetches through the shared storage summary cache owner', () => {
expect(storageSummarySource).toContain('readStorageSummaryCache(range, nodeId)');
expect(storageSummarySource).toContain('fetchStorageSummaryAndCache(requestedRange, {');
expect(storageSummarySource).not.toContain('ChartsAPI.getStorageSummaryCharts(');
expect(storageSummarySource).toContain("caller: options.caller ?? 'useStorageSummaryCharts'");
expect(storageSummaryCacheSource).toContain('const STORAGE_SUMMARY_CACHE_VERSION = 1;');
expect(storageSummaryCacheSource).toContain(
"return `${STORAGE_SUMMARY_CACHE_VERSION}::${orgScope}::${range}::${nodeId || '__all__'}`;",

View file

@ -79,6 +79,14 @@ describe('StorageSummary', () => {
{ timestamp: now - 60_000, value: 45 },
{ timestamp: now, value: 47 },
];
const buildSummaryData = (overrides?: Record<string, unknown>) => ({
pools: {},
disks: {},
stats: {
oldestDataTimestamp: now - 60_000,
},
...(overrides ?? {}),
});
beforeEach(() => {
mockGetStorageSummaryCharts.mockReset();
@ -90,7 +98,7 @@ describe('StorageSummary', () => {
});
it('keeps storage summary series page-scoped when a focused resource is selected', async () => {
mockGetStorageSummaryCharts.mockResolvedValueOnce({
const data = buildSummaryData({
pools: {
'pool:alpha': {
name: 'Alpha Pool',
@ -105,14 +113,18 @@ describe('StorageSummary', () => {
avail: twoPointSeries,
},
},
disks: {},
stats: {
oldestDataTimestamp: now - 60_000,
},
});
render(() => (
<StorageSummary poolCount={2} diskCount={0} timeRange="1h" focusedResourceId="pool:alpha" />
<StorageSummary
poolCount={2}
diskCount={0}
data={data}
loaded
fetchFailed={false}
timeRange="1h"
focusedResourceId="pool:alpha"
/>
));
await waitFor(() => {
@ -127,7 +139,7 @@ describe('StorageSummary', () => {
});
it('treats chart hover as the shared active storage entity across cards', async () => {
mockGetStorageSummaryCharts.mockResolvedValueOnce({
const data = buildSummaryData({
pools: {
'pool:alpha': {
name: 'Alpha Pool',
@ -148,12 +160,18 @@ describe('StorageSummary', () => {
temperature: twoPointSeries,
},
},
stats: {
oldestDataTimestamp: now - 60_000,
},
});
render(() => <StorageSummary poolCount={2} diskCount={1} timeRange="1h" />);
render(() => (
<StorageSummary
poolCount={2}
diskCount={1}
data={data}
loaded
fetchFailed={false}
timeRange="1h"
/>
));
await waitFor(() => {
expect(screen.getAllByTestId('sparkline')).toHaveLength(4);
@ -191,7 +209,7 @@ describe('StorageSummary', () => {
label: 'pve1 (2 pools)',
seriesIds: poolIds.slice(0, 2),
};
mockGetStorageSummaryCharts.mockResolvedValueOnce({
const data = buildSummaryData({
pools: {
'pool:alpha': {
name: 'Alpha Pool',
@ -212,16 +230,15 @@ describe('StorageSummary', () => {
avail: twoPointSeries,
},
},
disks: {},
stats: {
oldestDataTimestamp: now - 60_000,
},
});
render(() => (
<StorageSummary
poolCount={3}
diskCount={0}
data={data}
loaded
fetchFailed={false}
timeRange="1h"
hoveredGroupScope={hoveredGroupScope}
hoveredResourceId="pool:alpha"

View file

@ -73,6 +73,5 @@ describe('useStoragePageSummary', () => {
expect(result.diskCount()).toBe(1);
expect(result.poolsDegraded()).toBe(0);
expect(result.disksFailing()).toBe(0);
expect(result.summaryTimeRange()).toBe('1h');
});
});

View file

@ -1,5 +1,7 @@
import { createEffect, createMemo, createSignal, onCleanup, untrack } from 'solid-js';
import { useLocation, useNavigate } from '@solidjs/router';
import { SUMMARY_TIME_RANGE_LABEL, type SummaryTimeRange } from '@/components/shared/summaryTimeRange';
import { buildStorageCapacityDeltaPresentation } from '@/features/storageBackups/storageCapacityDeltaPresentation';
import {
resolvePhysicalDiskMetricResourceId,
resolveStorageRecordMetricResourceId,
@ -20,6 +22,7 @@ import { useStoragePageFilters } from './useStoragePageFilters';
import { useStoragePageResources } from './useStoragePageResources';
import { useStoragePageStatus } from './useStoragePageStatus';
import { useStorageResourceHighlight } from './useStorageResourceHighlight';
import { useStorageSummaryCharts } from './useStorageSummaryCharts';
import {
DEFAULT_STORAGE_SELECTED_NODE_ID,
DEFAULT_STORAGE_SOURCE_FILTER,
@ -41,6 +44,7 @@ export const useStoragePageModel = () => {
const [selectedStorageGroupId, setSelectedStorageGroupIdRaw] = createSignal<string | null>(null);
const [handledSummaryGroupId, setHandledSummaryGroupId] = createSignal<string | null>(null);
const [selectedDiskId, setSelectedDiskId] = createSignal<string | null>(null);
const [summaryTimeRange, setSummaryTimeRange] = createSignal<SummaryTimeRange>('24h');
const {
state,
activeAlerts,
@ -118,6 +122,11 @@ export const useStoragePageModel = () => {
void storageRecoveryResources.refetch();
reconnect();
};
const storageSummaryCharts = useStorageSummaryCharts({
timeRange: summaryTimeRange,
nodeId: selectedNodeId,
caller: 'useStoragePageModel',
});
const { expandedGroups, expandedPoolId, setExpandedPoolId: setExpandedPoolIdRaw, toggleGroup } =
useStorageExpansionState({
@ -138,6 +147,23 @@ export const useStoragePageModel = () => {
}
return ids;
});
const storageGrowthRangeLabel = createMemo(
() => SUMMARY_TIME_RANGE_LABEL[summaryTimeRange()] ?? summaryTimeRange(),
);
const storageGrowthColumnLabel = createMemo(
() => `Growth (${storageGrowthRangeLabel()})`,
);
const storageGrowthBySeriesId = createMemo(() => {
const growth = new Map<string, ReturnType<typeof buildStorageCapacityDeltaPresentation>>();
const pools = storageSummaryCharts.data()?.pools ?? {};
for (const [seriesId, pool] of Object.entries(pools)) {
growth.set(
seriesId,
buildStorageCapacityDeltaPresentation(pool.used ?? [], storageGrowthRangeLabel()),
);
}
return growth;
});
const hoveredStorageResourceId = createMemo(() => {
const hoveredId = hoveredStorageRowId();
if (!hoveredId) return null;
@ -380,6 +406,13 @@ export const useStoragePageModel = () => {
clearPinnedSummaryScope,
kioskMode,
reconnect: reconnectSurface,
summaryTimeRange,
setSummaryTimeRange,
storageGrowthBySeriesId,
storageGrowthColumnLabel,
storageSummaryData: storageSummaryCharts.data,
storageSummaryLoaded: storageSummaryCharts.loaded,
storageSummaryFetchFailed: storageSummaryCharts.fetchFailed,
selectedNodeId,
setSelectedNodeId,
view,

View file

@ -1,5 +1,4 @@
import { Accessor, createMemo, createSignal } from 'solid-js';
import type { SummaryTimeRange } from '@/components/shared/summaryTimeRange';
import { Accessor, createMemo } from 'solid-js';
import type { Resource } from '@/types/resource';
import type { StorageRecord } from '@/features/storageBackups/models';
import type { StoragePageNodeOption } from './storagePageState';
@ -15,8 +14,6 @@ type UseStoragePageSummaryOptions = {
const POOL_DEGRADED_HEALTHS = new Set(['warning', 'critical', 'offline']);
export const useStoragePageSummary = (options: UseStoragePageSummaryOptions) => {
const [summaryTimeRange, setSummaryTimeRange] = createSignal<SummaryTimeRange>('1h');
const poolCount = createMemo(() => options.filteredRecords().length);
const diskCount = createMemo(() =>
countVisiblePhysicalDisksForNode(
@ -48,8 +45,6 @@ export const useStoragePageSummary = (options: UseStoragePageSummaryOptions) =>
});
return {
summaryTimeRange,
setSummaryTimeRange,
poolCount,
diskCount,
poolsDegraded,

View file

@ -0,0 +1,144 @@
import { createEffect, createMemo, createSignal, onCleanup, type Accessor } from 'solid-js';
import type { StorageSummaryChartsResponse, TimeRange } from '@/api/charts';
import type { SummaryTimeRange } from '@/components/shared/summaryTimeRange';
import { eventBus } from '@/stores/events';
import {
fetchStorageSummaryAndCache,
readStorageSummaryCache,
} from '@/utils/storageSummaryCache';
const POLL_INTERVAL_MS = 30_000;
type UseStorageSummaryChartsOptions = {
timeRange: Accessor<SummaryTimeRange>;
nodeId?: Accessor<string | null | undefined>;
caller?: string;
};
export const useStorageSummaryCharts = (options: UseStorageSummaryChartsOptions) => {
const [data, setData] = createSignal<StorageSummaryChartsResponse | null>(null);
const [loaded, setLoaded] = createSignal(false);
const [fetchFailed, setFetchFailed] = createSignal(false);
const [orgVersion, setOrgVersion] = createSignal(0);
const unsubscribeOrgSwitch = eventBus.on('org_switched', () => {
setOrgVersion((value) => value + 1);
});
let activeFetchController: AbortController | null = null;
let activeFetchRequest = 0;
let refreshTimer: ReturnType<typeof setInterval> | undefined;
const selectedRange = createMemo<TimeRange>(() => (options.timeRange() as TimeRange) || '1h');
const selectedNodeId = createMemo(() => {
const raw = options.nodeId?.()?.trim();
return raw && raw !== 'all' ? raw : undefined;
});
const awaitAbortable = <T,>(promise: Promise<T>, signal: AbortSignal): Promise<T> => {
if (signal.aborted) {
return Promise.reject(new DOMException('Aborted', 'AbortError'));
}
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(new DOMException('Aborted', 'AbortError'));
signal.addEventListener('abort', onAbort, { once: true });
promise.then(
(value) => {
signal.removeEventListener('abort', onAbort);
resolve(value);
},
(error) => {
signal.removeEventListener('abort', onAbort);
reject(error);
},
);
});
};
const fetchData = async (options_: { prioritize?: boolean } = {}) => {
const prioritize = options_.prioritize === true;
if (activeFetchController && !prioritize) return;
if (activeFetchController && prioritize) {
activeFetchController.abort();
}
const requestedRange = selectedRange();
const requestedNodeId = selectedNodeId();
const controller = new AbortController();
const requestId = ++activeFetchRequest;
activeFetchController = controller;
try {
const response = await awaitAbortable(
fetchStorageSummaryAndCache(requestedRange, {
caller: options.caller ?? 'useStorageSummaryCharts',
nodeId: requestedNodeId,
}),
controller.signal,
);
if (requestId !== activeFetchRequest) return;
setData(response);
setFetchFailed(false);
} catch (error: unknown) {
if (error instanceof DOMException && error.name === 'AbortError') return;
if (requestId !== activeFetchRequest) return;
setFetchFailed(true);
const cached = readStorageSummaryCache(requestedRange, requestedNodeId);
if (cached) {
setData(cached);
}
} finally {
if (activeFetchController === controller) {
activeFetchController = null;
}
if (requestId === activeFetchRequest) {
setLoaded(true);
}
}
};
createEffect(() => {
const range = selectedRange();
const nodeId = selectedNodeId();
const _org = orgVersion();
void _org;
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
const cached = readStorageSummaryCache(range, nodeId);
if (cached) {
setData(cached);
setLoaded(true);
} else {
setData(null);
setLoaded(false);
}
setFetchFailed(false);
refreshTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
void fetchData({ prioritize: true });
onCleanup(() => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = undefined;
}
});
});
onCleanup(() => {
activeFetchController?.abort();
unsubscribeOrgSwitch();
});
return {
data,
loaded,
fetchFailed,
};
};
export default useStorageSummaryCharts;

View file

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
buildStorageCapacityDeltaPresentation,
computeStorageCapacityDelta,
formatStorageCapacityDelta,
getStorageCapacityDeltaToneClass,
} from '@/features/storageBackups/storageCapacityDeltaPresentation';
describe('storageCapacityDeltaPresentation', () => {
it('computes a smoothed capacity delta from the start and end of the series', () => {
const delta = computeStorageCapacityDelta([
{ timestamp: 1, value: 100 },
{ timestamp: 2, value: 110 },
{ timestamp: 3, value: 140 },
{ timestamp: 4, value: 150 },
]);
expect(delta).toBe(40);
});
it('formats increase, decrease, and neutral deltas canonically', () => {
expect(formatStorageCapacityDelta(2048)).toBe('+2.00 KB');
expect(formatStorageCapacityDelta(-2048)).toBe('-2.00 KB');
expect(formatStorageCapacityDelta(0)).toBe('0 B');
expect(formatStorageCapacityDelta(null)).toBe('—');
});
it('builds tone and title metadata for operator-visible growth labels', () => {
expect(getStorageCapacityDeltaToneClass(100)).toContain('text-amber-600');
expect(getStorageCapacityDeltaToneClass(-100)).toContain('text-sky-600');
expect(getStorageCapacityDeltaToneClass(0)).toBe('text-muted');
expect(buildStorageCapacityDeltaPresentation([], '24h')).toEqual({
deltaBytes: null,
label: '—',
title: 'No storage change history available for 24h.',
toneClass: 'text-muted',
});
expect(buildStorageCapacityDeltaPresentation([{ timestamp: 1, value: 100 }, { timestamp: 2, value: 100 }], '24h')).toEqual({
deltaBytes: 0,
label: '0 B',
title: 'No used-capacity change over 24h.',
toneClass: 'text-muted',
});
});
});

View file

@ -10,6 +10,7 @@ import {
getStorageEmptyStateMessage,
getStoragePageBannerActionLabel,
getStoragePageBannerMessage,
getStoragePoolTableColumns,
getStorageTableHeading,
STORAGE_BANNER_ACTION_BUTTON_CLASS,
STORAGE_CONTENT_CARD_BODY_CLASS,
@ -24,7 +25,6 @@ import {
STORAGE_POOLS_LOADING_STATE_CLASS,
STORAGE_POOLS_SCROLL_WRAP_CLASS,
STORAGE_POOLS_TABLE_CLASS,
STORAGE_POOL_TABLE_COLUMNS,
STORAGE_VIEW_OPTIONS,
shouldShowCephSummaryCard,
} from '@/features/storageBackups/storagePagePresentation';
@ -89,13 +89,14 @@ describe('storagePagePresentation', () => {
{ value: 'pools', label: 'Pools' },
{ value: 'disks', label: 'Physical Disks' },
]);
expect(STORAGE_POOL_TABLE_COLUMNS.map((column) => column.label)).toEqual([
expect(getStoragePoolTableColumns('Growth (24h)').map((column) => column.label)).toEqual([
'Storage',
'Source',
'Type',
'Host',
'Protection',
'Usage',
'Growth (24h)',
'Impact',
'Primary Issue',
]);

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { StorageRecord } from '@/features/storageBackups/models';
import {
buildStoragePoolRowModel,
STORAGE_POOL_ROW_GROWTH_TEXT_CLASS,
getStoragePoolImpactTextClass,
STORAGE_POOL_ROW_CLASS,
STORAGE_POOL_ROW_EXPANDED_CLASS,
@ -46,16 +47,24 @@ describe('storage pool row presentation', () => {
expect(STORAGE_POOL_ROW_NAME_TEXT_CLASS).toContain('font-semibold');
expect(STORAGE_POOL_ROW_SOURCE_BADGE_CLASS).toContain('text-[9px]');
expect(STORAGE_POOL_ROW_EXPANDED_CLASS).toBe('bg-surface-alt');
expect(STORAGE_POOL_ROW_GROWTH_TEXT_CLASS).toContain('font-mono');
expect(STORAGE_POOL_ROW_PLACEHOLDER_CLASS).toBe('text-muted');
expect(STORAGE_POOL_ROW_USAGE_FALLBACK_CLASS).toBe('text-[11px] text-muted');
const model = buildStoragePoolRowModel(baseRecord());
const model = buildStoragePoolRowModel(baseRecord(), {
deltaBytes: 40 * 1024 * 1024 * 1024,
label: '+40.00 GB',
title: 'Used capacity grew by 40.00 GB over 24h.',
toneClass: 'text-amber-600 dark:text-amber-300',
});
expect(model.platformLabel).toBe('PBS');
expect(model.platformToneClass).toContain('bg-indigo-100');
expect(model.hostLabel).toBe('pbs01');
expect(model.topologyLabel).toBe('Datastore');
expect(model.compactProtection).toBe('Protection Reduced');
expect(model.capacityDeltaLabel).toBe('+40.00 GB');
expect(model.capacityDeltaToneClass).toContain('text-amber-600');
expect(model.compactIssue).toBe('Capacity Pressure');
expect(model.compactImpact).toBe('—');
expect(model.freeBytes).toBe(200);

View file

@ -0,0 +1,98 @@
import type { MetricPoint } from '@/api/charts';
import { formatBytes } from '@/utils/format';
export interface StorageCapacityDeltaPresentation {
deltaBytes: number | null;
label: string;
title: string;
toneClass: string;
}
function normalizeMetricPoints(points: MetricPoint[]): MetricPoint[] {
return points
.filter(
(point) => Number.isFinite(point.timestamp) && Number.isFinite(point.value),
)
.sort((left, right) => left.timestamp - right.timestamp);
}
function averageMetricValues(points: MetricPoint[]): number {
if (points.length === 0) {
return 0;
}
return points.reduce((sum, point) => sum + point.value, 0) / points.length;
}
export function computeStorageCapacityDelta(points: MetricPoint[]): number | null {
const normalized = normalizeMetricPoints(points);
if (normalized.length < 2) {
return null;
}
const sampleWindowSize =
normalized.length >= 4
? Math.max(2, Math.floor(normalized.length * 0.25))
: 1;
const startAverage = averageMetricValues(normalized.slice(0, sampleWindowSize));
const endAverage = averageMetricValues(normalized.slice(normalized.length - sampleWindowSize));
const delta = endAverage - startAverage;
if (!Number.isFinite(delta)) {
return null;
}
if (Math.abs(delta) < 1) {
return 0;
}
return delta;
}
export function formatStorageCapacityDelta(deltaBytes: number | null): string {
if (deltaBytes === null) {
return '—';
}
if (deltaBytes === 0) {
return '0 B';
}
const sign = deltaBytes > 0 ? '+' : '-';
return `${sign}${formatBytes(Math.abs(deltaBytes))}`;
}
export function getStorageCapacityDeltaToneClass(deltaBytes: number | null): string {
if (deltaBytes === null || deltaBytes === 0) {
return 'text-muted';
}
if (deltaBytes > 0) {
return 'text-amber-600 dark:text-amber-300';
}
return 'text-sky-600 dark:text-sky-300';
}
export function buildStorageCapacityDeltaPresentation(
points: MetricPoint[],
rangeLabel: string,
): StorageCapacityDeltaPresentation {
const deltaBytes = computeStorageCapacityDelta(points);
const label = formatStorageCapacityDelta(deltaBytes);
if (deltaBytes === null) {
return {
deltaBytes,
label,
title: `No storage change history available for ${rangeLabel}.`,
toneClass: getStorageCapacityDeltaToneClass(deltaBytes),
};
}
if (deltaBytes === 0) {
return {
deltaBytes,
label,
title: `No used-capacity change over ${rangeLabel}.`,
toneClass: getStorageCapacityDeltaToneClass(deltaBytes),
};
}
const direction = deltaBytes > 0 ? 'Used capacity grew' : 'Used capacity shrank';
return {
deltaBytes,
label,
title: `${direction} by ${formatBytes(Math.abs(deltaBytes))} over ${rangeLabel}.`,
toneClass: getStorageCapacityDeltaToneClass(deltaBytes),
};
}

View file

@ -23,7 +23,9 @@ export const STORAGE_VIEW_OPTIONS: readonly StorageViewOption[] = [
{ value: 'disks', label: 'Physical Disks' },
];
export const STORAGE_POOL_TABLE_COLUMNS: readonly StoragePoolTableColumn[] = [
export const getStoragePoolTableColumns = (
growthColumnLabel: string,
): readonly StoragePoolTableColumn[] => [
{
label: 'Storage',
className: 'px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider',
@ -51,10 +53,15 @@ export const STORAGE_POOL_TABLE_COLUMNS: readonly StoragePoolTableColumn[] = [
className:
'px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider md:min-w-[190px] xl:min-w-[220px]',
},
{
label: growthColumnLabel,
className:
'hidden lg:table-cell px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider',
},
{
label: 'Impact',
className:
'px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider hidden lg:table-cell',
'hidden xl:table-cell px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider',
},
{
label: 'Primary Issue',

View file

@ -1,5 +1,6 @@
import { getSourcePlatformPresentation } from '@/utils/sourcePlatforms';
import type { StorageRecord } from './models';
import type { StorageCapacityDeltaPresentation } from './storageCapacityDeltaPresentation';
import {
getStorageRecordHostLabel,
getStorageRecordPlatformLabel,
@ -25,6 +26,9 @@ export interface StoragePoolRowModel {
topologyLabel: string;
compactProtection: string;
compactProtectionTitle: string;
capacityDeltaLabel: string;
capacityDeltaTitle: string;
capacityDeltaToneClass: string;
compactImpact: string;
compactIssue: string;
compactIssueSummary: string;
@ -47,6 +51,9 @@ export const STORAGE_POOL_ROW_PROTECTION_TEXT_CLASS = 'block truncate font-semib
export const STORAGE_POOL_ROW_USAGE_CELL_CLASS = 'px-2 py-1 align-middle md:min-w-[190px] xl:min-w-[220px]';
export const STORAGE_POOL_ROW_USAGE_WRAP_CLASS = 'flex items-center whitespace-nowrap text-[11px]';
export const STORAGE_POOL_ROW_USAGE_BAR_WRAP_CLASS = 'min-w-[120px] flex-1';
export const STORAGE_POOL_ROW_GROWTH_CELL_CLASS =
'hidden lg:table-cell px-2 py-1 align-middle text-[11px]';
export const STORAGE_POOL_ROW_GROWTH_TEXT_CLASS = 'block truncate font-mono font-semibold';
export const STORAGE_POOL_ROW_IMPACT_CELL_CLASS =
'hidden lg:table-cell px-2 py-1 align-middle text-[11px] text-base-content';
export const STORAGE_POOL_ROW_ISSUE_CELL_CLASS = 'px-2 py-1 align-middle text-[11px]';
@ -54,7 +61,10 @@ export const STORAGE_POOL_ROW_ISSUE_TEXT_CLASS = 'block truncate text-[11px] fon
export const STORAGE_POOL_ROW_PLACEHOLDER_CLASS = 'text-muted';
export const STORAGE_POOL_ROW_USAGE_FALLBACK_CLASS = 'text-[11px] text-muted';
export const buildStoragePoolRowModel = (record: StorageRecord): StoragePoolRowModel => {
export const buildStoragePoolRowModel = (
record: StorageRecord,
capacityDelta: StorageCapacityDeltaPresentation | null = null,
): StoragePoolRowModel => {
const totalBytes = record.capacity.totalBytes || 0;
const usedBytes = record.capacity.usedBytes || 0;
const freeBytes =
@ -72,6 +82,9 @@ export const buildStoragePoolRowModel = (record: StorageRecord): StoragePoolRowM
topologyLabel: getStorageRecordTopologyLabel(record),
compactProtection: getCompactStoragePoolProtectionLabel(record),
compactProtectionTitle: getCompactStoragePoolProtectionTitle(record),
capacityDeltaLabel: capacityDelta?.label ?? '—',
capacityDeltaTitle: capacityDelta?.title ?? 'No used-capacity change history available.',
capacityDeltaToneClass: capacityDelta?.toneClass ?? STORAGE_POOL_ROW_PLACEHOLDER_CLASS,
compactImpact: getCompactStoragePoolImpactLabel(record),
compactIssue: getCompactStoragePoolIssueLabel(record),
compactIssueSummary: getCompactStoragePoolIssueSummary(record),

View file

@ -278,7 +278,7 @@ import storagePageBannerSource from '@/components/Storage/StoragePageBanner.tsx?
import useStoragePageBannerModelSource from '@/components/Storage/useStoragePageBannerModel.ts?raw';
import storagePageBannersSource from '@/components/Storage/StoragePageBanners.tsx?raw';
import useStoragePageBannersModelSource from '@/components/Storage/useStoragePageBannersModel.ts?raw';
import storageSummarySource from '@/components/Storage/StorageSummary.tsx?raw';
import storageSummarySource from '@/components/Storage/useStorageSummaryCharts.ts?raw';
import storageSummaryCacheSource from '@/utils/storageSummaryCache.ts?raw';
import storagePageSummarySource from '@/components/Storage/StoragePageSummary.tsx?raw';
import storageCephSummaryCardSource from '@/components/Storage/StorageCephSummaryCard.tsx?raw';
@ -1902,7 +1902,7 @@ describe('frontend resource type boundaries', () => {
expect(storagePagePresentationSource).toContain('export const shouldShowCephSummaryCard');
expect(storagePagePresentationSource).toContain('export const getStoragePageBannerMessage');
expect(storagePagePresentationSource).toContain('export const STORAGE_VIEW_OPTIONS');
expect(storagePagePresentationSource).toContain('export const STORAGE_POOL_TABLE_COLUMNS');
expect(storagePagePresentationSource).toContain('export const getStoragePoolTableColumns');
expect(storagePagePresentationSource).toContain(
'export const STORAGE_BANNER_ACTION_BUTTON_CLASS',
);
@ -2048,7 +2048,7 @@ describe('frontend resource type boundaries', () => {
expect(useStorageContentCardModelSource).toContain("options.view() === 'disks'");
expect(useStorageCephSectionModelSource).toContain('shouldShowCephSummaryCard');
expect(storagePoolsTableSource).toContain('export const StoragePoolsTable');
expect(storagePoolsTableSource).toContain('STORAGE_POOL_TABLE_COLUMNS');
expect(storagePoolsTableSource).toContain('getStoragePoolTableColumns');
expect(storagePoolsTableSource).toContain('getStorageLoadingMessage');
expect(storagePoolsTableSource).toContain('useStoragePoolsTableModel');
expect(storagePoolsTableSource).toContain('StoragePoolRow');

View file

@ -0,0 +1,203 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test as base, type Page } from '@playwright/test';
import {
apiRequest,
createAuthenticatedStorageState,
getMockMode,
setMockMode,
} from './helpers';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type WorkerFixtures = {
authStorageStatePath: string;
};
type MetricPoint = {
timestamp: number;
value: number;
};
type StorageSeries = {
used?: MetricPoint[];
};
type StorageChartsResponse = {
pools?: Record<string, StorageSeries>;
};
const ARTIFACTS_DIR = path.resolve(__dirname, '..', '..', 'tmp', 'storage-growth-column');
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
await use(authStorageStatePath);
},
authStorageStatePath: [async ({ browser }, use, workerInfo) => {
const storageStatePath = path.resolve(
__dirname,
'..',
'..',
'tmp',
'playwright-auth',
`storage-growth-column-${workerInfo.project.name}.json`,
);
fs.mkdirSync(path.dirname(storageStatePath), { recursive: true });
await createAuthenticatedStorageState(browser, storageStatePath);
try {
await use(storageStatePath);
} finally {
fs.rmSync(storageStatePath, { force: true });
}
}, { scope: 'worker' }],
});
function average(values: number[]): number {
if (values.length === 0) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function formatBytes(bytes: number): string {
if (!bytes || bytes < 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const unitIndex = Math.floor(Math.log(bytes) / Math.log(k));
const value = bytes / Math.pow(k, unitIndex);
let precision: number;
if (value < 10) precision = 2;
else if (value < 100) precision = 1;
else precision = 0;
return `${value.toFixed(precision)} ${sizes[unitIndex]}`;
}
function computeStorageCapacityDelta(points: MetricPoint[]): number | null {
const normalized = points
.filter((point) => Number.isFinite(point.timestamp) && Number.isFinite(point.value))
.slice()
.sort((left, right) => left.timestamp - right.timestamp);
if (normalized.length < 2) {
return null;
}
const sampleWindowSize =
normalized.length >= 4
? Math.max(2, Math.floor(normalized.length * 0.25))
: 1;
const startAverage = average(normalized.slice(0, sampleWindowSize).map((point) => point.value));
const endAverage = average(normalized.slice(normalized.length - sampleWindowSize).map((point) => point.value));
const delta = endAverage - startAverage;
if (!Number.isFinite(delta)) {
return null;
}
if (Math.abs(delta) < 1) {
return 0;
}
return delta;
}
function formatStorageCapacityDelta(deltaBytes: number | null): string {
if (deltaBytes === null) {
return '—';
}
if (deltaBytes === 0) {
return '0 B';
}
const sign = deltaBytes > 0 ? '+' : '-';
return `${sign}${formatBytes(Math.abs(deltaBytes))}`;
}
function growthLabelForPool(series: StorageSeries | undefined): string {
return formatStorageCapacityDelta(computeStorageCapacityDelta(series?.used ?? []));
}
async function firstVisibleGrowthExpectation(
page: Page,
payload: StorageChartsResponse,
): Promise<{ seriesId: string; label: string }> {
for (const [seriesId, series] of Object.entries(payload.pools ?? {})) {
const label = growthLabelForPool(series);
if (label === '—') {
continue;
}
const row = page.locator(`tr[data-summary-series-id="${seriesId}"]`);
if ((await row.count()) > 0 && await row.first().isVisible()) {
return { seriesId, label };
}
}
throw new Error('Expected at least one visible storage pool growth label derived from used-capacity history.');
}
test.describe.serial('Storage growth column', () => {
test.setTimeout(120_000);
test('renders shared storage-history growth deltas for 24h and 7d', async ({
page,
}, testInfo) => {
test.skip(testInfo.project.name.startsWith('mobile-'), 'Desktop runtime proof');
const mockMode = await getMockMode(page);
if (!mockMode.enabled) {
await setMockMode(page, true);
}
fs.mkdirSync(ARTIFACTS_DIR, { recursive: true });
await page.addInitScript(() => {
localStorage.setItem('pulse_whats_new_v2_shown', 'true');
});
await page.goto('/storage', { waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/\/storage/);
await expect(page.getByTestId('storage-summary')).toBeVisible();
const poolsTable = page.locator('table').first();
await expect(poolsTable.getByText('Growth (24h)', { exact: true })).toBeVisible();
const defaultResponse = await apiRequest(page, '/api/storage-charts?range=1440');
expect(defaultResponse.ok()).toBeTruthy();
const defaultPayload = (await defaultResponse.json()) as StorageChartsResponse;
const defaultGrowth = await firstVisibleGrowthExpectation(page, defaultPayload);
const defaultGrowthCell = page.locator(
`tr[data-summary-series-id="${defaultGrowth.seriesId}"] td:nth-child(7)`,
);
await expect(defaultGrowthCell).toHaveText(defaultGrowth.label);
await page.screenshot({
path: path.resolve(ARTIFACTS_DIR, 'storage-growth-24h.png'),
fullPage: true,
});
const sevenDayPayloadPromise = apiRequest(page, '/api/storage-charts?range=10080');
const sevenDayResponsePromise = page.waitForResponse((response) => {
const url = response.url();
return response.request().method() === 'GET' &&
url.includes('/api/storage-charts?') &&
url.includes('range=10080');
});
await page.getByRole('button', { name: '7d', exact: true }).click();
const sevenDayResponse = await sevenDayPayloadPromise;
expect(sevenDayResponse.ok()).toBeTruthy();
const sevenDayPayload = (await sevenDayResponse.json()) as StorageChartsResponse;
await sevenDayResponsePromise;
await expect(poolsTable.getByText('Growth (7d)', { exact: true })).toBeVisible();
const sevenDayGrowthCell = page.locator(
`tr[data-summary-series-id="${defaultGrowth.seriesId}"] td:nth-child(7)`,
);
await expect(sevenDayGrowthCell).toHaveText(
growthLabelForPool(sevenDayPayload.pools?.[defaultGrowth.seriesId]),
);
await page.screenshot({
path: path.resolve(ARTIFACTS_DIR, 'storage-growth-7d.png'),
fullPage: true,
});
});
});