diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index d1113bfeb..1e65df5cb 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a23a081a6..9d5080b8b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -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 diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index 6e65242de..db14ea93e 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -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} diff --git a/frontend-modern/src/components/Storage/StorageContentCard.tsx b/frontend-modern/src/components/Storage/StorageContentCard.tsx index b3f6a43c5..867b2ac4b 100644 --- a/frontend-modern/src/components/Storage/StorageContentCard.tsx +++ b/frontend-modern/src/components/Storage/StorageContentCard.tsx @@ -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; + storageGrowthColumnLabel: () => string; nodeOnlineByLabel: () => Map; highlightedRecordId: () => string | null; getRecordAlertState: (recordId: string) => StorageAlertRowState; @@ -88,6 +91,8 @@ export const StorageContentCard: Component = (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()} diff --git a/frontend-modern/src/components/Storage/StoragePageSummary.tsx b/frontend-modern/src/components/Storage/StoragePageSummary.tsx index 14c7bad43..7254cef70 100644 --- a/frontend-modern/src/components/Storage/StoragePageSummary.tsx +++ b/frontend-modern/src/components/Storage/StoragePageSummary.tsx @@ -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 = (props) => { const { - summaryTimeRange, - setSummaryTimeRange, poolCount, diskCount, poolsDegraded, @@ -43,9 +48,11 @@ export const StoragePageSummary: Component = (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()} diff --git a/frontend-modern/src/components/Storage/StoragePoolRow.tsx b/frontend-modern/src/components/Storage/StoragePoolRow.tsx index 29d4e9f7a..6652cb0a1 100644 --- a/frontend-modern/src/components/Storage/StoragePoolRow.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolRow.tsx @@ -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 = (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 = (props) => { + + + {row().capacityDeltaLabel} + + + void; expandedPoolId: string | null; setExpandedPoolId: (value: string | null | ((current: string | null) => string | null)) => void; + storageGrowthBySeriesId: Map; + storageGrowthColumnLabel: string; physicalDisks: Resource[]; nodeOnlineByLabel: Map; highlightedRecordId: string | null; @@ -70,7 +73,7 @@ export const StoragePoolsTable: Component = (props) => { - + {(column) => {column.label}} @@ -83,17 +86,17 @@ export const StoragePoolsTable: Component = (props) => { {(() => { const groupSummaryScope = buildStorageSummaryGroupScope(group, props.groupBy); return ( - props.toggleGroup(group.key)} - summaryGroupScope={groupSummaryScope} - summaryActive={props.activeSummaryGroupScope?.id === groupSummaryScope?.id} - summaryFocused={props.focusedSummaryGroupId === groupSummaryScope?.id} - onFocusChange={props.onGroupFocusChange} - onHoverChange={props.onGroupHoverChange} - /> + props.toggleGroup(group.key)} + summaryGroupScope={groupSummaryScope} + summaryActive={props.activeSummaryGroupScope?.id === groupSummaryScope?.id} + summaryFocused={props.focusedSummaryGroupId === groupSummaryScope?.id} + onFocusChange={props.onGroupFocusChange} + onHoverChange={props.onGroupHoverChange} + /> ); })()} @@ -105,6 +108,11 @@ export const StoragePoolsTable: Component = (props) => { return ( void; - nodeId?: string; hoveredResourceId?: string | null; hoveredGroupScope?: SummarySeriesGroupScope | null; focusedResourceId?: string | null; @@ -57,9 +52,6 @@ interface StorageSummaryProps { // --------------------------------------------------------------------------- const StorageSummary: Component = (props) => { - const [data, setData] = createSignal(null); - const [loaded, setLoaded] = createSignal(false); - const [fetchFailed, setFetchFailed] = createSignal(false); const [localChartHoverSync, setLocalChartHoverSync] = createSignal( null, ); @@ -71,132 +63,12 @@ const StorageSummary: Component = (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 | undefined; - - const selectedRange = createMemo(() => (props.timeRange as TimeRange) || '1h'); - const selectedNodeId = createMemo(() => { - const id = props.nodeId?.trim(); - return id && id !== 'all' ? id : undefined; - }); - - const awaitAbortable = (promise: Promise, signal: AbortSignal): Promise => { - if (signal.aborted) { - return Promise.reject(new DOMException('Aborted', 'AbortError')); - } - return new Promise((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 = (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 = (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 = (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 = (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 = (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 = (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 = (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 = (props) => { label="Available Space" secondaryLabel={focusedLabel(poolAvailSeries())} headerValue={renderSyncedReadout(poolAvailSyncedReadout())} - loaded={loaded()} + loaded={props.loaded} hasData={hasPoolAvail()} emptyMessage={emptyLabel()} interactionState={summaryFocus.interactionStateFor(poolAvailSeries())} diff --git a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx index 9a3ccbf40..fa1f47201 100644 --- a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx @@ -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(() => ); + + 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', () => { { 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__'}`;", diff --git a/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx b/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx index 8459ea38f..35357137e 100644 --- a/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx @@ -79,6 +79,14 @@ describe('StorageSummary', () => { { timestamp: now - 60_000, value: 45 }, { timestamp: now, value: 47 }, ]; + const buildSummaryData = (overrides?: Record) => ({ + 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(() => ( - + )); 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(() => ); + render(() => ( + + )); 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(() => ( { expect(result.diskCount()).toBe(1); expect(result.poolsDegraded()).toBe(0); expect(result.disksFailing()).toBe(0); - expect(result.summaryTimeRange()).toBe('1h'); }); }); diff --git a/frontend-modern/src/components/Storage/useStoragePageModel.ts b/frontend-modern/src/components/Storage/useStoragePageModel.ts index 3947e6119..e0a76a3dc 100644 --- a/frontend-modern/src/components/Storage/useStoragePageModel.ts +++ b/frontend-modern/src/components/Storage/useStoragePageModel.ts @@ -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(null); const [handledSummaryGroupId, setHandledSummaryGroupId] = createSignal(null); const [selectedDiskId, setSelectedDiskId] = createSignal(null); + const [summaryTimeRange, setSummaryTimeRange] = createSignal('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>(); + 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, diff --git a/frontend-modern/src/components/Storage/useStoragePageSummary.ts b/frontend-modern/src/components/Storage/useStoragePageSummary.ts index 3eda69594..3eac2d8a6 100644 --- a/frontend-modern/src/components/Storage/useStoragePageSummary.ts +++ b/frontend-modern/src/components/Storage/useStoragePageSummary.ts @@ -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('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, diff --git a/frontend-modern/src/components/Storage/useStorageSummaryCharts.ts b/frontend-modern/src/components/Storage/useStorageSummaryCharts.ts new file mode 100644 index 000000000..1707ef71f --- /dev/null +++ b/frontend-modern/src/components/Storage/useStorageSummaryCharts.ts @@ -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; + nodeId?: Accessor; + caller?: string; +}; + +export const useStorageSummaryCharts = (options: UseStorageSummaryChartsOptions) => { + const [data, setData] = createSignal(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 | undefined; + + const selectedRange = createMemo(() => (options.timeRange() as TimeRange) || '1h'); + const selectedNodeId = createMemo(() => { + const raw = options.nodeId?.()?.trim(); + return raw && raw !== 'all' ? raw : undefined; + }); + + const awaitAbortable = (promise: Promise, signal: AbortSignal): Promise => { + if (signal.aborted) { + return Promise.reject(new DOMException('Aborted', 'AbortError')); + } + return new Promise((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; diff --git a/frontend-modern/src/features/storageBackups/__tests__/storageCapacityDeltaPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storageCapacityDeltaPresentation.test.ts new file mode 100644 index 000000000..44556f7ab --- /dev/null +++ b/frontend-modern/src/features/storageBackups/__tests__/storageCapacityDeltaPresentation.test.ts @@ -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', + }); + }); +}); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts index 55b017126..7d174d8b4 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts @@ -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', ]); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts index 20f189b3a..fe6cba5b0 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts @@ -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); diff --git a/frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts b/frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts new file mode 100644 index 000000000..2a8c8e6f6 --- /dev/null +++ b/frontend-modern/src/features/storageBackups/storageCapacityDeltaPresentation.ts @@ -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), + }; +} diff --git a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts index 3e144c18d..4421797f4 100644 --- a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts @@ -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', diff --git a/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts b/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts index 950a09ec3..8b43d4d06 100644 --- a/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts @@ -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), diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 1eb523387..641b012b2 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -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'); diff --git a/tests/integration/tests/62-storage-growth-column.spec.ts b/tests/integration/tests/62-storage-growth-column.spec.ts new file mode 100644 index 000000000..3739a22ea --- /dev/null +++ b/tests/integration/tests/62-storage-growth-column.spec.ts @@ -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; +}; + +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, + }); + }); +});