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.
This commit is contained in:
rcourtman 2026-06-11 15:58:58 +01:00
parent 55db13fc06
commit 809e2c9008
6 changed files with 150 additions and 47 deletions

View file

@ -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<DiskDetailProps> = (props) => {
<span class={STORAGE_DISK_DETAIL_MODEL_CLASS}>{diskData().model || 'Unknown Disk'}</span>
<span class={STORAGE_DETAIL_MONO_CHIP_CLASS}>{diskData().devPath}</span>
<span class={STORAGE_DISK_DETAIL_NODE_CLASS}>{diskData().node}</span>
<span
class={getPhysicalDiskSourceBadgePresentation(props.disk).className}
title="Data source"
>
{getPhysicalDiskSourceBadgePresentation(props.disk).label}
</span>
<Show when={diskData().serial}>
<span class={STORAGE_DISK_DETAIL_SERIAL_CLASS}>S/N: {diskData().serial}</span>
</Show>

View file

@ -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<DiskListProps> = (props) => {
<Table class={PHYSICAL_DISK_TABLE_CLASS}>
<colgroup>
<col class={PHYSICAL_DISK_COL_DISK_CLASS} />
<col class={PHYSICAL_DISK_COL_SOURCE_CLASS} />
<col class={PHYSICAL_DISK_COL_DEVICE_CLASS} />
<col class={PHYSICAL_DISK_COL_HOST_CLASS} />
<col class={PHYSICAL_DISK_COL_ROLE_CLASS} />
<col class={PHYSICAL_DISK_COL_PARENT_CLASS} />
<col class={PHYSICAL_DISK_COL_HEALTH_CLASS} />
<col class={PHYSICAL_DISK_COL_LIFE_CLASS} />
<col class={PHYSICAL_DISK_COL_TEMP_CLASS} />
<col class={PHYSICAL_DISK_COL_SIZE_CLASS} />
</colgroup>
<TableHeader>
<TableRow class={PHYSICAL_DISK_TABLE_HEADER_ROW_CLASS}>
<TableHead class={PHYSICAL_DISK_HEADER_DISK_CLASS}>Disk</TableHead>
<TableHead
class={PHYSICAL_DISK_HEADER_SOURCE_CLASS}
aria-label="Source"
title="Source"
>
<span aria-hidden="true" class="hidden sm:inline">
Source
</span>
<span aria-hidden="true" class="sm:hidden">
Src
</span>
</TableHead>
<TableHead class={PHYSICAL_DISK_HEADER_DEVICE_CLASS}>Device</TableHead>
<TableHead class={PHYSICAL_DISK_HEADER_HOST_CLASS}>Host</TableHead>
<TableHead class={PHYSICAL_DISK_HEADER_ROLE_CLASS}>Role</TableHead>
<TableHead
@ -211,6 +207,13 @@ export const DiskList: Component<DiskListProps> = (props) => {
Belongs
</TableHead>
<TableHead class={PHYSICAL_DISK_HEADER_HEALTH_CLASS}>Health</TableHead>
<TableHead
class={PHYSICAL_DISK_HEADER_LIFE_CLASS}
aria-label="SSD life remaining"
title="SSD life remaining"
>
Life
</TableHead>
<TableHead class={PHYSICAL_DISK_HEADER_TEMP_CLASS}>Temp</TableHead>
<TableHead class={PHYSICAL_DISK_HEADER_SIZE_CLASS}>Size</TableHead>
</TableRow>
@ -222,7 +225,6 @@ export const DiskList: Component<DiskListProps> = (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<DiskListProps> = (props) => {
</div>
</TableCell>
<TableCell class={PHYSICAL_DISK_CELL_SOURCE_CLASS}>
<span class={sourceBadge.className}>{sourceBadge.label}</span>
<TableCell class={PHYSICAL_DISK_CELL_DEVICE_CLASS}>
<Show
when={data.devPath}
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}></span>}
>
<span class={PHYSICAL_DISK_DEVICE_TEXT_CLASS} title={data.devPath}>
{data.devPath}
</span>
</Show>
</TableCell>
<TableCell class={PHYSICAL_DISK_CELL_HOST_CLASS}>
@ -325,12 +334,30 @@ export const DiskList: Component<DiskListProps> = (props) => {
</div>
</TableCell>
<TableCell class={PHYSICAL_DISK_CELL_TEMP_CLASS}>
<span
class={`${PHYSICAL_DISK_TEMPERATURE_CLASS} ${getTemperatureTextClass(data.temperature)}`}
<TableCell class={PHYSICAL_DISK_CELL_LIFE_CLASS}>
<Show
when={getPhysicalDiskLifeLabel(data)}
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}></span>}
>
{data.temperature > 0 ? formatTemperature(data.temperature) : '—'}
</span>
<span
class={`${PHYSICAL_DISK_LIFE_CLASS} ${getPhysicalDiskLifeTextClass(data)}`}
>
{getPhysicalDiskLifeLabel(data)}
</span>
</Show>
</TableCell>
<TableCell class={PHYSICAL_DISK_CELL_TEMP_CLASS}>
<Show
when={data.temperature > 0}
fallback={<span class={PHYSICAL_DISK_MUTED_PLACEHOLDER_CLASS}></span>}
>
<span
class={`${PHYSICAL_DISK_TEMPERATURE_CLASS} ${getTemperatureTextClass(data.temperature)}`}
>
{formatTemperature(data.temperature)}
</span>
</Show>
</TableCell>
<TableCell class={PHYSICAL_DISK_CELL_SIZE_CLASS}>
@ -355,7 +382,7 @@ export const DiskList: Component<DiskListProps> = (props) => {
<TableRow data-inline-detail-for={summarySeriesId}>
<TableCell
id={detailControlsId}
colSpan={8}
colSpan={9}
class={PHYSICAL_DISK_DETAIL_ROW_CELL_CLASS}
>
<DiskDetail disk={disk} nodes={props.nodes} />

View file

@ -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');
});

View file

@ -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({

View file

@ -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 `<col>` and does not render `<th>`
// 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));
}

View file

@ -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(');