35 Commits

Author SHA1 Message Date
9aa70ec645 Setup Mac development environment 2026-05-26 15:02:47 +01:00
bc2dda2147 docs: update README, CLAUDE.md, backlog, and .env.example
- README: add all features, correct tech stack (axios/setInterval), add IP addresses section, fix API routes
- CLAUDE.md: fix architecture, remove SWR references, fix env var names, add IP address patterns
- backlog: remove ip6= mention, clean duplicate entries, mark done
- .env.example: remove unused PROXMOX_CONSOLE_TOKEN

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 14:22:57 +01:00
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
14 changed files with 895 additions and 313 deletions

View File

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

64
CLAUDE.md Normal file
View File

@@ -0,0 +1,64 @@
# 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, polling, power action handler
│ ├── NodeList.js / NodeCard.js # Node overview (collapsible cards with subtotals)
│ ├── VMList.js / LXCList.js # VM/LXC lists grouped by node with search, power actions, console links, IPs
│ └── StatusBadge.js # Status indicator
└── lib/
└── proxmox.js # Proxmox API client — getNodes, getVMs, getLXC, getSummary, powerControl, getConfigIPs
```
## 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 `setInterval` polling (not SWR) for periodic data fetching. Power actions use direct `axios.get()` calls for instant UI sync.
- **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}&resize=scale` params. Dashboard opens it via `window.open()` in a new tab so browser session cookies handle Proxmox auth. LXC console uses `xtermjs=1`, QEMU uses `novnc=1`.
- **Per-VM/LXC IPs**: `getConfigIPs()` in proxmox.js fetches QEMU IPs from `/agent/network-get-interfaces`, LXC IPs from `/interfaces` endpoint (fallback to `/config` `net0`-`net7` parsing). IPv4 only.
- **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) |
| `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

@@ -4,17 +4,24 @@ 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
- **Node overview** — CPU, memory, uptime, and load for each Proxmox node, grouped into collapsible cards
- **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
- **Per-VM/LXC IP addresses** — IPv4 from guest agent (QEMU) or network config (LXC), displayed below VM/LXC name
- **Power control** — Start/stop/restart buttons per row
- **Console links** — Opens Proxmox web UI console in a new tab
- **Search / filter** — Client-side filtering by name, node, or status with auto-expand
- **Group by node** — Collapsible node cards with running/stopped subtotals
- **Manual refresh** — Button in header to refresh all data
- **Custom poll interval** — Adjustable polling interval (default 30s) via header input
- **Uptime display** — Formatted as "Xd Yh Zm"
- **Auto-refresh** — Manual `setInterval` polling every 30s (configurable)
## Tech Stack
- **Framework** — Next.js 15 (App Router)
- **UI** — React 19, Tailwind CSS 4
- **Data fetching** — SWR (auto-refresh), Axios
- **Data fetching** — Axios with manual `setInterval` polling
- **Proxmox** — API2 Token-based auth
## Getting Started
@@ -32,7 +39,7 @@ cp .env.example .env.local
```
PROXMOX_API_URL=https://your-proxmox-host:8006/api2/json
PROXMOX_TOKEN_ID=root@pam!token-name
PROXMOX_TOKEN_SECRET=your-secret-uuid
PROXMOX_TOKEN_SECRET=<your-token-secret>
API_POLL_INTERVAL=30000
NODE_TLS_REJECT_UNAUTHORIZED=0
```
@@ -69,27 +76,30 @@ src/
│ │ ├── 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
│ │ ── status/route.js # Aggregated summary
│ │ ├── power/route.js # Power control endpoint
│ │ └── proxy/[...]/ # Reverse proxy to Proxmox web UI
│ ├── 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
│ ├── Dashboard.js # Main dashboard with tabs, summary, polling
│ ├── NodeList.js # Node overview
│ ├── NodeCard.js # Single node card
│ ├── VMList.js # VM list with group by node, filter, power actions
│ ├── LXCList.js # LXC list with group by node, filter, power actions
│ └── StatusBadge.js # Status indicator
└── lib/
└── proxmox.js # Proxmox API client & data mappers
└── proxmox.js # Proxmox API client — nodes, VMs, LXC, IPs, power control
```
## Environment Variables
| Variable | Required | Description |
|---|---|---|
| `PROXMOX_API_URL` | Yes | Base URL of the Proxmox API |
| `PROXMOX_API_URL` | Yes | Base URL of the Proxmox API (e.g. `https://host:8006/api2/json`) |
| `PROXMOX_TOKEN_ID` | Yes | API token ID (e.g. `root@pam!monitorapp`) |
| `PROXMOX_TOKEN_SECRET` | Yes | API token secret |
| `PROXMOX_WEB_URL` | No | Web UI URL (defaults from `PROXMOX_API_URL`). Used for console links. |
| `API_POLL_INTERVAL` | No | Refresh interval in ms (default: `30000`) |
| `NODE_TLS_REJECT_UNAUTHORIZED` | No | Set to `0` for self-signed certs |
@@ -98,6 +108,14 @@ src/
| Route | Method | Description |
|---|---|---|
| `/api/nodes` | GET | All Proxmox nodes |
| `/api/vms` | GET | All QEMU VMs |
| `/api/lxc` | GET | All LXC containers |
| `/api/vms` | GET | All QEMU VMs (with IPv4 from guest agent) |
| `/api/lxc` | GET | All LXC containers (with IPv4 from interfaces/config) |
| `/api/status` | GET | Aggregated summary stats |
| `/api/power` | POST | Power control (start/stop/restart) |
| `/api/proxy/*` | GET | Reverse proxy to Proxmox web UI |
## Per-VM/LXC IP Addresses
- **QEMU VMs**: fetched from `/nodes/<node>/qemu/<id>/agent/network-get-interfaces` (requires QEMU guest agent installed)
- **LXC**: fetched from `/nodes/<node>/lxc/<id>/interfaces` (preferred) or parsed from `/config` `net0`-`net7` fields (fallback)
- IPv4 only — IPv6 addresses are excluded

72
backlog.md Normal file
View File

@@ -0,0 +1,72 @@
# 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: fetches from `/interfaces` endpoint; falls back to `ip=` parsing from `net0`-`net7` in `/config`.
- VMs: fetches from `/agent/network-get-interfaces` (requires QEMU guest agent).
- IPv4 only. 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**
## 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 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

84
package-lock.json generated
View File

@@ -136,9 +136,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -155,9 +152,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -174,9 +168,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -193,9 +184,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -212,9 +200,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -231,9 +216,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -250,9 +232,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -269,9 +248,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
@@ -288,9 +264,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -313,9 +286,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -338,9 +308,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -363,9 +330,6 @@
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -388,9 +352,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -413,9 +374,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -438,9 +396,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -463,9 +418,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -652,9 +604,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -671,9 +620,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -690,9 +636,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -709,9 +652,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -895,9 +835,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -915,9 +852,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -935,9 +869,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -955,9 +886,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1507,9 +1435,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1531,9 +1456,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1555,9 +1477,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1579,9 +1498,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [

View File

@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server';
import { getNodes, getLXC } from '@/lib/proxmox';
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) {
@@ -10,7 +12,20 @@ export async function GET(_req) {
for (const node of nodes) {
try {
const lxcs = await getLXC(node.name);
allLXC.push(...lxcs.map((lxc) => ({ ...lxc, node: node.name })));
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 */ }
}

View File

@@ -0,0 +1,28 @@
import { NextResponse } from 'next/server';
import { powerControl } from '@/lib/proxmox';
/**
* @param {import('next').Request} req
*/
export async function POST(req) {
try {
const { node, vmid, type, action } = await req.json();
if (!node || !vmid || !type || !action) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
if (!['qemu', 'lxc'].includes(type)) {
return NextResponse.json({ error: 'Invalid type, must be qemu or lxc' }, { status: 400 });
}
if (!['start', 'stop', 'restart'].includes(action)) {
return NextResponse.json({ error: 'Invalid action, must be start, stop, or restart' }, { status: 400 });
}
await powerControl(node, vmid, type, action);
return NextResponse.json({ ok: true, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, ok: false }, { status: 502 });
}
}

View File

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

View File

@@ -1,5 +1,7 @@
import { NextResponse } from 'next/server';
import { getNodes, getVMs } from '@/lib/proxmox';
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) {
@@ -10,7 +12,20 @@ export async function GET(_req) {
for (const node of nodes) {
try {
const vms = await getVMs(node.name);
allVMs.push(...vms.map((vm) => ({ ...vm, node: node.name })));
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 */ }
}

View File

@@ -1,53 +1,104 @@
'use client';
import { useState } from 'react';
import useSWR from 'swr';
import { useState, useEffect, useRef } from 'react';
import NodeList from './NodeList';
import VMList from './VMList';
import LXCList from './LXCList';
import axios from 'axios';
const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
const DEFAULT_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
/**
* 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}`);
}
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 { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, {
refreshInterval: POLL_INTERVAL,
});
const { data: vmsData, error: vmsError } = useSWR('/api/vms', fetchData, {
refreshInterval: POLL_INTERVAL,
});
const { data: lxcData, error: lxcError } = useSWR('/api/lxc', fetchData, {
refreshInterval: POLL_INTERVAL,
});
const { data: summaryData, error: summaryError } = useSWR('/api/status', fetchData, {
refreshInterval: POLL_INTERVAL,
});
const ref = useRef({ nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary });
useEffect(() => {
ref.current = { nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary };
}, []);
const nodes = nodesData?.data ?? [];
const vms = vmsData?.data ?? [];
const lxc = lxcData?.data ?? [];
const summary = summaryData?.data ?? null;
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' },
@@ -66,10 +117,30 @@ export default function Dashboard() {
{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 && !summaryError && (
{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>
@@ -94,9 +165,19 @@ export default function Dashboard() {
</div>
)}
{(summaryError || nodesError) && (
{error && (
<div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red">
{summaryError || nodesError}
{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>
)}
@@ -119,13 +200,13 @@ export default function Dashboard() {
{/* Tab content */}
<div>
{activeTab === 'nodes' && <NodeList nodes={nodes} />}
{activeTab === 'vms' && <VMList vms={vms} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} />}
{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 {POLL_INTERVAL / 1000}s Proxmox VE Monitor
Polling every {intervalMs / 1000}s Proxmox VE Monitor
</div>
</div>
);

View File

@@ -1,79 +1,167 @@
'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 }> }} 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
*/
export default function LXCList({ lxc }) {
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 LXC containers found.</p>;
return <p className="text-text-secondary text-sm">No LXCs found.</p>;
}
const running = lxc.filter((item) => item.status === 'running');
const stopped = lxc.filter((item) => item.status !== 'running');
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-6">
{running.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-card">
<th className="px-4 py-2.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">Name</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
</tr>
</thead>
<tbody>
{running.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
<td className="px-4 py-2 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
<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>
)}
{stopped.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-card">
<th className="px-4 py-2.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">Name</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
</tr>
</thead>
<tbody>
{stopped.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
</tr>
))}
</tbody>
</table>
<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>
</div>
);
})}
{!total && (
<p className="text-center text-text-secondary">No matches for "{filter}"</p>
)}
</div>
);

View File

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

View File

@@ -1,79 +1,167 @@
'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 }> }} 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
*/
export default function VMList({ vms }) {
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 running = vms.filter((vm) => vm.status === 'running');
const stopped = vms.filter((vm) => vm.status !== 'running');
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-6">
{running.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-card">
<th className="px-4 py-2.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">Name</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
</tr>
</thead>
<tbody>
{running.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2 text-text-secondary">{vm.node}</td>
<td className="px-4 py-2 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
<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>
)}
{stopped.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-card">
<th className="px-4 py-2.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">Name</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
</tr>
</thead>
<tbody>
{stopped.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2 text-text-secondary">{vm.node}</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
</tr>
))}
</tbody>
</table>
<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>
</div>
);
})}
{!total && (
<p className="text-center text-text-secondary">No matches for "{filter}"</p>
)}
</div>
);

View File

@@ -8,33 +8,13 @@ function getEnv(key) {
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) {
const TOKEN_ID = getEnv('PROXMOX_TOKEN_ID'); // e.g. "root@pam!monitorapp"
const TOKEN_ID = getEnv('PROXMOX_TOKEN_ID');
const TOKEN_SECRET = getEnv('PROXMOX_TOKEN_SECRET');
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) {
const headers = {
...createAuthHeaders(method, path, body),
'Content-Type': 'application/json',
};
const baseUrl = getEnv('PROXMOX_API_URL');
const base = baseUrl.replace(/\/+$/, '');
const cleanPath = path.replace(/^\/+/, '');
@@ -46,18 +26,47 @@ async function proxmoxFetch(path, method = 'GET', body) {
const res = await axios({
url: fullUrl,
method,
headers,
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');
}
}
/**
@@ -71,10 +80,6 @@ async function proxmoxFetch(path, method = 'GET', body) {
* @property {number} load
*/
/**
* Fetches all Proxmox nodes and returns mapped stats.
* @returns {Promise<ProxmoxNode[]>}
*/
export async function getNodes() {
const data = await proxmoxFetch('/nodes');
return (data.data || []).map((node) => ({
@@ -102,11 +107,6 @@ export async function getNodes() {
* @property {{ total: number; used: number }} disk
*/
/**
* Fetches QEMU VMs for a given node.
* @param {string} nodeName
* @returns {Promise<ProxmoxVM[]>}
*/
export async function getVMs(nodeName) {
const data = await proxmoxFetch(`/nodes/${nodeName}/qemu`);
return (data.data || []).map((vm) => ({
@@ -126,11 +126,6 @@ export async function getVMs(nodeName) {
}));
}
/**
* Fetches LXC containers for a given node.
* @param {string} nodeName
* @returns {Promise<ProxmoxVM[]>}
*/
export async function getLXC(nodeName) {
const data = await proxmoxFetch(`/nodes/${nodeName}/lxc`);
return (data.data || []).map((vm) => ({
@@ -159,10 +154,6 @@ export async function getLXC(nodeName) {
* @property {{ total: number; used: number }} totalMemory
*/
/**
* Aggregated summary across all nodes.
* @returns {Promise<SummaryStat>}
*/
export async function getSummary() {
const nodes = await getNodes();
let totalVMs = [];
@@ -193,3 +184,119 @@ export async function getSummary() {
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;
}