From 8abcc93aac4ed41826d72a9951b2de8998c9cc0a Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 13 Dec 2025 21:27:50 +0000 Subject: [PATCH] perf: optimize Dashboard guest sorting and parent node lookups - Pre-compute guest-to-parent-node mapping to avoid repeated lookups during render - Memoize sort comparator to avoid duplicating sorting logic - Reduces computational overhead when rendering large guest lists --- .../src/components/Dashboard/Dashboard.tsx | 219 ++++++++---------- .../src/components/Dashboard/GuestRow.tsx | 28 ++- 2 files changed, 109 insertions(+), 138 deletions(-) diff --git a/frontend-modern/src/components/Dashboard/Dashboard.tsx b/frontend-modern/src/components/Dashboard/Dashboard.tsx index 130f372..ecc1b42 100644 --- a/frontend-modern/src/components/Dashboard/Dashboard.tsx +++ b/frontend-modern/src/components/Dashboard/Dashboard.tsx @@ -361,27 +361,34 @@ export function Dashboard(props: DashboardProps) { return map; }); - const resolveParentNode = (guest: VM | Container): Node | undefined => { - if (!guest) return undefined; + // PERFORMANCE: Pre-compute guest-to-parent-node mapping for faster lookups + // This avoids repeated node lookups for each guest during render + const guestParentNodeMap = createMemo(() => { const nodes = nodeByInstance(); + const mapping = new Map(); - if (guest.id) { - const lastDash = guest.id.lastIndexOf('-'); - if (lastDash > 0) { - const nodeId = guest.id.slice(0, lastDash); - if (nodes[nodeId]) { - return nodes[nodeId]; + allGuests().forEach((guest) => { + // Try guest.id-based lookup first + if (guest.id) { + const lastDash = guest.id.lastIndexOf('-'); + if (lastDash > 0) { + const nodeId = guest.id.slice(0, lastDash); + if (nodes[nodeId]) { + mapping.set(guest.id, nodes[nodeId]); + return; + } } } - } + // Fallback to composite key + const compositeKey = `${guest.instance}-${guest.node}`; + if (nodes[compositeKey]) { + mapping.set(guest.id || `${guest.instance}-${guest.vmid}`, nodes[compositeKey]); + } + }); - const compositeKey = `${guest.instance}-${guest.node}`; - if (nodes[compositeKey]) { - return nodes[compositeKey]; - } + return mapping; + }); - return undefined; - }; // Sort handler const handleSort = (key: keyof (VM | Container)) => { if (sortKey() === key) { @@ -433,6 +440,65 @@ export function Dashboard(props: DashboardProps) { return null; }; + // PERFORMANCE: Memoized sort comparator to avoid duplicating sorting logic + // This comparator is reused by both flat and grouped modes in groupedGuests + const guestSortComparator = createMemo(() => { + const key = sortKey(); + const dir = sortDirection(); + + if (!key) { + return null; + } + + return (a: VM | Container, b: VM | Container): number => { + let aVal: string | number | boolean | null | undefined = a[key] as + | string + | number + | boolean + | null + | undefined; + let bVal: string | number | boolean | null | undefined = b[key] as + | string + | number + | boolean + | null + | undefined; + + // Special handling for percentage-based columns + if (key === 'cpu') { + aVal = a.cpu * 100; + bVal = b.cpu * 100; + } else if (key === 'memory') { + aVal = a.memory ? a.memory.usage || 0 : 0; + bVal = b.memory ? b.memory.usage || 0 : 0; + } else if (key === 'disk') { + aVal = getDiskUsagePercent(a); + bVal = getDiskUsagePercent(b); + } + + // Handle null/undefined/empty values - put at end for both asc and desc + const aIsEmpty = aVal === null || aVal === undefined || aVal === ''; + const bIsEmpty = bVal === null || bVal === undefined || bVal === ''; + + if (aIsEmpty && bIsEmpty) return 0; + if (aIsEmpty) return 1; + if (bIsEmpty) return -1; + + // Type-specific comparison + if (typeof aVal === 'number' && typeof bVal === 'number') { + const comparison = aVal < bVal ? -1 : 1; + return dir === 'asc' ? comparison : -comparison; + } else { + const aStr = String(aVal).toLowerCase(); + const bStr = String(bVal).toLowerCase(); + + if (aStr === bStr) return 0; + const comparison = aStr < bStr ? -1 : 1; + return dir === 'asc' ? comparison : -comparison; + } + }; + }); + // Handle keyboard shortcuts let searchInputRef: HTMLInputElement | undefined; @@ -662,61 +728,10 @@ export function Dashboard(props: DashboardProps) { // If flat mode, return all guests in a single group if (groupingMode() === 'flat') { const groups: Record = { '': guests }; - // Sort the flat list - const key = sortKey(); - const dir = sortDirection(); - if (key) { - groups[''] = groups[''].sort((a, b) => { - let aVal: string | number | boolean | null | undefined = a[key] as - | string - | number - | boolean - | null - | undefined; - let bVal: string | number | boolean | null | undefined = b[key] as - | string - | number - | boolean - | null - | undefined; - - // Special handling for percentage-based columns - if (key === 'cpu') { - // CPU is displayed as percentage - aVal = a.cpu * 100; - bVal = b.cpu * 100; - } else if (key === 'memory') { - // Memory is displayed as percentage (use pre-calculated usage) - aVal = a.memory ? a.memory.usage || 0 : 0; - bVal = b.memory ? b.memory.usage || 0 : 0; - } else if (key === 'disk') { - aVal = getDiskUsagePercent(a); - bVal = getDiskUsagePercent(b); - } - - // Handle null/undefined/empty values - put at end for both asc and desc - const aIsEmpty = aVal === null || aVal === undefined || aVal === ''; - const bIsEmpty = bVal === null || bVal === undefined || bVal === ''; - - if (aIsEmpty && bIsEmpty) return 0; - if (aIsEmpty) return 1; - if (bIsEmpty) return -1; - - // Type-specific value preparation - if (typeof aVal === 'number' && typeof bVal === 'number') { - // Numeric comparison - const comparison = aVal < bVal ? -1 : 1; - return dir === 'asc' ? comparison : -comparison; - } else { - // String comparison (case-insensitive) - const aStr = String(aVal).toLowerCase(); - const bStr = String(bVal).toLowerCase(); - - if (aStr === bStr) return 0; - const comparison = aStr < bStr ? -1 : 1; - return dir === 'asc' ? comparison : -comparison; - } - }); + // PERFORMANCE: Use memoized sort comparator (eliminates ~50 lines of duplicate code) + const comparator = guestSortComparator(); + if (comparator) { + groups[''] = groups[''].sort(comparator); } return groups; } @@ -733,62 +748,11 @@ export function Dashboard(props: DashboardProps) { groups[nodeId].push(guest); }); - // Sort within each node group - const key = sortKey(); - const dir = sortDirection(); - if (key) { + // PERFORMANCE: Use memoized sort comparator (eliminates ~50 lines of duplicate code) + const comparator = guestSortComparator(); + if (comparator) { Object.keys(groups).forEach((node) => { - groups[node] = groups[node].sort((a, b) => { - let aVal: string | number | boolean | null | undefined = a[key] as - | string - | number - | boolean - | null - | undefined; - let bVal: string | number | boolean | null | undefined = b[key] as - | string - | number - | boolean - | null - | undefined; - - // Special handling for percentage-based columns - if (key === 'cpu') { - // CPU is displayed as percentage - aVal = a.cpu * 100; - bVal = b.cpu * 100; - } else if (key === 'memory') { - // Memory is displayed as percentage (use pre-calculated usage) - aVal = a.memory ? a.memory.usage || 0 : 0; - bVal = b.memory ? b.memory.usage || 0 : 0; - } else if (key === 'disk') { - aVal = getDiskUsagePercent(a); - bVal = getDiskUsagePercent(b); - } - - // Handle null/undefined/empty values - put at end for both asc and desc - const aIsEmpty = aVal === null || aVal === undefined || aVal === ''; - const bIsEmpty = bVal === null || bVal === undefined || bVal === ''; - - if (aIsEmpty && bIsEmpty) return 0; - if (aIsEmpty) return 1; - if (bIsEmpty) return -1; - - // Type-specific value preparation - if (typeof aVal === 'number' && typeof bVal === 'number') { - // Numeric comparison - const comparison = aVal < bVal ? -1 : 1; - return dir === 'asc' ? comparison : -comparison; - } else { - // String comparison (case-insensitive) - const aStr = String(aVal).toLowerCase(); - const bStr = String(bVal).toLowerCase(); - - if (aStr === bStr) return 0; - const comparison = aStr < bStr ? -1 : 1; - return dir === 'asc' ? comparison : -comparison; - } - }); + groups[node] = groups[node].sort(comparator); }); } @@ -1122,7 +1086,7 @@ export function Dashboard(props: DashboardProps) { onClick={() => isSortable && handleSort(sortKeyForCol!)} title={col.icon ? col.label : undefined} > -
+
{col.icon ? ( {col.icon} ) : ( @@ -1160,7 +1124,8 @@ export function Dashboard(props: DashboardProps) { const metadata = guestMetadata()[guestId] || guestMetadata()[`${guest.node}-${guest.vmid}`]; - const parentNode = node ?? resolveParentNode(guest); + // PERFORMANCE: Use pre-computed parent node map instead of resolveParentNode + const parentNode = node ?? guestParentNodeMap().get(guestId); const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true; // Get adjacent guest IDs for merged AI context borders diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index e59648e..59d85d8 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -434,20 +434,20 @@ export const GUEST_COLUMNS: GuestColumnDef[] = [ { id: 'disk', label: 'Disk', priority: 'essential', width: '140px', sortKey: 'disk' }, // Secondary - visible on md+ (768px), user toggleable - use icons - { id: 'ip', label: 'IP', icon: , priority: 'secondary', width: '45px', toggleable: true }, - { id: 'uptime', label: 'Uptime', icon: , priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' }, - { id: 'node', label: 'Node', icon: , priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' }, + { id: 'ip', label: 'IP', icon: , priority: 'secondary', width: '45px', toggleable: true }, + { id: 'uptime', label: 'Uptime', icon: , priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' }, + { id: 'node', label: 'Node', icon: , priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' }, // Supplementary - visible on lg+ (1024px), user toggleable - { id: 'backup', label: 'Backup', icon: , priority: 'supplementary', width: '50px', toggleable: true }, - { id: 'tags', label: 'Tags', icon: , priority: 'supplementary', width: '60px', toggleable: true }, + { id: 'backup', label: 'Backup', icon: , priority: 'supplementary', width: '50px', toggleable: true }, + { id: 'tags', label: 'Tags', icon: , priority: 'supplementary', width: '60px', toggleable: true }, // Detailed - visible on xl+ (1280px), user toggleable { id: 'os', label: 'OS', priority: 'detailed', width: '45px', toggleable: true }, - { id: 'diskRead', label: 'D Read', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' }, - { id: 'diskWrite', label: 'D Write', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' }, - { id: 'netIn', label: 'Net In', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' }, - { id: 'netOut', label: 'Net Out', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' }, + { id: 'diskRead', label: 'D Read', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' }, + { id: 'diskWrite', label: 'D Write', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' }, + { id: 'netIn', label: 'Net In', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' }, + { id: 'netOut', label: 'Net Out', icon: , priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' }, ]; interface GuestRowProps { @@ -491,11 +491,17 @@ export function GuestRow(props: GuestRowProps) { // Get current metrics view mode (bars vs sparklines) const { viewMode } = useMetricsViewMode(); + // PERFORMANCE: Use memoized Set for O(1) column visibility lookups instead of O(n) array.includes() + const visibleColumnIdSet = createMemo(() => + props.visibleColumnIds ? new Set(props.visibleColumnIds) : null + ); + // Helper to check if a column is visible // If visibleColumnIds is not provided, show all columns for backwards compatibility const isColVisible = (colId: string) => { - if (!props.visibleColumnIds) return true; - return props.visibleColumnIds.includes(colId); + const set = visibleColumnIdSet(); + if (!set) return true; + return set.has(colId); }; // Create namespaced metrics key for sparklines