From e8b9aee4cb6f062a5114224b1587f081e0469bda Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 7 May 2026 08:07:38 +0100 Subject: [PATCH] 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 --- src/app/api/power/route.js | 28 ++++++++++++++++++++++++++++ src/components/Dashboard.js | 32 ++++++++++++++++++++++++++++++-- src/components/LXCList.js | 28 +++++++++++++++++++++++++++- src/components/VMList.js | 29 ++++++++++++++++++++++++++++- src/lib/proxmox.js | 15 ++++++++++++++- 5 files changed, 127 insertions(+), 5 deletions(-) create mode 100644 src/app/api/power/route.js diff --git a/src/app/api/power/route.js b/src/app/api/power/route.js new file mode 100644 index 0000000..fed109a --- /dev/null +++ b/src/app/api/power/route.js @@ -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 }); + } +} diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js index d088e8a..020ab28 100644 --- a/src/components/Dashboard.js +++ b/src/components/Dashboard.js @@ -6,6 +6,7 @@ import NodeList from './NodeList'; import VMList from './VMList'; import LXCList from './LXCList'; import axios from 'axios'; +import { SWRConfig } from 'swr'; const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000); @@ -30,6 +31,23 @@ async function fetchData(url) { /** @returns {import('react').ReactNode} */ export default function Dashboard() { 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, { refreshInterval: POLL_INTERVAL, @@ -100,6 +118,16 @@ export default function Dashboard() { )} + {powerActionStatus && ( +
+ {powerActionStatus.text} +
+ )} + {/* Tab navigation */}
{tabs.map((tab) => ( @@ -119,8 +147,8 @@ export default function Dashboard() { {/* Tab content */}
{activeTab === 'nodes' && } - {activeTab === 'vms' && } - {activeTab === 'lxc' && } + {activeTab === 'vms' && } + {activeTab === 'lxc' && }
{/* Footer */} diff --git a/src/components/LXCList.js b/src/components/LXCList.js index e6cf7e0..8d76b04 100644 --- a/src/components/LXCList.js +++ b/src/components/LXCList.js @@ -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 * @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) { return

No LXC containers found.

; } @@ -38,6 +41,20 @@ export default function LXCList({ lxc }) { {(item.cpu * 100).toFixed(1)}% {formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)} + + + + ))} @@ -58,6 +75,7 @@ export default function LXCList({ lxc }) { Node Disk Status + Actions @@ -68,6 +86,14 @@ export default function LXCList({ lxc }) { {item.node} {formatBytes(item.disk?.used || 0)} + + + ))} diff --git a/src/components/VMList.js b/src/components/VMList.js index 4477f43..a76dffe 100644 --- a/src/components/VMList.js +++ b/src/components/VMList.js @@ -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 * @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) { return

No VMs found.

; } @@ -27,6 +30,7 @@ export default function VMList({ vms }) { CPU Memory Status + Actions @@ -38,6 +42,20 @@ export default function VMList({ vms }) { {(vm.cpu * 100).toFixed(1)}% {formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)} + + + + ))} @@ -58,6 +76,7 @@ export default function VMList({ vms }) { Node Disk Status + Actions @@ -68,6 +87,14 @@ export default function VMList({ vms }) { {vm.node} {formatBytes(vm.disk?.used || 0)} + + + ))} diff --git a/src/lib/proxmox.js b/src/lib/proxmox.js index d217268..5127cb1 100644 --- a/src/lib/proxmox.js +++ b/src/lib/proxmox.js @@ -163,7 +163,20 @@ export async function getLXC(nodeName) { * Aggregated summary across all nodes. * @returns {Promise} */ -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} + */ +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(); let totalVMs = []; let totalLXC = [];