feat: add start/stop/restart buttons for VMs and LXCs

- New POST /api/power route for Proxmox power operations
- powerControl() method in proxmox.js library
- Start/Stop/Restart action buttons in VMList and LXCList tables
- Status feedback banner in Dashboard on power action

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-05-07 08:07:38 +01:00
parent 4c32eebb0d
commit e8b9aee4cb
5 changed files with 127 additions and 5 deletions

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { powerControl } from '@/lib/proxmox';
/**
* @param {import('next').Request} req
*/
export async function POST(req) {
try {
const { node, vmid, type, action } = await req.json();
if (!node || !vmid || !type || !action) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (!['qemu', 'lxc'].includes(type)) {
return NextResponse.json({ error: 'Invalid type, must be qemu or lxc' }, { status: 400 });
}
if (!['start', 'stop', 'restart'].includes(action)) {
return NextResponse.json({ error: 'Invalid action, must be start, stop, or restart' }, { status: 400 });
}
await powerControl(node, vmid, type, action);
return NextResponse.json({ ok: true, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, ok: false }, { status: 502 });
}
}

View File

@@ -6,6 +6,7 @@ import NodeList from './NodeList';
import VMList from './VMList'; import VMList from './VMList';
import LXCList from './LXCList'; import LXCList from './LXCList';
import axios from 'axios'; import axios from 'axios';
import { SWRConfig } from 'swr';
const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000); const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
@@ -30,6 +31,23 @@ async function fetchData(url) {
/** @returns {import('react').ReactNode} */ /** @returns {import('react').ReactNode} */
export default function Dashboard() { export default function Dashboard() {
const [activeTab, setActiveTab] = useState('nodes'); const [activeTab, setActiveTab] = useState('nodes');
const [powerActionStatus, setPowerActionStatus] = useState(null);
/**
* @param {string} node
* @param {number} vmid
* @param {string} type
* @param {string} action
*/
const handlePowerAction = async (node, vmid, type, action) => {
setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)}ing ${type === 'qemu' ? 'VM' : 'LXC'} ${vmid}...`, type: 'pending' });
try {
await axios.post('/api/power', { node, vmid, type, action });
setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)} successful`, type: 'success' });
} catch (err) {
setPowerActionStatus({ text: err.message || `${action.charAt(0).toUpperCase() + action.slice(1)} failed`, type: 'error' });
}
};
const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, { const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, {
refreshInterval: POLL_INTERVAL, refreshInterval: POLL_INTERVAL,
@@ -100,6 +118,16 @@ export default function Dashboard() {
</div> </div>
)} )}
{powerActionStatus && (
<div className={`mb-6 rounded-xl border p-4 text-sm ${
powerActionStatus.type === 'success' ? 'border-status-green/30 bg-status-green/10 text-status-green'
: powerActionStatus.type === 'error' ? 'border-status-red/30 bg-status-red/10 text-status-red'
: 'border-border-card bg-bg-card text-text-secondary'
}`}>
{powerActionStatus.text}
</div>
)}
{/* Tab navigation */} {/* Tab navigation */}
<div className="mb-4 flex gap-2 border-b border-border-card"> <div className="mb-4 flex gap-2 border-b border-border-card">
{tabs.map((tab) => ( {tabs.map((tab) => (
@@ -119,8 +147,8 @@ export default function Dashboard() {
{/* Tab content */} {/* Tab content */}
<div> <div>
{activeTab === 'nodes' && <NodeList nodes={nodes} />} {activeTab === 'nodes' && <NodeList nodes={nodes} />}
{activeTab === 'vms' && <VMList vms={vms} />} {activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} />} {activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} />}
</div> </div>
{/* Footer */} {/* Footer */}

View File

@@ -4,7 +4,10 @@ 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 * @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} * @returns {import('react').ReactNode}
*/ */
export default function LXCList({ lxc }) { /**
* @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 }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
*/
export default function LXCList({ lxc, onPowerAction }) {
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 LXC containers found.</p>;
} }
@@ -38,6 +41,20 @@ export default function LXCList({ lxc }) {
<td className="px-4 py-2 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</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 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"><StatusBadge status={item.status} /></td>
<td className="px-4 py-2">
<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> </tr>
))} ))}
</tbody> </tbody>
@@ -58,6 +75,7 @@ export default function LXCList({ lxc }) {
<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">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</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">Status</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -68,6 +86,14 @@ export default function LXCList({ lxc }) {
<td className="px-4 py-2 text-text-secondary">{item.node}</td> <td className="px-4 py-2 text-text-secondary">{item.node}</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td> <td className="px-4 py-2 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={item.status} /></td> <td className="px-4 py-2"><StatusBadge status={item.status} /></td>
<td className="px-4 py-2">
<button
onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'start')}
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"
>
Start
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -4,7 +4,10 @@ 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 * @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} * @returns {import('react').ReactNode}
*/ */
export default function VMList({ vms }) { /**
* @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 }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
*/
export default function VMList({ vms, onPowerAction }) {
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>;
} }
@@ -27,6 +30,7 @@ export default function VMList({ vms }) {
<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">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">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">Status</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -38,6 +42,20 @@ export default function VMList({ vms }) {
<td className="px-4 py-2 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</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 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"><StatusBadge status={vm.status} /></td>
<td className="px-4 py-2">
<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> </tr>
))} ))}
</tbody> </tbody>
@@ -58,6 +76,7 @@ export default function VMList({ vms }) {
<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">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</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">Status</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -68,6 +87,14 @@ export default function VMList({ vms }) {
<td className="px-4 py-2 text-text-secondary">{vm.node}</td> <td className="px-4 py-2 text-text-secondary">{vm.node}</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td> <td className="px-4 py-2 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td> <td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
<td className="px-4 py-2">
<button
onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'start')}
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"
>
Start
</button>
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -163,7 +163,20 @@ export async function getLXC(nodeName) {
* Aggregated summary across all nodes. * Aggregated summary across all nodes.
* @returns {Promise<SummaryStat>} * @returns {Promise<SummaryStat>}
*/ */
export async function getSummary() { /**
* Sends a power action to a VM or LXC.
* @param {string} nodeName
* @param {number} vmid
* @param {'qemu'|'lxc'} type
* @param {'start'|'stop'|'restart'} action
* @returns {Promise<void>}
*/
export async function powerControl(nodeName, vmid, type, action) {
await proxmoxFetch(`/nodes/${nodeName}/${type}/${vmid}/status/${action}`, 'POST');
}
/**
* Aggregated summary across all nodes.
const nodes = await getNodes(); const nodes = await getNodes();
let totalVMs = []; let totalVMs = [];
let totalLXC = []; let totalLXC = [];