From efb27113e222c83798ccf9bff707b6cb1808a269 Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Tue, 12 May 2026 11:53:40 +0100 Subject: [PATCH] feat: group VMs and LXCs by node with collapse/expand toggle - Each node shows a collapsible card with running/stopped counts - Items grouped by node (using Object.groupBy), sorted alphabetically - Both VMList and LXCList use the same pattern Co-Authored-By: Claude Opus 4.7 --- src/components/LXCList.js | 234 +++++++++++++++++++++---------------- src/components/VMList.js | 237 ++++++++++++++++++++++---------------- 2 files changed, 267 insertions(+), 204 deletions(-) diff --git a/src/components/LXCList.js b/src/components/LXCList.js index 11edde2..cf3ef0d 100644 --- a/src/components/LXCList.js +++ b/src/components/LXCList.js @@ -1,118 +1,148 @@ +'use client'; + +import { useState } from 'react'; import StatusBadge from './StatusBadge'; -/** - * @param {{ lxc: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string }> }} props - * @returns {import('react').ReactNode} - */ /** * @param {{ lxc: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props */ export default function LXCList({ lxc, onPowerAction, onOpenConsole }) { + const [expandedNodes, setExpandedNodes] = useState({}); + if (!lxc?.length) { - return

No LXC containers found.

; + return

No LXCs found.

; } - const running = lxc.filter((item) => item.status === 'running'); - const stopped = lxc.filter((item) => item.status !== 'running'); + const groups = Object.groupBy(lxc, (l) => l.node); + const sortedNodes = Object.keys(groups).sort(); + + function toggleNode(node) { + setExpandedNodes((prev) => ({ ...prev, [node]: !prev[node] })); + } return ( -
- {running.length > 0 && ( -
-

Running ({running.length})

-
- - - - - - - - - - - - - {running.map((item) => ( - - - - - - - - - - ))} - -
IDNameNodeCPUMemoryStatus
{item.vmid}{item.name}{item.node}{(item.cpu * 100).toFixed(1)}%{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)} - - - -
-
-
- )} +
+ {sortedNodes.map((node) => { + const running = groups[node].filter((l) => l.status === 'running'); + const stopped = groups[node].filter((l) => l.status !== 'running'); + const expanded = expandedNodes[node] ?? false; - {stopped.length > 0 && ( -
-

Stopped ({stopped.length})

-
- - - - - - - - - - - - - {stopped.map((item) => ( - - - - - - - - - ))} - -
IDNameNodeDiskStatusActions
{item.vmid}{item.name}{item.node}{formatBytes(item.disk?.used || 0)} - - -
+ return ( +
+ {/* Node header */} + + + {/* Node body */} + {expanded && ( + <> + {running.length > 0 && ( +
+
Running ({running.length})
+
+ + + + + + + + + + + + + + {running.map((item) => ( + + + + + + + + + + ))} + +
IDNameCPUMemoryDiskStatusActions
{item.vmid}{item.name}{(item.cpu * 100).toFixed(1)}%{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}{formatBytes(item.disk?.used || 0)} + + + +
+
+
+ )} + + {stopped.length > 0 && ( +
+
Stopped ({stopped.length})
+
+ + + + + + + + + + + + {stopped.map((item) => ( + + + + + + + + ))} + +
IDNameDiskStatusActions
{item.vmid}{item.name}{formatBytes(item.disk?.used || 0)} + + +
+
+
+ )} + + )}
-
- )} + ); + })}
); } diff --git a/src/components/VMList.js b/src/components/VMList.js index e902ad3..65a2f7f 100644 --- a/src/components/VMList.js +++ b/src/components/VMList.js @@ -1,119 +1,152 @@ +'use client'; + +import { useState } from 'react'; import StatusBadge from './StatusBadge'; -/** - * @param {{ vms: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string }> }} props - * @returns {import('react').ReactNode} - */ /** * @param {{ vms: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props */ export default function VMList({ vms, onPowerAction, onOpenConsole }) { + const [expandedNodes, setExpandedNodes] = useState({}); + if (!vms?.length) { return

No VMs found.

; } - const running = vms.filter((vm) => vm.status === 'running'); - const stopped = vms.filter((vm) => vm.status !== 'running'); + const groups = Object.groupBy(vms, (vm) => vm.node); + const sortedNodes = Object.keys(groups).sort(); + + function toggleNode(node) { + setExpandedNodes((prev) => ({ ...prev, [node]: !prev[node] })); + } + + function allExpanded() { + return sortedNodes.every((n) => expandedNodes[n]); + } return ( -
- {running.length > 0 && ( -
-

Running ({running.length})

-
- - - - - - - - - - - - - - {running.map((vm) => ( - - - - - - - - - - ))} - -
VMIDNameNodeCPUMemoryStatusActions
{vm.vmid}{vm.name}{vm.node}{(vm.cpu * 100).toFixed(1)}%{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)} - - - -
-
-
- )} +
+ {sortedNodes.map((node) => { + const running = groups[node].filter((vm) => vm.status === 'running'); + const stopped = groups[node].filter((vm) => vm.status !== 'running'); + const expanded = expandedNodes[node] ?? false; - {stopped.length > 0 && ( -
-

Stopped ({stopped.length})

-
- - - - - - - - - - - - - {stopped.map((vm) => ( - - - - - - - - - ))} - -
VMIDNameNodeDiskStatusActions
{vm.vmid}{vm.name}{vm.node}{formatBytes(vm.disk?.used || 0)} - - -
+ return ( +
+ {/* Node header */} + + + {/* Node body */} + {expanded && ( + <> + {running.length > 0 && ( +
+
Running ({running.length})
+
+ + + + + + + + + + + + + + {running.map((vm) => ( + + + + + + + + + + ))} + +
VMIDNameCPUMemoryDiskStatusActions
{vm.vmid}{vm.name}{(vm.cpu * 100).toFixed(1)}%{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)}{formatBytes(vm.disk?.used || 0)} + + + +
+
+
+ )} + + {stopped.length > 0 && ( +
+
Stopped ({stopped.length})
+
+ + + + + + + + + + + + {stopped.map((vm) => ( + + + + + + + + ))} + +
VMIDNameDiskStatusActions
{vm.vmid}{vm.name}{formatBytes(vm.disk?.used || 0)} + + +
+
+
+ )} + + )}
-
- )} + ); + })}
); }