From 3ec3a055ca9b804fc5b7658d969a7fa1a889fc2c Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 11 Jun 2026 18:55:11 +0100 Subject: [PATCH] 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. --- .../features/proxmox/ProxmoxNodesTable.tsx | 107 +++++++++++++++++- .../__tests__/ProxmoxNodesTable.test.tsx | 43 +++++++ 2 files changed, 148 insertions(+), 2 deletions(-) diff --git a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx index b48866d506..04af7f3b11 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx @@ -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; + +const TEXT_SORT_KEYS = new Set(['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(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<{ {(column) => ( - + handleSort(column.id)} + > {column.label} + {sortKey() === column.id && (sortDirection() === 'asc' ? ' ▲' : ' ▼')} )} - + {(node) => { const name = () => asTrimmedString(node.name) || node.id; const drawerNode = createMemo(() => nodeFromResource(node)); diff --git a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx index e9c318ece5..30f77bcf07 100644 --- a/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx +++ b/frontend-modern/src/features/proxmox/__tests__/ProxmoxNodesTable.test.tsx @@ -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(() => ( + } + 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(() => (