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
This commit is contained in:
rcourtman 2025-12-13 21:27:50 +00:00
parent 8ed0851fb9
commit 8abcc93aac
2 changed files with 109 additions and 138 deletions

View file

@ -361,27 +361,34 @@ export function Dashboard(props: DashboardProps) {
return map; return map;
}); });
const resolveParentNode = (guest: VM | Container): Node | undefined => { // PERFORMANCE: Pre-compute guest-to-parent-node mapping for faster lookups
if (!guest) return undefined; // This avoids repeated node lookups for each guest during render
const guestParentNodeMap = createMemo(() => {
const nodes = nodeByInstance(); const nodes = nodeByInstance();
const mapping = new Map<string, Node>();
allGuests().forEach((guest) => {
// Try guest.id-based lookup first
if (guest.id) { if (guest.id) {
const lastDash = guest.id.lastIndexOf('-'); const lastDash = guest.id.lastIndexOf('-');
if (lastDash > 0) { if (lastDash > 0) {
const nodeId = guest.id.slice(0, lastDash); const nodeId = guest.id.slice(0, lastDash);
if (nodes[nodeId]) { if (nodes[nodeId]) {
return nodes[nodeId]; mapping.set(guest.id, nodes[nodeId]);
return;
} }
} }
} }
// Fallback to composite key
const compositeKey = `${guest.instance}-${guest.node}`; const compositeKey = `${guest.instance}-${guest.node}`;
if (nodes[compositeKey]) { if (nodes[compositeKey]) {
return nodes[compositeKey]; mapping.set(guest.id || `${guest.instance}-${guest.vmid}`, nodes[compositeKey]);
} }
});
return mapping;
});
return undefined;
};
// Sort handler // Sort handler
const handleSort = (key: keyof (VM | Container)) => { const handleSort = (key: keyof (VM | Container)) => {
if (sortKey() === key) { if (sortKey() === key) {
@ -433,6 +440,65 @@ export function Dashboard(props: DashboardProps) {
return null; 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 // Handle keyboard shortcuts
let searchInputRef: HTMLInputElement | undefined; let searchInputRef: HTMLInputElement | undefined;
@ -662,61 +728,10 @@ export function Dashboard(props: DashboardProps) {
// If flat mode, return all guests in a single group // If flat mode, return all guests in a single group
if (groupingMode() === 'flat') { if (groupingMode() === 'flat') {
const groups: Record<string, (VM | Container)[]> = { '': guests }; const groups: Record<string, (VM | Container)[]> = { '': guests };
// Sort the flat list // PERFORMANCE: Use memoized sort comparator (eliminates ~50 lines of duplicate code)
const key = sortKey(); const comparator = guestSortComparator();
const dir = sortDirection(); if (comparator) {
if (key) { groups[''] = groups[''].sort(comparator);
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;
}
});
} }
return groups; return groups;
} }
@ -733,62 +748,11 @@ export function Dashboard(props: DashboardProps) {
groups[nodeId].push(guest); groups[nodeId].push(guest);
}); });
// Sort within each node group // PERFORMANCE: Use memoized sort comparator (eliminates ~50 lines of duplicate code)
const key = sortKey(); const comparator = guestSortComparator();
const dir = sortDirection(); if (comparator) {
if (key) {
Object.keys(groups).forEach((node) => { Object.keys(groups).forEach((node) => {
groups[node] = groups[node].sort((a, b) => { groups[node] = groups[node].sort(comparator);
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;
}
});
}); });
} }
@ -1160,7 +1124,8 @@ export function Dashboard(props: DashboardProps) {
const metadata = const metadata =
guestMetadata()[guestId] || guestMetadata()[guestId] ||
guestMetadata()[`${guest.node}-${guest.vmid}`]; 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; const parentNodeOnline = parentNode ? isNodeOnline(parentNode) : true;
// Get adjacent guest IDs for merged AI context borders // Get adjacent guest IDs for merged AI context borders

View file

@ -491,11 +491,17 @@ export function GuestRow(props: GuestRowProps) {
// Get current metrics view mode (bars vs sparklines) // Get current metrics view mode (bars vs sparklines)
const { viewMode } = useMetricsViewMode(); 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 // Helper to check if a column is visible
// If visibleColumnIds is not provided, show all columns for backwards compatibility // If visibleColumnIds is not provided, show all columns for backwards compatibility
const isColVisible = (colId: string) => { const isColVisible = (colId: string) => {
if (!props.visibleColumnIds) return true; const set = visibleColumnIdSet();
return props.visibleColumnIds.includes(colId); if (!set) return true;
return set.has(colId);
}; };
// Create namespaced metrics key for sparklines // Create namespaced metrics key for sparklines