From 175beae735ce25bc0a3dc303a76b8f71be4c71a9 Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Sun, 10 May 2026 18:56:29 +0100 Subject: [PATCH 1/4] fix: power control 502 error and add instant refresh - Power operations use /api2/extjs endpoint (not /api2/json) - Remove unprivileged parameter that causes 400 validation error - Refetch VM/LXC/node/status after successful power action via SWR mutate Co-Authored-By: Claude Opus 4.7 --- src/components/Dashboard.js | 7 ++- src/lib/proxmox.js | 89 +++++++++++++------------------------ 2 files changed, 38 insertions(+), 58 deletions(-) diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js index f6d2ae4..3bacdaf 100644 --- a/src/components/Dashboard.js +++ b/src/components/Dashboard.js @@ -1,7 +1,7 @@ 'use client'; import { useState } from 'react'; -import useSWR from 'swr'; +import useSWR, { mutate } from 'swr'; import NodeList from './NodeList'; import VMList from './VMList'; import LXCList from './LXCList'; @@ -45,6 +45,11 @@ export default function Dashboard() { 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 }); + // Immediately refetch all data instead of waiting for polling interval + await mutate('/api/nodes'); + await mutate('/api/vms'); + await mutate('/api/lxc'); + await mutate('/api/status'); 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' }); diff --git a/src/lib/proxmox.js b/src/lib/proxmox.js index 46c4a02..af09823 100644 --- a/src/lib/proxmox.js +++ b/src/lib/proxmox.js @@ -8,33 +8,13 @@ function getEnv(key) { return val; } -/** - * Generates the Proxmox API2 Authorization header. - * Format: PVEAPIToken=@!= - * @param {string} _method - * @param {string} _path - * @param {string} _body - * @returns {{ Authorization: string }} - */ function createAuthHeaders(_method, _path, _body) { - const TOKEN_ID = getEnv('PROXMOX_TOKEN_ID'); // e.g. "root@pam!monitorapp" + const TOKEN_ID = getEnv('PROXMOX_TOKEN_ID'); const TOKEN_SECRET = getEnv('PROXMOX_TOKEN_SECRET'); return { Authorization: `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}` }; } -/** - * Fetches the Proxmox API with authentication. - * @param {string} path - * @param {'GET'|'POST'|'PUT'|'DELETE'} [method='GET'] - * @param {string} [body] - * @returns {Promise<*>} - */ async function proxmoxFetch(path, method = 'GET', body) { - const headers = { - ...createAuthHeaders(method, path, body), - 'Content-Type': 'application/json', - }; - const baseUrl = getEnv('PROXMOX_API_URL'); const base = baseUrl.replace(/\/+$/, ''); const cleanPath = path.replace(/^\/+/, ''); @@ -46,18 +26,47 @@ async function proxmoxFetch(path, method = 'GET', body) { const res = await axios({ url: fullUrl, method, - headers, + headers: { + ...createAuthHeaders(method, path, body), + 'Content-Type': 'application/json', + }, data: body, }); - return res.data; } catch (err) { const status = err.response?.status || 'Unknown Status'; const message = err.response?.data?.message || err.response?.data || err.message; throw new Error(`Proxmox API error ${status}: ${message}`); } +} +/** + * Sends a power action to a VM or LXC. + * Uses /api2/extjs endpoint (different from /api2/json used by other routes). + * @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) { + const baseUrl = getEnv('PROXMOX_API_URL'); + const extjsUrl = baseUrl.replace('/api2/json', '/api2/extjs'); + const res = await axios({ + url: `${extjsUrl}/nodes/${nodeName}/${type}/${vmid}/status/${action}`, + method: 'POST', + headers: { + ...createAuthHeaders('POST', '', ''), + 'Content-Type': 'application/json', + }, + data: '{}', + }); + + const data = res.data?.data; + if (res.data?.success === 0 || !data) { + throw new Error(res.data?.message || 'Power operation failed'); + } } /** @@ -71,10 +80,6 @@ async function proxmoxFetch(path, method = 'GET', body) { * @property {number} load */ -/** - * Fetches all Proxmox nodes and returns mapped stats. - * @returns {Promise} - */ export async function getNodes() { const data = await proxmoxFetch('/nodes'); return (data.data || []).map((node) => ({ @@ -102,11 +107,6 @@ export async function getNodes() { * @property {{ total: number; used: number }} disk */ -/** - * Fetches QEMU VMs for a given node. - * @param {string} nodeName - * @returns {Promise} - */ export async function getVMs(nodeName) { const data = await proxmoxFetch(`/nodes/${nodeName}/qemu`); return (data.data || []).map((vm) => ({ @@ -126,11 +126,6 @@ export async function getVMs(nodeName) { })); } -/** - * Fetches LXC containers for a given node. - * @param {string} nodeName - * @returns {Promise} - */ export async function getLXC(nodeName) { const data = await proxmoxFetch(`/nodes/${nodeName}/lxc`); return (data.data || []).map((vm) => ({ @@ -159,26 +154,6 @@ export async function getLXC(nodeName) { * @property {{ total: number; used: number }} totalMemory */ -/** - * Aggregated summary across all nodes. - * @returns {Promise} - */ -/** - * 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. - * @returns {Promise} - */ export async function getSummary() { const nodes = await getNodes(); let totalVMs = []; From bfc6bf8526dca36c178dcf30041d0048786551cc Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Sun, 10 May 2026 19:18:35 +0100 Subject: [PATCH 2/4] fix: instant power action refresh with optimistic UI update Update VM/LXC cards immediately on click instead of waiting for API response or polling interval. On failure, revert the optimistic update. Background polling reconciles after 5 seconds. Co-Authored-By: Claude Opus 4.7 --- src/components/Dashboard.js | 44 +++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js index 3bacdaf..ee32bc2 100644 --- a/src/components/Dashboard.js +++ b/src/components/Dashboard.js @@ -1,12 +1,11 @@ 'use client'; import { useState } from 'react'; -import useSWR, { mutate } from 'swr'; +import useSWR from 'swr'; import NodeList from './NodeList'; import VMList from './VMList'; import LXCList from './LXCList'; import axios from 'axios'; -import { SWRConfig } from 'swr'; import ConsolePanel from './ConsolePanel'; const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000); @@ -17,8 +16,6 @@ const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000) * @returns {Promise<{ data: *, error: string | null }>} */ async function fetchData(url) { - - try { const res = await axios.get(url); return res.data; @@ -34,24 +31,36 @@ export default function Dashboard() { const [activeTab, setActiveTab] = useState('nodes'); const [activeConsole, setActiveConsole] = useState(null); const [powerActionStatus, setPowerActionStatus] = useState(null); + // Optimistic local state — updated before API response + const [vmsOptimistic, setVmsOptimistic] = useState(null); + const [lxcOptimistic, setLxcOptimistic] = 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' }); + + // Optimistic UI update — change happens instantly + if (type === 'qemu') { + setVmsOptimistic((prev) => + (prev ?? []).map((vm) => vm.vmid === vmid ? { ...vm, status: action === 'stop' || action === 'restart' ? 'stopped' : 'running' } : vm), + ); + } else { + setLxcOptimistic((prev) => + (prev ?? []).map((lxc) => lxc.vmid === vmid ? { ...lxc, status: action === 'stop' || action === 'restart' ? 'stopped' : 'running' } : lxc), + ); + } + try { await axios.post('/api/power', { node, vmid, type, action }); - // Immediately refetch all data instead of waiting for polling interval - await mutate('/api/nodes'); - await mutate('/api/vms'); - await mutate('/api/lxc'); - await mutate('/api/status'); setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)} successful`, type: 'success' }); + // Clear optimistic after successful API call — next refetch will reconcile + setTimeout(() => { + setVmsOptimistic(null); + setLxcOptimistic(null); + }, 5000); } catch (err) { + // Revert optimistic update on failure + setVmsOptimistic(null); + setLxcOptimistic(null); setPowerActionStatus({ text: err.message || `${action.charAt(0).toUpperCase() + action.slice(1)} failed`, type: 'error' }); } }; @@ -69,9 +78,10 @@ export default function Dashboard() { refreshInterval: POLL_INTERVAL, }); + // Use optimistic data when available, fall back to SWR data const nodes = nodesData?.data ?? []; - const vms = vmsData?.data ?? []; - const lxc = lxcData?.data ?? []; + const vms = vmsOptimistic ?? (vmsData?.data ?? []); + const lxc = lxcOptimistic ?? (lxcData?.data ?? []); const summary = summaryData?.data ?? null; const tabs = [ From ccef63f66d6a78d4f37c24c05c46b96c190254ea Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Sun, 10 May 2026 19:49:40 +0100 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20rewrite=20power=20action=20handler?= =?UTF-8?q?=20=E2=80=94=20instant=20status=20feedback=20+=20reliable=20sta?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status message always shows immediately on click (pending -> success/error) - Auto-dismiss after 3 seconds on success - Use fallbackData to prevent lists going empty during fetch - Direct state sync on success to keep summary counts in sync Co-Authored-By: Claude Opus 4.7 --- src/components/Dashboard.js | 85 +++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js index ee32bc2..8e17920 100644 --- a/src/components/Dashboard.js +++ b/src/components/Dashboard.js @@ -31,58 +31,59 @@ export default function Dashboard() { const [activeTab, setActiveTab] = useState('nodes'); const [activeConsole, setActiveConsole] = useState(null); const [powerActionStatus, setPowerActionStatus] = useState(null); - // Optimistic local state — updated before API response - const [vmsOptimistic, setVmsOptimistic] = useState(null); - const [lxcOptimistic, setLxcOptimistic] = useState(null); const handlePowerAction = async (node, vmid, type, action) => { - setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)}ing ${type === 'qemu' ? 'VM' : 'LXC'} ${vmid}...`, type: 'pending' }); - - // Optimistic UI update — change happens instantly - if (type === 'qemu') { - setVmsOptimistic((prev) => - (prev ?? []).map((vm) => vm.vmid === vmid ? { ...vm, status: action === 'stop' || action === 'restart' ? 'stopped' : 'running' } : vm), - ); - } else { - setLxcOptimistic((prev) => - (prev ?? []).map((lxc) => lxc.vmid === vmid ? { ...lxc, status: action === 'stop' || action === 'restart' ? 'stopped' : 'running' } : lxc), - ); - } + const label = action.charAt(0).toUpperCase() + action.slice(1); + const kind = type === 'qemu' ? 'VM' : 'LXC'; + setPowerActionStatus({ text: `${label}ing ${kind} ${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' }); - // Clear optimistic after successful API call — next refetch will reconcile - setTimeout(() => { - setVmsOptimistic(null); - setLxcOptimistic(null); - }, 5000); + // Re-fetch after success + const [nodesRes, vmsRes, lxcRes, statusRes] = await Promise.all([ + axios.get('/api/nodes'), + axios.get('/api/vms'), + axios.get('/api/lxc'), + axios.get('/api/status'), + ]); + setPowerActionStatus({ text: `${label} successful`, type: 'success' }); + // Update local state so summary counts stay in sync until poll + setNodes(nodesRes.data.data); + setVms(vmsRes.data.data); + setLxc(lxcRes.data.data); + setSummary(statusRes.data.data); + // Auto-dismiss after 3 seconds + setTimeout(() => setPowerActionStatus(null), 3000); } catch (err) { - // Revert optimistic update on failure - setVmsOptimistic(null); - setLxcOptimistic(null); - setPowerActionStatus({ text: err.message || `${action.charAt(0).toUpperCase() + action.slice(1)} failed`, type: 'error' }); + setPowerActionStatus({ text: err.message || `${label} failed`, type: 'error' }); } }; - const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, { - refreshInterval: POLL_INTERVAL, - }); - const { data: vmsData, error: vmsError } = useSWR('/api/vms', fetchData, { - refreshInterval: POLL_INTERVAL, - }); - const { data: lxcData, error: lxcError } = useSWR('/api/lxc', fetchData, { - refreshInterval: POLL_INTERVAL, - }); - const { data: summaryData, error: summaryError } = useSWR('/api/status', fetchData, { - refreshInterval: POLL_INTERVAL, - }); + const [nodes, setNodes] = useState([]); + const [vms, setVms] = useState([]); + const [lxc, setLxc] = useState([]); + const [summary, setSummary] = useState(null); - // Use optimistic data when available, fall back to SWR data - const nodes = nodesData?.data ?? []; - const vms = vmsOptimistic ?? (vmsData?.data ?? []); - const lxc = lxcOptimistic ?? (lxcData?.data ?? []); - const summary = summaryData?.data ?? null; + const { error: nodesError } = useSWR('/api/nodes', fetchData, { + refreshInterval: POLL_INTERVAL, + fallbackData: { data: [] }, + onSuccess: (data) => setNodes(data.data), + }); + const { error: vmsError } = useSWR('/api/vms', fetchData, { + refreshInterval: POLL_INTERVAL, + fallbackData: { data: [] }, + onSuccess: (data) => setVms(data.data), + }); + const { error: lxcError } = useSWR('/api/lxc', fetchData, { + refreshInterval: POLL_INTERVAL, + fallbackData: { data: [] }, + onSuccess: (data) => setLxc(data.data), + }); + const { error: summaryError } = useSWR('/api/status', fetchData, { + refreshInterval: POLL_INTERVAL, + fallbackData: { data: null }, + onSuccess: (data) => setSummary(data.data), + }); const tabs = [ { id: 'nodes', label: 'Nodes' }, From 2cc51146fcee786586bf75be1bd6d3a9b89cd383 Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Sun, 10 May 2026 20:15:41 +0100 Subject: [PATCH 4/4] fix: force paint before API call so status feedback is visible - requestAnimationFrame flush lets React paint the "Stopping..." message before the async API call begins - Lists never go empty thanks to fallbackData on all SWR hooks - State synced on success to keep summary counts in sync Co-Authored-By: Claude Opus 4.7 --- src/components/Dashboard.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js index 8e17920..cde13ec 100644 --- a/src/components/Dashboard.js +++ b/src/components/Dashboard.js @@ -36,6 +36,8 @@ export default function Dashboard() { const label = action.charAt(0).toUpperCase() + action.slice(1); const kind = type === 'qemu' ? 'VM' : 'LXC'; setPowerActionStatus({ text: `${label}ing ${kind} ${vmid}...`, type: 'pending' }); + // Flush the pending status so it paints before the API call + await new Promise((r) => requestAnimationFrame(r)); try { await axios.post('/api/power', { node, vmid, type, action }); @@ -47,7 +49,6 @@ export default function Dashboard() { axios.get('/api/status'), ]); setPowerActionStatus({ text: `${label} successful`, type: 'success' }); - // Update local state so summary counts stay in sync until poll setNodes(nodesRes.data.data); setVms(vmsRes.data.data); setLxc(lxcRes.data.data);