33 Commits

Author SHA1 Message Date
8f317641ec fix IP addresses: parse from config/agent endpoints instead of guest agent
- LXCs: use /interfaces endpoint first, fallback to net0-net7 config parsing
- QEMU: use /agent/network-get-interfaces with correct result nesting
- IPv4 only filtering (ip-address-type check)
- Drop ip6= and nameserver from config fallback

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 11:36:05 +01:00
15d0ea37f0 feat: display per-VM/LXC guest IPs in table rows
- Fetch IPs from /status/guest API for running VMs/LXCs (requires guest agent)
- IPs displayed below the name column in the table
- Gracefully handles missing guest agent (quietly skips)
- Backlog: mark Per-VM IP Addresses as done

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 16:35:29 +01:00
1a0d46a848 fix: parens around ?? in mixed || expression to fix operator precedence
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 13:57:18 +01:00
37efe83e88 docs: mark Search/Filter as done in backlog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 13:53:00 +01:00
a4bf56bdd0 feat: auto-expand single node and filter-matched nodes
- When only one node exists, expand its list by default
- When filtering, auto-expand all nodes with matching results
- Use useMemo for filter/grouping performance

Backlog: mark Search/Filter as done

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 13:50:18 +01:00
04ebe2d8ce feat: add search filter to VM and LXC lists
- Case-insensitive filter by name, node, or status
- Shows running/stopped counts and total matches when filtering
- "No matches" message when filter returns empty
- Backlog: mark Search/Filter as done

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 13:19:24 +01:00
8ebf100667 docs: reorganize backlog with all done items under Completed section
- Move all completed items (Power Control, Console Link, Manual Refresh, Custom Poll Interval, Group by Node, Uptime Duration) under Completed
- Remove "Status: done" lines from individual items
- Keep only Search/Filter under Quick Wins

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 12:35:12 +01:00
528c8f54e6 feat: enhance formatUptime to include minutes
- Now formats as "Xd Yh Zm", "Yh Zm", or "Zm" depending on value size
- Backlog: mark Uptime Duration as done

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 12:26:33 +01:00
7495e27288 docs: mark Group by Node as done in backlog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:55:24 +01:00
efb27113e2 feat: group VMs and LXCs by node with collapse/expand toggle
- Each node shows a collapsible card with running/stopped counts
- Items grouped by node (using Object.groupBy), sorted alphabetically
- Both VMList and LXCList use the same pattern

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:53:40 +01:00
6b18c507dc docs: mark Custom Poll Interval as done in backlog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:23:05 +01:00
8cb83d3dd7 feat: custom poll interval input in dashboard header
- Replace SWR polling with a manual setInterval that responds to interval changes
- Add number input in header (milliseconds) that updates polling in real-time
- Move error state to a single error variable shared by the poll effect
- Remove SWR dependency from Dashboard (still used by API routes for internal fetching)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 11:16:03 +01:00
da258e9d4c docs: mark Manual Refresh as done in backlog
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 10:20:27 +01:00
0f2f9f0333 feat: add manual refresh button to dashboard header
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-12 10:18:58 +01:00
fc8648f907 docs: update backlog.md with full current state
- Move power control and console link to Completed section
- Add notes about disk.total not being populated (Storage/Resource items)
- Clarify which items exist in code vs still pending

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:56:24 +01:00
6696db82e9 docs: update backlog and CLAUDE.md for console and power control
- backlog: mark Console Link as done (merged to main)
- CLAUDE.md: remove ConsolePanel and /api/vnc from architecture
- CLAUDE.md: note console URL format and new-tab behavior
- CLAUDE.md: remove PROXMOX_CONSOLE_TOKEN env var

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:18:10 +01:00
9cfcfd9971 feat: open console in new tab instead of iframe panel
- Remove ConsolePanel and /api/vnc route (iframe session auth won't work cross-origin)
- VMList/LXCList console buttons now open Proxmox web UI console in new tab
- Use web UI console URL format so browser session cookies handle auth automatically
- Add resize=scale and xtermjs=1 for better terminal UX

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 13:05:41 +01:00
d69e72c3eb docs: add CLAUDE.md with project guidance for Claude Code
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 09:46:35 +01:00
28235f4ac9 feat: merge power control fixes into main
- Fix 502 errors on power operations (switch to /api2/extjs endpoint)
- Instant UI refresh after power actions via direct state sync
- Status feedback messages (Stopping..., successful) with rAF flush
- Add fallbackData to SWR hooks to prevent empty lists

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 09:44:59 +01:00
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
27 changed files with 3554 additions and 2 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Proxmox VE API Configuration
# 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_CONSOLE_TOKEN=root@pam!monitorapp
PROXMOX_TOKEN_ID=root@pam!monitorapp
PROXMOX_TOKEN_SECRET=<your-token-secret>
API_POLL_INTERVAL=30000

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.next/
.env.local
dist/

63
CLAUDE.md Normal file
View File

@@ -0,0 +1,63 @@
# 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.

104
README.md
View File

@@ -1,3 +1,103 @@
# proxmox-monitor # Proxmox Monitor
monitor proxmox A real-time dashboard for monitoring Proxmox VE nodes, VMs, and LXC containers.
## Features
- **Node overview** — CPU, memory, uptime, and load for each Proxmox node
- **VM & LXC tracking** — status, resource usage across all nodes
- **Summary cards** — total nodes, running/stopped VMs, aggregate memory
- **Auto-refresh** — polling every 30s (configurable)
- **Tabbed navigation** — switch between Nodes, VMs, and LXCs
## Tech Stack
- **Framework** — Next.js 15 (App Router)
- **UI** — React 19, Tailwind CSS 4
- **Data fetching** — SWR (auto-refresh), Axios
- **Proxmox** — API2 Token-based auth
## Getting Started
### 1. Configure environment variables
Copy the example file and fill in your Proxmox credentials:
```bash
cp .env.example .env.local
```
`.env.local` contents:
```
PROXMOX_API_URL=https://your-proxmox-host:8006/api2/json
PROXMOX_TOKEN_ID=root@pam!token-name
PROXMOX_TOKEN_SECRET=your-secret-uuid
API_POLL_INTERVAL=30000
NODE_TLS_REJECT_UNAUTHORIZED=0
```
> **Creating an API token** in Proxmox: Datacenter > Permissions > API Tokens
### 2. Install dependencies
```bash
npm install
```
### 3. Run the development server
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
### 4. Build for production
```bash
npm run build
npm start
```
## Project Structure
```
src/
├── app/
│ ├── api/
│ │ ├── nodes/route.js # Proxmox node stats
│ │ ├── vms/route.js # QEMU VMs across all nodes
│ │ ├── lxc/route.js # LXC containers across all nodes
│ │ └── status/route.js # Aggregated summary
│ ├── layout.js
│ └── page.js
├── components/
│ ├── Dashboard.js # Main dashboard with tabs & summary
│ ├── NodeList.js # Node cards
│ ├── NodeCard.js # Single node display
│ ├── VMList.js # VM list view
│ ├── LXCList.js # LXC list view
│ └── StatusBadge.js # Online/good/warn/error/dead
└── lib/
└── proxmox.js # Proxmox API client & data mappers
```
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| `PROXMOX_API_URL` | Yes | Base URL of the Proxmox API |
| `PROXMOX_TOKEN_ID` | Yes | API token ID (e.g. `root@pam!monitorapp`) |
| `PROXMOX_TOKEN_SECRET` | Yes | API token secret |
| `API_POLL_INTERVAL` | No | Refresh interval in ms (default: `30000`) |
| `NODE_TLS_REJECT_UNAUTHORIZED` | No | Set to `0` for self-signed certs |
## API Routes
| Route | Method | Description |
|---|---|---|
| `/api/nodes` | GET | All Proxmox nodes |
| `/api/vms` | GET | All QEMU VMs |
| `/api/lxc` | GET | All LXC containers |
| `/api/status` | GET | Aggregated summary stats |

75
backlog.md Normal file
View File

@@ -0,0 +1,75 @@
# 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)
### Per-VM/LXC IP Addresses
- LXCs: parses `ip=` and `ip6=` from `net0`, `net1`, etc. fields in `/nodes/<node>/lxc/<id>/config`.
- VMs: fetches from `/nodes/<node>/qemu/<id>/agent/network-get-interfaces` and reads `ipaddresses`.
- Displays IPs below the name in the table.
- **Status: done**
### Search / Filter
- 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
### Storage Usage
- 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
- Toast/banner when a VM state changes between polls
### Resource Allocation
- 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
- Done — uses `/nodes/<node>/qemu/<id>/agent/network-get-interfaces` for VMs and config `net0`/`net1` parsing for LXCs.
### 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

7
jsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}

4
next.config.mjs Normal file
View File

@@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

1989
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "proxmox-monitor",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"axios": "^1.16.0",
"next": "^15.3.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"swr": "^2.3.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.0",
"postcss": "^8.5.0",
"tailwindcss": "^4.1.0"
}
}

8
postcss.config.mjs Normal file
View File

@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;

36
src/app/api/lxc/route.js Normal file
View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { getNodes, getLXC, getConfigIPs } from '@/lib/proxmox';
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) {
try {
const nodes = await getNodes();
const allLXC = [];
for (const node of nodes) {
try {
const lxcs = await getLXC(node.name);
for (const lxc of lxcs) {
let ips = [];
if (lxc.status === 'running') {
try {
ips = await getConfigIPs(node.name, lxc.vmid, 'lxc');
} catch { /* guest agent not available */ }
}
allLXC.push({
...lxc,
node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&vmid=${lxc.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&xtermjs=1`,
ips,
});
}
} catch { /* skip node */ }
}
return NextResponse.json({ data: allLXC, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getNodes } from '@/lib/proxmox';
/** @param {import('next').Request} _req */
export async function GET(_req) {
try {
const data = await getNodes();
return NextResponse.json({ data, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
}
}

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

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getSummary } from '@/lib/proxmox';
/** @param {import('next').Request} _req */
export async function GET(_req) {
try {
const data = await getSummary();
return NextResponse.json({ data, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
}
}

36
src/app/api/vms/route.js Normal file
View File

@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { getNodes, getVMs, getConfigIPs } from '@/lib/proxmox';
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) {
try {
const nodes = await getNodes();
const allVMs = [];
for (const node of nodes) {
try {
const vms = await getVMs(node.name);
for (const vm of vms) {
let ips = [];
if (vm.status === 'running') {
try {
ips = await getConfigIPs(node.name, vm.vmid, 'qemu');
} catch { /* guest agent not available */ }
}
allVMs.push({
...vm,
node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&vmid=${vm.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&novnc=1`,
ips,
});
}
} catch { /* skip node */ }
}
return NextResponse.json({ data: allVMs, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
}
}

22
src/app/globals.css Normal file
View File

@@ -0,0 +1,22 @@
@import "tailwindcss";
@theme {
--color-bg-primary: #0b0f19;
--color-bg-card: #111827;
--color-bg-card-hover: #1a2332;
--color-border-card: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-accent: #3b82f6;
--color-status-green: #22c55e;
--color-status-red: #ef4444;
--color-status-yellow: #eab308;
--color-status-gray: #64748b;
}
@layer base {
html {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
}

19
src/app/layout.js Normal file
View File

@@ -0,0 +1,19 @@
import './globals.css';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
/** @type {import('next').Metadata} */
export const metadata = {
title: 'Proxmox Monitor',
description: 'Real-time dashboard for Proxmox VE',
};
/** @param {{ children: import('react').ReactNode }} props */
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

6
src/app/page.js Normal file
View File

@@ -0,0 +1,6 @@
import Dashboard from '@/components/Dashboard';
/** @returns {import('react').ReactNode} */
export default function HomePage() {
return <Dashboard />;
}

213
src/components/Dashboard.js Normal file
View File

@@ -0,0 +1,213 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import NodeList from './NodeList';
import VMList from './VMList';
import LXCList from './LXCList';
import axios from 'axios';
const DEFAULT_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
function pollUrls() {
return ['/api/nodes', '/api/vms', '/api/lxc', '/api/status'];
}
/** @returns {import('react').ReactNode} */
export default function Dashboard() {
const [activeTab, setActiveTab] = useState('nodes');
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]);
const handlePowerAction = async (node, vmid, type, action) => {
const label = action.charAt(0).toUpperCase() + action.slice(1);
const kind = type === 'qemu' ? 'VM' : 'LXC';
setPowerActionStatus({ text: `${label}ing ${kind} ${vmid}...`, type: 'pending' });
await new Promise((r) => requestAnimationFrame(r));
try {
await axios.post('/api/power', { node, vmid, type, action });
await pollUrls().map((url) => axios.get(url));
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) {
setPowerActionStatus({ text: err.message || `${label} failed`, type: 'error' });
}
};
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
try {
await Promise.all(pollUrls().map((url) => axios.get(url)));
} finally {
setIsRefreshing(false);
}
};
const handleIntervalChange = (e) => {
const v = Number(e.target.value);
if (v > 0) setIntervalMs(v);
};
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 = [
{ id: 'nodes', label: 'Nodes' },
{ id: 'vms', label: 'VMs' },
{ id: 'lxc', label: 'LXCs' },
];
return (
<div className="min-h-screen p-4 sm:p-6 lg:p-8">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Proxmox Monitor</h1>
<p className="mt-1 text-sm text-text-secondary">
{nodes.length} node{nodes.length !== 1 ? 's' : ''} {summary?.runningVMs ?? 0} running Updated{' '}
{new Date().toLocaleTimeString()}
</p>
</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>
{/* Summary Cards */}
{summary && !error && (
<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="text-xs uppercase tracking-wider text-text-secondary">Nodes</div>
<div className="mt-1 text-2xl font-bold text-text-primary">{summary.totalNodes}</div>
</div>
<div className="rounded-xl border border-border-card bg-bg-card p-4">
<div className="text-xs uppercase tracking-wider text-text-secondary">Running VMs</div>
<div className="mt-1 text-2xl font-bold text-status-green">{summary.runningVMs}</div>
</div>
<div className="rounded-xl border border-border-card bg-bg-card p-4">
<div className="text-xs uppercase tracking-wider text-text-secondary">Stopped VMs</div>
<div className="mt-1 text-2xl font-bold text-status-red">{summary.stoppedVMs}</div>
</div>
<div className="rounded-xl border border-border-card bg-bg-card p-4">
<div className="text-xs uppercase tracking-wider text-text-secondary">Total Memory</div>
<div className="mt-1 text-xl font-bold text-text-primary">
{summary.totalMemory?.total
? `${(summary.totalMemory.used / (1024 ** 3)).toFixed(1)} / ${(summary.totalMemory.total / (1024 ** 3)).toFixed(1)} GB`
: 'N/A'}
</div>
</div>
</div>
)}
{error && (
<div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red">
{error}
</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 */}
<div className="mb-4 flex gap-2 border-b border-border-card">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${activeTab === tab.id
? 'border-b-2 border-accent text-text-primary'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
<div>
{activeTab === 'nodes' && <NodeList nodes={nodes} />}
{activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} onOpenConsole={handleOpenConsole} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={handleOpenConsole} />}
</div>
{/* Footer */}
<div className="mt-8 text-center text-xs text-text-secondary">
Polling every {intervalMs / 1000}s Proxmox VE Monitor
</div>
</div>
);
}

178
src/components/LXCList.js Normal file
View File

@@ -0,0 +1,178 @@
'use client';
import { useState, useMemo } from 'react';
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; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
*/
export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
const [expandedNodes, setExpandedNodes] = useState({});
const [filter, setFilter] = useState('');
if (!lxc?.length) {
return <p className="text-text-secondary text-sm">No LXCs found.</p>;
}
const filtered = useMemo(() => {
if (!filter) return lxc;
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 (
<div className="space-y-4">
{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 && (
<div className="px-4 pb-3">
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h5>
<div className="overflow-x-auto rounded-lg border border-border-card">
<table className="w-full text-sm">
<thead>
<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-3 py-1.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-3 py-1.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">Disk</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-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
</tr>
</thead>
<tbody>
{running.map((item) => (
<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-3 py-1.5">
<span className="font-medium text-text-primary">{item.name}</span>
{item.ips?.length > 0 && (
<div className="mt-0.5 text-xs text-text-secondary">
{item.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)}
</div>
)}
</td>
<td className="px-3 py-1.5 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td>
<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>
<td className="px-3 py-1.5"><StatusBadge status={item.status} /></td>
<td className="px-3 py-1.5">
<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 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>
<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>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{stopped.length > 0 && (
<div className="px-4 pb-3">
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h5>
<div className="overflow-x-auto rounded-lg border border-border-card">
<table className="w-full text-sm">
<thead>
<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-3 py-1.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-3 py-1.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>
</thead>
<tbody>
{stopped.map((item) => (
<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-3 py-1.5">
<span className="font-medium text-text-primary">{item.name}</span>
{item.ips?.length > 0 && (
<div className="mt-0.5 text-xs text-text-secondary">
{item.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)}
</div>
)}
</td>
<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>
<td className="px-3 py-1.5">
<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 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>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
})}
{!total && (
<p className="text-center text-text-secondary">No matches for "{filter}"</p>
)}
</div>
);
}
/** @param {number} bytes @returns {string} */
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(1)} ${units[i]}`;
}

View File

@@ -0,0 +1,84 @@
import StatusBadge from './StatusBadge';
/**
* Formats bytes to human-readable string.
* @param {number} bytes
* @returns {string}
*/
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(1)} ${units[i]}`;
}
/**
* Formats seconds to human-readable duration.
* @param {number} seconds
* @returns {string}
*/
function formatUptime(seconds) {
if (!seconds) return 'N/A';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days) return `${days}d ${hours}h ${minutes}m`;
if (hours) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
/**
* @param {{ node: { name: string; type: string; cpu: number; memory: { total: number; used: number }; status: string; uptime: number; load: number } }} props
* @returns {import('react').ReactNode}
*/
export default function NodeCard({ node }) {
const cpuPercent = Math.round((node.cpu || 0) * 100);
const memPercent = node.memory?.total ? Math.round((node.memory.used / node.memory.total) * 100) : 0;
return (
<div className="rounded-xl border border-border-card bg-bg-card p-5 transition-colors hover:bg-bg-card-hover">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold text-text-primary">{node.name}</h3>
<StatusBadge status={node.status} />
</div>
<div className="space-y-3 text-sm text-text-secondary">
<div className="flex justify-between">
<span>CPU</span>
<span className="text-text-primary">{cpuPercent}%</span>
</div>
<div className="h-2 w-full rounded-full bg-bg-primary">
<div
className="h-full rounded-full bg-accent transition-all"
style={{ width: `${Math.min(cpuPercent, 100)}%` }}
/>
</div>
<div className="flex justify-between">
<span>Memory</span>
<span className="text-text-primary">{formatBytes(node.memory?.used || 0)} / {formatBytes(node.memory?.total || 0)}</span>
</div>
<div className="h-2 w-full rounded-full bg-bg-primary">
<div
className={`h-full rounded-full transition-all ${
memPercent > 90 ? 'bg-status-red' :
memPercent > 70 ? 'bg-status-yellow' :
'bg-accent'
}`}
style={{ width: `${Math.min(memPercent, 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs">
<span>Load: {node.load?.toFixed(2) || 'N/A'}</span>
<span>Uptime: {formatUptime(node.uptime)}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import NodeCard from './NodeCard';
/**
* @param {{ nodes: Array<{ name: string; type: string; cpu: number; memory: { total: number; used: number }; status: string; uptime: number; load: number }> }} props
* @returns {import('react').ReactNode}
*/
export default function NodeList({ nodes }) {
if (!nodes?.length) {
return <p className="text-text-secondary text-sm">No nodes found.</p>;
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{nodes.map((node) => (
<NodeCard key={node.name} node={node} />
))}
</div>
);
}

View File

@@ -0,0 +1,25 @@
/**
* Renders a colored status badge.
* @param {{ status: string }} props
* @returns {import('react').ReactNode}
*/
export default function StatusBadge({ status }) {
const colors = {
running: 'bg-status-green/20 text-status-green border-status-green/30',
stopped: 'bg-status-red/20 text-status-red border-status-red/30',
paused: 'bg-status-yellow/20 text-status-yellow border-status-yellow/30',
};
const colorClass = colors[status] || 'bg-status-gray/20 text-status-gray border-status-gray/30';
return (
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize ${colorClass}`}>
<span className={`h-1.5 w-1.5 rounded-full ${
status === 'running' ? 'bg-status-green' :
status === 'stopped' ? 'bg-status-red' :
status === 'paused' ? 'bg-status-yellow' :
'bg-status-gray'
}`} />
{status}
</span>
);
}

178
src/components/VMList.js Normal file
View File

@@ -0,0 +1,178 @@
'use client';
import { useState, useMemo } from 'react';
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; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
*/
export default function VMList({ vms, onPowerAction, onOpenConsole }) {
const [expandedNodes, setExpandedNodes] = useState({});
const [filter, setFilter] = useState('');
if (!vms?.length) {
return <p className="text-text-secondary text-sm">No VMs found.</p>;
}
const filtered = useMemo(() => {
if (!filter) return vms;
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 (
<div className="space-y-4">
{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 && (
<div className="px-4 pb-3">
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h5>
<div className="overflow-x-auto rounded-lg border border-border-card">
<table className="w-full text-sm">
<thead>
<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-3 py-1.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-3 py-1.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">Disk</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-3 py-1.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
</tr>
</thead>
<tbody>
{running.map((vm) => (
<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-3 py-1.5">
<span className="font-medium text-text-primary">{vm.name}</span>
{vm.ips?.length > 0 && (
<div className="mt-0.5 text-xs text-text-secondary">
{vm.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)}
</div>
)}
</td>
<td className="px-3 py-1.5 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</td>
<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>
<td className="px-3 py-1.5"><StatusBadge status={vm.status} /></td>
<td className="px-3 py-1.5">
<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 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>
<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>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{stopped.length > 0 && (
<div className="px-4 pb-3">
<h5 className="mb-1 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h5>
<div className="overflow-x-auto rounded-lg border border-border-card">
<table className="w-full text-sm">
<thead>
<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-3 py-1.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-3 py-1.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>
</thead>
<tbody>
{stopped.map((vm) => (
<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-3 py-1.5">
<span className="font-medium text-text-primary">{vm.name}</span>
{vm.ips?.length > 0 && (
<div className="mt-0.5 text-xs text-text-secondary">
{vm.ips.map((ip) => <span key={ip} className="mr-1">{ip}</span>)}
</div>
)}
</td>
<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>
<td className="px-3 py-1.5">
<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 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>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
</div>
);
})}
{!total && (
<p className="text-center text-text-secondary">No matches for "{filter}"</p>
)}
</div>
);
}
/** @param {number} bytes @returns {string} */
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(1)} ${units[i]}`;
}

302
src/lib/proxmox.js Normal file
View File

@@ -0,0 +1,302 @@
import axios from 'axios';
function getEnv(key) {
const val = process.env[key];
if (!val) {
throw new Error(`Missing ${key} in environment variables.`);
}
return val;
}
function createAuthHeaders(_method, _path, _body) {
const TOKEN_ID = getEnv('PROXMOX_TOKEN_ID');
const TOKEN_SECRET = getEnv('PROXMOX_TOKEN_SECRET');
return { Authorization: `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}` };
}
async function proxmoxFetch(path, method = 'GET', body) {
const baseUrl = getEnv('PROXMOX_API_URL');
const base = baseUrl.replace(/\/+$/, '');
const cleanPath = path.replace(/^\/+/, '');
const fullUrl = `${base}/${cleanPath}`;
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
try {
const res = await axios({
url: fullUrl,
method,
headers: {
...createAuthHeaders(method, path, body),
'Content-Type': 'application/json',
},
data: body,
});
return res.data;
} catch (err) {
const status = err.response?.status || 'Unknown Status';
const message = err.response?.data?.message || err.response?.data || err.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');
}
}
/**
* @typedef {Object} ProxmoxNode
* @property {string} name
* @property {'node'} type
* @property {number} cpu
* @property {{ total: number; used: number }} memory
* @property {string} status
* @property {number} uptime
* @property {number} load
*/
export async function getNodes() {
const data = await proxmoxFetch('/nodes');
return (data.data || []).map((node) => ({
name: node.node,
type: 'node',
cpu: node.cpu ?? 0,
memory: {
total: node.maxmem ?? 0,
used: node.mem ?? 0,
},
status: node.status,
uptime: node.uptime ?? 0,
load: node.load ?? 0,
}));
}
/**
* @typedef {Object} ProxmoxVM
* @property {number} vmid
* @property {string} name
* @property {string} type
* @property {string} status
* @property {number} cpu
* @property {{ total: number; used: number }} memory
* @property {{ total: number; used: number }} disk
*/
export async function getVMs(nodeName) {
const data = await proxmoxFetch(`/nodes/${nodeName}/qemu`);
return (data.data || []).map((vm) => ({
vmid: vm.vmid,
name: vm.name || `VM ${vm.vmid}`,
type: 'qemu',
status: vm.status,
cpu: vm.cpu ?? 0,
memory: {
total: vm.maxmem ?? vm.maxmem ?? 0,
used: vm.mem ?? 0,
},
disk: {
total: 0,
used: vm.disk ?? 0,
},
}));
}
export async function getLXC(nodeName) {
const data = await proxmoxFetch(`/nodes/${nodeName}/lxc`);
return (data.data || []).map((vm) => ({
vmid: vm.vmid,
name: vm.name || `LXC ${vm.vmid}`,
type: 'lxc',
status: vm.status,
cpu: vm.cpu ?? 0,
memory: {
total: vm.maxmem ?? vm.maxmem ?? 0,
used: vm.mem ?? 0,
},
disk: {
total: 0,
used: vm.disk ?? 0,
},
}));
}
/**
* @typedef {Object} SummaryStat
* @property {number} totalNodes
* @property {number} runningVMs
* @property {number} stoppedVMs
* @property {number} totalCPUs
* @property {{ total: number; used: number }} totalMemory
*/
export async function getSummary() {
const nodes = await getNodes();
let totalVMs = [];
let totalLXC = [];
for (const node of nodes) {
try {
const vms = await getVMs(node.name);
totalVMs = totalVMs.concat(vms);
} catch { /* skip */ }
try {
const lxcs = await getLXC(node.name);
totalLXC = totalLXC.concat(lxcs);
} catch { /* skip */ }
}
const allVMs = [...totalVMs, ...totalLXC];
const runningVMs = allVMs.filter((vm) => vm.status === 'running').length;
const stoppedVMs = allVMs.filter((vm) => vm.status !== 'running').length;
const totalMemTotal = nodes.reduce((s, n) => s + (n.memory?.total || 0), 0);
const totalMemUsed = nodes.reduce((s, n) => s + (n.memory?.used || 0), 0);
return {
totalNodes: nodes.length,
runningVMs,
stoppedVMs,
totalCPUs: nodes.reduce((s, n) => s + n.cpu, 0),
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;
}

14
tailwind.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Config } from 'tailwindcss';
const config: Config = {
content: [
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
};
export default config;