proxmox(overview): make the nodes table sortable again

Every v5 NodeSummaryTable column header sorted; the v6 rebuild shipped
static headers. Restore click-to-sort on all columns except the Web
link: identity/text columns default ascending, metric columns default
descending, repeat clicks flip then clear back to the snapshot order,
and missing values (offline metrics, absent temperatures) stay last in
either direction. Flagged by the Proxmox overview parity audit.
This commit is contained in:
rcourtman
2026-06-11 18:55:11 +01:00
parent 37a22009b7
commit 3ec3a055ca
2 changed files with 148 additions and 2 deletions
@@ -60,6 +60,7 @@ import {
getProxmoxHostTableMinWidthClass,
getProxmoxHostVisibleColumnsForLayout,
type ProxmoxHostTableColumn,
type ProxmoxHostTableColumnId,
} from './proxmoxHostTableModel';
// Proxmox Overview mirrors the v5 Dashboard layout: a dedicated nodes table on
@@ -131,6 +132,60 @@ const formatPercentLabel = (value: number | null | undefined): string => {
return `${Math.round(Math.max(0, normalized))}%`;
};
type HostSortKey = Exclude<ProxmoxHostTableColumnId, 'web'>;
const TEXT_SORT_KEYS = new Set<ProxmoxHostTableColumnId>(['node', 'version', 'cluster']);
const getHostSortValue = (
node: Resource,
guests: Resource[],
key: HostSortKey,
): string | number | null => {
switch (key) {
case 'node':
return asTrimmedString(node.name) || node.id;
case 'version':
return asTrimmedString(getResourceVersion(node)) || null;
case 'cluster':
return getResourceClusterLabel(node);
case 'cpu':
return node.cpu?.current ?? null;
case 'memory': {
const total = node.memory?.total ?? 0;
if (total > 0) return ((node.memory?.used ?? 0) / total) * 100;
return typeof node.memory?.current === 'number' ? node.memory.current : null;
}
case 'disk':
return node.disk?.current ?? null;
case 'temp':
return typeof node.temperature === 'number' && node.temperature > 0
? node.temperature
: null;
case 'uptime':
return node.uptime ?? null;
case 'vms':
return countGuestsForNode(guests, getResourceNodeName(node)).vms;
case 'cts':
return countGuestsForNode(guests, getResourceNodeName(node)).containers;
default:
key satisfies never;
return null;
}
};
const isEmptyHostSortValue = (value: string | number | null): boolean =>
value === null || (typeof value === 'number' && Number.isNaN(value));
const compareHostSortValues = (a: string | number | null, b: string | number | null): number => {
const aEmpty = isEmptyHostSortValue(a);
const bEmpty = isEmptyHostSortValue(b);
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
if (typeof a === 'number' && typeof b === 'number') return a === b ? 0 : a < b ? -1 : 1;
return String(a).localeCompare(String(b), undefined, { sensitivity: 'base' });
};
export const ProxmoxNodesTable: Component<{
nodes: Resource[];
guests: Resource[];
@@ -150,6 +205,41 @@ export const ProxmoxNodesTable: Component<{
const visibleColumnIds = createMemo(() => visibleColumns().map((column) => column.id));
const displayMode = () => props.metricDisplayMode?.() ?? 'bars';
const isSparklineMode = () => displayMode() === 'sparklines';
const [sortKey, setSortKey] = createSignal<HostSortKey | null>(null);
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
const handleSort = (column: ProxmoxHostTableColumnId) => {
if (column === 'web') return;
const key = column as HostSortKey;
const defaultDirection = TEXT_SORT_KEYS.has(key) ? 'asc' : 'desc';
// Cycle: default direction → flipped → cleared.
if (sortKey() === key) {
if (sortDirection() === defaultDirection) {
setSortDirection(defaultDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(null);
setSortDirection('asc');
}
return;
}
setSortKey(key);
setSortDirection(defaultDirection);
};
const sortedNodes = createMemo(() => {
const key = sortKey();
if (!key) return props.nodes;
const dir = sortDirection() === 'asc' ? 1 : -1;
const decorated = props.nodes.map(
(node) => [getHostSortValue(node, props.guests, key), node] as const,
);
decorated.sort(([a], [b]) => {
// Missing values stay last in either direction.
if (isEmptyHostSortValue(a) || isEmptyHostSortValue(b)) return compareHostSortValues(a, b);
return compareHostSortValues(a, b) * dir;
});
return decorated.map(([, node]) => node);
});
// Use the same canonical history reader the workloads table uses; cache
// keys collide so the two readers dedupe their fetches.
@@ -190,15 +280,28 @@ export const ProxmoxNodesTable: Component<{
<TableRow class={PLATFORM_TABLE_HEADER_ROW_CLASS}>
<For each={visibleColumns()}>
{(column) => (
<TableHead class={getPlatformTableHeadClassForKind(column.kind)}>
<TableHead
class={`${getPlatformTableHeadClassForKind(column.kind)} ${
column.id !== 'web' ? 'cursor-pointer hover:bg-surface-hover' : ''
}`}
aria-sort={
sortKey() === column.id
? sortDirection() === 'asc'
? 'ascending'
: 'descending'
: undefined
}
onClick={() => handleSort(column.id)}
>
{column.label}
{sortKey() === column.id && (sortDirection() === 'asc' ? ' ▲' : ' ▼')}
</TableHead>
)}
</For>
</TableRow>
</TableHeader>
<TableBody class={PLATFORM_TABLE_BODY_CLASS}>
<For each={props.nodes}>
<For each={sortedNodes()}>
{(node) => {
const name = () => asTrimmedString(node.name) || node.id;
const drawerNode = createMemo(() => nodeFromResource(node));
@@ -173,6 +173,49 @@ describe('ProxmoxNodesTable', () => {
expect(offlineRow).not.toHaveAttribute('data-workload-alert-accent');
});
it('sorts rows from the column headers with metric columns defaulting to descending', async () => {
render(() => (
<ProxmoxNodesTable
nodes={[
makeNodeResource({ cpu: { current: 10 } }),
makeNodeResource({
id: 'agent:pve-node-2',
name: 'pve-node-2',
displayName: 'pve-node-2',
cpu: { current: 80 },
proxmox: { clusterName: 'homelab', nodeName: 'pve-node-2' },
}),
]}
guests={[]}
emptyIcon={<span />}
emptyTitle="No Proxmox VE nodes"
emptyDescription="No nodes"
/>
));
const rowNames = () =>
screen
.getAllByText(/pve-node-\d/)
.map((el) => el.textContent)
.filter((text) => text === 'pve-node-1' || text === 'pve-node-2');
expect(rowNames()).toEqual(['pve-node-1', 'pve-node-2']);
const cpuHeader = screen.getByText('CPU');
await fireEvent.click(cpuHeader);
expect(cpuHeader.closest('th')).toHaveAttribute('aria-sort', 'descending');
expect(rowNames()).toEqual(['pve-node-2', 'pve-node-1']);
await fireEvent.click(cpuHeader);
expect(cpuHeader.closest('th')).toHaveAttribute('aria-sort', 'ascending');
expect(rowNames()).toEqual(['pve-node-1', 'pve-node-2']);
// Third click returns to the default (unsorted) order.
await fireEvent.click(cpuHeader);
expect(cpuHeader.closest('th')).not.toHaveAttribute('aria-sort');
expect(rowNames()).toEqual(['pve-node-1', 'pve-node-2']);
});
it('opens the host details drawer from the host-owned top table row', async () => {
render(() => (
<ProxmoxNodesTable