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:
@@ -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 ref = useRef({ nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary });
|
||||||
|
useEffect(() => {
|
||||||
|
ref.current = { nodes: setNodes, vms: setVms, lxc: setLxc, status: setSummary };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const poll = async () => {
|
||||||
|
try {
|
||||||
|
const [n, v, l, s] = await Promise.all([
|
||||||
|
axios.get('/api/nodes'),
|
||||||
|
axios.get('/api/vms'),
|
||||||
|
axios.get('/api/lxc'),
|
||||||
|
axios.get('/api/status'),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
setNodes(n.data.data);
|
||||||
|
setVms(v.data.data);
|
||||||
|
setLxc(l.data.data);
|
||||||
|
setSummary(s.data.data);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
const id = setInterval(poll, intervalMs);
|
||||||
|
return () => { cancelled = true; clearInterval(id); };
|
||||||
|
}, [intervalMs]);
|
||||||
|
|
||||||
const handlePowerAction = async (node, vmid, type, action) => {
|
const handlePowerAction = async (node, vmid, type, action) => {
|
||||||
const label = action.charAt(0).toUpperCase() + action.slice(1);
|
const label = action.charAt(0).toUpperCase() + action.slice(1);
|
||||||
const kind = type === 'qemu' ? 'VM' : 'LXC';
|
const kind = type === 'qemu' ? 'VM' : 'LXC';
|
||||||
setPowerActionStatus({ text: `${label}ing ${kind} ${vmid}...`, type: 'pending' });
|
setPowerActionStatus({ text: `${label}ing ${kind} ${vmid}...`, type: 'pending' });
|
||||||
// Flush the pending status so it paints before the API call
|
|
||||||
await new Promise((r) => requestAnimationFrame(r));
|
await new Promise((r) => requestAnimationFrame(r));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post('/api/power', { node, vmid, type, action });
|
await axios.post('/api/power', { node, vmid, type, action });
|
||||||
// Re-fetch after success
|
await pollUrls().map((url) => axios.get(url));
|
||||||
const [nodesRes, vmsRes, lxcRes, statusRes] = await Promise.all([
|
|
||||||
axios.get('/api/nodes'),
|
|
||||||
axios.get('/api/vms'),
|
|
||||||
axios.get('/api/lxc'),
|
|
||||||
axios.get('/api/status'),
|
|
||||||
]);
|
|
||||||
setPowerActionStatus({ text: `${label} successful`, type: 'success' });
|
setPowerActionStatus({ text: `${label} successful`, type: 'success' });
|
||||||
setNodes(nodesRes.data.data);
|
// Sync state from poll responses
|
||||||
setVms(vmsRes.data.data);
|
setNodes(nodes.map((n) => n));
|
||||||
setLxc(lxcRes.data.data);
|
setVms(vms.map((v) => v));
|
||||||
setSummary(statusRes.data.data);
|
setLxc(lxc.map((l) => l));
|
||||||
// Auto-dismiss after 3 seconds
|
|
||||||
setTimeout(() => setPowerActionStatus(null), 3000);
|
setTimeout(() => setPowerActionStatus(null), 3000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setPowerActionStatus({ text: err.message || `${label} failed`, type: 'error' });
|
setPowerActionStatus({ text: err.message || `${label} failed`, type: 'error' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const [nodes, setNodes] = useState([]);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [vms, setVms] = useState([]);
|
const handleRefresh = async () => {
|
||||||
const [lxc, setLxc] = useState([]);
|
setIsRefreshing(true);
|
||||||
const [summary, setSummary] = useState(null);
|
try {
|
||||||
|
await Promise.all(pollUrls().map((url) => axios.get(url)));
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { error: nodesError } = useSWR('/api/nodes', fetchData, {
|
const handleIntervalChange = (e) => {
|
||||||
refreshInterval: POLL_INTERVAL,
|
const v = Number(e.target.value);
|
||||||
fallbackData: { data: [] },
|
if (v > 0) setIntervalMs(v);
|
||||||
onSuccess: (data) => setNodes(data.data),
|
};
|
||||||
});
|
|
||||||
const { error: vmsError } = useSWR('/api/vms', fetchData, {
|
const [intervalInput, setIntervalInput] = useState(DEFAULT_INTERVAL);
|
||||||
refreshInterval: POLL_INTERVAL,
|
useEffect(() => { setIntervalInput(intervalMs); }, [intervalMs]);
|
||||||
fallbackData: { data: [] },
|
|
||||||
onSuccess: (data) => setVms(data.data),
|
const handleIntervalBlur = () => {
|
||||||
});
|
handleIntervalChange({ target: { value: intervalInput } });
|
||||||
const { error: lxcError } = useSWR('/api/lxc', fetchData, {
|
};
|
||||||
refreshInterval: POLL_INTERVAL,
|
|
||||||
fallbackData: { data: [] },
|
const handleOpenConsole = (vm) => {
|
||||||
onSuccess: (data) => setLxc(data.data),
|
window.open(vm.consoleUrl, '_blank');
|
||||||
});
|
};
|
||||||
const { error: summaryError } = useSWR('/api/status', fetchData, {
|
|
||||||
refreshInterval: POLL_INTERVAL,
|
|
||||||
fallbackData: { data: null },
|
|
||||||
onSuccess: (data) => setSummary(data.data),
|
|
||||||
});
|
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'nodes', label: 'Nodes' },
|
{ id: 'nodes', label: 'Nodes' },
|
||||||
@@ -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,17 +117,30 @@ export default function Dashboard() {
|
|||||||
{new Date().toLocaleTimeString()}
|
{new Date().toLocaleTimeString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex gap-2 self-start sm:self-auto">
|
||||||
onClick={handleRefresh}
|
<input
|
||||||
disabled={isRefreshing}
|
type="number"
|
||||||
className="self-start rounded-md border border-border-card bg-bg-card px-3 py-1.5 text-sm font-medium text-text-secondary hover:border-accent hover:text-accent disabled:opacity-50"
|
min="1000"
|
||||||
>
|
step="1000"
|
||||||
{isRefreshing ? 'Refreshing…' : '↻ Refresh'}
|
value={intervalInput}
|
||||||
</button>
|
onChange={(e) => setIntervalInput(Number(e.target.value))}
|
||||||
|
onBlur={handleIntervalBlur}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.currentTarget.blur(); } }}
|
||||||
|
className="w-24 rounded-md border border-border-card bg-bg-card px-2 py-1.5 text-sm text-text-primary focus:border-accent focus:outline-none"
|
||||||
|
placeholder="ms"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="self-start rounded-md border border-border-card bg-bg-card px-3 py-1.5 text-sm font-medium text-text-secondary hover:border-accent hover:text-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRefreshing ? 'Refreshing…' : '↻ Refresh'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
{summary && !summaryError && (
|
{summary && !error && (
|
||||||
<div className="mb-6 grid gap-4 sm:grid-cols-4">
|
<div className="mb-6 grid gap-4 sm:grid-cols-4">
|
||||||
<div className="rounded-xl border border-border-card bg-bg-card p-4">
|
<div className="rounded-xl border border-border-card bg-bg-card p-4">
|
||||||
<div className="text-xs uppercase tracking-wider text-text-secondary">Nodes</div>
|
<div className="text-xs uppercase tracking-wider text-text-secondary">Nodes</div>
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user