14 Commits

Author SHA1 Message Date
2cc51146fc 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 <noreply@anthropic.com>
2026-05-10 20:15:41 +01:00
ccef63f66d fix: rewrite power action handler — instant status feedback + reliable state
- 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 <noreply@anthropic.com>
2026-05-10 19:49:40 +01:00
bfc6bf8526 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 <noreply@anthropic.com>
2026-05-10 19:18:35 +01:00
175beae735 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>
2026-05-10 18:56:29 +01:00
1f6a594872 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
52850c4e15 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
461f1d55e4 fix: use correct Proxmox console URL format from docs
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 11:38:46 +01:00
aa5a3d637b update: mark console link as done in backlog
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 10:38:31 +01:00
c12090b3f4 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
7baf6a50fa 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
d5e85ce3c1 fix: restore broken getSummary function declaration
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 09:07:55 +01:00
e8b9aee4cb 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
4c32eebb0d Merge branch 'main' of https://gitea.rdbcloud.co.uk/robbond/proxmox-monitor 2026-05-07 07:19:10 +01:00
49dd3554f1 initial commit 2026-05-07 06:41:36 +01:00
12 changed files with 464 additions and 66 deletions

View File

@@ -2,6 +2,8 @@
# Get your token from Proxmox → Datacenter → Permissions → API Tokens # Get your token from Proxmox → Datacenter → Permissions → API Tokens
PROXMOX_API_URL=http://proxmox.lan:8006/api2/json PROXMOX_API_URL=http://proxmox.lan:8006/api2/json
PROXMOX_WEB_URL=http://proxmox.lan:8006
PROXMOX_CONSOLE_TOKEN=root@pam!monitorapp
PROXMOX_TOKEN_ID=root@pam!monitorapp PROXMOX_TOKEN_ID=root@pam!monitorapp
PROXMOX_TOKEN_SECRET=103d622c-05a3-4002-9356-f67691e10d40 PROXMOX_TOKEN_SECRET=<your-token-secret>
API_POLL_INTERVAL=30000 API_POLL_INTERVAL=30000

57
backlog.md Normal file
View File

@@ -0,0 +1,57 @@
# Feature Backlog — proxmox-monitor
## Quick Wins (low effort, high value)
### Console Link
- Add a clickable name link per row linking to Proxmox web console: `/pve/console.html?node=X&vmid=Y`
- **Status: done (pending push)**
### Manual Refresh
- Button in dashboard header that calls `mutate()` on all SWR caches
- ~5 lines of code
### Custom Poll Interval
- Input field in header to adjust `refreshInterval` instead of env-only config
### Group by Node
- Collapse/expand VMs/LXCs by node with toggle
### Uptime Duration
- Format uptime seconds to "3d 4h 12m" via `formatUptime()` helper
### Search / Filter
- Client-side text input filtering table rows by name/node/status
## Medium Effort
### Storage Usage
- Show disk usage per Proxmox storage backend (like node cards)
### Status Change Alert
- Toast/banner when a VM state changes between polls
### Resource Allocation
- Show vCPU count, total disk size alongside usage stats
### Per-VM IP Addresses
- Fetch guest network IP addresses (extra API call needed)
### Per-VM CPU/Memory Trend
- Periodic polling to build a small trend chart
## Larger Features
### Cluster Status
- HA, quorum, and failover info per cluster
### Network Health
- Network interfaces and link status per node
### Recent Proxmox Events
- Audit log / recent events from the Proxmox cluster
### Docker Deployment
- Dockerfile + docker-compose for easy hosting
### Health Check Endpoint
- `/api/health` for reverse proxy / uptime monitoring

View File

@@ -1,6 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getNodes, getLXC } from '@/lib/proxmox'; import { getNodes, getLXC } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
const PROXMOX_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
/** @param {import('next').Request} _req */ /** @param {import('next').Request} _req */
export async function GET(_req) { export async function GET(_req) {
try { try {
@@ -10,7 +13,13 @@ export async function GET(_req) {
for (const node of nodes) { for (const node of nodes) {
try { try {
const lxcs = await getLXC(node.name); const lxcs = await getLXC(node.name);
allLXC.push(...lxcs.map((lxc) => ({ ...lxc, node: node.name }))); allLXC.push(
...lxcs.map((lxc) => ({
...lxc,
node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
})),
);
} catch { /* skip node */ } } catch { /* skip node */ }
} }

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

@@ -0,0 +1,86 @@
import { NextResponse } from 'next/server';
import axios from 'axios';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || 'http://proxmox:8006';
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
/** @param {import('next').Request} req */
export async function GET(req) {
const url = new URL(req.url);
const pathSegments = url.pathname.split('/api/proxy/')[1];
if (!pathSegments) {
return NextResponse.json({ error: 'Invalid proxy path' }, { status: 400 });
}
const proxmoxPath = `/${pathSegments}`;
const targetUrl = `${PROXMOX_WEB_URL.replace(/\/+$/, '')}${proxmoxPath}`;
const headers = {
Authorization: `PVEAPIToken=${process.env.PROXMOX_TOKEN_ID}=${process.env.PROXMOX_TOKEN_SECRET}`,
};
try {
const res = await axios({ url: targetUrl, method: 'GET', headers, responseType: 'text' });
let body = res.data;
// Rewrite relative URLs back through our proxy
body = body.replace(
/href="([^"]*)"/g,
(_, href) => {
if (href.startsWith('http') || href.startsWith('javascript:')) return `href="${href}"`;
return `href="/api/proxy/${href}"`;
},
);
body = body.replace(
/src="([^"]*)"/g,
(_, src) => {
if (src.startsWith('http') || src.startsWith('data:') || src.startsWith('blob:')) return `src="${src}"`;
return `src="/api/proxy/${src}"`;
},
);
return new NextResponse(body, {
status: res.status,
headers: {
'Content-Type': res.headers['content-type'] || 'text/html',
'X-Frame-Options': 'SAMEORIGIN',
},
});
} catch (err) {
const msg = err.response?.data?.message || err.response?.data || err.message;
return NextResponse.json({ error: msg }, { status: 502 });
}
}
/** @param {import('next').Request} req */
export async function POST(req) {
const url = new URL(req.url);
const pathSegments = url.pathname.split('/api/proxy/')[1];
if (!pathSegments) {
return NextResponse.json({ error: 'Invalid proxy path' }, { status: 400 });
}
const proxmoxPath = `/${pathSegments}`;
const targetUrl = `${PROXMOX_WEB_URL.replace(/\/+$/, '')}${proxmoxPath}`;
const headers = {
Authorization: `PVEAPIToken=${process.env.PROXMOX_TOKEN_ID}=${process.env.PROXMOX_TOKEN_SECRET}`,
'Content-Type': 'application/x-www-form-urlencoded',
};
const body = await req.text();
try {
const res = await axios({
url: targetUrl,
method: 'POST',
headers,
data: body,
});
return NextResponse.json(res.data);
} catch (err) {
const msg = err.response?.data?.message || err.response?.data || err.message;
return NextResponse.json({ error: msg }, { status: 502 });
}
}

View File

@@ -1,6 +1,9 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getNodes, getVMs } from '@/lib/proxmox'; import { getNodes, getVMs } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
const PROXMOX_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
/** @param {import('next').Request} _req */ /** @param {import('next').Request} _req */
export async function GET(_req) { export async function GET(_req) {
try { try {
@@ -10,7 +13,13 @@ export async function GET(_req) {
for (const node of nodes) { for (const node of nodes) {
try { try {
const vms = await getVMs(node.name); const vms = await getVMs(node.name);
allVMs.push(...vms.map((vm) => ({ ...vm, node: node.name }))); allVMs.push(
...vms.map((vm) => ({
...vm,
node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
})),
);
} catch { /* skip node */ } } catch { /* skip node */ }
} }

45
src/app/api/vnc/route.js Normal file
View File

@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import axios from 'axios';
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || PROXMOX_API_URL?.replace('/api2/json', '');
/** @param {import('next').Request} req */
export async function GET(req) {
const url = new URL(req.url);
const node = url.searchParams.get('node');
const vmid = url.searchParams.get('vmid');
const type = url.searchParams.get('type');
if (!node || !vmid || !type) {
return NextResponse.json({ error: 'Missing node, vmid, or type' }, { status: 400 });
}
try {
const ticketRes = await axios({
url: `${PROXMOX_API_URL}/nodes/${node}/${type}/${vmid}/vncproxy`,
method: 'POST',
headers: {
Authorization: `PVEAPIToken=${process.env.PROXMOX_TOKEN_ID}=${process.env.PROXMOX_TOKEN_SECRET}`,
'Content-Type': 'application/json',
},
data: JSON.stringify({ websocket: 0 }),
});
const ticket = ticketRes.data?.ticket;
if (!ticket) {
return NextResponse.json(
{ error: 'No VNC ticket returned from Proxmox API' },
{ status: 502 },
);
}
const consoleUrl = `${PROXMOX_WEB_URL}/?vncticket=${encodeURIComponent(ticket)}&novnc=1&node=${encodeURIComponent(node)}&vmid=${vmid}`;
return NextResponse.json({ consoleUrl });
} catch (err) {
const msg = err.response?.data?.message || err.response?.data || err.message;
return NextResponse.json({ error: msg }, { status: 502 });
}
}

View File

@@ -0,0 +1,43 @@
'use client';
import { useState } from 'react';
/**
* @param {{ vmid: number; name: string; node: string; type: string; consoleUrl: string }} vm
* @param {() => void} onClose
* @returns {import('react').ReactNode}
*/
export default function ConsolePanel({ vm, onClose }) {
const [loading, setLoading] = useState(true);
return (
<div className="fixed bottom-0 left-0 right-0 z-50 flex h-[60vh] flex-col rounded-t-xl border border-border-card bg-bg-card shadow-2xl sm:right-4">
{/* Header */}
<div className="flex items-center justify-between border-b border-border-card px-4 py-2">
<div className="flex items-center gap-2 text-sm font-medium text-text-primary">
<span>Console</span>
<span className="text-[10px] text-text-secondary">
({vm.type.toUpperCase()} {vm.vmid} on {vm.node})
</span>
</div>
<div className="flex items-center gap-2">
{loading && <span className="text-xs text-text-secondary">Loading</span>}
<button
onClick={onClose}
className="rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-secondary hover:text-text-primary"
>
Close
</button>
</div>
</div>
{/* Frame */}
<iframe
src={vm.consoleUrl}
title={`${vm.name} console`}
className="h-full w-full border-0"
onLoad={() => setLoading(false)}
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
/>
</div>
);
}

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 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);
@@ -15,8 +16,6 @@ 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;
@@ -30,29 +29,68 @@ 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 [activeConsole, setActiveConsole] = useState(null);
const [powerActionStatus, setPowerActionStatus] = useState(null);
const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, { const handlePowerAction = async (node, vmid, type, action) => {
refreshInterval: POLL_INTERVAL, const label = action.charAt(0).toUpperCase() + action.slice(1);
}); const kind = type === 'qemu' ? 'VM' : 'LXC';
const { data: vmsData, error: vmsError } = useSWR('/api/vms', fetchData, { setPowerActionStatus({ text: `${label}ing ${kind} ${vmid}...`, type: 'pending' });
refreshInterval: POLL_INTERVAL, // Flush the pending status so it paints before the API call
}); await new Promise((r) => requestAnimationFrame(r));
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 = nodesData?.data ?? []; try {
const vms = vmsData?.data ?? []; await axios.post('/api/power', { node, vmid, type, action });
const lxc = lxcData?.data ?? []; // Re-fetch after success
const summary = summaryData?.data ?? null; 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) {
setPowerActionStatus({ text: err.message || `${label} failed`, type: 'error' });
}
};
const [nodes, setNodes] = useState([]);
const [vms, setVms] = useState([]);
const [lxc, setLxc] = useState([]);
const [summary, setSummary] = useState(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 = [ const tabs = [
{ id: 'nodes', label: 'Nodes' }, { id: 'nodes', label: 'Nodes' },
{ id: 'vms', label: 'VMs' }, { id: 'vms', label: 'VMs' },
{ id: 'lxc', label: 'LXCs' }, { id: 'lxc', label: 'LXCs' },
...(activeConsole ? [{ id: 'console', label: `Console: ${activeConsole.name}` }] : []),
]; ];
return ( return (
@@ -100,6 +138,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 +167,11 @@ 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} onOpenConsole={(vm) => { setActiveConsole(vm); setActiveTab('console'); }} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} />} {activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={(lxc) => { setActiveConsole(lxc); setActiveTab('console'); }} />}
{activeTab === 'console' && activeConsole && (
<ConsolePanel vm={activeConsole} onClose={() => setActiveConsole(null)} />
)}
</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; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
*/
export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
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,26 @@ 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={() => onOpenConsole?.(item)}
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"
>
Console
</button>
<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 +81,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 +92,20 @@ 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={() => onOpenConsole?.(item)}
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"
>
Console
</button>
<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; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
*/
export default function VMList({ vms, onPowerAction, onOpenConsole }) {
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,26 @@ 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={() => onOpenConsole?.(vm)}
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"
>
Console
</button>
<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 +82,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 +93,20 @@ 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={() => onOpenConsole?.(vm)}
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"
>
Console
</button>
<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

@@ -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,10 +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>}
*/
export async function getSummary() { export async function getSummary() {
const nodes = await getNodes(); const nodes = await getNodes();
let totalVMs = []; let totalVMs = [];