15 Commits

Author SHA1 Message Date
a2144687b1 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
015a9d7ed8 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
c07e448d40 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
59bb0f24db 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
ad328efa3c 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
33d2f6e42a 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
2ab78012ba 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
607844f184 fix: use correct Proxmox console URL format from docs
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 11:38:46 +01:00
e79221642b update: mark console link as done in backlog
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 10:38:31 +01:00
f27bc3e4eb 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
77b067ea35 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
6b61c3240a fix: restore broken getSummary function declaration
Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-07 09:07:55 +01:00
51753269ca 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
47f10cc2c2 Merge branch 'main' of https://gitea.rdbcloud.co.uk/robbond/proxmox-monitor 2026-05-07 07:19:10 +01:00
783400884a initial commit 2026-05-07 06:41:36 +01:00
14 changed files with 488 additions and 672 deletions

View File

@@ -3,6 +3,7 @@
PROXMOX_API_URL=http://proxmox.lan:8006/api2/json PROXMOX_API_URL=http://proxmox.lan:8006/api2/json
PROXMOX_WEB_URL=http://proxmox.lan:8006 PROXMOX_WEB_URL=http://proxmox.lan:8006
PROXMOX_CONSOLE_TOKEN=root@pam!monitorapp
PROXMOX_TOKEN_ID=root@pam!monitorapp PROXMOX_TOKEN_ID=root@pam!monitorapp
PROXMOX_TOKEN_SECRET=<your-token-secret> PROXMOX_TOKEN_SECRET=103d622c-05a3-4002-9356-f67691e10d40
API_POLL_INTERVAL=30000 API_POLL_INTERVAL=30000

View File

@@ -1,64 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Real-time dashboard for monitoring Proxmox VE nodes, VMs, and LXC containers. Next.js 15 App Router with Tailwind CSS 4.
## Commands
```
npm run dev # Start dev server (usually on port 3002)
npm run build # Production build
npm run start # Start production server
```
## Architecture
```
src/
├── app/
│ ├── api/ # Next.js API routes (server-side)
│ │ ├── proxy/[...]/route.js # Reverse proxy to Proxmox web UI
│ │ ├── power/route.js # Power control (start/stop/restart)
│ │ ├── nodes/vms/lxc/route.js # Query endpoints (aggregates across nodes)
│ │ └── status/route.js # Aggregated summary stats
│ ├── layout.js # Root layout (Inter font, metadata)
│ └── page.js # Entry — renders <Dashboard />
├── components/
│ ├── Dashboard.js # Main UI: tabs, summary cards, 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,24 +4,17 @@ A real-time dashboard for monitoring Proxmox VE nodes, VMs, and LXC containers.
## Features ## Features
- **Node overview** — CPU, memory, uptime, and load for each Proxmox node, grouped into collapsible cards - **Node overview** — CPU, memory, uptime, and load for each Proxmox node
- **VM & LXC tracking** — status, resource usage across all nodes - **VM & LXC tracking** — status, resource usage across all nodes
- **Summary cards** — total nodes, running/stopped VMs, aggregate memory - **Summary cards** — total nodes, running/stopped VMs, aggregate memory
- **Per-VM/LXC IP addresses** — IPv4 from guest agent (QEMU) or network config (LXC), displayed below VM/LXC name - **Auto-refresh** — polling every 30s (configurable)
- **Power control** — Start/stop/restart buttons per row - **Tabbed navigation** — switch between Nodes, VMs, and LXCs
- **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 ## Tech Stack
- **Framework** — Next.js 15 (App Router) - **Framework** — Next.js 15 (App Router)
- **UI** — React 19, Tailwind CSS 4 - **UI** — React 19, Tailwind CSS 4
- **Data fetching** — Axios with manual `setInterval` polling - **Data fetching** — SWR (auto-refresh), Axios
- **Proxmox** — API2 Token-based auth - **Proxmox** — API2 Token-based auth
## Getting Started ## Getting Started
@@ -39,7 +32,7 @@ cp .env.example .env.local
``` ```
PROXMOX_API_URL=https://your-proxmox-host:8006/api2/json PROXMOX_API_URL=https://your-proxmox-host:8006/api2/json
PROXMOX_TOKEN_ID=root@pam!token-name PROXMOX_TOKEN_ID=root@pam!token-name
PROXMOX_TOKEN_SECRET=<your-token-secret> PROXMOX_TOKEN_SECRET=your-secret-uuid
API_POLL_INTERVAL=30000 API_POLL_INTERVAL=30000
NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_TLS_REJECT_UNAUTHORIZED=0
``` ```
@@ -76,30 +69,27 @@ src/
│ │ ├── nodes/route.js # Proxmox node stats │ │ ├── nodes/route.js # Proxmox node stats
│ │ ├── vms/route.js # QEMU VMs across all nodes │ │ ├── vms/route.js # QEMU VMs across all nodes
│ │ ├── lxc/route.js # LXC containers 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 │ ├── layout.js
│ └── page.js │ └── page.js
├── components/ ├── components/
│ ├── Dashboard.js # Main dashboard with tabs, summary, polling │ ├── Dashboard.js # Main dashboard with tabs & summary
│ ├── NodeList.js # Node overview │ ├── NodeList.js # Node cards
│ ├── NodeCard.js # Single node card │ ├── NodeCard.js # Single node display
│ ├── VMList.js # VM list with group by node, filter, power actions │ ├── VMList.js # VM list view
│ ├── LXCList.js # LXC list with group by node, filter, power actions │ ├── LXCList.js # LXC list view
│ └── StatusBadge.js # Status indicator │ └── StatusBadge.js # Online/good/warn/error/dead
└── lib/ └── lib/
└── proxmox.js # Proxmox API client — nodes, VMs, LXC, IPs, power control └── proxmox.js # Proxmox API client & data mappers
``` ```
## Environment Variables ## Environment Variables
| Variable | Required | Description | | Variable | Required | Description |
|---|---|---| |---|---|---|
| `PROXMOX_API_URL` | Yes | Base URL of the Proxmox API (e.g. `https://host:8006/api2/json`) | | `PROXMOX_API_URL` | Yes | Base URL of the Proxmox API |
| `PROXMOX_TOKEN_ID` | Yes | API token ID (e.g. `root@pam!monitorapp`) | | `PROXMOX_TOKEN_ID` | Yes | API token ID (e.g. `root@pam!monitorapp`) |
| `PROXMOX_TOKEN_SECRET` | Yes | API token secret | | `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`) | | `API_POLL_INTERVAL` | No | Refresh interval in ms (default: `30000`) |
| `NODE_TLS_REJECT_UNAUTHORIZED` | No | Set to `0` for self-signed certs | | `NODE_TLS_REJECT_UNAUTHORIZED` | No | Set to `0` for self-signed certs |
@@ -108,14 +98,6 @@ src/
| Route | Method | Description | | Route | Method | Description |
|---|---|---| |---|---|---|
| `/api/nodes` | GET | All Proxmox nodes | | `/api/nodes` | GET | All Proxmox nodes |
| `/api/vms` | GET | All QEMU VMs (with IPv4 from guest agent) | | `/api/vms` | GET | All QEMU VMs |
| `/api/lxc` | GET | All LXC containers (with IPv4 from interfaces/config) | | `/api/lxc` | GET | All LXC containers |
| `/api/status` | GET | Aggregated summary stats | | `/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

View File

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

84
package-lock.json generated
View File

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

View File

@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getNodes, getLXC, getConfigIPs } from '@/lib/proxmox'; import { getNodes, getLXC } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', ''); const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
const PROXMOX_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
/** @param {import('next').Request} _req */ /** @param {import('next').Request} _req */
export async function GET(_req) { export async function GET(_req) {
@@ -12,20 +13,13 @@ export async function GET(_req) {
for (const node of nodes) { for (const node of nodes) {
try { try {
const lxcs = await getLXC(node.name); const lxcs = await getLXC(node.name);
for (const lxc of lxcs) { allLXC.push(
let ips = []; ...lxcs.map((lxc) => ({
if (lxc.status === 'running') {
try {
ips = await getConfigIPs(node.name, lxc.vmid, 'lxc');
} catch { /* guest agent not available */ }
}
allLXC.push({
...lxc, ...lxc,
node: node.name, node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&vmid=${lxc.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&xtermjs=1`, consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
ips, })),
}); );
}
} catch { /* skip node */ } } catch { /* skip node */ }
} }

View File

@@ -1,7 +1,8 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getNodes, getVMs, getConfigIPs } from '@/lib/proxmox'; import { getNodes, getVMs } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', ''); const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
const PROXMOX_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
/** @param {import('next').Request} _req */ /** @param {import('next').Request} _req */
export async function GET(_req) { export async function GET(_req) {
@@ -12,20 +13,13 @@ export async function GET(_req) {
for (const node of nodes) { for (const node of nodes) {
try { try {
const vms = await getVMs(node.name); const vms = await getVMs(node.name);
for (const vm of vms) { allVMs.push(
let ips = []; ...vms.map((vm) => ({
if (vm.status === 'running') {
try {
ips = await getConfigIPs(node.name, vm.vmid, 'qemu');
} catch { /* guest agent not available */ }
}
allVMs.push({
...vm, ...vm,
node: node.name, node: node.name,
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&vmid=${vm.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&novnc=1`, consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
ips, })),
}); );
}
} catch { /* skip node */ } } catch { /* skip node */ }
} }

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

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

View File

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

View File

@@ -1,109 +1,96 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState } from 'react';
import useSWR from 'swr';
import NodeList from './NodeList'; import NodeList from './NodeList';
import VMList from './VMList'; import VMList from './VMList';
import LXCList from './LXCList'; import LXCList from './LXCList';
import axios from 'axios'; import axios from 'axios';
import ConsolePanel from './ConsolePanel';
const DEFAULT_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000); const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
function pollUrls() { /**
return ['/api/nodes', '/api/vms', '/api/lxc', '/api/status']; * Fetch helper for SWR.
* @param {string} url
* @returns {Promise<{ data: *, error: string | null }>}
*/
async function fetchData(url) {
try {
const res = await axios.get(url);
return res.data;
} catch (err) {
const status = err.response?.status || 'Unknown';
const message = err.response?.data?.message || err.response?.data?.errors || err.message || String(err);
throw new Error(`API error ${status}: ${message}`);
}
} }
/** @returns {import('react').ReactNode} */ /** @returns {import('react').ReactNode} */
export default function Dashboard() { export default function Dashboard() {
const [activeTab, setActiveTab] = useState('nodes'); const [activeTab, setActiveTab] = useState('nodes');
const [activeConsole, setActiveConsole] = useState(null);
const [powerActionStatus, setPowerActionStatus] = useState(null); const [powerActionStatus, setPowerActionStatus] = useState(null);
const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL);
const [nodes, setNodes] = useState([]);
const [vms, setVms] = useState([]);
const [lxc, setLxc] = useState([]);
const [summary, setSummary] = useState(null);
const [error, setError] = useState(null);
const ref = useRef({ nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary });
useEffect(() => {
ref.current = { nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary };
}, []);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const [n, v, l, s] = await Promise.all([
axios.get('/api/nodes'),
axios.get('/api/vms'),
axios.get('/api/lxc'),
axios.get('/api/status'),
]);
if (cancelled) return;
setNodes(n.data.data);
setVms(v.data.data);
setLxc(l.data.data);
setSummary(s.data.data);
setError(null);
} catch (err) {
if (!cancelled) setError(err.message);
}
};
poll();
const id = setInterval(poll, intervalMs);
return () => { cancelled = true; clearInterval(id); };
}, [intervalMs]);
const handlePowerAction = async (node, vmid, type, action) => { const handlePowerAction = async (node, vmid, type, action) => {
const label = action.charAt(0).toUpperCase() + action.slice(1); const label = action.charAt(0).toUpperCase() + action.slice(1);
const kind = type === 'qemu' ? 'VM' : 'LXC'; const kind = type === 'qemu' ? 'VM' : 'LXC';
setPowerActionStatus({ text: `${label}ing ${kind} ${vmid}...`, type: 'pending' }); setPowerActionStatus({ text: `${label}ing ${kind} ${vmid}...`, type: 'pending' });
// Flush the pending status so it paints before the API call
await new Promise((r) => requestAnimationFrame(r)); await new Promise((r) => requestAnimationFrame(r));
try { try {
await axios.post('/api/power', { node, vmid, type, action }); await axios.post('/api/power', { node, vmid, type, action });
await pollUrls().map((url) => axios.get(url)); // Re-fetch after success
const [nodesRes, vmsRes, lxcRes, statusRes] = await Promise.all([
axios.get('/api/nodes'),
axios.get('/api/vms'),
axios.get('/api/lxc'),
axios.get('/api/status'),
]);
setPowerActionStatus({ text: `${label} successful`, type: 'success' }); setPowerActionStatus({ text: `${label} successful`, type: 'success' });
// Sync state from poll responses setNodes(nodesRes.data.data);
setNodes(nodes.map((n) => n)); setVms(vmsRes.data.data);
setVms(vms.map((v) => v)); setLxc(lxcRes.data.data);
setLxc(lxc.map((l) => l)); setSummary(statusRes.data.data);
// Auto-dismiss after 3 seconds
setTimeout(() => setPowerActionStatus(null), 3000); setTimeout(() => setPowerActionStatus(null), 3000);
} catch (err) { } catch (err) {
setPowerActionStatus({ text: err.message || `${label} failed`, type: 'error' }); setPowerActionStatus({ text: err.message || `${label} failed`, type: 'error' });
} }
}; };
const [isRefreshing, setIsRefreshing] = useState(false); const [nodes, setNodes] = useState([]);
const handleRefresh = async () => { const [vms, setVms] = useState([]);
setIsRefreshing(true); const [lxc, setLxc] = useState([]);
try { const [summary, setSummary] = useState(null);
await Promise.all(pollUrls().map((url) => axios.get(url)));
} finally {
setIsRefreshing(false);
}
};
const handleIntervalChange = (e) => { const { error: nodesError } = useSWR('/api/nodes', fetchData, {
const v = Number(e.target.value); refreshInterval: POLL_INTERVAL,
if (v > 0) setIntervalMs(v); fallbackData: { data: [] },
}; onSuccess: (data) => setNodes(data.data),
});
const [intervalInput, setIntervalInput] = useState(DEFAULT_INTERVAL); const { error: vmsError } = useSWR('/api/vms', fetchData, {
useEffect(() => { setIntervalInput(intervalMs); }, [intervalMs]); refreshInterval: POLL_INTERVAL,
fallbackData: { data: [] },
const handleIntervalBlur = () => { onSuccess: (data) => setVms(data.data),
handleIntervalChange({ target: { value: intervalInput } }); });
}; const { error: lxcError } = useSWR('/api/lxc', fetchData, {
refreshInterval: POLL_INTERVAL,
const handleOpenConsole = (vm) => { fallbackData: { data: [] },
window.open(vm.consoleUrl, '_blank'); onSuccess: (data) => setLxc(data.data),
}; });
const { error: summaryError } = useSWR('/api/status', fetchData, {
refreshInterval: POLL_INTERVAL,
fallbackData: { data: null },
onSuccess: (data) => setSummary(data.data),
});
const tabs = [ const tabs = [
{ id: 'nodes', label: 'Nodes' }, { id: 'nodes', label: 'Nodes' },
{ id: 'vms', label: 'VMs' }, { id: 'vms', label: 'VMs' },
{ id: 'lxc', label: 'LXCs' }, { id: 'lxc', label: 'LXCs' },
...(activeConsole ? [{ id: 'console', label: `Console: ${activeConsole.name}` }] : []),
]; ];
return ( return (
@@ -117,30 +104,10 @@ export default function Dashboard() {
{new Date().toLocaleTimeString()} {new Date().toLocaleTimeString()}
</p> </p>
</div> </div>
<div className="flex gap-2 self-start sm:self-auto">
<input
type="number"
min="1000"
step="1000"
value={intervalInput}
onChange={(e) => setIntervalInput(Number(e.target.value))}
onBlur={handleIntervalBlur}
onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); } }}
className="w-24 rounded-md border border-border-card bg-bg-card px-2 py-1.5 text-sm text-text-primary focus:border-accent focus:outline-none"
placeholder="ms"
/>
<button
onClick={handleRefresh}
disabled={isRefreshing}
className="self-start rounded-md border border-border-card bg-bg-card px-3 py-1.5 text-sm font-medium text-text-secondary hover:border-accent hover:text-accent disabled:opacity-50"
>
{isRefreshing ? 'Refreshing…' : '↻ Refresh'}
</button>
</div>
</div> </div>
{/* Summary Cards */} {/* Summary Cards */}
{summary && !error && ( {summary && !summaryError && (
<div className="mb-6 grid gap-4 sm:grid-cols-4"> <div className="mb-6 grid gap-4 sm:grid-cols-4">
<div className="rounded-xl border border-border-card bg-bg-card p-4"> <div className="rounded-xl border border-border-card bg-bg-card p-4">
<div className="text-xs uppercase tracking-wider text-text-secondary">Nodes</div> <div className="text-xs uppercase tracking-wider text-text-secondary">Nodes</div>
@@ -165,9 +132,9 @@ export default function Dashboard() {
</div> </div>
)} )}
{error && ( {(summaryError || nodesError) && (
<div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red"> <div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red">
{error} {summaryError || nodesError}
</div> </div>
)} )}
@@ -200,13 +167,16 @@ export default function Dashboard() {
{/* Tab content */} {/* Tab content */}
<div> <div>
{activeTab === 'nodes' && <NodeList nodes={nodes} />} {activeTab === 'nodes' && <NodeList nodes={nodes} />}
{activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} onOpenConsole={handleOpenConsole} />} {activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} onOpenConsole={(vm) => { setActiveConsole(vm); setActiveTab('console'); }} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={handleOpenConsole} />} {activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={(lxc) => { setActiveConsole(lxc); setActiveTab('console'); }} />}
{activeTab === 'console' && activeConsole && (
<ConsolePanel vm={activeConsole} onClose={() => setActiveConsole(null)} />
)}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="mt-8 text-center text-xs text-text-secondary"> <div className="mt-8 text-center text-xs text-text-secondary">
Polling every {intervalMs / 1000}s Proxmox VE Monitor Polling every {POLL_INTERVAL / 1000}s Proxmox VE Monitor
</div> </div>
</div> </div>
); );

View File

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

View File

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

View File

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

View File

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