From 809e2c9008fc1eb43c8710fae19dfe506fdc308f Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Jun 2026 15:58:58 +0100 Subject: [PATCH] storage(disks): restore v5 information density to the physical disks table The v6 physical disks table regressed against its v5 ancestor: device path was demoted to a tooltip (identical models became indistinguishable), SSD wearout vanished from the surface entirely despite flowing all the way into the presentation layer, the Belongs column rendered empty for every disk without a ZFS pool mapping, and a Source badge spent a column saying PVE on every row. Flagged from the side-by-side screenshots in #1471. - Add a Device column (mono devPath, md+). - Add a Life column rendering wearout with v5-style thresholds (md+). - Fall back Belongs to Proxmox's usage string (ZFS / ext4 / BIOS boot) when no pool mapping exists; searchable via the existing haystack. - Demote the Source badge to the row expansion header next to the serial; drop the table column. - Render missing temperatures as muted dashes instead of green ones (getTemperatureTextClass(0) returned the healthy tone). - Tighten rows from 38px to 32px to match the platform table standard. --- .../src/components/Storage/DiskDetail.tsx | 7 ++ .../src/components/Storage/DiskList.tsx | 79 +++++++++++++------ .../Storage/__tests__/DiskList.test.tsx | 28 ++++++- .../__tests__/diskPresentation.test.ts | 21 ++++- .../storageBackups/diskPresentation.ts | 57 +++++++++---- .../frontendResourceTypeBoundaries.test.ts | 5 +- 6 files changed, 150 insertions(+), 47 deletions(-) diff --git a/frontend-modern/src/components/Storage/DiskDetail.tsx b/frontend-modern/src/components/Storage/DiskDetail.tsx index 0b4c45916..e51164af8 100644 --- a/frontend-modern/src/components/Storage/DiskDetail.tsx +++ b/frontend-modern/src/components/Storage/DiskDetail.tsx @@ -38,6 +38,7 @@ import { STORAGE_DETAIL_MONO_CHIP_CLASS, STORAGE_DETAIL_SECTION_TITLE_CLASS, } from '@/features/storageBackups/detailPresentation'; +import { getPhysicalDiskSourceBadgePresentation } from '@/features/storageBackups/diskPresentation'; import { StorageDetailMetricCard } from './StorageDetailMetricCard'; import { useDiskDetailModel } from './useDiskDetailModel'; @@ -82,6 +83,12 @@ export const DiskDetail: Component = (props) => { {diskData().model || 'Unknown Disk'} {diskData().devPath} {diskData().node} + + {getPhysicalDiskSourceBadgePresentation(props.disk).label} + S/N: {diskData().serial} diff --git a/frontend-modern/src/components/Storage/DiskList.tsx b/frontend-modern/src/components/Storage/DiskList.tsx index 97fb6a50a..f118f80c6 100644 --- a/frontend-modern/src/components/Storage/DiskList.tsx +++ b/frontend-modern/src/components/Storage/DiskList.tsx @@ -16,21 +16,23 @@ import { import { formatBytes } from '@/utils/format'; import { formatTemperature, getTemperatureTextClass } from '@/utils/temperature'; import { + PHYSICAL_DISK_CELL_DEVICE_CLASS, PHYSICAL_DISK_CELL_DISK_CLASS, PHYSICAL_DISK_CELL_HEALTH_CLASS, PHYSICAL_DISK_CELL_HOST_CLASS, + PHYSICAL_DISK_CELL_LIFE_CLASS, PHYSICAL_DISK_CELL_PARENT_CLASS, PHYSICAL_DISK_CELL_ROLE_CLASS, PHYSICAL_DISK_CELL_SIZE_CLASS, - PHYSICAL_DISK_CELL_SOURCE_CLASS, PHYSICAL_DISK_CELL_TEMP_CLASS, + PHYSICAL_DISK_COL_DEVICE_CLASS, PHYSICAL_DISK_COL_DISK_CLASS, PHYSICAL_DISK_COL_HEALTH_CLASS, PHYSICAL_DISK_COL_HOST_CLASS, + PHYSICAL_DISK_COL_LIFE_CLASS, PHYSICAL_DISK_COL_PARENT_CLASS, PHYSICAL_DISK_COL_ROLE_CLASS, PHYSICAL_DISK_COL_SIZE_CLASS, - PHYSICAL_DISK_COL_SOURCE_CLASS, PHYSICAL_DISK_COL_TEMP_CLASS, PHYSICAL_DISK_DETAIL_ROW_CELL_CLASS, PHYSICAL_DISK_EMPTY_CARD_CLASS, @@ -42,17 +44,20 @@ import { PHYSICAL_DISK_EMPTY_REQUIREMENTS_NOTE_CLASS, PHYSICAL_DISK_EMPTY_REQUIREMENTS_TITLE_CLASS, PHYSICAL_DISK_EMPTY_TITLE_CLASS, + PHYSICAL_DISK_HEADER_DEVICE_CLASS, PHYSICAL_DISK_HEADER_DISK_CLASS, PHYSICAL_DISK_HEADER_HEALTH_CLASS, PHYSICAL_DISK_HEADER_HOST_CLASS, + PHYSICAL_DISK_HEADER_LIFE_CLASS, PHYSICAL_DISK_HEADER_PARENT_CLASS, PHYSICAL_DISK_HEADER_ROLE_CLASS, PHYSICAL_DISK_HEADER_SIZE_CLASS, - PHYSICAL_DISK_HEADER_SOURCE_CLASS, PHYSICAL_DISK_HEADER_TEMP_CLASS, PHYSICAL_DISK_HEALTH_LABEL_CLASS, PHYSICAL_DISK_HEALTH_SUMMARY_CLASS, PHYSICAL_DISK_HEALTH_WRAP_CLASS, + PHYSICAL_DISK_DEVICE_TEXT_CLASS, + PHYSICAL_DISK_LIFE_CLASS, PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS, PHYSICAL_DISK_NAME_TEXT_CLASS, PHYSICAL_DISK_NAME_WRAP_CLASS, @@ -70,9 +75,10 @@ import { getPhysicalDiskHealthStatus, getPhysicalDiskHealthSummary, getPhysicalDiskHostLabel, + getPhysicalDiskLifeLabel, + getPhysicalDiskLifeTextClass, getPhysicalDiskParentLabel, getPhysicalDiskRoleLabel, - getPhysicalDiskSourceBadgePresentation, } from '@/features/storageBackups/diskPresentation'; import type { Resource } from '@/types/resource'; import type { StorageHealthFilter } from '@/features/storageBackups/models'; @@ -178,29 +184,19 @@ export const DiskList: Component = (props) => { - + + Disk - - - - + Device Host Role = (props) => { Belongs Health + + Life + Temp Size @@ -222,7 +225,6 @@ export const DiskList: Component = (props) => { const status = getPhysicalDiskHealthStatus(data); const hostLabel = getPhysicalDiskHostLabel(data, disk); const healthSummary = getPhysicalDiskHealthSummary(status); - const sourceBadge = getPhysicalDiskSourceBadgePresentation(disk); const isSelected = () => model.selectedDisk()?.id === disk.id; const summarySeriesId = resolvePhysicalDiskMetricResourceId(disk); const isSummaryHighlighted = () => @@ -269,8 +271,15 @@ export const DiskList: Component = (props) => { - - {sourceBadge.label} + + —} + > + + {data.devPath} + + @@ -325,12 +334,30 @@ export const DiskList: Component = (props) => { - - + } > - {data.temperature > 0 ? formatTemperature(data.temperature) : '—'} - + + {getPhysicalDiskLifeLabel(data)} + + + + + + 0} + fallback={} + > + + {formatTemperature(data.temperature)} + + @@ -355,7 +382,7 @@ export const DiskList: Component = (props) => { diff --git a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx index 39c49025a..17780b24d 100644 --- a/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx +++ b/frontend-modern/src/components/Storage/__tests__/DiskList.test.tsx @@ -105,17 +105,39 @@ describe('DiskList', () => { }); expect(screen.getByRole('columnheader', { name: 'Disk' })).toBeInTheDocument(); - expect(screen.getByRole('columnheader', { name: 'Source' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'Device' })).toBeInTheDocument(); expect(screen.getByRole('columnheader', { name: 'Host' })).toBeInTheDocument(); + expect(screen.getByRole('columnheader', { name: 'SSD life remaining' })).toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Source' })).not.toBeInTheDocument(); expect(screen.getByText('Disk sda')).toBeInTheDocument(); + expect(screen.getByText('/dev/sda')).toBeInTheDocument(); expect(screen.getByText('tower')).toBeInTheDocument(); - expect(screen.getByText('PVE')).toBeInTheDocument(); expect(screen.getByText('Parity')).toBeInTheDocument(); expect(screen.getByText('Tower Array')).toBeInTheDocument(); expect(screen.getByText('Needs Attention')).toBeInTheDocument(); expect(screen.getByText('Pending sectors detected.')).toBeInTheDocument(); }); + it('renders SSD life and falls back to the Proxmox usage string for Belongs', () => { + renderDiskList({ + disks: [ + buildDisk('sda', 'tower', { + diskType: 'ssd', + wearout: 96, + storageGroup: '', + used: 'ZFS', + storageRole: '', + }), + ], + nodes: [buildNode('node-tower', 'tower')], + selectedNode: null, + searchTerm: '', + }); + + expect(screen.getByText('96%')).toBeInTheDocument(); + expect(screen.getByText('ZFS')).toBeInTheDocument(); + }); + it('filters disks by search term and supports row expansion', () => { renderDiskList({ disks: [buildDisk('sda', 'tower'), buildDisk('sdb', 'tower', { model: 'Cache SSD' })], @@ -196,7 +218,7 @@ describe('DiskList', () => { searchTerm: '', }); - expect(screen.getByText('TrueNAS')).toBeInTheDocument(); + expect(screen.getByText('truenas-main')).toBeInTheDocument(); fireEvent.click(screen.getByText('Seagate IronWolf')); expect(screen.getByTestId('disk-detail')).toHaveTextContent('sda'); }); diff --git a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts index ae9330151..e751f6984 100644 --- a/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts +++ b/frontend-modern/src/features/storageBackups/__tests__/diskPresentation.test.ts @@ -10,8 +10,9 @@ import { PHYSICAL_DISK_EMPTY_CARD_CLASS, PHYSICAL_DISK_ALL_GROUPS_FILTER_LABEL, PHYSICAL_DISK_ALL_ROLES_FILTER_LABEL, + PHYSICAL_DISK_HEADER_DEVICE_CLASS, PHYSICAL_DISK_HEADER_DISK_CLASS, - PHYSICAL_DISK_HEADER_SOURCE_CLASS, + PHYSICAL_DISK_HEADER_LIFE_CLASS, PHYSICAL_DISK_NAME_TEXT_CLASS, PHYSICAL_DISK_SOURCE_BADGE_CLASS, PHYSICAL_DISK_TABLE_CLASS, @@ -20,6 +21,8 @@ import { getPhysicalDiskHealthStatus, getPhysicalDiskHealthSummary, getPhysicalDiskHostLabel, + getPhysicalDiskLifeLabel, + getPhysicalDiskLifeTextClass, getPhysicalDiskNormalizedHealth, getPhysicalDiskParentLabel, getPhysicalDiskPlatformLabel, @@ -63,7 +66,8 @@ describe('diskPresentation', () => { expect(PHYSICAL_DISK_TABLE_CLASS).toBe('w-full table-fixed text-xs'); expect(PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS).toContain('hover:bg-surface-hover'); expect(PHYSICAL_DISK_HEADER_DISK_CLASS).toContain('uppercase'); - expect(PHYSICAL_DISK_HEADER_SOURCE_CLASS).toContain('sm:w-[11%]'); + expect(PHYSICAL_DISK_HEADER_DEVICE_CLASS).toContain('md:table-cell'); + expect(PHYSICAL_DISK_HEADER_LIFE_CLASS).toContain('md:table-cell'); expect(PHYSICAL_DISK_NAME_TEXT_CLASS).toContain('font-semibold'); expect(PHYSICAL_DISK_SOURCE_BADGE_CLASS).toContain('justify-center'); @@ -119,6 +123,19 @@ describe('diskPresentation', () => { storageGroup: 'tank', }), ).toBe('tank'); + expect(getPhysicalDiskParentLabel(makeDiskData({ storageGroup: '', used: 'ZFS' }))).toBe('ZFS'); + expect(getPhysicalDiskParentLabel(makeDiskData({ storageGroup: 'tank', used: 'ZFS' }))).toBe( + 'tank', + ); + expect(getPhysicalDiskParentLabel(makeDiskData({ storageGroup: '', used: 'unknown' }))).toBe( + '', + ); + expect(getPhysicalDiskLifeLabel(makeDiskData({ wearout: 96 }))).toBe('96%'); + expect(getPhysicalDiskLifeLabel(makeDiskData({ wearout: 0 }))).toBe(''); + expect(getPhysicalDiskLifeLabel(makeDiskData({ wearout: -1 }))).toBe(''); + expect(getPhysicalDiskLifeTextClass(makeDiskData({ wearout: 96 }))).toContain('text-green'); + expect(getPhysicalDiskLifeTextClass(makeDiskData({ wearout: 35 }))).toContain('text-amber'); + expect(getPhysicalDiskLifeTextClass(makeDiskData({ wearout: 9 }))).toContain('text-red'); expect(getPhysicalDiskPlatformLabel({} as Resource, 'PVE')).toBe('PVE'); expect( getPhysicalDiskSourceBadgePresentation({ diff --git a/frontend-modern/src/features/storageBackups/diskPresentation.ts b/frontend-modern/src/features/storageBackups/diskPresentation.ts index d5270a9f1..e23146748 100644 --- a/frontend-modern/src/features/storageBackups/diskPresentation.ts +++ b/frontend-modern/src/features/storageBackups/diskPresentation.ts @@ -96,25 +96,28 @@ export const PHYSICAL_DISK_TABLE_BODY_CLASS = 'divide-y divide-border'; export const PHYSICAL_DISK_TABLE_ROW_CLASS = 'cursor-pointer transition-colors'; export const PHYSICAL_DISK_TABLE_ROW_SELECTED_CLASS = 'bg-blue-50 dark:bg-blue-900'; export const PHYSICAL_DISK_TABLE_ROW_HOVER_CLASS = 'hover:bg-surface-hover'; -export const PHYSICAL_DISK_TABLE_ROW_STYLE = { height: '38px' } as const; +export const PHYSICAL_DISK_TABLE_ROW_STYLE = { height: '32px' } as const; export const PHYSICAL_DISK_DETAIL_ROW_CELL_CLASS = 'border-b border-border-subtle bg-surface-alt px-4 py-4 shadow-inner'; -export const PHYSICAL_DISK_COL_DISK_CLASS = 'w-[42%] sm:w-[28%] md:w-[28%] xl:w-[22%]'; -export const PHYSICAL_DISK_COL_SOURCE_CLASS = 'w-[15%] sm:w-[11%] md:w-[9%] xl:w-[8%]'; +export const PHYSICAL_DISK_COL_DISK_CLASS = 'w-[47%] sm:w-[31%] md:w-[24%] xl:w-[19%]'; +export const PHYSICAL_DISK_COL_DEVICE_CLASS = 'hidden md:table-column md:w-[11%] xl:w-[9%]'; export const PHYSICAL_DISK_COL_HOST_CLASS = - 'hidden sm:table-column sm:w-[18%] md:w-[14%] xl:w-[12%]'; -export const PHYSICAL_DISK_COL_ROLE_CLASS = 'hidden xl:table-column xl:w-[10%]'; -export const PHYSICAL_DISK_COL_PARENT_CLASS = 'hidden xl:table-column xl:w-[13%]'; -export const PHYSICAL_DISK_COL_HEALTH_CLASS = 'w-[25%] sm:w-[24%] md:w-[20%] xl:w-[16%]'; -export const PHYSICAL_DISK_COL_TEMP_CLASS = 'hidden md:table-column md:w-[9%] xl:w-[7%]'; -export const PHYSICAL_DISK_COL_SIZE_CLASS = 'w-[18%] sm:w-[19%] md:w-[20%] xl:w-[12%]'; + 'hidden sm:table-column sm:w-[20%] md:w-[12%] xl:w-[10%]'; +export const PHYSICAL_DISK_COL_ROLE_CLASS = 'hidden xl:table-column xl:w-[8%]'; +export const PHYSICAL_DISK_COL_PARENT_CLASS = 'hidden xl:table-column xl:w-[12%]'; +export const PHYSICAL_DISK_COL_HEALTH_CLASS = 'w-[29%] sm:w-[27%] md:w-[19%] xl:w-[17%]'; +export const PHYSICAL_DISK_COL_LIFE_CLASS = 'hidden md:table-column md:w-[7%] xl:w-[6%]'; +export const PHYSICAL_DISK_COL_TEMP_CLASS = 'hidden md:table-column md:w-[8%] xl:w-[7%]'; +export const PHYSICAL_DISK_COL_SIZE_CLASS = 'w-[24%] sm:w-[22%] md:w-[19%] xl:w-[12%]'; +const PHYSICAL_DISK_CELL_DEVICE_RESPONSIVE_CLASS = 'hidden md:table-cell md:w-[11%] xl:w-[9%]'; const PHYSICAL_DISK_CELL_HOST_RESPONSIVE_CLASS = - 'hidden sm:table-cell sm:w-[18%] md:w-[14%] xl:w-[12%]'; -const PHYSICAL_DISK_CELL_ROLE_RESPONSIVE_CLASS = 'hidden xl:table-cell xl:w-[10%]'; -const PHYSICAL_DISK_CELL_PARENT_RESPONSIVE_CLASS = 'hidden xl:table-cell xl:w-[13%]'; -const PHYSICAL_DISK_CELL_TEMP_RESPONSIVE_CLASS = 'hidden md:table-cell md:w-[9%] xl:w-[7%]'; + 'hidden sm:table-cell sm:w-[20%] md:w-[12%] xl:w-[10%]'; +const PHYSICAL_DISK_CELL_ROLE_RESPONSIVE_CLASS = 'hidden xl:table-cell xl:w-[8%]'; +const PHYSICAL_DISK_CELL_PARENT_RESPONSIVE_CLASS = 'hidden xl:table-cell xl:w-[12%]'; +const PHYSICAL_DISK_CELL_LIFE_RESPONSIVE_CLASS = 'hidden md:table-cell md:w-[7%] xl:w-[6%]'; +const PHYSICAL_DISK_CELL_TEMP_RESPONSIVE_CLASS = 'hidden md:table-cell md:w-[8%] xl:w-[7%]'; export const PHYSICAL_DISK_HEADER_DISK_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLASS} ${PHYSICAL_DISK_COL_DISK_CLASS}`; -export const PHYSICAL_DISK_HEADER_SOURCE_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLASS} ${PHYSICAL_DISK_COL_SOURCE_CLASS}`; +export const PHYSICAL_DISK_HEADER_DEVICE_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLASS} ${PHYSICAL_DISK_CELL_DEVICE_RESPONSIVE_CLASS}`; // Headers for responsively-hidden columns must use `table-cell` (not // `table-column`, which is for `` and does not render `
` // content), otherwise the cells render but the column header is blank. @@ -122,14 +125,16 @@ export const PHYSICAL_DISK_HEADER_HOST_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLA export const PHYSICAL_DISK_HEADER_ROLE_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLASS} ${PHYSICAL_DISK_CELL_ROLE_RESPONSIVE_CLASS}`; export const PHYSICAL_DISK_HEADER_PARENT_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLASS} ${PHYSICAL_DISK_CELL_PARENT_RESPONSIVE_CLASS}`; export const PHYSICAL_DISK_HEADER_HEALTH_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLASS} ${PHYSICAL_DISK_COL_HEALTH_CLASS}`; +export const PHYSICAL_DISK_HEADER_LIFE_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLASS} ${PHYSICAL_DISK_CELL_LIFE_RESPONSIVE_CLASS}`; export const PHYSICAL_DISK_HEADER_TEMP_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLASS} ${PHYSICAL_DISK_CELL_TEMP_RESPONSIVE_CLASS}`; export const PHYSICAL_DISK_HEADER_SIZE_CLASS = `${PHYSICAL_DISK_TABLE_HEADER_CLASS} ${PHYSICAL_DISK_COL_SIZE_CLASS}`; export const PHYSICAL_DISK_CELL_DISK_CLASS = `${PHYSICAL_DISK_COL_DISK_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs`; -export const PHYSICAL_DISK_CELL_SOURCE_CLASS = `${PHYSICAL_DISK_COL_SOURCE_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs`; +export const PHYSICAL_DISK_CELL_DEVICE_CLASS = `${PHYSICAL_DISK_CELL_DEVICE_RESPONSIVE_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs`; export const PHYSICAL_DISK_CELL_HOST_CLASS = `${PHYSICAL_DISK_CELL_HOST_RESPONSIVE_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs`; export const PHYSICAL_DISK_CELL_ROLE_CLASS = `${PHYSICAL_DISK_CELL_ROLE_RESPONSIVE_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs`; export const PHYSICAL_DISK_CELL_PARENT_CLASS = `${PHYSICAL_DISK_CELL_PARENT_RESPONSIVE_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs`; export const PHYSICAL_DISK_CELL_HEALTH_CLASS = `${PHYSICAL_DISK_COL_HEALTH_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs`; +export const PHYSICAL_DISK_CELL_LIFE_CLASS = `${PHYSICAL_DISK_CELL_LIFE_RESPONSIVE_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs whitespace-nowrap`; export const PHYSICAL_DISK_CELL_TEMP_CLASS = `${PHYSICAL_DISK_CELL_TEMP_RESPONSIVE_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs whitespace-nowrap`; export const PHYSICAL_DISK_CELL_SIZE_CLASS = `${PHYSICAL_DISK_COL_SIZE_CLASS} overflow-hidden px-1 sm:px-1.5 lg:px-2 py-1 align-middle text-xs whitespace-nowrap`; export const PHYSICAL_DISK_NAME_WRAP_CLASS = 'flex min-w-0 items-center gap-1.5 whitespace-nowrap'; @@ -138,7 +143,10 @@ export const PHYSICAL_DISK_NAME_TEXT_CLASS = export const PHYSICAL_DISK_SOURCE_BADGE_CLASS = 'inline-flex max-w-full min-w-0 justify-center overflow-hidden text-ellipsis px-1 sm:px-1.5 py-px text-[9px] font-medium'; export const PHYSICAL_DISK_VALUE_TEXT_CLASS = 'block truncate text-[11px] text-base-content'; +export const PHYSICAL_DISK_DEVICE_TEXT_CLASS = + 'block truncate font-mono text-[11px] text-base-content'; export const PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS = 'text-[11px] text-muted'; +export const PHYSICAL_DISK_LIFE_CLASS = 'text-[11px] font-medium'; export const PHYSICAL_DISK_HEALTH_WRAP_CLASS = 'flex min-w-0 items-center gap-1.5 whitespace-nowrap'; export const PHYSICAL_DISK_HEALTH_LABEL_CLASS = 'min-w-0 truncate text-[11px] font-semibold'; @@ -568,9 +576,28 @@ export function getPhysicalDiskRoleLabel(disk: PhysicalDiskPresentationData): st export function getPhysicalDiskParentLabel(disk: PhysicalDiskPresentationData): string { if (disk.storageGroup?.trim()) return disk.storageGroup.trim(); + // Proxmox reports a usage string ("ZFS", "ext4", "BIOS boot", "LVM", ...) + // for disks it cannot map to a named pool; better than an empty cell. + const used = disk.used?.trim() || ''; + if (used && used.toLowerCase() !== 'unknown') return used; return ''; } +export function getPhysicalDiskLifeLabel(disk: PhysicalDiskPresentationData): string { + // Wearout is "% life remaining" (100 = new); 0 and -1 both mean unreported. + if (typeof disk.wearout !== 'number' || disk.wearout <= 0) return ''; + return `${Math.min(disk.wearout, 100)}%`; +} + +export function getPhysicalDiskLifeTextClass(disk: PhysicalDiskPresentationData): string { + if (typeof disk.wearout !== 'number' || disk.wearout <= 0) { + return PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS; + } + if (disk.wearout < 20) return 'text-red-600 dark:text-red-400'; + if (disk.wearout < 50) return 'text-amber-600 dark:text-amber-400'; + return 'text-green-600 dark:text-green-400'; +} + export function getPhysicalDiskRoleFilterValue(disk: PhysicalDiskPresentationData): string { return normalizePhysicalDiskFacetFilter(getPhysicalDiskRoleLabel(disk)); } diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index ff431c9c2..37c0c7c57 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -1238,7 +1238,10 @@ describe('frontend resource type boundaries', () => { expect(diskListSource).toContain('getPhysicalDiskEmptyStatePresentation'); expect(diskListSource).toContain('getPhysicalDiskRoleLabel'); expect(diskListSource).toContain('getPhysicalDiskParentLabel'); - expect(diskListSource).toContain('getPhysicalDiskSourceBadgePresentation'); + expect(diskListSource).toContain('getPhysicalDiskLifeLabel'); + // The source badge renders in the expansion (DiskDetail), not the table. + expect(diskListSource).not.toContain('getPhysicalDiskSourceBadgePresentation'); + expect(diskDetailSource).toContain('getPhysicalDiskSourceBadgePresentation'); expect(diskListSource).not.toContain('const titleize ='); expect(diskListSource).not.toContain('const platformLabel ='); expect(diskListSource).not.toContain('function hasSmartWarning(');