10 Commits

Author SHA1 Message Date
33d2f6e42a feat: merge power control and console link into main
- Start/stop/restart buttons for VMs and LXCs
- Console link using /vncproxy endpoint
- Various fixes to get console working
2026-05-10 17:49:30 +01:00
2ab78012ba fix: use /vncproxy endpoint with websocket:0 parameter
The /vncproxy endpoint requires websocket: 0 (not -1) in the POST body.
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 18:44:13 +01:00
607844f184 fix: use correct Proxmox console URL format from docs
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 11:38:46 +01:00
e79221642b update: mark console link as done in backlog
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 10:38:31 +01:00
f27bc3e4eb fix: use correct Proxmox console URL path (/pve/console.html)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 10:31:53 +01:00
77b067ea35 feat: add console link next to VM/LXC names
Clicking the name opens the Proxmox web console in a new tab.
URLs are constructed in the API routes using the configured PROXMOX_API_URL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 10:22:29 +01:00
6b61c3240a fix: restore broken getSummary function declaration
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 09:07:55 +01:00
51753269ca 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>
2026-05-07 08:07:38 +01:00
47f10cc2c2 Merge branch 'main' of https://gitea.rdbcloud.co.uk/robbond/proxmox-monitor 2026-05-07 07:19:10 +01:00
783400884a initial commit 2026-05-07 06:41:36 +01:00
3 changed files with 86 additions and 78 deletions

View File

@@ -5,5 +5,5 @@ PROXMOX_API_URL=http://proxmox.lan:8006/api2/json
PROXMOX_WEB_URL=http://proxmox.lan:8006 PROXMOX_WEB_URL=http://proxmox.lan:8006
PROXMOX_CONSOLE_TOKEN=root@pam!monitorapp PROXMOX_CONSOLE_TOKEN=root@pam!monitorapp
PROXMOX_TOKEN_ID=root@pam!monitorapp PROXMOX_TOKEN_ID=root@pam!monitorapp
PROXMOX_TOKEN_SECRET=<your-token-secret> PROXMOX_TOKEN_SECRET=103d622c-05a3-4002-9356-f67691e10d40
API_POLL_INTERVAL=30000 API_POLL_INTERVAL=30000

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';
import ConsolePanel from './ConsolePanel'; import ConsolePanel from './ConsolePanel';
const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000); const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
@@ -16,6 +17,8 @@ const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000)
* @returns {Promise<{ data: *, error: string | null }>} * @returns {Promise<{ data: *, error: string | null }>}
*/ */
async function fetchData(url) { async function fetchData(url) {
try { try {
const res = await axios.get(url); const res = await axios.get(url);
return res.data; return res.data;
@@ -32,59 +35,39 @@ export default function Dashboard() {
const [activeConsole, setActiveConsole] = useState(null); const [activeConsole, setActiveConsole] = useState(null);
const [powerActionStatus, setPowerActionStatus] = useState(null); const [powerActionStatus, setPowerActionStatus] = useState(null);
/**
* @param {string} node
* @param {number} vmid
* @param {string} type
* @param {string} action
*/
const handlePowerAction = async (node, vmid, type, action) => { const handlePowerAction = async (node, vmid, type, action) => {
const label = action.charAt(0).toUpperCase() + action.slice(1); setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)}ing ${type === 'qemu' ? 'VM' : 'LXC'} ${vmid}...`, type: 'pending' });
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 { try {
await axios.post('/api/power', { node, vmid, type, action }); await axios.post('/api/power', { node, vmid, type, action });
// Re-fetch after success setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)} successful`, type: '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' });
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) { } catch (err) {
setPowerActionStatus({ text: err.message || `${label} failed`, type: 'error' }); setPowerActionStatus({ text: err.message || `${action.charAt(0).toUpperCase() + action.slice(1)} failed`, type: 'error' });
} }
}; };
const [nodes, setNodes] = useState([]); const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, {
const [vms, setVms] = useState([]); refreshInterval: POLL_INTERVAL,
const [lxc, setLxc] = useState([]); });
const [summary, setSummary] = useState(null); 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 { error: nodesError } = useSWR('/api/nodes', fetchData, { const nodes = nodesData?.data ?? [];
refreshInterval: POLL_INTERVAL, const vms = vmsData?.data ?? [];
fallbackData: { data: [] }, const lxc = lxcData?.data ?? [];
onSuccess: (data) => setNodes(data.data), const summary = summaryData?.data ?? null;
});
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 = [ const tabs = [
{ id: 'nodes', label: 'Nodes' }, { id: 'nodes', label: 'Nodes' },

View File

@@ -8,13 +8,33 @@ 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'); const TOKEN_ID = getEnv('PROXMOX_TOKEN_ID'); // e.g. "root@pam!monitorapp"
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(/^\/+/, '');
@@ -26,47 +46,18 @@ 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');
}
} }
/** /**
@@ -80,6 +71,10 @@ export async function powerControl(nodeName, vmid, type, action) {
* @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) => ({
@@ -107,6 +102,11 @@ 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,6 +126,11 @@ 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) => ({
@@ -154,6 +159,26 @@ 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 = [];