Compare commits
8 Commits
47f10cc2c2
...
33d2f6e42a
| Author | SHA1 | Date | |
|---|---|---|---|
| 33d2f6e42a | |||
| 2ab78012ba | |||
| 607844f184 | |||
| e79221642b | |||
| f27bc3e4eb | |||
| 77b067ea35 | |||
| 6b61c3240a | |||
| 51753269ca |
@@ -2,6 +2,8 @@
|
|||||||
# Get your token from Proxmox → Datacenter → Permissions → API Tokens
|
# Get your token from Proxmox → Datacenter → Permissions → API Tokens
|
||||||
|
|
||||||
PROXMOX_API_URL=http://proxmox.lan:8006/api2/json
|
PROXMOX_API_URL=http://proxmox.lan:8006/api2/json
|
||||||
|
PROXMOX_WEB_URL=http://proxmox.lan:8006
|
||||||
|
PROXMOX_CONSOLE_TOKEN=root@pam!monitorapp
|
||||||
PROXMOX_TOKEN_ID=root@pam!monitorapp
|
PROXMOX_TOKEN_ID=root@pam!monitorapp
|
||||||
PROXMOX_TOKEN_SECRET=103d622c-05a3-4002-9356-f67691e10d40
|
PROXMOX_TOKEN_SECRET=103d622c-05a3-4002-9356-f67691e10d40
|
||||||
API_POLL_INTERVAL=30000
|
API_POLL_INTERVAL=30000
|
||||||
|
|||||||
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
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getNodes, getLXC } from '@/lib/proxmox';
|
import { getNodes, getLXC } from '@/lib/proxmox';
|
||||||
|
|
||||||
|
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
|
||||||
|
const PROXMOX_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) {
|
||||||
try {
|
try {
|
||||||
@@ -10,7 +13,13 @@ export async function GET(_req) {
|
|||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
try {
|
try {
|
||||||
const lxcs = await getLXC(node.name);
|
const lxcs = await getLXC(node.name);
|
||||||
allLXC.push(...lxcs.map((lxc) => ({ ...lxc, node: 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 */ }
|
} catch { /* skip node */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { getNodes, getVMs } from '@/lib/proxmox';
|
import { getNodes, getVMs } from '@/lib/proxmox';
|
||||||
|
|
||||||
|
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || process.env.PROXMOX_API_URL?.replace('/api2/json', '');
|
||||||
|
const PROXMOX_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) {
|
||||||
try {
|
try {
|
||||||
@@ -10,7 +13,13 @@ export async function GET(_req) {
|
|||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
try {
|
try {
|
||||||
const vms = await getVMs(node.name);
|
const vms = await getVMs(node.name);
|
||||||
allVMs.push(...vms.map((vm) => ({ ...vm, node: 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 */ }
|
} catch { /* skip node */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
45
src/app/api/vnc/route.js
Normal file
45
src/app/api/vnc/route.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
const PROXMOX_API_URL = process.env.PROXMOX_API_URL;
|
||||||
|
const PROXMOX_WEB_URL = process.env.PROXMOX_WEB_URL || PROXMOX_API_URL?.replace('/api2/json', '');
|
||||||
|
|
||||||
|
/** @param {import('next').Request} req */
|
||||||
|
export async function GET(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const node = url.searchParams.get('node');
|
||||||
|
const vmid = url.searchParams.get('vmid');
|
||||||
|
const type = url.searchParams.get('type');
|
||||||
|
|
||||||
|
if (!node || !vmid || !type) {
|
||||||
|
return NextResponse.json({ error: 'Missing node, vmid, or type' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticketRes = await axios({
|
||||||
|
url: `${PROXMOX_API_URL}/nodes/${node}/${type}/${vmid}/vncproxy`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `PVEAPIToken=${process.env.PROXMOX_TOKEN_ID}=${process.env.PROXMOX_TOKEN_SECRET}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
data: JSON.stringify({ websocket: 0 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ticket = ticketRes.data?.ticket;
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'No VNC ticket returned from Proxmox API' },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const consoleUrl = `${PROXMOX_WEB_URL}/?vncticket=${encodeURIComponent(ticket)}&novnc=1&node=${encodeURIComponent(node)}&vmid=${vmid}`;
|
||||||
|
|
||||||
|
return NextResponse.json({ consoleUrl });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err.response?.data?.message || err.response?.data || err.message;
|
||||||
|
return NextResponse.json({ error: msg }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/components/ConsolePanel.js
Normal file
43
src/components/ConsolePanel.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ vmid: number; name: string; node: string; type: string; consoleUrl: string }} vm
|
||||||
|
* @param {() => void} onClose
|
||||||
|
* @returns {import('react').ReactNode}
|
||||||
|
*/
|
||||||
|
export default function ConsolePanel({ vm, onClose }) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50 flex h-[60vh] flex-col rounded-t-xl border border-border-card bg-bg-card shadow-2xl sm:right-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border-card px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-text-primary">
|
||||||
|
<span>Console</span>
|
||||||
|
<span className="text-[10px] text-text-secondary">
|
||||||
|
({vm.type.toUpperCase()} {vm.vmid} on {vm.node})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{loading && <span className="text-xs text-text-secondary">Loading…</span>}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md border border-border-card bg-bg-card px-2 py-1 text-xs text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Frame */}
|
||||||
|
<iframe
|
||||||
|
src={vm.consoleUrl}
|
||||||
|
title={`${vm.name} console`}
|
||||||
|
className="h-full w-full border-0"
|
||||||
|
onLoad={() => setLoading(false)}
|
||||||
|
sandbox="allow-same-origin allow-scripts allow-popups allow-forms"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ 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 { SWRConfig } from 'swr';
|
||||||
|
import ConsolePanel from './ConsolePanel';
|
||||||
|
|
||||||
const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
|
const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
|
||||||
|
|
||||||
@@ -30,6 +32,24 @@ async function fetchData(url) {
|
|||||||
/** @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);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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, {
|
const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, {
|
||||||
refreshInterval: POLL_INTERVAL,
|
refreshInterval: POLL_INTERVAL,
|
||||||
@@ -53,6 +73,7 @@ export default function Dashboard() {
|
|||||||
{ 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 (
|
||||||
@@ -100,6 +121,16 @@ export default function Dashboard() {
|
|||||||
</div>
|
</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 */}
|
{/* Tab navigation */}
|
||||||
<div className="mb-4 flex gap-2 border-b border-border-card">
|
<div className="mb-4 flex gap-2 border-b border-border-card">
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
@@ -119,8 +150,11 @@ 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} />}
|
{activeTab === 'vms' && <VMList vms={vms} onPowerAction={handlePowerAction} onOpenConsole={(vm) => { setActiveConsole(vm); setActiveTab('console'); }} />}
|
||||||
{activeTab === 'lxc' && <LXCList lxc={lxc} />}
|
{activeTab === 'lxc' && <LXCList lxc={lxc} onPowerAction={handlePowerAction} onOpenConsole={(lxc) => { setActiveConsole(lxc); setActiveTab('console'); }} />}
|
||||||
|
{activeTab === 'console' && activeConsole && (
|
||||||
|
<ConsolePanel vm={activeConsole} onClose={() => setActiveConsole(null)} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ 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
|
* @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}
|
* @returns {import('react').ReactNode}
|
||||||
*/
|
*/
|
||||||
export default function LXCList({ lxc }) {
|
/**
|
||||||
|
* @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) {
|
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 LXC containers found.</p>;
|
||||||
}
|
}
|
||||||
@@ -38,6 +41,26 @@ export default function LXCList({ lxc }) {
|
|||||||
<td className="px-4 py-2 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</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 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"><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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -58,6 +81,7 @@ export default function LXCList({ lxc }) {
|
|||||||
<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">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">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">Status</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -68,6 +92,20 @@ export default function LXCList({ lxc }) {
|
|||||||
<td className="px-4 py-2 text-text-secondary">{item.node}</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 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"><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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ 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
|
* @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}
|
* @returns {import('react').ReactNode}
|
||||||
*/
|
*/
|
||||||
export default function VMList({ vms }) {
|
/**
|
||||||
|
* @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) {
|
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>;
|
||||||
}
|
}
|
||||||
@@ -27,6 +30,7 @@ export default function VMList({ vms }) {
|
|||||||
<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">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">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">Status</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -38,6 +42,26 @@ export default function VMList({ vms }) {
|
|||||||
<td className="px-4 py-2 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</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 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"><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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -58,6 +82,7 @@ export default function VMList({ vms }) {
|
|||||||
<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">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">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">Status</th>
|
||||||
|
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -68,6 +93,20 @@ export default function VMList({ vms }) {
|
|||||||
<td className="px-4 py-2 text-text-secondary">{vm.node}</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 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"><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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -159,6 +159,22 @@ export async function getLXC(nodeName) {
|
|||||||
* @property {{ total: number; used: number }} totalMemory
|
* @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.
|
* Aggregated summary across all nodes.
|
||||||
* @returns {Promise<SummaryStat>}
|
* @returns {Promise<SummaryStat>}
|
||||||
|
|||||||
Reference in New Issue
Block a user