From 51753269cafd702854e261ef066b3b61f3d065c4 Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 7 May 2026 08:07:38 +0100 Subject: [PATCH 1/7] 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 = []; From 6b61c3240a65e180127ba23a061cb0dbf197e5a5 Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 7 May 2026 09:07:55 +0100 Subject: [PATCH 2/7] fix: restore broken getSummary function declaration Co-Authored-By: Claude --- src/lib/proxmox.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/proxmox.js b/src/lib/proxmox.js index 5127cb1..46c4a02 100644 --- a/src/lib/proxmox.js +++ b/src/lib/proxmox.js @@ -177,6 +177,9 @@ export async function powerControl(nodeName, vmid, type, action) { /** * Aggregated summary across all nodes. + * @returns {Promise} + */ +export async function getSummary() { const nodes = await getNodes(); let totalVMs = []; let totalLXC = []; From 77b067ea355000355a9db77bde6048ce28a9707c Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 7 May 2026 10:22:29 +0100 Subject: [PATCH 3/7] 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 --- src/app/api/lxc/route.js | 11 ++++++++++- src/app/api/vms/route.js | 11 ++++++++++- src/components/LXCList.js | 32 ++++++++++++++++++++++++++++++-- src/components/VMList.js | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 80 insertions(+), 6 deletions(-) diff --git a/src/app/api/lxc/route.js b/src/app/api/lxc/route.js index 4e10751..0811d4e 100644 --- a/src/app/api/lxc/route.js +++ b/src/app/api/lxc/route.js @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server'; import { getNodes, getLXC } from '@/lib/proxmox'; +const PROXMOX_API_URL = process.env.PROXMOX_API_URL || 'https://proxmox:8006/api2/json'; +const PROXMOX_WEBUI = PROXMOX_API_URL.replace('/api2/json', ''); + /** @param {import('next').Request} _req */ export async function GET(_req) { try { @@ -10,7 +13,13 @@ export async function GET(_req) { for (const node of nodes) { try { 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_WEBUI}/pve/console?node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}`, + })), + ); } catch { /* skip node */ } } diff --git a/src/app/api/vms/route.js b/src/app/api/vms/route.js index d4c787d..d5f3256 100644 --- a/src/app/api/vms/route.js +++ b/src/app/api/vms/route.js @@ -1,6 +1,9 @@ import { NextResponse } from 'next/server'; import { getNodes, getVMs } from '@/lib/proxmox'; +const PROXMOX_API_URL = process.env.PROXMOX_API_URL || 'https://proxmox:8006/api2/json'; +const PROXMOX_WEBUI = PROXMOX_API_URL.replace('/api2/json', ''); + /** @param {import('next').Request} _req */ export async function GET(_req) { try { @@ -10,7 +13,13 @@ export async function GET(_req) { for (const node of nodes) { try { 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_WEBUI}/pve/console?node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}`, + })), + ); } catch { /* skip node */ } } diff --git a/src/components/LXCList.js b/src/components/LXCList.js index 8d76b04..78fbf1c 100644 --- a/src/components/LXCList.js +++ b/src/components/LXCList.js @@ -36,7 +36,21 @@ export default function LXCList({ lxc, onPowerAction }) { {running.map((item) => ( {item.vmid} - {item.name} + + {item.consoleUrl ? ( + + {item.name} + [⟶] + + ) : ( + {item.name} + )} + {item.node} {(item.cpu * 100).toFixed(1)}% {formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)} @@ -82,7 +96,21 @@ export default function LXCList({ lxc, onPowerAction }) { {stopped.map((item) => ( {item.vmid} - {item.name} + + {item.consoleUrl ? ( + + {item.name} + [⟶] + + ) : ( + {item.name} + )} + {item.node} {formatBytes(item.disk?.used || 0)} diff --git a/src/components/VMList.js b/src/components/VMList.js index a76dffe..3dcf8db 100644 --- a/src/components/VMList.js +++ b/src/components/VMList.js @@ -37,7 +37,21 @@ export default function VMList({ vms, onPowerAction }) { {running.map((vm) => ( {vm.vmid} - {vm.name} + + {vm.consoleUrl ? ( + + {vm.name} + [⟶] + + ) : ( + {vm.name} + )} + {vm.node} {(vm.cpu * 100).toFixed(1)}% {formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)} @@ -83,7 +97,21 @@ export default function VMList({ vms, onPowerAction }) { {stopped.map((vm) => ( {vm.vmid} - {vm.name} + + {vm.consoleUrl ? ( + + {vm.name} + [⟶] + + ) : ( + {vm.name} + )} + {vm.node} {formatBytes(vm.disk?.used || 0)} From f27bc3e4eb69d5f80dde1a3f872c96d6856022d2 Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 7 May 2026 10:31:53 +0100 Subject: [PATCH 4/7] fix: use correct Proxmox console URL path (/pve/console.html) Co-Authored-By: Claude --- src/app/api/lxc/route.js | 2 +- src/app/api/vms/route.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/api/lxc/route.js b/src/app/api/lxc/route.js index 0811d4e..8526b74 100644 --- a/src/app/api/lxc/route.js +++ b/src/app/api/lxc/route.js @@ -17,7 +17,7 @@ export async function GET(_req) { ...lxcs.map((lxc) => ({ ...lxc, node: node.name, - consoleUrl: `${PROXMOX_WEBUI}/pve/console?node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}`, + consoleUrl: `${PROXMOX_WEBUI}/pve/console.html?node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}`, })), ); } catch { /* skip node */ } diff --git a/src/app/api/vms/route.js b/src/app/api/vms/route.js index d5f3256..1ef5e82 100644 --- a/src/app/api/vms/route.js +++ b/src/app/api/vms/route.js @@ -17,7 +17,7 @@ export async function GET(_req) { ...vms.map((vm) => ({ ...vm, node: node.name, - consoleUrl: `${PROXMOX_WEBUI}/pve/console?node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}`, + consoleUrl: `${PROXMOX_WEBUI}/pve/console.html?node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}`, })), ); } catch { /* skip node */ } From e79221642bf5534e04387913026e9abd5eaa397e Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 7 May 2026 10:38:31 +0100 Subject: [PATCH 5/7] update: mark console link as done in backlog Co-Authored-By: Claude --- backlog.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 backlog.md diff --git a/backlog.md b/backlog.md new file mode 100644 index 0000000..fa2034a --- /dev/null +++ b/backlog.md @@ -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 From 607844f184c3fdb6039e6a971531e931dafee2b9 Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 7 May 2026 11:38:46 +0100 Subject: [PATCH 6/7] fix: use correct Proxmox console URL format from docs Co-Authored-By: Claude --- .env.example | 1 + src/app/api/lxc/route.js | 5 ++--- src/app/api/vms/route.js | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index c8ca668..df5d7d1 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ # Get your token from Proxmox → Datacenter → Permissions → API Tokens PROXMOX_API_URL=http://proxmox.lan:8006/api2/json +PROXMOX_WEB_URL=http://proxmox.lan:8006 PROXMOX_TOKEN_ID=root@pam!monitorapp PROXMOX_TOKEN_SECRET=103d622c-05a3-4002-9356-f67691e10d40 API_POLL_INTERVAL=30000 diff --git a/src/app/api/lxc/route.js b/src/app/api/lxc/route.js index 8526b74..883bbd6 100644 --- a/src/app/api/lxc/route.js +++ b/src/app/api/lxc/route.js @@ -1,8 +1,7 @@ import { NextResponse } from 'next/server'; import { getNodes, getLXC } from '@/lib/proxmox'; -const PROXMOX_API_URL = process.env.PROXMOX_API_URL || 'https://proxmox:8006/api2/json'; -const PROXMOX_WEBUI = PROXMOX_API_URL.replace('/api2/json', ''); +const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', ''); /** @param {import('next').Request} _req */ export async function GET(_req) { @@ -17,7 +16,7 @@ export async function GET(_req) { ...lxcs.map((lxc) => ({ ...lxc, node: node.name, - consoleUrl: `${PROXMOX_WEBUI}/pve/console.html?node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}`, + consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}`, })), ); } catch { /* skip node */ } diff --git a/src/app/api/vms/route.js b/src/app/api/vms/route.js index 1ef5e82..cea4079 100644 --- a/src/app/api/vms/route.js +++ b/src/app/api/vms/route.js @@ -1,8 +1,7 @@ import { NextResponse } from 'next/server'; import { getNodes, getVMs } from '@/lib/proxmox'; -const PROXMOX_API_URL = process.env.PROXMOX_API_URL || 'https://proxmox:8006/api2/json'; -const PROXMOX_WEBUI = PROXMOX_API_URL.replace('/api2/json', ''); +const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', ''); /** @param {import('next').Request} _req */ export async function GET(_req) { @@ -17,7 +16,7 @@ export async function GET(_req) { ...vms.map((vm) => ({ ...vm, node: node.name, - consoleUrl: `${PROXMOX_WEBUI}/pve/console.html?node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}`, + consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}`, })), ); } catch { /* skip node */ } From 2ab78012bae7840c447ee9b6720fd28417c30d51 Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Thu, 7 May 2026 18:44:13 +0100 Subject: [PATCH 7/7] fix: use /vncproxy endpoint with websocket:0 parameter The /vncproxy endpoint requires websocket: 0 (not -1) in the POST body. Co-Authored-By: Claude --- .env.example | 1 + src/app/api/lxc/route.js | 3 +- src/app/api/proxy/[...path]/route.js | 86 ++++++++++++++++++++++++++++ src/app/api/vms/route.js | 3 +- src/app/api/vnc/route.js | 45 +++++++++++++++ src/components/ConsolePanel.js | 43 ++++++++++++++ src/components/Dashboard.js | 10 +++- src/components/LXCList.js | 48 ++++++---------- src/components/VMList.js | 48 ++++++---------- 9 files changed, 219 insertions(+), 68 deletions(-) create mode 100644 src/app/api/proxy/[...path]/route.js create mode 100644 src/app/api/vnc/route.js create mode 100644 src/components/ConsolePanel.js diff --git a/.env.example b/.env.example index df5d7d1..b01de68 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ 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_SECRET=103d622c-05a3-4002-9356-f67691e10d40 API_POLL_INTERVAL=30000 diff --git a/src/app/api/lxc/route.js b/src/app/api/lxc/route.js index 883bbd6..d66ecf3 100644 --- a/src/app/api/lxc/route.js +++ b/src/app/api/lxc/route.js @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; 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 */ export async function GET(_req) { @@ -16,7 +17,7 @@ export async function GET(_req) { ...lxcs.map((lxc) => ({ ...lxc, node: node.name, - consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}`, + consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`, })), ); } catch { /* skip node */ } diff --git a/src/app/api/proxy/[...path]/route.js b/src/app/api/proxy/[...path]/route.js new file mode 100644 index 0000000..ce12d4c --- /dev/null +++ b/src/app/api/proxy/[...path]/route.js @@ -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 }); + } +} diff --git a/src/app/api/vms/route.js b/src/app/api/vms/route.js index cea4079..abac524 100644 --- a/src/app/api/vms/route.js +++ b/src/app/api/vms/route.js @@ -2,6 +2,7 @@ import { NextResponse } from 'next/server'; 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 */ export async function GET(_req) { @@ -16,7 +17,7 @@ export async function GET(_req) { ...vms.map((vm) => ({ ...vm, node: node.name, - consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}`, + consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`, })), ); } catch { /* skip node */ } diff --git a/src/app/api/vnc/route.js b/src/app/api/vnc/route.js new file mode 100644 index 0000000..69a7308 --- /dev/null +++ b/src/app/api/vnc/route.js @@ -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 }); + } +} diff --git a/src/components/ConsolePanel.js b/src/components/ConsolePanel.js new file mode 100644 index 0000000..d1ece79 --- /dev/null +++ b/src/components/ConsolePanel.js @@ -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 ( +
+ {/* Header */} +
+
+ Console + + ({vm.type.toUpperCase()} {vm.vmid} on {vm.node}) + +
+
+ {loading && Loading…} + +
+
+ {/* Frame */} +