mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
Expose shared storage growth deltas on the storage page
This commit is contained in:
parent
4711d11163
commit
92f4e2c0a9
22 changed files with 802 additions and 194 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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())}
|
||||
|
|
|
|||
|
|
@ -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__'}`;",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
203
tests/integration/tests/62-storage-growth-column.spec.ts
Normal file
203
tests/integration/tests/62-storage-growth-column.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue