|
|
|
@@ -31,60 +31,58 @@ export default function Dashboard() {
|
|
|
|
const [activeTab, setActiveTab] = useState('nodes');
|
|
|
|
const [activeTab, setActiveTab] = useState('nodes');
|
|
|
|
const [activeConsole, setActiveConsole] = useState(null);
|
|
|
|
const [activeConsole, setActiveConsole] = useState(null);
|
|
|
|
const [powerActionStatus, setPowerActionStatus] = useState(null);
|
|
|
|
const [powerActionStatus, setPowerActionStatus] = useState(null);
|
|
|
|
|
|
|
|
// Optimistic local state — updated before API response
|
|
|
|
|
|
|
|
const [vmsOptimistic, setVmsOptimistic] = useState(null);
|
|
|
|
|
|
|
|
const [lxcOptimistic, setLxcOptimistic] = useState(null);
|
|
|
|
|
|
|
|
|
|
|
|
const handlePowerAction = async (node, vmid, type, action) => {
|
|
|
|
const handlePowerAction = async (node, vmid, type, action) => {
|
|
|
|
const label = action.charAt(0).toUpperCase() + action.slice(1);
|
|
|
|
setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)}ing ${type === 'qemu' ? 'VM' : 'LXC'} ${vmid}...`, type: 'pending' });
|
|
|
|
const kind = type === 'qemu' ? 'VM' : 'LXC';
|
|
|
|
|
|
|
|
setPowerActionStatus({ text: `${label}ing ${kind} ${vmid}...`, type: 'pending' });
|
|
|
|
// Optimistic UI update — change happens instantly
|
|
|
|
// Flush the pending status so it paints before the API call
|
|
|
|
if (type === 'qemu') {
|
|
|
|
await new Promise((r) => requestAnimationFrame(r));
|
|
|
|
setVmsOptimistic((prev) =>
|
|
|
|
|
|
|
|
(prev ?? []).map((vm) => vm.vmid === vmid ? { ...vm, status: action === 'stop' || action === 'restart' ? 'stopped' : 'running' } : vm),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
} else {
|
|
|
|
|
|
|
|
setLxcOptimistic((prev) =>
|
|
|
|
|
|
|
|
(prev ?? []).map((lxc) => lxc.vmid === vmid ? { ...lxc, status: action === 'stop' || action === 'restart' ? 'stopped' : 'running' } : lxc),
|
|
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
try {
|
|
|
|
await axios.post('/api/power', { node, vmid, type, action });
|
|
|
|
await axios.post('/api/power', { node, vmid, type, action });
|
|
|
|
// Re-fetch after success
|
|
|
|
setPowerActionStatus({ text: `${action.charAt(0).toUpperCase() + action.slice(1)} successful`, type: 'success' });
|
|
|
|
const [nodesRes, vmsRes, lxcRes, statusRes] = await Promise.all([
|
|
|
|
// Clear optimistic after successful API call — next refetch will reconcile
|
|
|
|
axios.get('/api/nodes'),
|
|
|
|
setTimeout(() => {
|
|
|
|
axios.get('/api/vms'),
|
|
|
|
setVmsOptimistic(null);
|
|
|
|
axios.get('/api/lxc'),
|
|
|
|
setLxcOptimistic(null);
|
|
|
|
axios.get('/api/status'),
|
|
|
|
}, 5000);
|
|
|
|
]);
|
|
|
|
|
|
|
|
setPowerActionStatus({ text: `${label} successful`, type: 'success' });
|
|
|
|
|
|
|
|
setNodes(nodesRes.data.data);
|
|
|
|
|
|
|
|
setVms(vmsRes.data.data);
|
|
|
|
|
|
|
|
setLxc(lxcRes.data.data);
|
|
|
|
|
|
|
|
setSummary(statusRes.data.data);
|
|
|
|
|
|
|
|
// Auto-dismiss after 3 seconds
|
|
|
|
|
|
|
|
setTimeout(() => setPowerActionStatus(null), 3000);
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
} catch (err) {
|
|
|
|
setPowerActionStatus({ text: err.message || `${label} failed`, type: 'error' });
|
|
|
|
// Revert optimistic update on failure
|
|
|
|
|
|
|
|
setVmsOptimistic(null);
|
|
|
|
|
|
|
|
setLxcOptimistic(null);
|
|
|
|
|
|
|
|
setPowerActionStatus({ text: err.message || `${action.charAt(0).toUpperCase() + action.slice(1)} failed`, type: 'error' });
|
|
|
|
}
|
|
|
|
}
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const [nodes, setNodes] = useState([]);
|
|
|
|
const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, {
|
|
|
|
const [vms, setVms] = useState([]);
|
|
|
|
refreshInterval: POLL_INTERVAL,
|
|
|
|
const [lxc, setLxc] = useState([]);
|
|
|
|
});
|
|
|
|
const [summary, setSummary] = useState(null);
|
|
|
|
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 { error: nodesError } = useSWR('/api/nodes', fetchData, {
|
|
|
|
// Use optimistic data when available, fall back to SWR data
|
|
|
|
refreshInterval: POLL_INTERVAL,
|
|
|
|
const nodes = nodesData?.data ?? [];
|
|
|
|
fallbackData: { data: [] },
|
|
|
|
const vms = vmsOptimistic ?? (vmsData?.data ?? []);
|
|
|
|
onSuccess: (data) => setNodes(data.data),
|
|
|
|
const lxc = lxcOptimistic ?? (lxcData?.data ?? []);
|
|
|
|
});
|
|
|
|
const summary = summaryData?.data ?? null;
|
|
|
|
const { error: vmsError } = useSWR('/api/vms', fetchData, {
|
|
|
|
|
|
|
|
refreshInterval: POLL_INTERVAL,
|
|
|
|
|
|
|
|
fallbackData: { data: [] },
|
|
|
|
|
|
|
|
onSuccess: (data) => setVms(data.data),
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
const { error: lxcError } = useSWR('/api/lxc', fetchData, {
|
|
|
|
|
|
|
|
refreshInterval: POLL_INTERVAL,
|
|
|
|
|
|
|
|
fallbackData: { data: [] },
|
|
|
|
|
|
|
|
onSuccess: (data) => setLxc(data.data),
|
|
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
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' },
|
|
|
|
|