19 Commits

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

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

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

Backlog: mark Search/Filter as done

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 09:44:59 +01:00
11 changed files with 637 additions and 384 deletions

63
CLAUDE.md Normal file
View File

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

View File

@@ -1,40 +1,58 @@
# 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)
### Console Link ### Per-VM/LXC IP Addresses
- Add a clickable name link per row linking to Proxmox web console: `/pve/console.html?node=X&vmid=Y` - LXCs: parses `ip=` and `ip6=` from `net0`, `net1`, etc. fields in `/nodes/<node>/lxc/<id>/config`.
- **Status: done (pending push)** - VMs: fetches from `/nodes/<node>/qemu/<id>/agent/network-get-interfaces` and reads `ipaddresses`.
- Displays IPs below the name in the table.
### Manual Refresh - **Status: done**
- Button in dashboard header that calls `mutate()` on all SWR caches
- ~5 lines of code
### Custom Poll Interval
- Input field in header to adjust `refreshInterval` instead of env-only config
### Group by Node
- Collapse/expand VMs/LXCs by node with toggle
### Uptime Duration
- Format uptime seconds to "3d 4h 12m" via `formatUptime()` helper
### Search / Filter ### Search / Filter
- Client-side text input filtering table rows by name/node/status - Client-side text input filtering table rows by name/node/status
- Uses `useMemo` for performant filtering, auto-expands single node and filter-matched nodes
- **Status: done (merged to main)**
## Medium Effort ## Medium Effort
### Storage Usage ### Storage Usage
- Show disk usage per Proxmox storage backend (like node cards) - Show disk usage per Proxmox storage backend (like node cards)
- Note: `disk.total` is not populated in the API (always 0) — needs `pvesh` or extra API calls
### Status Change Alert ### Status Change Alert
- Toast/banner when a VM state changes between polls - Toast/banner when a VM state changes between polls
### Resource Allocation ### Resource Allocation
- Show vCPU count, total disk size alongside usage stats - Show vCPU count, total disk size alongside usage stats
- Note: disk.total is not populated by Proxmox API — only disk.used is available
### Per-VM IP Addresses ### Per-VM IP Addresses
- Fetch guest network IP addresses (extra API call needed) - Done — uses `/nodes/<node>/qemu/<id>/agent/network-get-interfaces` for VMs and config `net0`/`net1` parsing for LXCs.
### Per-VM CPU/Memory Trend ### Per-VM CPU/Memory Trend
- Periodic polling to build a small trend chart - Periodic polling to build a small trend chart

View File

@@ -1,8 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getNodes, getLXC } from '@/lib/proxmox'; import { getNodes, getLXC, getConfigIPs } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', ''); 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) {
@@ -13,13 +12,20 @@ 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);
allLXC.push( for (const lxc of lxcs) {
...lxcs.map((lxc) => ({ let ips = [];
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&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`, consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&vmid=${lxc.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&xtermjs=1`,
})), ips,
); });
}
} catch { /* skip node */ } } catch { /* skip node */ }
} }

View File

@@ -1,8 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getNodes, getVMs } from '@/lib/proxmox'; import { getNodes, getVMs, getConfigIPs } from '@/lib/proxmox';
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', ''); 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) {
@@ -13,13 +12,20 @@ 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);
allVMs.push( for (const vm of vms) {
...vms.map((vm) => ({ let ips = [];
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&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`, consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&vmid=${vm.vmid}&node=${encodeURIComponent(node.name)}&resize=scale&novnc=1`,
})), ips,
); });
}
} catch { /* skip node */ } } catch { /* skip node */ }
} }

View File

@@ -1,45 +0,0 @@
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

@@ -1,43 +0,0 @@
'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,96 +1,109 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect, useRef } 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 POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000); const DEFAULT_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
/** function pollUrls() {
* Fetch helper for SWR. return ['/api/nodes', '/api/vms', '/api/lxc', '/api/status'];
* @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 });
// Re-fetch after success await pollUrls().map((url) => axios.get(url));
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' });
setNodes(nodesRes.data.data); // Sync state from poll responses
setVms(vmsRes.data.data); setNodes(nodes.map((n) => n));
setLxc(lxcRes.data.data); setVms(vms.map((v) => v));
setSummary(statusRes.data.data); setLxc(lxc.map((l) => l));
// 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 [nodes, setNodes] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false);
const [vms, setVms] = useState([]); const handleRefresh = async () => {
const [lxc, setLxc] = useState([]); setIsRefreshing(true);
const [summary, setSummary] = useState(null); try {
await Promise.all(pollUrls().map((url) => axios.get(url)));
} finally {
setIsRefreshing(false);
}
};
const { error: nodesError } = useSWR('/api/nodes', fetchData, { const handleIntervalChange = (e) => {
refreshInterval: POLL_INTERVAL, const v = Number(e.target.value);
fallbackData: { data: [] }, if (v > 0) setIntervalMs(v);
onSuccess: (data) => setNodes(data.data), };
});
const { error: vmsError } = useSWR('/api/vms', fetchData, { const [intervalInput, setIntervalInput] = useState(DEFAULT_INTERVAL);
refreshInterval: POLL_INTERVAL, useEffect(() => { setIntervalInput(intervalMs); }, [intervalMs]);
fallbackData: { data: [] },
onSuccess: (data) => setVms(data.data), const handleIntervalBlur = () => {
}); handleIntervalChange({ target: { value: intervalInput } });
const { error: lxcError } = useSWR('/api/lxc', fetchData, { };
refreshInterval: POLL_INTERVAL,
fallbackData: { data: [] }, const handleOpenConsole = (vm) => {
onSuccess: (data) => setLxc(data.data), window.open(vm.consoleUrl, '_blank');
}); };
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 (
@@ -104,10 +117,30 @@ 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 && !summaryError && ( {summary && !error && (
<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>
@@ -132,9 +165,9 @@ export default function Dashboard() {
</div> </div>
)} )}
{(summaryError || nodesError) && ( {error && (
<div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red"> <div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red">
{summaryError || nodesError} {error}
</div> </div>
)} )}
@@ -167,16 +200,13 @@ 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={(vm) => { setActiveConsole(vm); setActiveTab('console'); }} />} {activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} onOpenConsole={handleOpenConsole} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={(lxc) => { setActiveConsole(lxc); setActiveTab('console'); }} />} {activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={handleOpenConsole} />}
{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 {POLL_INTERVAL / 1000}s Proxmox VE Monitor Polling every {intervalMs / 1000}s Proxmox VE Monitor
</div> </div>
</div> </div>
); );

View File

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

View File

@@ -26,7 +26,10 @@ 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);
return `${days}d ${hours}h`; const minutes = Math.floor((seconds % 3600) / 60);
if (days) return `${days}d ${hours}h ${minutes}m`;
if (hours) return `${hours}h ${minutes}m`;
return `${minutes}m`;
} }
/** /**

View File

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

View File

@@ -184,3 +184,119 @@ 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;
}