fix: resolve grouping issues when nodes have duplicate hostnames

addresses #476

when multiple nodes have the same hostname, the dashboard and storage views
were incorrectly grouping VMs/containers/storage by hostname instead of by
unique node instance ID. this caused:
- incorrect VM/container counts in node summary
- mixed display of resources from different nodes
- incorrect grouping in storage view

changed grouping logic to use guest.instance (unique node ID) instead of
guest.node (hostname). updated both Dashboard and Storage components to
properly map instance IDs to node objects for display while maintaining
correct data separation.
This commit is contained in:
rcourtman
2025-09-30 16:50:51 +00:00
parent e0b8b3e60a
commit 5180b84d4e
2 changed files with 73 additions and 58 deletions
@@ -98,17 +98,16 @@ export function Dashboard(props: DashboardProps) {
}
});
// Create a mapping from node name to host URL
const nodeHostMap = createMemo(() => {
const map: Record<string, string> = {};
// Create a mapping from node instance ID to node object
const nodeByInstance = createMemo(() => {
const map: Record<string, Node> = {};
props.nodes.forEach((node) => {
if (node.host) {
map[node.name] = node.host;
}
map[node.id] = node;
});
return map;
});
// Persist filter states to localStorage
createEffect(() => {
localStorage.setItem('dashboardViewMode', viewMode());
@@ -356,13 +355,14 @@ export function Dashboard(props: DashboardProps) {
return groups;
}
// Original grouped by node logic
// Group by node instance ID (not hostname) to handle nodes with duplicate names
const groups: Record<string, (VM | Container)[]> = {};
guests.forEach((guest) => {
if (!groups[guest.node]) {
groups[guest.node] = [];
const instanceId = guest.instance; // Use unique instance ID instead of hostname
if (!groups[instanceId]) {
groups[instanceId] = [];
}
groups[guest.node].push(guest);
groups[instanceId].push(guest);
});
// Sort within each node group
@@ -557,10 +557,10 @@ export function Dashboard(props: DashboardProps) {
const diskPercent = () =>
node.disk ? Math.round((node.disk.used / node.disk.total) * 100) : 0;
// Count VMs and containers for this node
const nodeVMs = () => props.vms.filter((vm) => vm.node === node.name).length;
// Count VMs and containers for this node (use instance ID to handle duplicate node names)
const nodeVMs = () => props.vms.filter((vm) => vm.instance === node.id).length;
const nodeContainers = () =>
props.containers.filter((ct) => ct.node === node.name).length;
props.containers.filter((ct) => ct.instance === node.id).length;
const isSelected = () => search().includes(`node:${node.name}`);
@@ -915,25 +915,29 @@ export function Dashboard(props: DashboardProps) {
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For
each={Object.entries(groupedGuests()).sort(([a], [b]) => a.localeCompare(b))}
each={Object.entries(groupedGuests()).sort(([instanceIdA], [instanceIdB]) => {
// Sort by node name for display, not instance ID
const nodeA = nodeByInstance()[instanceIdA];
const nodeB = nodeByInstance()[instanceIdB];
return (nodeA?.name || '').localeCompare(nodeB?.name || '');
})}
fallback={<></>}
>
{([node, guests]) => (
{([instanceId, guests]) => {
const node = nodeByInstance()[instanceId];
return (
<>
<Show when={node && groupingMode() === 'grouped'}>
<tr class="bg-gray-50/50 dark:bg-gray-700/30">
<td class="py-0.5 pl-6 pr-2 text-xs font-medium text-gray-600 dark:text-gray-400 w-[200px]">
<a
href={
nodeHostMap()[node] ||
(node.includes(':') ? `https://${node}` : `https://${node}:8006`)
}
href={node.host || `https://${node.name}:8006`}
target="_blank"
rel="noopener noreferrer"
class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${node} web interface`}
title={`Open ${node.name} web interface`}
>
{node}
{node.name}
</a>
</td>
<td colspan="10" class="py-0.5 px-2"></td>
@@ -962,7 +966,8 @@ export function Dashboard(props: DashboardProps) {
)}
</For>
</>
)}
);
}}
</For>
</tbody>
</table>
@@ -20,17 +20,16 @@ const Storage: Component = () => {
const [sortKey, setSortKey] = createSignal<StorageSortKey>('name');
const [sortDirection, setSortDirection] = createSignal<'asc' | 'desc'>('asc');
// Create a mapping from node name to host URL
const nodeHostMap = createMemo(() => {
const map: Record<string, string> = {};
// Create a mapping from node instance ID to node object
const nodeByInstance = createMemo(() => {
const map: Record<string, typeof state.nodes[0]> = {};
(state.nodes || []).forEach((node) => {
if (node.host) {
map[node.name] = node.host;
}
map[node.id] = node;
});
return map;
});
const sortKeyOptions: { value: StorageSortKey; label: string }[] = [
{ value: 'name', label: 'Name' },
{ value: 'node', label: 'Node' },
@@ -243,10 +242,12 @@ const Storage: Component = () => {
const mode = viewMode();
if (mode === 'node') {
// Group by instance ID (not node name) to handle duplicate node names
const groups: Record<string, StorageType[]> = {};
storage.forEach((s) => {
if (!groups[s.node]) groups[s.node] = [];
groups[s.node].push(s);
const key = s.instance; // Use unique instance ID instead of hostname
if (!groups[key]) groups[key] = [];
groups[key].push(s);
});
return groups;
} else {
@@ -553,34 +554,42 @@ const Storage: Component = () => {
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<For
each={Object.entries(groupedStorage()).sort(([a], [b]) => a.localeCompare(b))}
each={Object.entries(groupedStorage()).sort(([instanceIdA], [instanceIdB]) => {
// Sort by node name for display when in node mode
if (viewMode() === 'node') {
const nodeA = nodeByInstance()[instanceIdA];
const nodeB = nodeByInstance()[instanceIdB];
return (nodeA?.name || '').localeCompare(nodeB?.name || '');
}
// Sort by storage name when in storage mode
return instanceIdA.localeCompare(instanceIdB);
})}
>
{([groupName, storages]) => (
<>
{/* Group Header */}
<Show when={viewMode() === 'node'}>
<tr class="bg-gray-50/50 dark:bg-gray-700/30">
<td
class="p-0.5 px-1.5 text-xs font-medium text-gray-600 dark:text-gray-400"
colspan="9"
>
<a
href={
nodeHostMap()[groupName] ||
(groupName.includes(':')
? `https://${groupName}`
: `https://${groupName}:8006`)
}
target="_blank"
rel="noopener noreferrer"
class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${groupName} web interface`}
>
{groupName}
</a>
</td>
</tr>
</Show>
{([groupKey, storages]) => {
const node = viewMode() === 'node' ? nodeByInstance()[groupKey] : null;
return (
<>
{/* Group Header */}
<Show when={viewMode() === 'node' && node}>
{(validNode) => (
<tr class="bg-gray-50/50 dark:bg-gray-700/30">
<td
class="p-0.5 px-1.5 text-xs font-medium text-gray-600 dark:text-gray-400"
colspan="9"
>
<a
href={validNode().host || `https://${validNode().name}:8006`}
target="_blank"
rel="noopener noreferrer"
class="text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-150 cursor-pointer"
title={`Open ${validNode().name} web interface`}
>
{validNode().name}
</a>
</td>
</tr>
)}
</Show>
{/* Storage Rows */}
<For each={storages} fallback={<></>}>
@@ -817,7 +826,8 @@ const Storage: Component = () => {
}}
</For>
</>
)}
);
}}
</For>
</tbody>
</table>