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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,118 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import StatusBadge from './StatusBadge';
|
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
|
* @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 }) {
|
export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
|
||||||
|
const [expandedNodes, setExpandedNodes] = useState({});
|
||||||
|
|
||||||
if (!lxc?.length) {
|
if (!lxc?.length) {
|
||||||
return <p className="text-text-secondary text-sm">No LXC containers found.</p>;
|
return <p className="text-text-secondary text-sm">No LXCs found.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const running = lxc.filter((item) => item.status === 'running');
|
const groups = Object.groupBy(lxc, (l) => l.node);
|
||||||
const stopped = lxc.filter((item) => item.status !== 'running');
|
const sortedNodes = Object.keys(groups).sort();
|
||||||
|
|
||||||
|
function toggleNode(node) {
|
||||||
|
setExpandedNodes((prev) => ({ ...prev, [node]: !prev[node] }));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{running.length > 0 && (
|
{sortedNodes.map((node) => {
|
||||||
<div>
|
const running = groups[node].filter((l) => l.status === 'running');
|
||||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
|
const stopped = groups[node].filter((l) => l.status !== 'running');
|
||||||
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
|
const expanded = expandedNodes[node] ?? false;
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border-card">
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{running.map((item) => (
|
|
||||||
<tr key={item.vmid} className="border-b border-border-card last:border-0">
|
|
||||||
<td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
|
|
||||||
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
|
|
||||||
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
|
|
||||||
<td className="px-4 py-2 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td>
|
|
||||||
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}</td>
|
|
||||||
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenConsole?.(item)}
|
|
||||||
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
|
|
||||||
>
|
|
||||||
Console
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'restart')}
|
|
||||||
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
|
|
||||||
>
|
|
||||||
Restart
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'stop')}
|
|
||||||
className="rounded-md border border-status-red/30 bg-status-red/10 px-2 py-1 text-xs text-status-red hover:bg-status-red/20"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stopped.length > 0 && (
|
return (
|
||||||
<div>
|
<div key={node} className="rounded-xl border border-border-card bg-bg-card">
|
||||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
|
{/* Node header */}
|
||||||
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
|
<button
|
||||||
<table className="w-full text-sm">
|
onClick={() => toggleNode(node)}
|
||||||
<thead>
|
className="flex w-full items-center justify-between rounded-t-xl px-4 py-2.5 text-left"
|
||||||
<tr className="border-b border-border-card">
|
>
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
|
<span className="text-sm font-medium text-text-primary">
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
{node} ({running.length} running, {stopped.length} stopped)
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
|
</span>
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
|
<span className="text-xl text-text-secondary">{expanded ? '▾' : '▸'}</span>
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
</button>
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
|
||||||
</tr>
|
{/* Node body */}
|
||||||
</thead>
|
{expanded && (
|
||||||
<tbody>
|
<>
|
||||||
{stopped.map((item) => (
|
{running.length > 0 && (
|
||||||
<tr key={item.vmid} className="border-b border-border-card last:border-0">
|
<div className="px-4 pb-3">
|
||||||
<td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
|
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h5>
|
||||||
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
|
<div className="overflow-x-auto rounded-lg border border-border-card">
|
||||||
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
|
<table className="w-full text-sm">
|
||||||
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
|
<thead>
|
||||||
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
|
<tr className="border-b border-border-card">
|
||||||
<td className="px-4 py-2">
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
|
||||||
<button
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
||||||
onClick={() => onOpenConsole?.(item)}
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
|
||||||
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
|
||||||
>
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
|
||||||
Console
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
||||||
</button>
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
||||||
<button
|
</tr>
|
||||||
onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'start')}
|
</thead>
|
||||||
className="rounded-md border border-status-green/30 bg-status-green/10 px-2 py-1 text-xs text-status-green hover:bg-status-green/20"
|
<tbody>
|
||||||
>
|
{running.map((item) => (
|
||||||
Start
|
<tr key={item.vmid} className="border-b border-border-card last:border-0">
|
||||||
</button>
|
<td className="px-3 py-1.5 text-text-secondary">{item.vmid}</td>
|
||||||
</td>
|
<td className="px-3 py-1.5 font-medium text-text-primary">{item.name}</td>
|
||||||
</tr>
|
<td className="px-3 py-1.5 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td>
|
||||||
))}
|
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}</td>
|
||||||
</tbody>
|
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
|
||||||
</table>
|
<td className="px-3 py-1.5"><StatusBadge status={item.status} /></td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenConsole?.(item)}
|
||||||
|
className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
Console
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'restart')}
|
||||||
|
className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'stop')}
|
||||||
|
className="rounded border border-status-red/30 bg-status-red/10 px-1.5 py-0.5 text-xs text-status-red hover:bg-status-red/20"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stopped.length > 0 && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h5>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border-card">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-card">
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stopped.map((item) => (
|
||||||
|
<tr key={item.vmid} className="border-b border-border-card last:border-0">
|
||||||
|
<td className="px-3 py-1.5 text-text-secondary">{item.vmid}</td>
|
||||||
|
<td className="px-3 py-1.5 font-medium text-text-primary">{item.name}</td>
|
||||||
|
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
|
||||||
|
<td className="px-3 py-1.5"><StatusBadge status={item.status} /></td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenConsole?.(item)}
|
||||||
|
className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
Console
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'start')}
|
||||||
|
className="rounded border border-status-green/30 bg-status-green/10 px-1.5 py-0.5 text-xs text-status-green hover:bg-status-green/20"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,119 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import StatusBadge from './StatusBadge';
|
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
|
* @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 }) {
|
export default function VMList({ vms, onPowerAction, onOpenConsole }) {
|
||||||
|
const [expandedNodes, setExpandedNodes] = useState({});
|
||||||
|
|
||||||
if (!vms?.length) {
|
if (!vms?.length) {
|
||||||
return <p className="text-text-secondary text-sm">No VMs found.</p>;
|
return <p className="text-text-secondary text-sm">No VMs found.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const running = vms.filter((vm) => vm.status === 'running');
|
const groups = Object.groupBy(vms, (vm) => vm.node);
|
||||||
const stopped = vms.filter((vm) => vm.status !== 'running');
|
const sortedNodes = Object.keys(groups).sort();
|
||||||
|
|
||||||
|
function toggleNode(node) {
|
||||||
|
setExpandedNodes((prev) => ({ ...prev, [node]: !prev[node] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allExpanded() {
|
||||||
|
return sortedNodes.every((n) => expandedNodes[n]);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
{running.length > 0 && (
|
{sortedNodes.map((node) => {
|
||||||
<div>
|
const running = groups[node].filter((vm) => vm.status === 'running');
|
||||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
|
const stopped = groups[node].filter((vm) => vm.status !== 'running');
|
||||||
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
|
const expanded = expandedNodes[node] ?? false;
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border-card">
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{running.map((vm) => (
|
|
||||||
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
|
|
||||||
<td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
|
|
||||||
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
|
|
||||||
<td className="px-4 py-2 text-text-secondary">{vm.node}</td>
|
|
||||||
<td className="px-4 py-2 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</td>
|
|
||||||
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)}</td>
|
|
||||||
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
|
|
||||||
<td className="px-4 py-2">
|
|
||||||
<button
|
|
||||||
onClick={() => onOpenConsole?.(vm)}
|
|
||||||
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
|
|
||||||
>
|
|
||||||
Console
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'restart')}
|
|
||||||
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
|
|
||||||
>
|
|
||||||
Restart
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'stop')}
|
|
||||||
className="rounded-md border border-status-red/30 bg-status-red/10 px-2 py-1 text-xs text-status-red hover:bg-status-red/20"
|
|
||||||
>
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{stopped.length > 0 && (
|
return (
|
||||||
<div>
|
<div key={node} className="rounded-xl border border-border-card bg-bg-card">
|
||||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
|
{/* Node header */}
|
||||||
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
|
<button
|
||||||
<table className="w-full text-sm">
|
onClick={() => toggleNode(node)}
|
||||||
<thead>
|
className="flex w-full items-center justify-between rounded-t-xl px-4 py-2.5 text-left"
|
||||||
<tr className="border-b border-border-card">
|
>
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
|
<span className="text-sm font-medium text-text-primary">
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
{node} ({running.length} running, {stopped.length} stopped)
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
|
</span>
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
|
<span className="text-xl text-text-secondary">{expanded ? '▾' : '▸'}</span>
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
</button>
|
||||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
|
||||||
</tr>
|
{/* Node body */}
|
||||||
</thead>
|
{expanded && (
|
||||||
<tbody>
|
<>
|
||||||
{stopped.map((vm) => (
|
{running.length > 0 && (
|
||||||
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
|
<div className="px-4 pb-3">
|
||||||
<td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
|
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h5>
|
||||||
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
|
<div className="overflow-x-auto rounded-lg border border-border-card">
|
||||||
<td className="px-4 py-2 text-text-secondary">{vm.node}</td>
|
<table className="w-full text-sm">
|
||||||
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
|
<thead>
|
||||||
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
|
<tr className="border-b border-border-card">
|
||||||
<td className="px-4 py-2">
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
|
||||||
<button
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
||||||
onClick={() => onOpenConsole?.(vm)}
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
|
||||||
className="mr-2 rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-primary hover:border-accent hover:text-accent"
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
|
||||||
>
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
|
||||||
Console
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
||||||
</button>
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
||||||
<button
|
</tr>
|
||||||
onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'start')}
|
</thead>
|
||||||
className="rounded-md border border-status-green/30 bg-status-green/10 px-2 py-1 text-xs text-status-green hover:bg-status-green/20"
|
<tbody>
|
||||||
>
|
{running.map((vm) => (
|
||||||
Start
|
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
|
||||||
</button>
|
<td className="px-3 py-1.5 text-text-secondary">{vm.vmid}</td>
|
||||||
</td>
|
<td className="px-3 py-1.5 font-medium text-text-primary">{vm.name}</td>
|
||||||
</tr>
|
<td className="px-3 py-1.5 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</td>
|
||||||
))}
|
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)}</td>
|
||||||
</tbody>
|
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
|
||||||
</table>
|
<td className="px-3 py-1.5"><StatusBadge status={vm.status} /></td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenConsole?.(vm)}
|
||||||
|
className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
Console
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'restart')}
|
||||||
|
className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
Restart
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'stop')}
|
||||||
|
className="rounded border border-status-red/30 bg-status-red/10 px-1.5 py-0.5 text-xs text-status-red hover:bg-status-red/20"
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stopped.length > 0 && (
|
||||||
|
<div className="px-4 pb-3">
|
||||||
|
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h5>
|
||||||
|
<div className="overflow-x-auto rounded-lg border border-border-card">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-card">
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{stopped.map((vm) => (
|
||||||
|
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
|
||||||
|
<td className="px-3 py-1.5 text-text-secondary">{vm.vmid}</td>
|
||||||
|
<td className="px-3 py-1.5 font-medium text-text-primary">{vm.name}</td>
|
||||||
|
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
|
||||||
|
<td className="px-3 py-1.5"><StatusBadge status={vm.status} /></td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenConsole?.(vm)}
|
||||||
|
className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent"
|
||||||
|
>
|
||||||
|
Console
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'start')}
|
||||||
|
className="rounded border border-status-green/30 bg-status-green/10 px-1.5 py-0.5 text-xs text-status-green hover:bg-status-green/20"
|
||||||
|
>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user