9 Commits

Author SHA1 Message Date
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 433 additions and 678 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=103d622c-05a3-4002-9356-f67691e10d40 PROXMOX_TOKEN_SECRET=<your-token-secret>
API_POLL_INTERVAL=30000 API_POLL_INTERVAL=30000

View File

@@ -1,63 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Real-time dashboard for monitoring Proxmox VE nodes, VMs, and LXC containers. Next.js 15 App Router with Tailwind CSS 4.
## Commands
```
npm run dev # Start dev server (usually on port 3002)
npm run build # Production build
npm run start # Start production server
```
## Architecture
```
src/
├── app/
│ ├── api/ # Next.js API routes (server-side)
│ │ ├── proxy/[...]/route.js # Reverse proxy to Proxmox web UI
│ │ ├── power/route.js # Power control (start/stop/restart)
│ │ ├── nodes/vms/lxc/route.js # Query endpoints (aggregates across nodes)
│ │ └── status/route.js # Aggregated summary stats
│ ├── layout.js # Root layout (Inter font, metadata)
│ └── page.js # Entry — renders <Dashboard />
├── components/
│ ├── Dashboard.js # Main UI: tabs, summary cards, power action handler
│ ├── NodeList.js / NodeCard.js # Node overview
│ ├── VMList.js / LXCList.js # VM/LXC list views (each row has Console/Start/Stop buttons)
│ └── StatusBadge.js # Status indicator
└── lib/
└── proxmox.js # Proxmox API client — getNodes, getVMs, getLXC, getSummary, powerControl
```
## Key Patterns
- **Dual API endpoints**: Queries use `/api2/json`, power operations use `/api2/extjs`. Power actions send empty body `{}`.
- **API routes** aggregate data across all Proxmox nodes before returning to the client. They are thin wrappers around `lib/proxmox.js`.
- **Dashboard** uses direct React state + manual `axios.get()` calls for power actions (instant UI sync), and SWR with `refreshInterval` for periodic polling of the same endpoints.
- **Power action flow**: `handlePowerAction` in Dashboard.js fires `POST /api/power`, then immediately re-fetches all four caches (`/api/nodes`, `/api/vms`, `/api/lxc`, `/api/status`) and sets state directly. Use `requestAnimationFrame` flush before the API call so status messages paint.
- **Auth**: PVEAPIToken header: `Authorization: PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}`.
- **Console**: VMList/LXCList build `consoleUrl` from `PROXMOX_WEB_URL` with `?console={qemu|lxc}&vmid={id}&node={node}` params. Dashboard opens it via `window.open()` in a new tab so browser session cookies handle Proxmox auth.
- **TLS**: `NODE_TLS_REJECT_UNAUTHORIZED=0` in `.env.local` for self-signed certs. Shows a console warning — acceptable for internal lab use.
- **Tailwind CSS 4**: Uses `@tailwindcss/postcss` config. CSS vars in `globals.css` for theming.
- **All API routes return 502 on Proxmox failures** with `{ error, data: null }`.
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| `PROXMOX_API_URL` | Yes | Base URL, e.g. `https://host:8006/api2/json` |
| `PROXMOX_TOKEN_ID` | Yes | API token ID |
| `PROXMOX_TOKEN_SECRET` | Yes | API token secret |
| `PROXMOX_WEB_URL` | No | Web UI URL (defaults from PROXMOX_API_URL) |
| `NEXT_PUBLIC_API_POLL_INTERVAL` | No | Poll interval ms (default 30000) |
| `NODE_TLS_REJECT_UNAUTHORIZED` | No | `0` for self-signed certs |
## Git Workflow
Repository is on Gitea. User works in feature branches, merges via PR. Use clean main as base. Gitea token may need to be embedded in remote URL for auth.

View File

@@ -1,58 +1,40 @@
# Feature Backlog — proxmox-monitor # Feature Backlog — proxmox-monitor
## Completed
### Power Control
- Start/stop/restart buttons per row on VMs and LXCs.
- Power action uses `/api2/extjs` endpoint with empty body `{}`.
- Instant UI refresh via direct state sync + rAF flush for status messages.
### Console Link
- Console button per row opens Proxmox web UI console in new tab.
- URL format: `/?console=lxc|qemu&vmid={id}&node={node}&resize=scale&xtermjs=1`
- Browser session cookies handle Proxmox auth automatically in new tab.
### Manual Refresh
- Button in dashboard header that calls `mutate()` on all SWR caches.
### Custom Poll Interval
- Number input in header (milliseconds) that updates polling in real-time.
- Replaced SWR polling with a manual `setInterval` effect.
### Group by Node
- Collapse/expand VMs/LXCs by node with toggle. Each node is a collapsible card with running/stopped subtotals.
### Uptime Duration
- `formatUptime()` now formats to "3d 4h 12m" (days, hours, minutes). Falls back to "4h 12m" or "12m" when days aren't needed.
## Quick Wins (low effort, high value) ## Quick Wins (low effort, high value)
### Per-VM/LXC IP Addresses ### Console Link
- LXCs: parses `ip=` and `ip6=` from `net0`, `net1`, etc. fields in `/nodes/<node>/lxc/<id>/config`. - Add a clickable name link per row linking to Proxmox web console: `/pve/console.html?node=X&vmid=Y`
- VMs: fetches from `/nodes/<node>/qemu/<id>/agent/network-get-interfaces` and reads `ipaddresses`. - **Status: done (pending push)**
- Displays IPs below the name in the table.
- **Status: done** ### 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 ### Search / Filter
- Client-side text input filtering table rows by name/node/status - Client-side text input filtering table rows by name/node/status
- Uses `useMemo` for performant filtering, auto-expands single node and filter-matched nodes
- **Status: done (merged to main)**
## Medium Effort ## Medium Effort
### Storage Usage ### Storage Usage
- Show disk usage per Proxmox storage backend (like node cards) - Show disk usage per Proxmox storage backend (like node cards)
- Note: `disk.total` is not populated in the API (always 0) — needs `pvesh` or extra API calls
### Status Change Alert ### Status Change Alert
- Toast/banner when a VM state changes between polls - Toast/banner when a VM state changes between polls
### Resource Allocation ### Resource Allocation
- Show vCPU count, total disk size alongside usage stats - Show vCPU count, total disk size alongside usage stats
- Note: disk.total is not populated by Proxmox API — only disk.used is available
### Per-VM IP Addresses ### Per-VM IP Addresses
- Done — uses `/nodes/<node>/qemu/<id>/agent/network-get-interfaces` for VMs and config `net0`/`net1` parsing for LXCs. - Fetch guest network IP addresses (extra API call needed)
### Per-VM CPU/Memory Trend ### Per-VM CPU/Memory Trend
- Periodic polling to build a small trend chart - Periodic polling to build a small trend chart

View File

@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getNodes, getLXC, getConfigIPs } 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_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) {
@@ -12,20 +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);
for (const lxc of lxcs) { allLXC.push(
let ips = []; ...lxcs.map((lxc) => ({
if (lxc.status === 'running') {
try {
ips = await getConfigIPs(node.name, lxc.vmid, 'lxc');
} catch { /* guest agent not available */ }
}
allLXC.push({
...lxc, ...lxc,
node: node.name, node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&vmid=${lxc.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&xtermjs=1`, consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
ips, })),
}); );
}
} catch { /* skip node */ } } catch { /* skip node */ }
} }

View File

@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getNodes, getVMs, getConfigIPs } 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_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) {
@@ -12,20 +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);
for (const vm of vms) { allVMs.push(
let ips = []; ...vms.map((vm) => ({
if (vm.status === 'running') {
try {
ips = await getConfigIPs(node.name, vm.vmid, 'qemu');
} catch { /* guest agent not available */ }
}
allVMs.push({
...vm, ...vm,
node: node.name, node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&vmid=${vm.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&novnc=1`, consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
ips, })),
}); );
}
} 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

@@ -1,109 +1,79 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState } from 'react';
import useSWR 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';
import axios from 'axios'; import axios from 'axios';
import { SWRConfig } from 'swr';
import ConsolePanel from './ConsolePanel';
const DEFAULT_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000); const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
function pollUrls() { /**
return ['/api/nodes', '/api/vms', '/api/lxc', '/api/status']; * Fetch helper for SWR.
* @param {string} url
* @returns {Promise<{ data: *, error: string | null }>}
*/
async function fetchData(url) {
try {
const res = await axios.get(url);
return res.data;
} catch (err) {
const status = err.response?.status || 'Unknown';
const message = err.response?.data?.message || err.response?.data?.errors || err.message || String(err);
throw new Error(`API error ${status}: ${message}`);
}
} }
/** @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 [powerActionStatus, setPowerActionStatus] = useState(null);
const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL);
const [nodes, setNodes] = useState([]);
const [vms, setVms] = useState([]);
const [lxc, setLxc] = useState([]);
const [summary, setSummary] = useState(null);
const [error, setError] = useState(null);
const ref = useRef({ nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary });
useEffect(() => {
ref.current = { nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary };
}, []);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const [n, v, l, s] = await Promise.all([
axios.get('/api/nodes'),
axios.get('/api/vms'),
axios.get('/api/lxc'),
axios.get('/api/status'),
]);
if (cancelled) return;
setNodes(n.data.data);
setVms(v.data.data);
setLxc(l.data.data);
setSummary(s.data.data);
setError(null);
} catch (err) {
if (!cancelled) setError(err.message);
}
};
poll();
const id = setInterval(poll, intervalMs);
return () => { cancelled = true; clearInterval(id); };
}, [intervalMs]);
/**
* @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' });
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 });
await pollUrls().map((url) => axios.get(url)); setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)} successful`, type: 'success' });
setPowerActionStatus({ text: `${label} successful`, type: 'success' });
// Sync state from poll responses
setNodes(nodes.map((n) => n));
setVms(vms.map((v) => v));
setLxc(lxc.map((l) => l));
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 [isRefreshing, setIsRefreshing] = useState(false); const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, {
const handleRefresh = async () => { refreshInterval: POLL_INTERVAL,
setIsRefreshing(true); });
try { const { data: vmsData, error: vmsError } = useSWR('/api/vms', fetchData, {
await Promise.all(pollUrls().map((url) => axios.get(url))); refreshInterval: POLL_INTERVAL,
} finally { });
setIsRefreshing(false); 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 handleIntervalChange = (e) => { const nodes = nodesData?.data ?? [];
const v = Number(e.target.value); const vms = vmsData?.data ?? [];
if (v > 0) setIntervalMs(v); const lxc = lxcData?.data ?? [];
}; const summary = summaryData?.data ?? null;
const [intervalInput, setIntervalInput] = useState(DEFAULT_INTERVAL);
useEffect(() => { setIntervalInput(intervalMs); }, [intervalMs]);
const handleIntervalBlur = () => {
handleIntervalChange({ target: { value: intervalInput } });
};
const handleOpenConsole = (vm) => {
window.open(vm.consoleUrl, '_blank');
};
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 (
@@ -117,30 +87,10 @@ export default function Dashboard() {
{new Date().toLocaleTimeString()} {new Date().toLocaleTimeString()}
</p> </p>
</div> </div>
<div className="flex gap-2 self-start sm:self-auto">
<input
type="number"
min="1000"
step="1000"
value={intervalInput}
onChange={(e) => setIntervalInput(Number(e.target.value))}
onBlur={handleIntervalBlur}
onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); } }}
className="w-24 rounded-md border border-border-card bg-bg-card px-2 py-1.5 text-sm text-text-primary focus:border-accent focus:outline-none"
placeholder="ms"
/>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="self-start rounded-md border border-border-card bg-bg-card px-3 py-1.5 text-sm font-medium text-text-secondary hover:border-accent hover:text-accent disabled:opacity-50"
>
{isRefreshing ? 'Refreshing…' : '↻ Refresh'}
</button>
</div>
</div> </div>
{/* Summary Cards */} {/* Summary Cards */}
{summary && !error && ( {summary && !summaryError && (
<div className="mb-6 grid gap-4 sm:grid-cols-4"> <div className="mb-6 grid gap-4 sm:grid-cols-4">
<div className="rounded-xl border border-border-card bg-bg-card p-4"> <div className="rounded-xl border border-border-card bg-bg-card p-4">
<div className="text-xs uppercase tracking-wider text-text-secondary">Nodes</div> <div className="text-xs uppercase tracking-wider text-text-secondary">Nodes</div>
@@ -165,9 +115,9 @@ export default function Dashboard() {
</div> </div>
)} )}
{error && ( {(summaryError || nodesError) && (
<div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red"> <div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red">
{error} {summaryError || nodesError}
</div> </div>
)} )}
@@ -200,13 +150,16 @@ 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} onPowerAction={handlePowerAction} onOpenConsole={handleOpenConsole} />} {activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} onOpenConsole={(vm) => { setActiveConsole(vm); setActiveTab('console'); }} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={handleOpenConsole} />} {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 */}
<div className="mt-8 text-center text-xs text-text-secondary"> <div className="mt-8 text-center text-xs text-text-secondary">
Polling every {intervalMs / 1000}s Proxmox VE Monitor Polling every {POLL_INTERVAL / 1000}s Proxmox VE Monitor
</div> </div>
</div> </div>
); );

View File

@@ -1,111 +1,65 @@
'use client';
import { useState, useMemo } from 'react';
import StatusBadge from './StatusBadge'; 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}
*/
/** /**
* @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 * @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 }) { export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
const [expandedNodes, setExpandedNodes] = useState({});
const [filter, setFilter] = useState('');
if (!lxc?.length) { if (!lxc?.length) {
return <p className="text-text-secondary text-sm">No LXCs found.</p>; return <p className="text-text-secondary text-sm">No LXC containers found.</p>;
} }
const filtered = useMemo(() => { const running = lxc.filter((item) => item.status === 'running');
if (!filter) return lxc; const stopped = lxc.filter((item) => item.status !== 'running');
const q = filter.toLowerCase();
return lxc.filter((item) =>
[item.name, item.node, item.status].some((v) => v.toLowerCase().includes(q)),
);
}, [lxc, filter]);
const groups = useMemo(() => Object.groupBy(filtered, (item) => item.node), [filtered]);
const sortedNodes = Object.keys(groups).sort();
const autoExpand = sortedNodes.length === 1;
const total = filtered.length;
const running = filtered.filter((item) => item.status === 'running').length;
function toggleNode(node) {
setExpandedNodes((prev) => ({ ...prev, [node]: !prev[node] }));
}
return ( return (
<div className="space-y-4"> <div className="space-y-6">
{filter && (
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>{running} running / {total - running} stopped</span>
<span>·</span>
<span>{total} of {lxc.length}</span>
</div>
)}
<input
type="text"
placeholder="Filter by name, node, or status…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full rounded-md border border-border-card bg-bg-card px-3 py-2 text-sm text-text-primary placeholder-text-secondary focus:border-accent focus:outline-none"
/>
{sortedNodes.map((node) => {
const running = groups[node].filter((item) => item.status === 'running');
const stopped = groups[node].filter((item) => item.status !== 'running');
const expanded = autoExpand || (expandedNodes[node] ?? false);
return (
<div key={node} className="rounded-xl border border-border-card bg-bg-card">
<button
onClick={() => toggleNode(node)}
className="flex w-full items-center justify-between rounded-t-xl px-4 py-2.5 text-left"
>
<span className="text-sm font-medium text-text-primary">
{node} ({running.length} running, {stopped.length} stopped)
</span>
<span className="text-xl text-text-secondary">{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<>
{running.length > 0 && ( {running.length > 0 && (
<div className="px-4 pb-3"> <div>
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h5> <h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border-card"> <div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border-card"> <tr className="border-b border-border-card">
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
<th className="px-3 py-1.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">Node</th>
<th className="px-3 py-1.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">CPU</th>
<th className="px-3 py-1.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">Memory</th>
<th className="px-3 py-1.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-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{running.map((item) => ( {running.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0"> <tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-3 py-1.5 text-text-secondary">{item.vmid}</td> <td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
<td className="px-3 py-1.5"> <td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
<span className="font-medium text-text-primary">{item.name}</span> <td className="px-4 py-2 text-text-secondary">{item.node}</td>
{item.ips?.length > 0 && ( <td className="px-4 py-2 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td>
<div className="mt-0.5 text-xs text-text-secondary"> <td className="px-4 py-2 text-text-secondary">{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}</td>
{item.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)} <td className="px-4 py-2"><StatusBadge status={item.status} /></td>
</div> <td className="px-4 py-2">
)} <button
</td> onClick={() => onOpenConsole?.(item)}
<td className="px-3 py-1.5 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td> 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"
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}</td> >
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td> Console
<td className="px-3 py-1.5"><StatusBadge status={item.status} /></td> </button>
<td className="px-3 py-1.5"> <button
<button onClick={() => onOpenConsole?.(item)} className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent">Console</button> onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'restart')}
<button onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'restart')} className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent">Restart</button> 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"
<button onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'stop')} className="rounded border border-status-red/30 bg-status-red/10 px-1.5 py-0.5 text-xs text-status-red hover:bg-status-red/20">Stop</button> >
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> </td>
</tr> </tr>
))} ))}
@@ -116,36 +70,41 @@ export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
)} )}
{stopped.length > 0 && ( {stopped.length > 0 && (
<div className="px-4 pb-3"> <div>
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h5> <h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border-card"> <div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border-card"> <tr className="border-b border-border-card">
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
<th className="px-3 py-1.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">Node</th>
<th className="px-3 py-1.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">Disk</th>
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</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>
{stopped.map((item) => ( {stopped.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0"> <tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-3 py-1.5 text-text-secondary">{item.vmid}</td> <td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
<td className="px-3 py-1.5"> <td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
<span className="font-medium text-text-primary">{item.name}</span> <td className="px-4 py-2 text-text-secondary">{item.node}</td>
{item.ips?.length > 0 && ( <td className="px-4 py-2 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
<div className="mt-0.5 text-xs text-text-secondary"> <td className="px-4 py-2"><StatusBadge status={item.status} /></td>
{item.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)} <td className="px-4 py-2">
</div> <button
)} onClick={() => onOpenConsole?.(item)}
</td> 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"
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td> >
<td className="px-3 py-1.5"><StatusBadge status={item.status} /></td> Console
<td className="px-3 py-1.5"> </button>
<button onClick={() => onOpenConsole?.(item)} className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent">Console</button> <button
<button onClick={() => onPowerAction(item.node, item.vmid, 'lxc', 'start')} className="rounded border border-status-green/30 bg-status-green/10 px-1.5 py-0.5 text-xs text-status-green hover:bg-status-green/20">Start</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> </td>
</tr> </tr>
))} ))}
@@ -154,15 +113,6 @@ export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
</div> </div>
</div> </div>
)} )}
</>
)}
</div>
);
})}
{!total && (
<p className="text-center text-text-secondary">No matches for "{filter}"</p>
)}
</div> </div>
); );
} }

View File

@@ -26,10 +26,7 @@ function formatUptime(seconds) {
if (!seconds) return 'N/A'; if (!seconds) return 'N/A';
const days = Math.floor(seconds / 86400); const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600); const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60); return `${days}d ${hours}h`;
if (days) return `${days}d ${hours}h ${minutes}m`;
if (hours) return `${hours}h ${minutes}m`;
return `${minutes}m`;
} }
/** /**

View File

@@ -1,111 +1,66 @@
'use client';
import { useState, useMemo } from 'react';
import StatusBadge from './StatusBadge'; 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}
*/
/** /**
* @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 * @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 }) { export default function VMList({ vms, onPowerAction, onOpenConsole }) {
const [expandedNodes, setExpandedNodes] = useState({});
const [filter, setFilter] = useState('');
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>;
} }
const filtered = useMemo(() => { const running = vms.filter((vm) => vm.status === 'running');
if (!filter) return vms; const stopped = vms.filter((vm) => vm.status !== 'running');
const q = filter.toLowerCase();
return vms.filter((vm) =>
[vm.name, vm.node, vm.status].some((v) => v.toLowerCase().includes(q)),
);
}, [vms, filter]);
const groups = useMemo(() => Object.groupBy(filtered, (vm) => vm.node), [filtered]);
const sortedNodes = Object.keys(groups).sort();
const autoExpand = sortedNodes.length === 1;
const total = filtered.length;
const running = filtered.filter((vm) => vm.status === 'running').length;
function toggleNode(node) {
setExpandedNodes((prev) => ({ ...prev, [node]: !prev[node] }));
}
return ( return (
<div className="space-y-4"> <div className="space-y-6">
{filter && (
<div className="flex items-center gap-2 text-sm text-text-secondary">
<span>{running} running / {total - running} stopped</span>
<span>·</span>
<span>{total} of {vms.length}</span>
</div>
)}
<input
type="text"
placeholder="Filter by name, node, or status…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full rounded-md border border-border-card bg-bg-card px-3 py-2 text-sm text-text-primary placeholder-text-secondary focus:border-accent focus:outline-none"
/>
{sortedNodes.map((node) => {
const running = groups[node].filter((vm) => vm.status === 'running');
const stopped = groups[node].filter((vm) => vm.status !== 'running');
const expanded = autoExpand || (expandedNodes[node] ?? false);
return (
<div key={node} className="rounded-xl border border-border-card bg-bg-card">
<button
onClick={() => toggleNode(node)}
className="flex w-full items-center justify-between rounded-t-xl px-4 py-2.5 text-left"
>
<span className="text-sm font-medium text-text-primary">
{node} ({running.length} running, {stopped.length} stopped)
</span>
<span className="text-xl text-text-secondary">{expanded ? '▾' : '▸'}</span>
</button>
{expanded && (
<>
{running.length > 0 && ( {running.length > 0 && (
<div className="px-4 pb-3"> <div>
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h5> <h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border-card"> <div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border-card"> <tr className="border-b border-border-card">
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
<th className="px-3 py-1.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">Node</th>
<th className="px-3 py-1.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">CPU</th>
<th className="px-3 py-1.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">Memory</th>
<th className="px-3 py-1.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-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</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>
{running.map((vm) => ( {running.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0"> <tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-3 py-1.5 text-text-secondary">{vm.vmid}</td> <td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
<td className="px-3 py-1.5"> <td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<span className="font-medium text-text-primary">{vm.name}</span> <td className="px-4 py-2 text-text-secondary">{vm.node}</td>
{vm.ips?.length > 0 && ( <td className="px-4 py-2 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</td>
<div className="mt-0.5 text-xs text-text-secondary"> <td className="px-4 py-2 text-text-secondary">{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)}</td>
{vm.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)} <td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
</div> <td className="px-4 py-2">
)} <button
</td> onClick={() => onOpenConsole?.(vm)}
<td className="px-3 py-1.5 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</td> 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"
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)}</td> >
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td> Console
<td className="px-3 py-1.5"><StatusBadge status={vm.status} /></td> </button>
<td className="px-3 py-1.5"> <button
<button onClick={() => onOpenConsole?.(vm)} className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent">Console</button> onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'restart')}
<button onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'restart')} className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent">Restart</button> 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"
<button onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'stop')} className="rounded border border-status-red/30 bg-status-red/10 px-1.5 py-0.5 text-xs text-status-red hover:bg-status-red/20">Stop</button> >
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> </td>
</tr> </tr>
))} ))}
@@ -116,36 +71,41 @@ export default function VMList({ vms, onPowerAction, onOpenConsole }) {
)} )}
{stopped.length > 0 && ( {stopped.length > 0 && (
<div className="px-4 pb-3"> <div>
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h5> <h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
<div className="overflow-x-auto rounded-lg border border-border-card"> <div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border-card"> <tr className="border-b border-border-card">
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th> <th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
<th className="px-3 py-1.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">Node</th>
<th className="px-3 py-1.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">Disk</th>
<th className="px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</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>
{stopped.map((vm) => ( {stopped.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0"> <tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-3 py-1.5 text-text-secondary">{vm.vmid}</td> <td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
<td className="px-3 py-1.5"> <td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<span className="font-medium text-text-primary">{vm.name}</span> <td className="px-4 py-2 text-text-secondary">{vm.node}</td>
{vm.ips?.length > 0 && ( <td className="px-4 py-2 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
<div className="mt-0.5 text-xs text-text-secondary"> <td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
{vm.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)} <td className="px-4 py-2">
</div> <button
)} onClick={() => onOpenConsole?.(vm)}
</td> 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"
<td className="px-3 py-1.5 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td> >
<td className="px-3 py-1.5"><StatusBadge status={vm.status} /></td> Console
<td className="px-3 py-1.5"> </button>
<button onClick={() => onOpenConsole?.(vm)} className="mr-1 rounded border border-border-card bg-bg-card px-1.5 py-0.5 text-xs text-text-primary hover:border-accent hover:text-accent">Console</button> <button
<button onClick={() => onPowerAction(vm.node, vm.vmid, 'qemu', 'start')} className="rounded border border-status-green/30 bg-status-green/10 px-1.5 py-0.5 text-xs text-status-green hover:bg-status-green/20">Start</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> </td>
</tr> </tr>
))} ))}
@@ -154,15 +114,6 @@ export default function VMList({ vms, onPowerAction, onOpenConsole }) {
</div> </div>
</div> </div>
)} )}
</>
)}
</div>
);
})}
{!total && (
<p className="text-center text-text-secondary">No matches for "{filter}"</p>
)}
</div> </div>
); );
} }

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 = [];
@@ -184,119 +209,3 @@ export async function getSummary() {
totalMemory: { total: totalMemTotal, used: totalMemUsed }, totalMemory: { total: totalMemTotal, used: totalMemUsed },
}; };
} }
/**
* Extract IPs from a Proxmox LXC interfaces endpoint response.
* Response shape: { data: [{ "ip-addresses": [{ "ip-address": "..." }] }] }
* Also handles keyed objects (net0, net1) and /agent/result nesting.
* @param {Object} data — raw response from /nodes/<node>/lxc/<id>/interfaces
* @returns {string[]}
*/
function parseLXCInterfaceIPs(data) {
const rawIface = data?.data;
const ifaces = Array.isArray(rawIface)
? rawIface
: (rawIface?.interfaces
? rawIface.interfaces
: (rawIface?.result ? rawIface.result : rawIface && typeof rawIface === 'object' ? Object.values(rawIface) : []));
const ips = [];
for (const iface of ifaces) {
const addrs = iface?.ip4_addresses || iface?.ip_addresses || iface?.['ip-addresses'] || [];
for (const addr of addrs) {
if (typeof addr === 'string') {
// raw string — assume IPv4 if it looks like one
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(addr)) ips.push(addr.split('/')[0]);
continue;
}
// typed entry — only include IPv4
const type = addr?.['ip-address-type'] || addr?.['ip-address-type'];
if (type && type !== 'inet' && type !== 'ipv4') continue;
const ip = addr?.['ip-address'] || addr?.ip || addr?.address;
if (typeof ip === 'string' && ip !== '127.0.0.1') {
ips.push(ip.split('/')[0]);
}
}
}
return [...new Set(ips)];
}
/**
* Extract IPs from a Proxmox LXC config object.
* Parses ip= and ip6= from net0, net1, etc. plus nameserver field.
* @param {Object} config
* @returns {string[]}
*/
function parseLXCIPsFromConfig(config) {
const ips = [];
for (let i = 0; i < 8; i++) {
const field = config[`net${i}`];
if (!field) continue;
const kv = parseKV(field);
if (kv.ip) ips.push(kv.ip.split('/')[0]);
}
return [...new Set(ips)];
}
/**
* Extract IPs from a Proxmox QEMU guest agent network-get-interfaces response.
* Response shape: { data: { result: [{ ip-addresses: [{ "ip-address": "..." }] }] } }
* @param {Object} data — raw response from /agent/network-get-interfaces
* @returns {string[]}
*/
function parseQEMUIPs(data) {
const ifaces = data?.data?.result || data?.data || [];
const ips = [];
for (const iface of ifaces) {
for (const addr of iface?.ip_addresses || iface?.['ip-addresses'] || []) {
if (typeof addr === 'string') {
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(addr)) ips.push(addr.split('/')[0]);
continue;
}
const type = addr?.['ip-address-type'];
if (type && type !== 'ipv4') continue;
const ip = addr?.['ip-address'] || addr?.ip || addr?.address;
if (typeof ip === 'string' && ip !== '127.0.0.1') {
ips.push(ip.split('/')[0]);
}
}
}
return [...new Set(ips)];
}
/**
* Fetch IPs from config (LXC) or guest agent (QEMU).
* @param {string} nodeName
* @param {number} vmid
* @param {'qemu'|'lxc'} type
* @returns {Promise<string[]>}
*/
export async function getConfigIPs(nodeName, vmid, type) {
if (type === 'lxc') {
// Try /interfaces endpoint first (more reliable, includes DHCP IPs)
try {
const data = await proxmoxFetch(`/nodes/${nodeName}/lxc/${vmid}/interfaces`);
const ips = parseLXCInterfaceIPs(data);
if (ips.length) return ips;
} catch { /* fallback to config */ }
// Fallback: parse net0/net1 ip= from config
const config = await proxmoxFetch(`/nodes/${nodeName}/lxc/${vmid}/config`);
return parseLXCIPsFromConfig(config.data || config);
}
// QEMU: use guest agent API
const data = await proxmoxFetch(`/nodes/${nodeName}/qemu/${vmid}/agent/network-get-interfaces`);
return parseQEMUIPs(data);
}
/** @param {string} s @returns {Object} */
function parseKV(s) {
const result = {};
s.split(',').forEach((part) => {
const eq = part.indexOf('=');
if (eq === -1) return;
const k = part.slice(0, eq);
const v = part.slice(eq + 1);
// strip quotes
result[k] = v.startsWith('"') && v.endsWith('"') ? v.slice(1, -1) : v;
});
return result;
}