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:
parent
8ed0851fb9
commit
8abcc93aac
2 changed files with 109 additions and 138 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue