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