Compare commits
9 Commits
feature/vm
...
2ab78012ba
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ab78012ba | |||
| 607844f184 | |||
| e79221642b | |||
| f27bc3e4eb | |||
| 77b067ea35 | |||
| 6b61c3240a | |||
| 51753269ca | |||
| 47f10cc2c2 | |||
| 783400884a |
9
.env.example
Normal file
9
.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Proxmox VE API Configuration
|
||||
# Get your token from Proxmox → Datacenter → Permissions → API Tokens
|
||||
|
||||
PROXMOX_API_URL=http://proxmox.lan:8006/api2/json
|
||||
PROXMOX_WEB_URL=http://proxmox.lan:8006
|
||||
PROXMOX_CONSOLE_TOKEN=root@pam!monitorapp
|
||||
PROXMOX_TOKEN_ID=root@pam!monitorapp
|
||||
PROXMOX_TOKEN_SECRET=103d622c-05a3-4002-9356-f67691e10d40
|
||||
API_POLL_INTERVAL=30000
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.next/
|
||||
.env.local
|
||||
dist/
|
||||
104
README.md
104
README.md
@@ -1,3 +1,103 @@
|
||||
# proxmox-monitor
|
||||
# Proxmox Monitor
|
||||
|
||||
monitor proxmox
|
||||
A real-time dashboard for monitoring Proxmox VE nodes, VMs, and LXC containers.
|
||||
|
||||
## Features
|
||||
|
||||
- **Node overview** — CPU, memory, uptime, and load for each Proxmox node
|
||||
- **VM & LXC tracking** — status, resource usage across all nodes
|
||||
- **Summary cards** — total nodes, running/stopped VMs, aggregate memory
|
||||
- **Auto-refresh** — polling every 30s (configurable)
|
||||
- **Tabbed navigation** — switch between Nodes, VMs, and LXCs
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework** — Next.js 15 (App Router)
|
||||
- **UI** — React 19, Tailwind CSS 4
|
||||
- **Data fetching** — SWR (auto-refresh), Axios
|
||||
- **Proxmox** — API2 Token-based auth
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Configure environment variables
|
||||
|
||||
Copy the example file and fill in your Proxmox credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
`.env.local` contents:
|
||||
|
||||
```
|
||||
PROXMOX_API_URL=https://your-proxmox-host:8006/api2/json
|
||||
PROXMOX_TOKEN_ID=root@pam!token-name
|
||||
PROXMOX_TOKEN_SECRET=your-secret-uuid
|
||||
API_POLL_INTERVAL=30000
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0
|
||||
```
|
||||
|
||||
> **Creating an API token** in Proxmox: Datacenter > Permissions > API Tokens
|
||||
|
||||
### 2. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 3. Run the development server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
### 4. Build for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── api/
|
||||
│ │ ├── nodes/route.js # Proxmox node stats
|
||||
│ │ ├── vms/route.js # QEMU VMs across all nodes
|
||||
│ │ ├── lxc/route.js # LXC containers across all nodes
|
||||
│ │ └── status/route.js # Aggregated summary
|
||||
│ ├── layout.js
|
||||
│ └── page.js
|
||||
├── components/
|
||||
│ ├── Dashboard.js # Main dashboard with tabs & summary
|
||||
│ ├── NodeList.js # Node cards
|
||||
│ ├── NodeCard.js # Single node display
|
||||
│ ├── VMList.js # VM list view
|
||||
│ ├── LXCList.js # LXC list view
|
||||
│ └── StatusBadge.js # Online/good/warn/error/dead
|
||||
└── lib/
|
||||
└── proxmox.js # Proxmox API client & data mappers
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|---|---|---|
|
||||
| `PROXMOX_API_URL` | Yes | Base URL of the Proxmox API |
|
||||
| `PROXMOX_TOKEN_ID` | Yes | API token ID (e.g. `root@pam!monitorapp`) |
|
||||
| `PROXMOX_TOKEN_SECRET` | Yes | API token secret |
|
||||
| `API_POLL_INTERVAL` | No | Refresh interval in ms (default: `30000`) |
|
||||
| `NODE_TLS_REJECT_UNAUTHORIZED` | No | Set to `0` for self-signed certs |
|
||||
|
||||
## API Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|---|---|---|
|
||||
| `/api/nodes` | GET | All Proxmox nodes |
|
||||
| `/api/vms` | GET | All QEMU VMs |
|
||||
| `/api/lxc` | GET | All LXC containers |
|
||||
| `/api/status` | GET | Aggregated summary stats |
|
||||
|
||||
57
backlog.md
Normal file
57
backlog.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Feature Backlog — proxmox-monitor
|
||||
|
||||
## Quick Wins (low effort, high value)
|
||||
|
||||
### Console Link
|
||||
- Add a clickable name link per row linking to Proxmox web console: `/pve/console.html?node=X&vmid=Y`
|
||||
- **Status: done (pending push)**
|
||||
|
||||
### 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
|
||||
- Client-side text input filtering table rows by name/node/status
|
||||
|
||||
## Medium Effort
|
||||
|
||||
### Storage Usage
|
||||
- Show disk usage per Proxmox storage backend (like node cards)
|
||||
|
||||
### Status Change Alert
|
||||
- Toast/banner when a VM state changes between polls
|
||||
|
||||
### Resource Allocation
|
||||
- Show vCPU count, total disk size alongside usage stats
|
||||
|
||||
### Per-VM IP Addresses
|
||||
- Fetch guest network IP addresses (extra API call needed)
|
||||
|
||||
### Per-VM CPU/Memory Trend
|
||||
- Periodic polling to build a small trend chart
|
||||
|
||||
## Larger Features
|
||||
|
||||
### Cluster Status
|
||||
- HA, quorum, and failover info per cluster
|
||||
|
||||
### Network Health
|
||||
- Network interfaces and link status per node
|
||||
|
||||
### Recent Proxmox Events
|
||||
- Audit log / recent events from the Proxmox cluster
|
||||
|
||||
### Docker Deployment
|
||||
- Dockerfile + docker-compose for easy hosting
|
||||
|
||||
### Health Check Endpoint
|
||||
- `/api/health` for reverse proxy / uptime monitoring
|
||||
7
jsconfig.json
Normal file
7
jsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
4
next.config.mjs
Normal file
4
next.config.mjs
Normal file
@@ -0,0 +1,4 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {};
|
||||
|
||||
export default nextConfig;
|
||||
1989
package-lock.json
generated
Normal file
1989
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "proxmox-monitor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"next": "^15.3.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"swr": "^2.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.0",
|
||||
"postcss": "^8.5.0",
|
||||
"tailwindcss": "^4.1.0"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
30
src/app/api/lxc/route.js
Normal file
30
src/app/api/lxc/route.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
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_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
|
||||
|
||||
/** @param {import('next').Request} _req */
|
||||
export async function GET(_req) {
|
||||
try {
|
||||
const nodes = await getNodes();
|
||||
const allLXC = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
try {
|
||||
const lxcs = await getLXC(node.name);
|
||||
allLXC.push(
|
||||
...lxcs.map((lxc) => ({
|
||||
...lxc,
|
||||
node: node.name,
|
||||
consoleUrl: `${PROXMOX_WEB_URL}/?console=lxc&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${lxc.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
|
||||
})),
|
||||
);
|
||||
} catch { /* skip node */ }
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: allLXC, timestamp: Date.now() });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/nodes/route.js
Normal file
12
src/app/api/nodes/route.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getNodes } from '@/lib/proxmox';
|
||||
|
||||
/** @param {import('next').Request} _req */
|
||||
export async function GET(_req) {
|
||||
try {
|
||||
const data = await getNodes();
|
||||
return NextResponse.json({ data, timestamp: Date.now() });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
|
||||
}
|
||||
}
|
||||
28
src/app/api/power/route.js
Normal file
28
src/app/api/power/route.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { powerControl } from '@/lib/proxmox';
|
||||
|
||||
/**
|
||||
* @param {import('next').Request} req
|
||||
*/
|
||||
export async function POST(req) {
|
||||
try {
|
||||
const { node, vmid, type, action } = await req.json();
|
||||
|
||||
if (!node || !vmid || !type || !action) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['qemu', 'lxc'].includes(type)) {
|
||||
return NextResponse.json({ error: 'Invalid type, must be qemu or lxc' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!['start', 'stop', 'restart'].includes(action)) {
|
||||
return NextResponse.json({ error: 'Invalid action, must be start, stop, or restart' }, { status: 400 });
|
||||
}
|
||||
|
||||
await powerControl(node, vmid, type, action);
|
||||
return NextResponse.json({ ok: true, timestamp: Date.now() });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, ok: false }, { status: 502 });
|
||||
}
|
||||
}
|
||||
86
src/app/api/proxy/[...path]/route.js
Normal file
86
src/app/api/proxy/[...path]/route.js
Normal file
@@ -0,0 +1,86 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import axios from 'axios';
|
||||
|
||||
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || 'http://proxmox:8006';
|
||||
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
|
||||
|
||||
/** @param {import('next').Request} req */
|
||||
export async function GET(req) {
|
||||
const url = new URL(req.url);
|
||||
const pathSegments = url.pathname.split('/api/proxy/')[1];
|
||||
if (!pathSegments) {
|
||||
return NextResponse.json({ error: 'Invalid proxy path' }, { status: 400 });
|
||||
}
|
||||
|
||||
const proxmoxPath = `/${pathSegments}`;
|
||||
const targetUrl = `${PROXMOX_WEB_URL.replace(/\/+$/, '')}${proxmoxPath}`;
|
||||
|
||||
const headers = {
|
||||
Authorization: `PVEAPIToken=${process.env.PROXMOX_TOKEN_ID}=${process.env.PROXMOX_TOKEN_SECRET}`,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await axios({ url: targetUrl, method: 'GET', headers, responseType: 'text' });
|
||||
|
||||
let body = res.data;
|
||||
// Rewrite relative URLs back through our proxy
|
||||
body = body.replace(
|
||||
/href="([^"]*)"/g,
|
||||
(_, href) => {
|
||||
if (href.startsWith('http') || href.startsWith('javascript:')) return `href="${href}"`;
|
||||
return `href="/api/proxy/${href}"`;
|
||||
},
|
||||
);
|
||||
body = body.replace(
|
||||
/src="([^"]*)"/g,
|
||||
(_, src) => {
|
||||
if (src.startsWith('http') || src.startsWith('data:') || src.startsWith('blob:')) return `src="${src}"`;
|
||||
return `src="/api/proxy/${src}"`;
|
||||
},
|
||||
);
|
||||
|
||||
return new NextResponse(body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
'Content-Type': res.headers['content-type'] || 'text/html',
|
||||
'X-Frame-Options': 'SAMEORIGIN',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.message || err.response?.data || err.message;
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
}
|
||||
|
||||
/** @param {import('next').Request} req */
|
||||
export async function POST(req) {
|
||||
const url = new URL(req.url);
|
||||
const pathSegments = url.pathname.split('/api/proxy/')[1];
|
||||
if (!pathSegments) {
|
||||
return NextResponse.json({ error: 'Invalid proxy path' }, { status: 400 });
|
||||
}
|
||||
|
||||
const proxmoxPath = `/${pathSegments}`;
|
||||
const targetUrl = `${PROXMOX_WEB_URL.replace(/\/+$/, '')}${proxmoxPath}`;
|
||||
|
||||
const headers = {
|
||||
Authorization: `PVEAPIToken=${process.env.PROXMOX_TOKEN_ID}=${process.env.PROXMOX_TOKEN_SECRET}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
|
||||
const body = await req.text();
|
||||
|
||||
try {
|
||||
const res = await axios({
|
||||
url: targetUrl,
|
||||
method: 'POST',
|
||||
headers,
|
||||
data: body,
|
||||
});
|
||||
|
||||
return NextResponse.json(res.data);
|
||||
} catch (err) {
|
||||
const msg = err.response?.data?.message || err.response?.data || err.message;
|
||||
return NextResponse.json({ error: msg }, { status: 502 });
|
||||
}
|
||||
}
|
||||
12
src/app/api/status/route.js
Normal file
12
src/app/api/status/route.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getSummary } from '@/lib/proxmox';
|
||||
|
||||
/** @param {import('next').Request} _req */
|
||||
export async function GET(_req) {
|
||||
try {
|
||||
const data = await getSummary();
|
||||
return NextResponse.json({ data, timestamp: Date.now() });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/vms/route.js
Normal file
30
src/app/api/vms/route.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
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_CONSOLE_TOKEN = process.env.PROXMOX_CONSOLE_TOKEN || process.env.PROXMOX_TOKEN_ID;
|
||||
|
||||
/** @param {import('next').Request} _req */
|
||||
export async function GET(_req) {
|
||||
try {
|
||||
const nodes = await getNodes();
|
||||
const allVMs = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
try {
|
||||
const vms = await getVMs(node.name);
|
||||
allVMs.push(
|
||||
...vms.map((vm) => ({
|
||||
...vm,
|
||||
node: node.name,
|
||||
consoleUrl: `${PROXMOX_WEB_URL}/?console=qemu&novnc=1&node=${encodeURIComponent(node.name)}&vmid=${vm.vmid}&token=${encodeURIComponent(PROXMOX_CONSOLE_TOKEN)}`,
|
||||
})),
|
||||
);
|
||||
} catch { /* skip node */ }
|
||||
}
|
||||
|
||||
return NextResponse.json({ data: allVMs, timestamp: Date.now() });
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
22
src/app/globals.css
Normal file
22
src/app/globals.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg-primary: #0b0f19;
|
||||
--color-bg-card: #111827;
|
||||
--color-bg-card-hover: #1a2332;
|
||||
--color-border-card: #1e293b;
|
||||
--color-text-primary: #f1f5f9;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-accent: #3b82f6;
|
||||
--color-status-green: #22c55e;
|
||||
--color-status-red: #ef4444;
|
||||
--color-status-yellow: #eab308;
|
||||
--color-status-gray: #64748b;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
19
src/app/layout.js
Normal file
19
src/app/layout.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import './globals.css';
|
||||
import { Inter } from 'next/font/google';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
/** @type {import('next').Metadata} */
|
||||
export const metadata = {
|
||||
title: 'Proxmox Monitor',
|
||||
description: 'Real-time dashboard for Proxmox VE',
|
||||
};
|
||||
|
||||
/** @param {{ children: import('react').ReactNode }} props */
|
||||
export default function RootLayout({ children }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
6
src/app/page.js
Normal file
6
src/app/page.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import Dashboard from '@/components/Dashboard';
|
||||
|
||||
/** @returns {import('react').ReactNode} */
|
||||
export default function HomePage() {
|
||||
return <Dashboard />;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
166
src/components/Dashboard.js
Normal file
166
src/components/Dashboard.js
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import NodeList from './NodeList';
|
||||
import VMList from './VMList';
|
||||
import LXCList from './LXCList';
|
||||
import axios from 'axios';
|
||||
import { SWRConfig } from 'swr';
|
||||
import ConsolePanel from './ConsolePanel';
|
||||
|
||||
const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
|
||||
|
||||
/**
|
||||
* Fetch helper for SWR.
|
||||
* @param {string} url
|
||||
* @returns {Promise<{ data: *, error: string | null }>}
|
||||
*/
|
||||
async function fetchData(url) {
|
||||
|
||||
|
||||
try {
|
||||
const res = await axios.get(url);
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
const status = err.response?.status || 'Unknown';
|
||||
const message = err.response?.data?.message || err.response?.data?.errors || err.message || String(err);
|
||||
throw new Error(`API error ${status}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {import('react').ReactNode} */
|
||||
export default function Dashboard() {
|
||||
const [activeTab, setActiveTab] = useState('nodes');
|
||||
const [activeConsole, setActiveConsole] = useState(null);
|
||||
const [powerActionStatus, setPowerActionStatus] = useState(null);
|
||||
|
||||
/**
|
||||
* @param {string} node
|
||||
* @param {number} vmid
|
||||
* @param {string} type
|
||||
* @param {string} action
|
||||
*/
|
||||
const handlePowerAction = async (node, vmid, type, action) => {
|
||||
setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)}ing ${type === 'qemu' ? 'VM' : 'LXC'} ${vmid}...`, type: 'pending' });
|
||||
try {
|
||||
await axios.post('/api/power', { node, vmid, type, action });
|
||||
setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)} successful`, type: 'success' });
|
||||
} catch (err) {
|
||||
setPowerActionStatus({ text: err.message || `${action.charAt(0).toUpperCase() + action.slice(1)} failed`, type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, {
|
||||
refreshInterval: POLL_INTERVAL,
|
||||
});
|
||||
const { data: vmsData, error: vmsError } = useSWR('/api/vms', fetchData, {
|
||||
refreshInterval: POLL_INTERVAL,
|
||||
});
|
||||
const { data: lxcData, error: lxcError } = useSWR('/api/lxc', fetchData, {
|
||||
refreshInterval: POLL_INTERVAL,
|
||||
});
|
||||
const { data: summaryData, error: summaryError } = useSWR('/api/status', fetchData, {
|
||||
refreshInterval: POLL_INTERVAL,
|
||||
});
|
||||
|
||||
const nodes = nodesData?.data ?? [];
|
||||
const vms = vmsData?.data ?? [];
|
||||
const lxc = lxcData?.data ?? [];
|
||||
const summary = summaryData?.data ?? null;
|
||||
|
||||
const tabs = [
|
||||
{ id: 'nodes', label: 'Nodes' },
|
||||
{ id: 'vms', label: 'VMs' },
|
||||
{ id: 'lxc', label: 'LXCs' },
|
||||
...(activeConsole ? [{ id: 'console', label: `Console: ${activeConsole.name}` }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen p-4 sm:p-6 lg:p-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">Proxmox Monitor</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">
|
||||
{nodes.length} node{nodes.length !== 1 ? 's' : ''} • {summary?.runningVMs ?? 0} running • Updated{' '}
|
||||
{new Date().toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
{summary && !summaryError && (
|
||||
<div className="mb-6 grid gap-4 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-border-card bg-bg-card p-4">
|
||||
<div className="text-xs uppercase tracking-wider text-text-secondary">Nodes</div>
|
||||
<div className="mt-1 text-2xl font-bold text-text-primary">{summary.totalNodes}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border-card bg-bg-card p-4">
|
||||
<div className="text-xs uppercase tracking-wider text-text-secondary">Running VMs</div>
|
||||
<div className="mt-1 text-2xl font-bold text-status-green">{summary.runningVMs}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border-card bg-bg-card p-4">
|
||||
<div className="text-xs uppercase tracking-wider text-text-secondary">Stopped VMs</div>
|
||||
<div className="mt-1 text-2xl font-bold text-status-red">{summary.stoppedVMs}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-border-card bg-bg-card p-4">
|
||||
<div className="text-xs uppercase tracking-wider text-text-secondary">Total Memory</div>
|
||||
<div className="mt-1 text-xl font-bold text-text-primary">
|
||||
{summary.totalMemory?.total
|
||||
? `${(summary.totalMemory.used / (1024 ** 3)).toFixed(1)} / ${(summary.totalMemory.total / (1024 ** 3)).toFixed(1)} GB`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(summaryError || nodesError) && (
|
||||
<div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red">
|
||||
{summaryError || nodesError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{powerActionStatus && (
|
||||
<div className={`mb-6 rounded-xl border p-4 text-sm ${
|
||||
powerActionStatus.type === 'success' ? 'border-status-green/30 bg-status-green/10 text-status-green'
|
||||
: powerActionStatus.type === 'error' ? 'border-status-red/30 bg-status-red/10 text-status-red'
|
||||
: 'border-border-card bg-bg-card text-text-secondary'
|
||||
}`}>
|
||||
{powerActionStatus.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab navigation */}
|
||||
<div className="mb-4 flex gap-2 border-b border-border-card">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${activeTab === tab.id
|
||||
? 'border-b-2 border-accent text-text-primary'
|
||||
: 'text-text-secondary hover:text-text-primary'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div>
|
||||
{activeTab === 'nodes' && <NodeList nodes={nodes} />}
|
||||
{activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} onOpenConsole={(vm) => { setActiveConsole(vm); setActiveTab('console'); }} />}
|
||||
{activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={(lxc) => { setActiveConsole(lxc); setActiveTab('console'); }} />}
|
||||
{activeTab === 'console' && activeConsole && (
|
||||
<ConsolePanel vm={activeConsole} onClose={() => setActiveConsole(null)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-text-secondary">
|
||||
Polling every {POLL_INTERVAL / 1000}s • Proxmox VE Monitor
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
src/components/LXCList.js
Normal file
128
src/components/LXCList.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import StatusBadge from './StatusBadge';
|
||||
|
||||
/**
|
||||
* @param {{ lxc: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string }> }} props
|
||||
* @returns {import('react').ReactNode}
|
||||
*/
|
||||
/**
|
||||
* @param {{ lxc: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
|
||||
*/
|
||||
export default function LXCList({ lxc, onPowerAction, onOpenConsole }) {
|
||||
if (!lxc?.length) {
|
||||
return <p className="text-text-secondary text-sm">No LXC containers found.</p>;
|
||||
}
|
||||
|
||||
const running = lxc.filter((item) => item.status === 'running');
|
||||
const stopped = lxc.filter((item) => item.status !== 'running');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{running.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
|
||||
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-card">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{running.map((item) => (
|
||||
<tr key={item.vmid} className="border-b border-border-card last:border-0">
|
||||
<td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
|
||||
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
|
||||
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
|
||||
<td className="px-4 py-2 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td>
|
||||
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{stopped.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
|
||||
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-card">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
||||
<th className="px-4 py-2.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-4 py-2 text-text-secondary">{item.vmid}</td>
|
||||
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
|
||||
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
|
||||
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
|
||||
<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', '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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {number} bytes @returns {string} */
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
81
src/components/NodeCard.js
Normal file
81
src/components/NodeCard.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import StatusBadge from './StatusBadge';
|
||||
|
||||
/**
|
||||
* Formats bytes to human-readable string.
|
||||
* @param {number} bytes
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats seconds to human-readable duration.
|
||||
* @param {number} seconds
|
||||
* @returns {string}
|
||||
*/
|
||||
function formatUptime(seconds) {
|
||||
if (!seconds) return 'N/A';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
return `${days}d ${hours}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ node: { name: string; type: string; cpu: number; memory: { total: number; used: number }; status: string; uptime: number; load: number } }} props
|
||||
* @returns {import('react').ReactNode}
|
||||
*/
|
||||
export default function NodeCard({ node }) {
|
||||
const cpuPercent = Math.round((node.cpu || 0) * 100);
|
||||
const memPercent = node.memory?.total ? Math.round((node.memory.used / node.memory.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border-card bg-bg-card p-5 transition-colors hover:bg-bg-card-hover">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{node.name}</h3>
|
||||
<StatusBadge status={node.status} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-sm text-text-secondary">
|
||||
<div className="flex justify-between">
|
||||
<span>CPU</span>
|
||||
<span className="text-text-primary">{cpuPercent}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-bg-primary">
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-all"
|
||||
style={{ width: `${Math.min(cpuPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span>Memory</span>
|
||||
<span className="text-text-primary">{formatBytes(node.memory?.used || 0)} / {formatBytes(node.memory?.total || 0)}</span>
|
||||
</div>
|
||||
<div className="h-2 w-full rounded-full bg-bg-primary">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
memPercent > 90 ? 'bg-status-red' :
|
||||
memPercent > 70 ? 'bg-status-yellow' :
|
||||
'bg-accent'
|
||||
}`}
|
||||
style={{ width: `${Math.min(memPercent, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs">
|
||||
<span>Load: {node.load?.toFixed(2) || 'N/A'}</span>
|
||||
<span>Uptime: {formatUptime(node.uptime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/components/NodeList.js
Normal file
19
src/components/NodeList.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import NodeCard from './NodeCard';
|
||||
|
||||
/**
|
||||
* @param {{ nodes: Array<{ name: string; type: string; cpu: number; memory: { total: number; used: number }; status: string; uptime: number; load: number }> }} props
|
||||
* @returns {import('react').ReactNode}
|
||||
*/
|
||||
export default function NodeList({ nodes }) {
|
||||
if (!nodes?.length) {
|
||||
return <p className="text-text-secondary text-sm">No nodes found.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{nodes.map((node) => (
|
||||
<NodeCard key={node.name} node={node} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/components/StatusBadge.js
Normal file
25
src/components/StatusBadge.js
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Renders a colored status badge.
|
||||
* @param {{ status: string }} props
|
||||
* @returns {import('react').ReactNode}
|
||||
*/
|
||||
export default function StatusBadge({ status }) {
|
||||
const colors = {
|
||||
running: 'bg-status-green/20 text-status-green border-status-green/30',
|
||||
stopped: 'bg-status-red/20 text-status-red border-status-red/30',
|
||||
paused: 'bg-status-yellow/20 text-status-yellow border-status-yellow/30',
|
||||
};
|
||||
const colorClass = colors[status] || 'bg-status-gray/20 text-status-gray border-status-gray/30';
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize ${colorClass}`}>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${
|
||||
status === 'running' ? 'bg-status-green' :
|
||||
status === 'stopped' ? 'bg-status-red' :
|
||||
status === 'paused' ? 'bg-status-yellow' :
|
||||
'bg-status-gray'
|
||||
}`} />
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
129
src/components/VMList.js
Normal file
129
src/components/VMList.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import StatusBadge from './StatusBadge';
|
||||
|
||||
/**
|
||||
* @param {{ vms: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string }> }} props
|
||||
* @returns {import('react').ReactNode}
|
||||
*/
|
||||
/**
|
||||
* @param {{ vms: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string; consoleUrl?: string }>, onPowerAction: (node: string, vmid: number, type: string, action: string) => void }} props
|
||||
*/
|
||||
export default function VMList({ vms, onPowerAction, onOpenConsole }) {
|
||||
if (!vms?.length) {
|
||||
return <p className="text-text-secondary text-sm">No VMs found.</p>;
|
||||
}
|
||||
|
||||
const running = vms.filter((vm) => vm.status === 'running');
|
||||
const stopped = vms.filter((vm) => vm.status !== 'running');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{running.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
|
||||
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-card">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{stopped.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
|
||||
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-card">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
|
||||
<th className="px-4 py-2.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-4 py-2 text-text-secondary">{vm.vmid}</td>
|
||||
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
|
||||
<td className="px-4 py-2 text-text-secondary">{vm.node}</td>
|
||||
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
|
||||
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
|
||||
<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', '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>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** @param {number} bytes @returns {string} */
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let i = 0;
|
||||
let v = bytes;
|
||||
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
||||
return `${v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
211
src/lib/proxmox.js
Normal file
211
src/lib/proxmox.js
Normal file
@@ -0,0 +1,211 @@
|
||||
import axios from 'axios';
|
||||
|
||||
function getEnv(key) {
|
||||
const val = process.env[key];
|
||||
if (!val) {
|
||||
throw new Error(`Missing ${key} in environment variables.`);
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the Proxmox API2 Authorization header.
|
||||
* Format: PVEAPIToken=<user>@<realm>!<token-id>=<secret>
|
||||
* @param {string} _method
|
||||
* @param {string} _path
|
||||
* @param {string} _body
|
||||
* @returns {{ Authorization: string }}
|
||||
*/
|
||||
function createAuthHeaders(_method, _path, _body) {
|
||||
const TOKEN_ID = getEnv('PROXMOX_TOKEN_ID'); // e.g. "root@pam!monitorapp"
|
||||
const TOKEN_SECRET = getEnv('PROXMOX_TOKEN_SECRET');
|
||||
return { Authorization: `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the Proxmox API with authentication.
|
||||
* @param {string} path
|
||||
* @param {'GET'|'POST'|'PUT'|'DELETE'} [method='GET']
|
||||
* @param {string} [body]
|
||||
* @returns {Promise<*>}
|
||||
*/
|
||||
async function proxmoxFetch(path, method = 'GET', body) {
|
||||
const headers = {
|
||||
...createAuthHeaders(method, path, body),
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const baseUrl = getEnv('PROXMOX_API_URL');
|
||||
const base = baseUrl.replace(/\/+$/, '');
|
||||
const cleanPath = path.replace(/^\/+/, '');
|
||||
const fullUrl = `${base}/${cleanPath}`;
|
||||
|
||||
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
|
||||
|
||||
try {
|
||||
const res = await axios({
|
||||
url: fullUrl,
|
||||
method,
|
||||
headers,
|
||||
data: body,
|
||||
});
|
||||
|
||||
return res.data;
|
||||
} catch (err) {
|
||||
const status = err.response?.status || 'Unknown Status';
|
||||
const message = err.response?.data?.message || err.response?.data || err.message;
|
||||
throw new Error(`Proxmox API error ${status}: ${message}`);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProxmoxNode
|
||||
* @property {string} name
|
||||
* @property {'node'} type
|
||||
* @property {number} cpu
|
||||
* @property {{ total: number; used: number }} memory
|
||||
* @property {string} status
|
||||
* @property {number} uptime
|
||||
* @property {number} load
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches all Proxmox nodes and returns mapped stats.
|
||||
* @returns {Promise<ProxmoxNode[]>}
|
||||
*/
|
||||
export async function getNodes() {
|
||||
const data = await proxmoxFetch('/nodes');
|
||||
return (data.data || []).map((node) => ({
|
||||
name: node.node,
|
||||
type: 'node',
|
||||
cpu: node.cpu ?? 0,
|
||||
memory: {
|
||||
total: node.maxmem ?? 0,
|
||||
used: node.mem ?? 0,
|
||||
},
|
||||
status: node.status,
|
||||
uptime: node.uptime ?? 0,
|
||||
load: node.load ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} ProxmoxVM
|
||||
* @property {number} vmid
|
||||
* @property {string} name
|
||||
* @property {string} type
|
||||
* @property {string} status
|
||||
* @property {number} cpu
|
||||
* @property {{ total: number; used: number }} memory
|
||||
* @property {{ total: number; used: number }} disk
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetches QEMU VMs for a given node.
|
||||
* @param {string} nodeName
|
||||
* @returns {Promise<ProxmoxVM[]>}
|
||||
*/
|
||||
export async function getVMs(nodeName) {
|
||||
const data = await proxmoxFetch(`/nodes/${nodeName}/qemu`);
|
||||
return (data.data || []).map((vm) => ({
|
||||
vmid: vm.vmid,
|
||||
name: vm.name || `VM ${vm.vmid}`,
|
||||
type: 'qemu',
|
||||
status: vm.status,
|
||||
cpu: vm.cpu ?? 0,
|
||||
memory: {
|
||||
total: vm.maxmem ?? vm.maxmem ?? 0,
|
||||
used: vm.mem ?? 0,
|
||||
},
|
||||
disk: {
|
||||
total: 0,
|
||||
used: vm.disk ?? 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches LXC containers for a given node.
|
||||
* @param {string} nodeName
|
||||
* @returns {Promise<ProxmoxVM[]>}
|
||||
*/
|
||||
export async function getLXC(nodeName) {
|
||||
const data = await proxmoxFetch(`/nodes/${nodeName}/lxc`);
|
||||
return (data.data || []).map((vm) => ({
|
||||
vmid: vm.vmid,
|
||||
name: vm.name || `LXC ${vm.vmid}`,
|
||||
type: 'lxc',
|
||||
status: vm.status,
|
||||
cpu: vm.cpu ?? 0,
|
||||
memory: {
|
||||
total: vm.maxmem ?? vm.maxmem ?? 0,
|
||||
used: vm.mem ?? 0,
|
||||
},
|
||||
disk: {
|
||||
total: 0,
|
||||
used: vm.disk ?? 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} SummaryStat
|
||||
* @property {number} totalNodes
|
||||
* @property {number} runningVMs
|
||||
* @property {number} stoppedVMs
|
||||
* @property {number} totalCPUs
|
||||
* @property {{ total: number; used: number }} totalMemory
|
||||
*/
|
||||
|
||||
/**
|
||||
* Aggregated summary across all nodes.
|
||||
* @returns {Promise<SummaryStat>}
|
||||
*/
|
||||
/**
|
||||
* Sends a power action to a VM or LXC.
|
||||
* @param {string} nodeName
|
||||
* @param {number} vmid
|
||||
* @param {'qemu'|'lxc'} type
|
||||
* @param {'start'|'stop'|'restart'} action
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function powerControl(nodeName, vmid, type, action) {
|
||||
await proxmoxFetch(`/nodes/${nodeName}/${type}/${vmid}/status/${action}`, 'POST');
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated summary across all nodes.
|
||||
* @returns {Promise<SummaryStat>}
|
||||
*/
|
||||
export async function getSummary() {
|
||||
const nodes = await getNodes();
|
||||
let totalVMs = [];
|
||||
let totalLXC = [];
|
||||
for (const node of nodes) {
|
||||
try {
|
||||
const vms = await getVMs(node.name);
|
||||
totalVMs = totalVMs.concat(vms);
|
||||
} catch { /* skip */ }
|
||||
try {
|
||||
const lxcs = await getLXC(node.name);
|
||||
totalLXC = totalLXC.concat(lxcs);
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const allVMs = [...totalVMs, ...totalLXC];
|
||||
const runningVMs = allVMs.filter((vm) => vm.status === 'running').length;
|
||||
const stoppedVMs = allVMs.filter((vm) => vm.status !== 'running').length;
|
||||
|
||||
const totalMemTotal = nodes.reduce((s, n) => s + (n.memory?.total || 0), 0);
|
||||
const totalMemUsed = nodes.reduce((s, n) => s + (n.memory?.used || 0), 0);
|
||||
|
||||
return {
|
||||
totalNodes: nodes.length,
|
||||
runningVMs,
|
||||
stoppedVMs,
|
||||
totalCPUs: nodes.reduce((s, n) => s + n.cpu, 0),
|
||||
totalMemory: { total: totalMemTotal, used: totalMemUsed },
|
||||
};
|
||||
}
|
||||
14
tailwind.config.ts
Normal file
14
tailwind.config.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
export default config;
|
||||
Reference in New Issue
Block a user