diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx index cac9c074d..aef6920af 100644 --- a/frontend-modern/src/components/Storage/DiskList.tsx +++ b/frontend-modern/src/components/Storage/DiskList.tsx @@ -287,9 +287,21 @@ export const DiskList: Component = (props) => { - - {formatBytes(data.size)} - + 0} + fallback={ + + — + + } + > + + {formatBytes(data.size)} + + diff --git a/frontend-modern/src/components/Storage/Storage.tsx b/frontend-modern/src/components/Storage/Storage.tsx index db14ea93e..b58b64dec 100644 --- a/frontend-modern/src/components/Storage/Storage.tsx +++ b/frontend-modern/src/components/Storage/Storage.tsx @@ -105,6 +105,13 @@ const Storage: Component = () => { onChartHoverSyncChange={setChartHoverSync} showJumpToActiveRow={shouldShowJumpToActiveStorageRow} onJumpToActiveRow={jumpToActiveStorageRow} + onScopeToDegradedPools={() => { + setView('pools'); + setStorageFilterStatus('warning'); + }} + onScopeToFailingDisks={() => { + setView('disks'); + }} /> diff --git a/frontend-modern/src/components/Storage/StoragePageSummary.tsx b/frontend-modern/src/components/Storage/StoragePageSummary.tsx index 7254cef70..edce5b71d 100644 --- a/frontend-modern/src/components/Storage/StoragePageSummary.tsx +++ b/frontend-modern/src/components/Storage/StoragePageSummary.tsx @@ -27,6 +27,8 @@ type StoragePageSummaryProps = { onChartHoverSyncChange: (value: SummaryChartHoverSync | null) => void; showJumpToActiveRow: () => boolean; onJumpToActiveRow: () => void; + onScopeToDegradedPools?: () => void; + onScopeToFailingDisks?: () => void; }; export const StoragePageSummary: Component = (props) => { @@ -61,6 +63,8 @@ export const StoragePageSummary: Component = (props) => onChartHoverSyncChange={props.onChartHoverSyncChange} showJumpToActiveRow={props.showJumpToActiveRow()} onJumpToActiveRow={props.onJumpToActiveRow} + onScopeToDegradedPools={props.onScopeToDegradedPools} + onScopeToFailingDisks={props.onScopeToFailingDisks} /> ); }; diff --git a/frontend-modern/src/components/Storage/StoragePoolRow.tsx b/frontend-modern/src/components/Storage/StoragePoolRow.tsx index 6652cb0a1..fb138c3f1 100644 --- a/frontend-modern/src/components/Storage/StoragePoolRow.tsx +++ b/frontend-modern/src/components/Storage/StoragePoolRow.tsx @@ -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 = (props) => { + + —} + > + + {row().compactIssue} + + + + {row().platformLabel} @@ -160,28 +172,6 @@ export const StoragePoolRow: Component = (props) => { - - - {row().compactImpact} - - - - - —} - > - - {row().compactIssue} - - - void; showJumpToActiveRow?: boolean; onJumpToActiveRow?: () => void; + onScopeToDegradedPools?: () => void; + onScopeToFailingDisks?: () => void; } // --------------------------------------------------------------------------- @@ -158,6 +160,44 @@ const StorageSummary: Component = (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(); + 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(); + 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 = (props) => { } > 0}> - - {props.poolsDegraded} degraded - + + {props.poolsDegraded} degraded + + } + > + + 0}> - - {props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'} - + + {props.disksFailing} {props.disksFailing === 1 ? 'disk failing' : 'disks failing'} + + } + > + + @@ -274,7 +342,7 @@ const StorageSummary: Component = (props) => { loaded={props.loaded} hasData={hasPoolUsage()} emptyMessage={emptyLabel()} - interactionState={summaryFocus.interactionStateFor(poolUsageSeries())} + interactionState={interactionStateForFamily('pool', poolUsageSeries())} > = (props) => { highlightNearestSeriesOnHover hoverSourceKey="pool-usage" hoverSync={chartHoverSync()} - highlightSeriesId={summaryFocus.activeSeriesId()} - interactionState={summaryFocus.interactionStateFor(poolUsageSeries())} + highlightSeriesId={highlightIdForFamily('pool')} + interactionState={interactionStateForFamily('pool', poolUsageSeries())} onHoverSyncChange={setChartHoverSync} /> @@ -298,7 +366,7 @@ const StorageSummary: Component = (props) => { loaded={props.loaded} hasData={hasDiskTemp()} emptyMessage={emptyLabel()} - interactionState={summaryFocus.interactionStateFor(diskTempSeries())} + interactionState={interactionStateForFamily('disk', diskTempSeries())} > = (props) => { highlightNearestSeriesOnHover hoverSourceKey="disk-temperature" hoverSync={chartHoverSync()} - highlightSeriesId={summaryFocus.activeSeriesId()} - interactionState={summaryFocus.interactionStateFor(diskTempSeries())} + highlightSeriesId={highlightIdForFamily('disk')} + interactionState={interactionStateForFamily('disk', diskTempSeries())} onHoverSyncChange={setChartHoverSync} /> @@ -324,7 +392,7 @@ const StorageSummary: Component = (props) => { loaded={props.loaded} hasData={hasPoolUsed()} emptyMessage={emptyLabel()} - interactionState={summaryFocus.interactionStateFor(poolUsedSeries())} + interactionState={interactionStateForFamily('pool', poolUsedSeries())} > = (props) => { highlightNearestSeriesOnHover hoverSourceKey="used-capacity" hoverSync={chartHoverSync()} - highlightSeriesId={summaryFocus.activeSeriesId()} - interactionState={summaryFocus.interactionStateFor(poolUsedSeries())} + highlightSeriesId={highlightIdForFamily('pool')} + interactionState={interactionStateForFamily('pool', poolUsedSeries())} onHoverSyncChange={setChartHoverSync} /> @@ -350,7 +418,7 @@ const StorageSummary: Component = (props) => { loaded={props.loaded} hasData={hasPoolAvail()} emptyMessage={emptyLabel()} - interactionState={summaryFocus.interactionStateFor(poolAvailSeries())} + interactionState={interactionStateForFamily('pool', poolAvailSeries())} > = (props) => { highlightNearestSeriesOnHover hoverSourceKey="available-space" hoverSync={chartHoverSync()} - highlightSeriesId={summaryFocus.activeSeriesId()} - interactionState={summaryFocus.interactionStateFor(poolAvailSeries())} + highlightSeriesId={highlightIdForFamily('pool')} + interactionState={interactionStateForFamily('pool', poolAvailSeries())} onHoverSyncChange={setChartHoverSync} /> diff --git a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx index fa1f47201..6d438a6ec 100644 --- a/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/Storage.test.tsx @@ -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') diff --git a/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx b/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx index 35357137e..07be05606 100644 --- a/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/StorageSummary.test.tsx @@ -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'); }); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts index 7d174d8b4..bca20f33c 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePagePresentation.test.ts @@ -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', ]); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts index fe6cba5b0..d7b634f9f 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/storagePoolRowPresentation.test.ts @@ -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'); }); }); diff --git a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts index 4421797f4..8b236063f 100644 --- a/frontend-modern/src/features/storageBackups/storagePagePresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePagePresentation.ts @@ -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 = diff --git a/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts b/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts index 8b43d4d06..0cf9718a8 100644 --- a/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts +++ b/frontend-modern/src/features/storageBackups/storagePoolRowPresentation.ts @@ -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(); diff --git a/tests/integration/tests/62-storage-growth-column.spec.ts b/tests/integration/tests/62-storage-growth-column.spec.ts index 3739a22ea..0917d899d 100644 --- a/tests/integration/tests/62-storage-growth-column.spec.ts +++ b/tests/integration/tests/62-storage-growth-column.spec.ts @@ -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]),