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>
This commit is contained in:
2026-05-12 11:16:03 +01:00
parent 1e8164ddf5
commit 7c7d0262dd

View File

@@ -1,88 +1,104 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useEffect, useRef } from 'react';
import useSWR, { useSWRConfig } 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';
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 [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 handlePowerAction = async (node, vmid, type, action) => { const ref = useRef({ nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary });
const label = action.charAt(0).toUpperCase() + action.slice(1); useEffect(() => {
const kind = type === 'qemu' ? 'VM' : 'LXC'; ref.current = { nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary };
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));
useEffect(() => {
let cancelled = false;
const poll = async () => {
try { try {
await axios.post('/api/power', { node, vmid, type, action }); const [n, v, l, s] = await Promise.all([
// Re-fetch after success
const [nodesRes, vmsRes, lxcRes, statusRes] = await Promise.all([
axios.get('/api/nodes'), axios.get('/api/nodes'),
axios.get('/api/vms'), axios.get('/api/vms'),
axios.get('/api/lxc'), axios.get('/api/lxc'),
axios.get('/api/status'), 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 label = action.charAt(0).toUpperCase() + action.slice(1);
const kind = type === 'qemu' ? 'VM' : 'LXC';
setPowerActionStatus({ text: `${label}ing ${kind} ${vmid}...`, type: 'pending' });
await new Promise((r) => requestAnimationFrame(r));
try {
await axios.post('/api/power', { node, vmid, type, action });
await pollUrls().map((url) => axios.get(url));
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' },
@@ -90,19 +106,6 @@ export default function Dashboard() {
{ id: 'lxc', label: 'LXCs' }, { id: 'lxc', label: 'LXCs' },
]; ];
const handleOpenConsole = (vm) => {
window.open(vm.consoleUrl, '_blank');
};
const { mutate } = useSWRConfig();
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = async () => {
setIsRefreshing(true);
await mutate(['/api/nodes', '/api/vms', '/api/lxc', '/api/status']);
setIsRefreshing(false);
};
return ( return (
<div className="min-h-screen p-4 sm:p-6 lg:p-8"> <div className="min-h-screen p-4 sm:p-6 lg:p-8">
{/* Header */} {/* Header */}
@@ -114,6 +117,18 @@ 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 <button
onClick={handleRefresh} onClick={handleRefresh}
disabled={isRefreshing} disabled={isRefreshing}
@@ -122,9 +137,10 @@ export default function Dashboard() {
{isRefreshing ? 'Refreshing…' : '↻ Refresh'} {isRefreshing ? 'Refreshing…' : '↻ Refresh'}
</button> </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>
@@ -149,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>
)} )}
@@ -190,7 +206,7 @@ export default function Dashboard() {
{/* 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>
); );