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 <noreply@anthropic.com>
This commit is contained in:
2026-05-10 18:56:29 +01:00
parent 1f6a594872
commit 175beae735
2 changed files with 38 additions and 58 deletions

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import useSWR from 'swr'; import useSWR, { mutate } from 'swr';
import NodeList from './NodeList'; import NodeList from './NodeList';
import VMList from './VMList'; import VMList from './VMList';
import LXCList from './LXCList'; 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' }); setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)}ing ${type === 'qemu' ? 'VM' : 'LXC'} ${vmid}...`, type: 'pending' });
try { try {
await axios.post('/api/power', { node, vmid, type, action }); 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' }); setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)} successful`, type: 'success' });
} catch (err) { } catch (err) {
setPowerActionStatus({ text: err.message || `${action.charAt(0).toUpperCase() + action.slice(1)} failed`, type: 'error' }); setPowerActionStatus({ text: err.message || `${action.charAt(0).toUpperCase() + action.slice(1)} failed`, type: 'error' });

View File

@@ -8,33 +8,13 @@ function getEnv(key) {
return val; return val;
} }
/**
* Generates the Proxmox API2 Authorization header.
* Format: PVEAPIToken=<user>@<realm>!<token-id>=<secret>
* @param {string} _method
* @param {string} _path
* @param {string} _body
* @returns {{ Authorization: string }}
*/
function createAuthHeaders(_method, _path, _body) { 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'); const TOKEN_SECRET = getEnv('PROXMOX_TOKEN_SECRET');
return { Authorization: `PVEAPIToken=${TOKEN_ID}=${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) { async function proxmoxFetch(path, method = 'GET', body) {
const headers = {
...createAuthHeaders(method, path, body),
'Content-Type': 'application/json',
};
const baseUrl = getEnv('PROXMOX_API_URL'); const baseUrl = getEnv('PROXMOX_API_URL');
const base = baseUrl.replace(/\/+$/, ''); const base = baseUrl.replace(/\/+$/, '');
const cleanPath = path.replace(/^\/+/, ''); const cleanPath = path.replace(/^\/+/, '');
@@ -46,18 +26,47 @@ async function proxmoxFetch(path, method = 'GET', body) {
const res = await axios({ const res = await axios({
url: fullUrl, url: fullUrl,
method, method,
headers, headers: {
...createAuthHeaders(method, path, body),
'Content-Type': 'application/json',
},
data: body, data: body,
}); });
return res.data; return res.data;
} catch (err) { } catch (err) {
const status = err.response?.status || 'Unknown Status'; const status = err.response?.status || 'Unknown Status';
const message = err.response?.data?.message || err.response?.data || err.message; const message = err.response?.data?.message || err.response?.data || err.message;
throw new Error(`Proxmox API error ${status}: ${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<void>}
*/
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 * @property {number} load
*/ */
/**
* Fetches all Proxmox nodes and returns mapped stats.
* @returns {Promise<ProxmoxNode[]>}
*/
export async function getNodes() { export async function getNodes() {
const data = await proxmoxFetch('/nodes'); const data = await proxmoxFetch('/nodes');
return (data.data || []).map((node) => ({ return (data.data || []).map((node) => ({
@@ -102,11 +107,6 @@ export async function getNodes() {
* @property {{ total: number; used: number }} disk * @property {{ total: number; used: number }} disk
*/ */
/**
* Fetches QEMU VMs for a given node.
* @param {string} nodeName
* @returns {Promise<ProxmoxVM[]>}
*/
export async function getVMs(nodeName) { export async function getVMs(nodeName) {
const data = await proxmoxFetch(`/nodes/${nodeName}/qemu`); const data = await proxmoxFetch(`/nodes/${nodeName}/qemu`);
return (data.data || []).map((vm) => ({ 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<ProxmoxVM[]>}
*/
export async function getLXC(nodeName) { export async function getLXC(nodeName) {
const data = await proxmoxFetch(`/nodes/${nodeName}/lxc`); const data = await proxmoxFetch(`/nodes/${nodeName}/lxc`);
return (data.data || []).map((vm) => ({ return (data.data || []).map((vm) => ({
@@ -159,26 +154,6 @@ export async function getLXC(nodeName) {
* @property {{ total: number; used: number }} totalMemory * @property {{ total: number; used: number }} totalMemory
*/ */
/**
* Aggregated summary across all nodes.
* @returns {Promise<SummaryStat>}
*/
/**
* 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.
* @returns {Promise<SummaryStat>}
*/
export async function getSummary() { export async function getSummary() {
const nodes = await getNodes(); const nodes = await getNodes();
let totalVMs = []; let totalVMs = [];