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;
});
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<string, Node>();
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<string, (VM | Container)[]> = { '': 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}
>
<div class={`flex items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`} style={{ "min-height": "14px" }}>
<div class={`flex items-center gap-0.5 ${isFirst() ? 'justify-start' : 'justify-center'}`} style={{ "min-height": "14px" }}>
{col.icon ? (
<span class="flex items-center">{col.icon}</span>
) : (
@ -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

View file

@ -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: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/></svg>, priority: 'secondary', width: '45px', toggleable: true },
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>, priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' },
{ id: 'node', label: 'Node', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"/></svg>, priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' },
{ id: 'ip', label: 'IP', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" /></svg>, priority: 'secondary', width: '45px', toggleable: true },
{ id: 'uptime', label: 'Uptime', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>, priority: 'secondary', width: '60px', toggleable: true, sortKey: 'uptime' },
{ id: 'node', label: 'Node', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" /></svg>, priority: 'secondary', width: '55px', toggleable: true, sortKey: 'node' },
// Supplementary - visible on lg+ (1024px), user toggleable
{ id: 'backup', label: 'Backup', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>, priority: 'supplementary', width: '50px', toggleable: true },
{ id: 'tags', label: 'Tags', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"/></svg>, priority: 'supplementary', width: '60px', toggleable: true },
{ id: 'backup', label: 'Backup', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>, priority: 'supplementary', width: '50px', toggleable: true },
{ id: 'tags', label: 'Tags', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" /></svg>, 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: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' },
{ id: 'diskWrite', label: 'D Write', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' },
{ id: 'netIn', label: 'Net In', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18"/></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' },
{ id: 'netOut', label: 'Net Out', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"/></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkOut' },
{ id: 'diskRead', label: 'D Read', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskRead' },
{ id: 'diskWrite', label: 'D Write', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'diskWrite' },
{ id: 'netIn', label: 'Net In', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16l-4-4m0 0l4-4m-4 4h18" /></svg>, priority: 'detailed', width: '55px', toggleable: true, sortKey: 'networkIn' },
{ id: 'netOut', label: 'Net Out', icon: <svg class="w-3.5 h-3.5 block" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3" /></svg>, 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