- Power operations use /api2/extjs endpoint (not /api2/json) - Remove unprivileged parameter that causes 400 validation error - Refetch VM/LXC/node/status after successful power action via SWR mutate Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
172 lines
6.8 KiB
JavaScript
172 lines
6.8 KiB
JavaScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import useSWR, { mutate } 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 });
|
|
// Immediately refetch all data instead of waiting for polling interval
|
|
await mutate('/api/nodes');
|
|
await mutate('/api/vms');
|
|
await mutate('/api/lxc');
|
|
await mutate('/api/status');
|
|
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>
|
|
);
|
|
}
|