Compare commits
15 Commits
setup/mac-
...
a2144687b1
| Author | SHA1 | Date | |
|---|---|---|---|
| a2144687b1 | |||
| 015a9d7ed8 | |||
| c07e448d40 | |||
| 59bb0f24db | |||
| ad328efa3c | |||
| 33d2f6e42a | |||
| 2ab78012ba | |||
| 607844f184 | |||
| e79221642b | |||
| f27bc3e4eb | |||
| 77b067ea35 | |||
| 6b61c3240a | |||
| 51753269ca | |||
| 47f10cc2c2 | |||
| 783400884a |
@@ -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
|
||||||
|
|||||||
64
CLAUDE.md
64
CLAUDE.md
@@ -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.
|
|
||||||
50
README.md
50
README.md
@@ -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
|
|
||||||
|
|||||||
53
backlog.md
53
backlog.md
@@ -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
84
package-lock.json
generated
@@ -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": [
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
45
src/app/api/vnc/route.js
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/components/ConsolePanel.js
Normal file
43
src/components/ConsolePanel.js
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user