Refine storage summary and table interactions

This commit is contained in:
rcourtman 2026-04-23 20:41:13 +01:00
parent 386099aeee
commit 46ba8c6ef8
12 changed files with 141 additions and 70 deletions

View file

@ -287,9 +287,21 @@ export const DiskList: Component<DiskListProps> = (props) => {
</TableCell>
<TableCell class={PHYSICAL_DISK_CELL_SIZE_CLASS}>
<span class={PHYSICAL_DISK_SIZE_VALUE_CLASS}>
{formatBytes(data.size)}
</span>
<Show
when={data.size > 0}
fallback={
<span
class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}
title="Disk size not reported by SMART/agent"
>
</span>
}
>
<span class={PHYSICAL_DISK_SIZE_VALUE_CLASS}>
{formatBytes(data.size)}
</span>
</Show>
</TableCell>
</TableRow>

View file

@ -105,6 +105,13 @@ const Storage: Component = () => {
onChartHoverSyncChange={setChartHoverSync}
showJumpToActiveRow={shouldShowJumpToActiveStorageRow}
onJumpToActiveRow={jumpToActiveStorageRow}
onScopeToDegradedPools={() => {
setView('pools');
setStorageFilterStatus('warning');
}}
onScopeToFailingDisks={() => {
setView('disks');
}}
/>
</StickySummarySection>

View file

@ -27,6 +27,8 @@ type StoragePageSummaryProps = {
onChartHoverSyncChange: (value: SummaryChartHoverSync | null) => void;
showJumpToActiveRow: () => boolean;
onJumpToActiveRow: () => void;
onScopeToDegradedPools?: () => void;
onScopeToFailingDisks?: () => void;
};
export const StoragePageSummary: Component<StoragePageSummaryProps> = (props) => {
@ -61,6 +63,8 @@ export const StoragePageSummary: Component<StoragePageSummaryProps> = (props) =>
onChartHoverSyncChange={props.onChartHoverSyncChange}
showJumpToActiveRow={props.showJumpToActiveRow()}
onJumpToActiveRow={props.onJumpToActiveRow}
onScopeToDegradedPools={props.onScopeToDegradedPools}
onScopeToFailingDisks={props.onScopeToFailingDisks}
/>
);
};

View file

@ -8,13 +8,11 @@ import {
buildStoragePoolRowModel,
STORAGE_POOL_ROW_GROWTH_CELL_CLASS,
STORAGE_POOL_ROW_GROWTH_TEXT_CLASS,
getStoragePoolImpactTextClass,
STORAGE_POOL_ROW_CLASS,
STORAGE_POOL_ROW_EXPANDED_CLASS,
STORAGE_POOL_ROW_HEIGHT_CLASS,
STORAGE_POOL_ROW_PLACEHOLDER_CLASS,
STORAGE_POOL_ROW_HOST_CELL_CLASS,
STORAGE_POOL_ROW_IMPACT_CELL_CLASS,
STORAGE_POOL_ROW_ISSUE_CELL_CLASS,
STORAGE_POOL_ROW_ISSUE_TEXT_CLASS,
STORAGE_POOL_ROW_NAME_CELL_CLASS,
@ -101,6 +99,20 @@ export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
</div>
</td>
<td class={STORAGE_POOL_ROW_ISSUE_CELL_CLASS}>
<Show
when={row().compactIssue !== '—'}
fallback={<span class={STORAGE_POOL_ROW_PLACEHOLDER_CLASS}></span>}
>
<span
class={`${STORAGE_POOL_ROW_ISSUE_TEXT_CLASS} ${getStoragePoolIssueTextClass(props.record)}`}
title={row().compactIssueSummary || row().compactIssue}
>
{row().compactIssue}
</span>
</Show>
</td>
<td class={STORAGE_POOL_ROW_SOURCE_CELL_CLASS}>
<span class={`${row().platformToneClass} ${STORAGE_POOL_ROW_SOURCE_BADGE_CLASS}`}>
{row().platformLabel}
@ -160,28 +172,6 @@ export const StoragePoolRow: Component<StoragePoolRowProps> = (props) => {
</span>
</td>
<td class={STORAGE_POOL_ROW_IMPACT_CELL_CLASS}>
<span
class={getStoragePoolImpactTextClass(row().compactImpact)}
title={row().compactImpact}
>
{row().compactImpact}
</span>
</td>
<td class={STORAGE_POOL_ROW_ISSUE_CELL_CLASS}>
<Show
when={row().compactIssue !== '—'}
fallback={<span class={STORAGE_POOL_ROW_PLACEHOLDER_CLASS}></span>}
>
<span
class={`${STORAGE_POOL_ROW_ISSUE_TEXT_CLASS} ${getStoragePoolIssueTextClass(props.record)}`}
title={row().compactIssueSummary || row().compactIssue}
>
{row().compactIssue}
</span>
</Show>
</td>
</tr>
<Show when={props.expanded}>
<StoragePoolDetail

View file

@ -45,6 +45,8 @@ interface StorageSummaryProps {
onChartHoverSyncChange?: (value: SummaryChartHoverSync | null) => void;
showJumpToActiveRow?: boolean;
onJumpToActiveRow?: () => void;
onScopeToDegradedPools?: () => void;
onScopeToFailingDisks?: () => void;
}
// ---------------------------------------------------------------------------
@ -158,6 +160,44 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
summaryFocus.filterSeriesForActiveScope(allDiskTempSeries()),
);
// Symmetric cross-card scoping: a pool-row hover scopes the three pool
// charts; a disk-row hover scopes the disk chart. The "foreign" cards
// fall back to default state instead of dimming, because the hovered
// entity has no representation in their dataset.
const poolSeriesIdSet = createMemo(() => {
const ids = new Set<string>();
for (const s of allPoolUsageSeries()) if (s.id) ids.add(s.id);
for (const s of allPoolUsedSeries()) if (s.id) ids.add(s.id);
for (const s of allPoolAvailSeries()) if (s.id) ids.add(s.id);
return ids;
});
const diskSeriesIdSet = createMemo(() => {
const ids = new Set<string>();
for (const s of allDiskTempSeries()) if (s.id) ids.add(s.id);
return ids;
});
type SeriesFamily = 'pool' | 'disk';
const activeFamily = (): SeriesFamily | null => {
const id = summaryFocus.activeSeriesId();
if (!id) return null;
if (poolSeriesIdSet().has(id)) return 'pool';
if (diskSeriesIdSet().has(id)) return 'disk';
return null;
};
const interactionStateForFamily = (
family: SeriesFamily,
series: InteractiveSparklineSeries[],
) => {
const af = activeFamily();
if (af && af !== family) return 'default' as const;
return summaryFocus.interactionStateFor(series);
};
const highlightIdForFamily = (family: SeriesFamily) => {
const af = activeFamily();
if (af && af !== family) return null;
return summaryFocus.activeSeriesId();
};
const hasPoolUsage = () => poolUsageSeries().length > 0;
const hasDiskTemp = () => diskTempSeries().length > 0;
const hasPoolUsed = () => poolUsedSeries().length > 0;
@ -249,14 +289,42 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
}
>
<Show when={(props.poolsDegraded ?? 0) > 0}>
<span class="text-amber-600 dark:text-amber-400">
{props.poolsDegraded} degraded
</span>
<Show
when={props.onScopeToDegradedPools}
fallback={
<span class="text-amber-600 dark:text-amber-400">
{props.poolsDegraded} degraded
</span>
}
>
<button
type="button"
onClick={() => props.onScopeToDegradedPools?.()}
title="Show degraded pools"
class="text-amber-600 hover:text-amber-700 hover:underline focus:underline focus:outline-none dark:text-amber-400 dark:hover:text-amber-300"
>
{props.poolsDegraded} degraded
</button>
</Show>
</Show>
<Show when={(props.disksFailing ?? 0) > 0}>
<span class="text-amber-600 dark:text-amber-400">
{props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'}
</span>
<Show
when={props.onScopeToFailingDisks}
fallback={
<span class="text-amber-600 dark:text-amber-400">
{props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'}
</span>
}
>
<button
type="button"
onClick={() => props.onScopeToFailingDisks?.()}
title="Show physical disks"
class="text-amber-600 hover:text-amber-700 hover:underline focus:underline focus:outline-none dark:text-amber-400 dark:hover:text-amber-300"
>
{props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'}
</button>
</Show>
</Show>
</Show>
<Show when={props.showJumpToActiveRow && props.onJumpToActiveRow}>
@ -274,7 +342,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
loaded={props.loaded}
hasData={hasPoolUsage()}
emptyMessage={emptyLabel()}
interactionState={summaryFocus.interactionStateFor(poolUsageSeries())}
interactionState={interactionStateForFamily('pool', poolUsageSeries())}
>
<InteractiveSparkline
series={poolUsageSeries()}
@ -285,8 +353,8 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
highlightNearestSeriesOnHover
hoverSourceKey="pool-usage"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(poolUsageSeries())}
highlightSeriesId={highlightIdForFamily('pool')}
interactionState={interactionStateForFamily('pool', poolUsageSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
@ -298,7 +366,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
loaded={props.loaded}
hasData={hasDiskTemp()}
emptyMessage={emptyLabel()}
interactionState={summaryFocus.interactionStateFor(diskTempSeries())}
interactionState={interactionStateForFamily('disk', diskTempSeries())}
>
<InteractiveSparkline
series={diskTempSeries()}
@ -311,8 +379,8 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
highlightNearestSeriesOnHover
hoverSourceKey="disk-temperature"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(diskTempSeries())}
highlightSeriesId={highlightIdForFamily('disk')}
interactionState={interactionStateForFamily('disk', diskTempSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
@ -324,7 +392,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
loaded={props.loaded}
hasData={hasPoolUsed()}
emptyMessage={emptyLabel()}
interactionState={summaryFocus.interactionStateFor(poolUsedSeries())}
interactionState={interactionStateForFamily('pool', poolUsedSeries())}
>
<InteractiveSparkline
series={poolUsedSeries()}
@ -337,8 +405,8 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
highlightNearestSeriesOnHover
hoverSourceKey="used-capacity"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(poolUsedSeries())}
highlightSeriesId={highlightIdForFamily('pool')}
interactionState={interactionStateForFamily('pool', poolUsedSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>
@ -350,7 +418,7 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
loaded={props.loaded}
hasData={hasPoolAvail()}
emptyMessage={emptyLabel()}
interactionState={summaryFocus.interactionStateFor(poolAvailSeries())}
interactionState={interactionStateForFamily('pool', poolAvailSeries())}
>
<InteractiveSparkline
series={poolAvailSeries()}
@ -363,8 +431,8 @@ const StorageSummary: Component<StorageSummaryProps> = (props) => {
highlightNearestSeriesOnHover
hoverSourceKey="available-space"
hoverSync={chartHoverSync()}
highlightSeriesId={summaryFocus.activeSeriesId()}
interactionState={summaryFocus.interactionStateFor(poolAvailSeries())}
highlightSeriesId={highlightIdForFamily('pool')}
interactionState={interactionStateForFamily('pool', poolAvailSeries())}
onHoverSyncChange={setChartHoverSync}
/>
</SummaryMetricCard>

View file

@ -542,7 +542,7 @@ describe('Storage', () => {
'[data-highlight-series-active="true"][data-highlight-series-id="pool:alpha"][data-active-series-display="isolate"][data-rendered-series-count="1"]',
).length,
).toBe(3);
expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(1);
expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(0);
});
fireEvent.pointerLeave(alphaRow, { pointerType: 'mouse' });
@ -588,7 +588,7 @@ describe('Storage', () => {
'[data-highlight-series-active="true"][data-highlight-series-id="pool:alpha"]',
).length,
).toBe(3);
expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(1);
expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(0);
});
fireEvent.mouseLeave(poolUsageChart);
@ -1175,7 +1175,7 @@ describe('Storage', () => {
'[data-highlight-series-active="true"][data-highlight-series-id="pool:alpha"]',
).length,
).toBe(1);
expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(3);
expect(summary.querySelectorAll('[data-summary-card-state="inactive"]').length).toBe(2);
expect(
screen
.getByText('Used Capacity')

View file

@ -192,13 +192,14 @@ describe('StorageSummary', () => {
expect(poolCards).toHaveLength(3);
for (const sparkline of sparklines) {
expect(sparkline.getAttribute('data-highlight-series-id')).toBe('pool:alpha');
expect(sparkline.getAttribute('data-hover-sync-series-id')).toBe('pool:alpha');
}
for (const sparkline of poolCards) {
expect(sparkline.getAttribute('data-highlight-series-id')).toBe('pool:alpha');
expect(sparkline.getAttribute('data-interaction-state')).toBe('active');
}
expect(diskTempCard?.getAttribute('data-interaction-state')).toBe('inactive');
expect(diskTempCard?.getAttribute('data-highlight-series-id') ?? '').toBe('');
expect(diskTempCard?.getAttribute('data-interaction-state')).toBe('default');
});
});

View file

@ -91,14 +91,13 @@ describe('storagePagePresentation', () => {
]);
expect(getStoragePoolTableColumns('Growth (24h)').map((column) => column.label)).toEqual([
'Storage',
'Primary Issue',
'Source',
'Type',
'Host',
'Protection',
'Usage',
'Growth (24h)',
'Impact',
'Primary Issue',
]);
});

View file

@ -3,7 +3,6 @@ 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,
STORAGE_POOL_ROW_HEIGHT_CLASS,
@ -68,6 +67,5 @@ describe('storage pool row presentation', () => {
expect(model.compactIssue).toBe('Capacity Pressure');
expect(model.compactImpact).toBe('—');
expect(model.freeBytes).toBe(200);
expect(getStoragePoolImpactTextClass('—')).toContain('text-muted');
});
});

View file

@ -30,6 +30,10 @@ export const getStoragePoolTableColumns = (
label: 'Storage',
className: 'px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider',
},
{
label: 'Primary Issue',
className: 'px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider',
},
{
label: 'Source',
className: 'px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider',
@ -58,15 +62,6 @@ export const getStoragePoolTableColumns = (
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:
'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',
className: 'px-1.5 sm:px-2 py-0.5 text-left text-[11px] sm:text-xs font-medium uppercase tracking-wider',
},
];
export const STORAGE_BANNER_ACTION_BUTTON_CLASS =

View file

@ -54,9 +54,7 @@ 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]';
export const STORAGE_POOL_ROW_ISSUE_CELL_CLASS = 'px-2 py-1 align-middle text-[11px] max-w-[14rem]';
export const STORAGE_POOL_ROW_ISSUE_TEXT_CLASS = 'block truncate text-[11px] font-semibold';
export const STORAGE_POOL_ROW_PLACEHOLDER_CLASS = 'text-muted';
export const STORAGE_POOL_ROW_USAGE_FALLBACK_CLASS = 'text-[11px] text-muted';
@ -91,5 +89,3 @@ export const buildStoragePoolRowModel = (
};
};
export const getStoragePoolImpactTextClass = (impact: string): string =>
`${STORAGE_POOL_ROW_TEXT_TRUNCATE_CLASS} ${impact === '—' ? STORAGE_POOL_ROW_PLACEHOLDER_CLASS : ''}`.trim();

View file

@ -30,6 +30,7 @@ type StorageChartsResponse = {
};
const ARTIFACTS_DIR = path.resolve(__dirname, '..', '..', 'tmp', 'storage-growth-column');
const STORAGE_POOL_GROWTH_CELL_SELECTOR = 'td:nth-child(8)';
const test = base.extend<{}, WorkerFixtures>({
storageState: async ({ authStorageStatePath }, use) => {
@ -165,7 +166,7 @@ test.describe.serial('Storage growth column', () => {
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)`,
`tr[data-summary-series-id="${defaultGrowth.seriesId}"] ${STORAGE_POOL_GROWTH_CELL_SELECTOR}`,
);
await expect(defaultGrowthCell).toHaveText(defaultGrowth.label);
@ -189,7 +190,7 @@ test.describe.serial('Storage growth column', () => {
await expect(poolsTable.getByText('Growth (7d)', { exact: true })).toBeVisible();
const sevenDayGrowthCell = page.locator(
`tr[data-summary-series-id="${defaultGrowth.seriesId}"] td:nth-child(7)`,
`tr[data-summary-series-id="${defaultGrowth.seriesId}"] ${STORAGE_POOL_GROWTH_CELL_SELECTOR}`,
);
await expect(sevenDayGrowthCell).toHaveText(
growthLabelForPool(sevenDayPayload.pools?.[defaultGrowth.seriesId]),