Align Proxmox hosts table styling

This commit is contained in:
rcourtman
2026-05-16 23:10:12 +01:00
parent dae4fb3bb4
commit e6bb190b98
4 changed files with 415 additions and 166 deletions
@@ -124,6 +124,7 @@ import workloadsTableSource from '@/components/Workloads/WorkloadsTable.tsx?raw'
import workloadPanelSource from '@/components/Workloads/WorkloadPanel.tsx?raw';
import guestRowStateSource from '@/components/Workloads/useGuestRowState.ts?raw';
import workloadSelectionStateSource from '@/components/Workloads/useWorkloadSelectionState.ts?raw';
import proxmoxNodesTableSource from '@/features/proxmox/ProxmoxNodesTable.tsx?raw';
import infrastructureSummarySource from '@/components/Infrastructure/InfrastructureSummary.tsx?raw';
import infrastructureSummaryStateSource from '@/components/Infrastructure/useInfrastructureSummaryState.ts?raw';
import unifiedResourceHostTableCardSource from '@/components/Infrastructure/UnifiedResourceHostTableCard.tsx?raw';
@@ -348,6 +349,7 @@ describe('shared primitive guardrails', () => {
expect(summaryTableCardHeaderSource).not.toContain('border-b border-border bg-surface-hover');
for (const source of [
workloadsTableSource,
proxmoxNodesTableSource,
unifiedResourceHostTableCardSource,
unifiedResourceServiceInfrastructureCardSource,
storageContentCardSource,
@@ -370,6 +372,7 @@ describe('shared primitive guardrails', () => {
for (const source of [
workloadsTableSource,
proxmoxNodesTableSource,
unifiedResourceHostTableCardSource,
unifiedResourceServiceInfrastructureCardSource,
storageContentCardSource,
@@ -1,12 +1,14 @@
import { For, Show, type Accessor, type Component, type JSX } from 'solid-js';
import { Card } from '@/components/shared/Card';
import { For, Show, createMemo, type Accessor, type Component, type JSX } from 'solid-js';
import { EmptyState } from '@/components/shared/EmptyState';
import { StatusDot } from '@/components/shared/StatusDot';
import { ResponsiveMetricCell } from '@/components/shared/responsive';
import { TableCard } from '@/components/shared/TableCard';
import { TableCardHeader } from '@/components/shared/TableCardHeader';
import { StackedMemoryBar } from '@/components/Workloads/StackedMemoryBar';
import { StackedDiskBar } from '@/components/Workloads/StackedDiskBar';
import { MetricMiniSparkline } from '@/components/Workloads/MetricMiniSparkline';
import { TemperatureGauge } from '@/components/shared/TemperatureGauge';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import {
Table,
TableBody,
@@ -20,6 +22,7 @@ import { asTrimmedString } from '@/utils/stringUtils';
import { normalizeDiskArray } from '@/utils/format';
import { buildMetricKeyForUnifiedResource } from '@/utils/metricsKeys';
import { useWorkloadTableMetricHistory } from '@/components/Workloads/useWorkloadTableMetricHistory';
import { getWorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel';
import { type WorkloadsMetricDisplayMode } from '@/components/Workloads/workloadsFilterModel';
import { type WorkloadTableMetricHistoryRange } from '@/components/Workloads/workloadMetricHistoryModel';
import type { Disk, Node as LegacyNode } from '@/types/api';
@@ -29,6 +32,12 @@ import {
getResourceNodeName,
getResourceVersion,
} from './proxmoxPageModel';
import {
getProxmoxHostColumnWidthStyle,
getProxmoxHostTableMinWidthClass,
getProxmoxHostVisibleColumnsForLayout,
type ProxmoxHostTableColumn,
} from './proxmoxHostTableModel';
// Proxmox Overview mirrors the v5 Dashboard layout: a dedicated nodes table on
// top, the canonical Workloads filter + guest table below. The nodes table
@@ -103,6 +112,18 @@ const formatPercentLabel = (value: number | null | undefined): string => {
return `${Math.round(Math.max(0, normalized))}%`;
};
const getHostHeaderCellClass = (column: ProxmoxHostTableColumn): string => {
const align =
column.align === 'right' ? 'text-right' : column.align === 'center' ? 'text-center' : '';
return `px-1.5 sm:px-2 py-0.5 font-medium ${align}`.trim();
};
const getHostBodyCellClass = (column: ProxmoxHostTableColumn): string => {
const align =
column.align === 'right' ? 'text-right' : column.align === 'center' ? 'text-center' : '';
return `px-1.5 sm:px-2 py-1 ${align}`.trim();
};
export const ProxmoxNodesTable: Component<{
nodes: Resource[];
guests: Resource[];
@@ -112,6 +133,10 @@ export const ProxmoxNodesTable: Component<{
emptyTitle: string;
emptyDescription: string;
}> = (props) => {
const breakpoint = useBreakpoint();
const layoutMode = createMemo(() => getWorkloadTableLayoutMode(breakpoint.width()));
const visibleColumns = createMemo(() => getProxmoxHostVisibleColumnsForLayout(layoutMode()));
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
const displayMode = () => props.metricDisplayMode?.() ?? 'bars';
const isSparklineMode = () => displayMode() === 'sparklines';
@@ -127,38 +152,43 @@ export const ProxmoxNodesTable: Component<{
<Show
when={props.nodes.length > 0}
fallback={
<Card padding="lg">
<EmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
</Card>
<TableCard>
<div class="p-6">
<EmptyState
icon={props.emptyIcon}
title={props.emptyTitle}
description={props.emptyDescription}
/>
</div>
</TableCard>
}
>
<Card padding="none" tone="card" class="overflow-hidden">
<Table class="w-full min-w-[1080px] border-collapse text-xs">
<TableHeader class="bg-surface-alt text-muted border-b border-border">
<TableRow class="text-left text-[10px] uppercase tracking-wide">
<TableHead class="px-3 py-2 font-medium">Node</TableHead>
<TableHead class="px-3 py-2 font-medium">Version</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Uptime</TableHead>
<TableHead class="px-3 py-2 font-medium" style={{ width: '180px' }}>
CPU
</TableHead>
<TableHead class="px-3 py-2 font-medium" style={{ width: '180px' }}>
Memory
</TableHead>
<TableHead class="px-3 py-2 font-medium" style={{ width: '180px' }}>
Disk
</TableHead>
<TableHead class="px-3 py-2 font-medium text-right">Temp</TableHead>
<TableHead class="px-3 py-2 font-medium text-center">VMs</TableHead>
<TableHead class="px-3 py-2 font-medium text-center">CTs</TableHead>
<TableHead class="px-3 py-2 font-medium">Cluster</TableHead>
<TableCard class="rounded-md">
<TableCardHeader title="Hosts" />
<Table class={`${getProxmoxHostTableMinWidthClass(layoutMode())} table-fixed text-xs`}>
<colgroup>
<For each={visibleColumns()}>
{(column) => (
<col
style={getProxmoxHostColumnWidthStyle(
column.id,
layoutMode(),
visibleColumnIds(),
)}
/>
)}
</For>
</colgroup>
<TableHeader>
<TableRow class="bg-surface-alt text-muted border-b border-border">
<For each={visibleColumns()}>
{(column) => (
<TableHead class={getHostHeaderCellClass(column)}>{column.label}</TableHead>
)}
</For>
</TableRow>
</TableHeader>
<TableBody class="divide-y divide-border-subtle">
<TableBody class="divide-y divide-border">
<For each={props.nodes}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
@@ -194,151 +224,192 @@ export const ProxmoxNodesTable: Component<{
} as Disk)
: undefined;
const legacyNode = () => projectResourceToLegacyNode(node);
const cpuSeries = () =>
metricHistory.getNodeMetricSeries(legacyNode(), 'cpu');
const cpuSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'cpu');
const memorySeries = () =>
metricHistory.getNodeMetricSeries(legacyNode(), 'memory');
const diskSeries = () =>
metricHistory.getNodeMetricSeries(legacyNode(), 'disk');
const diskSeries = () => metricHistory.getNodeMetricSeries(legacyNode(), 'disk');
const renderColumnCell = (column: ProxmoxHostTableColumn): JSX.Element => {
switch (column.id) {
case 'node':
return (
<TableCell class={getHostBodyCellClass(column)}>
<div class="flex min-w-0 items-center gap-2">
<StatusDot
size="sm"
variant={indicator().variant}
title={node.status || 'unknown'}
ariaHidden
/>
<span class="truncate font-semibold text-base-content" title={name()}>
{name()}
</span>
</div>
</TableCell>
);
case 'version':
return (
<TableCell class={getHostBodyCellClass(column)}>
<Show when={version()} fallback={<span class="text-muted"></span>}>
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 font-mono text-[10px] text-base-content">
{version()}
</span>
</Show>
</TableCell>
);
case 'uptime':
return (
<TableCell
class={`${getHostBodyCellClass(column)} tabular-nums ${
uptime().warn
? 'text-orange-600 dark:text-orange-400'
: 'text-base-content'
}`}
>
{uptime().label}
</TableCell>
);
case 'cpu':
return (
<TableCell class={getHostBodyCellClass(column)}>
<Show
when={isSparklineMode()}
fallback={
<ResponsiveMetricCell
class="w-full"
value={cpuPercent()}
type="cpu"
resourceId={metricsKey()}
isRunning={isOnline()}
showMobile={false}
/>
}
>
<MetricMiniSparkline
series={cpuSeries()}
valueLabel={formatPercentLabel(cpuPercent())}
title={`${name()} CPU history`}
/>
</Show>
</TableCell>
);
case 'memory':
return (
<TableCell class={getHostBodyCellClass(column)}>
<Show
when={isSparklineMode()}
fallback={
<Show
when={
isOnline() && (memoryTotal() > 0 || memoryPercentOnly() != null)
}
fallback={
<div class="flex justify-center">
<span class="text-xs text-muted" aria-hidden="true">
</span>
</div>
}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
/>
</Show>
}
>
<MetricMiniSparkline
series={memorySeries()}
valueLabel={formatPercentLabel(memoryPercent())}
title={`${name()} memory history`}
/>
</Show>
</TableCell>
);
case 'disk':
return (
<TableCell class={getHostBodyCellClass(column)}>
<Show
when={isSparklineMode()}
fallback={
<Show
when={isOnline() && (aggregateDisk() || node.agent?.disks?.length)}
fallback={
<div class="flex justify-center">
<span class="text-xs text-muted" aria-hidden="true">
</span>
</div>
}
>
<StackedDiskBar
disks={normalizeDiskArray(node.agent?.disks)}
aggregateDisk={aggregateDisk()}
/>
</Show>
}
>
<MetricMiniSparkline
series={diskSeries()}
valueLabel={formatPercentLabel(diskPercent())}
title={`${name()} disk history`}
/>
</Show>
</TableCell>
);
case 'temp':
return (
<TableCell class={getHostBodyCellClass(column)}>
<Show
when={
typeof temperature() === 'number' && (temperature() as number) > 0
}
fallback={<span class="text-xs text-muted"></span>}
>
<TemperatureGauge value={temperature() as number} />
</Show>
</TableCell>
);
case 'vms':
return (
<TableCell class={getHostBodyCellClass(column)}>
<span class={counts().vms > 0 ? VMS_BADGE : ZERO_BADGE}>
{counts().vms}
</span>
</TableCell>
);
case 'cts':
return (
<TableCell class={getHostBodyCellClass(column)}>
<span class={counts().containers > 0 ? CTS_BADGE : ZERO_BADGE}>
{counts().containers}
</span>
</TableCell>
);
case 'cluster':
return (
<TableCell class={getHostBodyCellClass(column)}>
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-base-content">
{cluster()}
</span>
</TableCell>
);
default:
column.id satisfies never;
return <></>;
}
};
return (
<TableRow class="hover:bg-surface-hover">
<TableCell class="px-3 py-2">
<div class="flex items-center gap-2 min-w-0">
<StatusDot
size="sm"
variant={indicator().variant}
title={node.status || 'unknown'}
ariaHidden
/>
<span class="font-semibold text-base-content truncate" title={name()}>
{name()}
</span>
</div>
</TableCell>
<TableCell class="px-3 py-2">
<Show when={version()} fallback={<span class="text-muted"></span>}>
<span class="inline-flex items-center rounded bg-surface-alt px-1.5 py-0.5 font-mono text-[10px] text-base-content">
{version()}
</span>
</Show>
</TableCell>
<TableCell
class={`px-3 py-2 text-right tabular-nums ${
uptime().warn
? 'text-orange-600 dark:text-orange-400'
: 'text-base-content'
}`}
>
{uptime().label}
</TableCell>
<TableCell class="px-3 py-2">
<Show
when={isSparklineMode()}
fallback={
<ResponsiveMetricCell
class="w-full"
value={cpuPercent()}
type="cpu"
resourceId={metricsKey()}
isRunning={isOnline()}
showMobile={false}
/>
}
>
<MetricMiniSparkline
series={cpuSeries()}
valueLabel={formatPercentLabel(cpuPercent())}
title={`${name()} CPU history`}
/>
</Show>
</TableCell>
<TableCell class="px-3 py-2">
<Show
when={isSparklineMode()}
fallback={
<Show
when={isOnline() && (memoryTotal() > 0 || memoryPercentOnly() != null)}
fallback={
<div class="flex justify-center">
<span class="text-xs text-muted" aria-hidden="true">
</span>
</div>
}
>
<StackedMemoryBar
used={memoryUsed()}
total={memoryTotal()}
percentOnly={memoryPercentOnly()}
/>
</Show>
}
>
<MetricMiniSparkline
series={memorySeries()}
valueLabel={formatPercentLabel(memoryPercent())}
title={`${name()} memory history`}
/>
</Show>
</TableCell>
<TableCell class="px-3 py-2">
<Show
when={isSparklineMode()}
fallback={
<Show
when={isOnline() && (aggregateDisk() || node.agent?.disks?.length)}
fallback={
<div class="flex justify-center">
<span class="text-xs text-muted" aria-hidden="true">
</span>
</div>
}
>
<StackedDiskBar
disks={normalizeDiskArray(node.agent?.disks)}
aggregateDisk={aggregateDisk()}
/>
</Show>
}
>
<MetricMiniSparkline
series={diskSeries()}
valueLabel={formatPercentLabel(diskPercent())}
title={`${name()} disk history`}
/>
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-right">
<Show
when={typeof temperature() === 'number' && (temperature() as number) > 0}
fallback={<span class="text-xs text-muted"></span>}
>
<TemperatureGauge value={temperature() as number} />
</Show>
</TableCell>
<TableCell class="px-3 py-2 text-center">
<span class={counts().vms > 0 ? VMS_BADGE : ZERO_BADGE}>
{counts().vms}
</span>
</TableCell>
<TableCell class="px-3 py-2 text-center">
<span class={counts().containers > 0 ? CTS_BADGE : ZERO_BADGE}>
{counts().containers}
</span>
</TableCell>
<TableCell class="px-3 py-2">
<span class="inline-flex items-center rounded-md bg-surface-alt px-2 py-0.5 text-[11px] font-medium text-base-content">
{cluster()}
</span>
</TableCell>
<TableRow class="text-[11px] sm:text-xs">
<For each={visibleColumns()}>{(column) => renderColumnCell(column)}</For>
</TableRow>
);
}}
</For>
</TableBody>
</Table>
</Card>
</TableCard>
</Show>
);
};
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import {
getProxmoxHostColumnWidthStyle,
getProxmoxHostTableMinWidthClass,
getProxmoxHostVisibleColumnsForLayout,
} from '../proxmoxHostTableModel';
describe('proxmoxHostTableModel', () => {
it('prioritizes live utilization columns in the mobile host table', () => {
const columns = getProxmoxHostVisibleColumnsForLayout('mobile');
const ids = columns.map((column) => column.id);
expect(ids).toEqual(['node', 'cpu', 'memory', 'disk']);
expect(getProxmoxHostTableMinWidthClass('mobile')).toBe('min-w-full');
expect(getProxmoxHostColumnWidthStyle('node', 'mobile', ids)).toEqual({ width: '38%' });
expect(getProxmoxHostColumnWidthStyle('cpu', 'mobile', ids)).toEqual({ width: '20%' });
expect(getProxmoxHostColumnWidthStyle('memory', 'mobile', ids)).toEqual({ width: '21%' });
expect(getProxmoxHostColumnWidthStyle('disk', 'mobile', ids)).toEqual({ width: '21%' });
});
it('adds temperature and guest counts before slower-changing metadata on tablet', () => {
expect(getProxmoxHostVisibleColumnsForLayout('tablet').map((column) => column.id)).toEqual([
'node',
'cpu',
'memory',
'disk',
'temp',
'vms',
'cts',
]);
expect(getProxmoxHostTableMinWidthClass('tablet')).toBe('min-w-full');
});
it('keeps the full host inventory table on compact and wide layouts', () => {
const compactIds = getProxmoxHostVisibleColumnsForLayout('compact').map((column) => column.id);
expect(compactIds).toEqual([
'node',
'version',
'uptime',
'cpu',
'memory',
'disk',
'temp',
'vms',
'cts',
'cluster',
]);
expect(getProxmoxHostTableMinWidthClass('compact')).toBe('min-w-[1080px]');
expect(getProxmoxHostTableMinWidthClass('wide')).toBe('min-w-[1080px]');
expect(getProxmoxHostColumnWidthStyle('cluster', 'compact', compactIds)).toEqual({
width: '12%',
});
});
});
@@ -0,0 +1,119 @@
import type { JSX } from 'solid-js';
import type { WorkloadTableLayoutMode } from '@/components/Workloads/guestRowModel';
export type ProxmoxHostTableColumnId =
| 'node'
| 'version'
| 'uptime'
| 'cpu'
| 'memory'
| 'disk'
| 'temp'
| 'vms'
| 'cts'
| 'cluster';
export type ProxmoxHostTableColumn = {
id: ProxmoxHostTableColumnId;
label: string;
align?: 'left' | 'right' | 'center';
};
const HOST_TABLE_LAYOUT_ORDER: Record<WorkloadTableLayoutMode, number> = {
mobile: 0,
tablet: 1,
compact: 2,
wide: 3,
};
const HOST_COLUMN_MIN_LAYOUT: Record<ProxmoxHostTableColumnId, WorkloadTableLayoutMode> = {
node: 'mobile',
cpu: 'mobile',
memory: 'mobile',
disk: 'mobile',
temp: 'tablet',
vms: 'tablet',
cts: 'tablet',
version: 'compact',
uptime: 'compact',
cluster: 'compact',
};
const HOST_COLUMN_DESKTOP_WIDTHS: Record<ProxmoxHostTableColumnId, number> = {
node: 15,
version: 8,
uptime: 7,
cpu: 13,
memory: 13,
disk: 15,
temp: 7,
vms: 5,
cts: 5,
cluster: 12,
};
const HOST_COLUMN_RESPONSIVE_WEIGHTS: Record<
Exclude<WorkloadTableLayoutMode, 'wide'>,
Partial<Record<ProxmoxHostTableColumnId, number>>
> = {
mobile: {
node: 38,
cpu: 20,
memory: 21,
disk: 21,
},
tablet: {
node: 26,
cpu: 17,
memory: 17,
disk: 19,
temp: 8,
vms: 6,
cts: 6,
},
compact: HOST_COLUMN_DESKTOP_WIDTHS,
};
const formatPercentage = (value: number): string => `${Number(value.toFixed(4))}%`;
export const PROXMOX_HOST_TABLE_COLUMNS: ProxmoxHostTableColumn[] = [
{ id: 'node', label: 'Node' },
{ id: 'version', label: 'Version' },
{ id: 'uptime', label: 'Uptime', align: 'right' },
{ id: 'cpu', label: 'CPU' },
{ id: 'memory', label: 'Memory' },
{ id: 'disk', label: 'Disk' },
{ id: 'temp', label: 'Temp', align: 'right' },
{ id: 'vms', label: 'VMs', align: 'center' },
{ id: 'cts', label: 'CTs', align: 'center' },
{ id: 'cluster', label: 'Cluster' },
];
export const getProxmoxHostVisibleColumnsForLayout = (
layoutMode: WorkloadTableLayoutMode,
): ProxmoxHostTableColumn[] => {
const layoutRank = HOST_TABLE_LAYOUT_ORDER[layoutMode];
return PROXMOX_HOST_TABLE_COLUMNS.filter(
(column) => HOST_TABLE_LAYOUT_ORDER[HOST_COLUMN_MIN_LAYOUT[column.id]] <= layoutRank,
);
};
export const getProxmoxHostColumnWidthStyle = (
columnId: ProxmoxHostTableColumnId,
layoutMode: WorkloadTableLayoutMode,
visibleColumnIds: readonly ProxmoxHostTableColumnId[],
): JSX.CSSProperties => {
const weights =
layoutMode === 'wide' ? HOST_COLUMN_DESKTOP_WIDTHS : HOST_COLUMN_RESPONSIVE_WEIGHTS[layoutMode];
const columnWeight = weights[columnId] ?? 0;
const totalWeight = visibleColumnIds.reduce((total, id) => total + (weights[id] ?? 0), 0);
const width = totalWeight > 0 ? (columnWeight / totalWeight) * 100 : 0;
return { width: formatPercentage(width) };
};
export const getProxmoxHostTableMinWidthClass = (
layoutMode: WorkloadTableLayoutMode,
): 'min-w-full' | 'min-w-[1080px]' =>
layoutMode === 'mobile' || layoutMode === 'tablet' ? 'min-w-full' : 'min-w-[1080px]';