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(() => (