From bfc6bf8526dca36c178dcf30041d0048786551cc Mon Sep 17 00:00:00 2001 From: Rob Bond Date: Sun, 10 May 2026 19:18:35 +0100 Subject: [PATCH] fix: instant power action refresh with optimistic UI update Update VM/LXC cards immediately on click instead of waiting for API response or polling interval. On failure, revert the optimistic update. Background polling reconciles after 5 seconds. Co-Authored-By: Claude Opus 4.7 --- src/components/Dashboard.js | 44 +++++++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/src/components/Dashboard.js b/src/components/Dashboard.js index 3bacdaf..ee32bc2 100644 --- a/src/components/Dashboard.js +++ b/src/components/Dashboard.js @@ -1,12 +1,11 @@ 'use client'; import { useState } from 'react'; -import useSWR, { mutate } from 'swr'; +import useSWR 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); @@ -17,8 +16,6 @@ const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000) * @returns {Promise<{ data: *, error: string | null }>} */ async function fetchData(url) { - - try { const res = await axios.get(url); return res.data; @@ -34,24 +31,36 @@ export default function Dashboard() { const [activeTab, setActiveTab] = useState('nodes'); const [activeConsole, setActiveConsole] = useState(null); const [powerActionStatus, setPowerActionStatus] = useState(null); + // Optimistic local state — updated before API response + const [vmsOptimistic, setVmsOptimistic] = useState(null); + const [lxcOptimistic, setLxcOptimistic] = 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' }); + + // Optimistic UI update — change happens instantly + if (type === 'qemu') { + 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 { 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' }); + // Clear optimistic after successful API call — next refetch will reconcile + setTimeout(() => { + setVmsOptimistic(null); + setLxcOptimistic(null); + }, 5000); } catch (err) { + // Revert optimistic update on failure + setVmsOptimistic(null); + setLxcOptimistic(null); setPowerActionStatus({ text: err.message || `${action.charAt(0).toUpperCase() + action.slice(1)} failed`, type: 'error' }); } }; @@ -69,9 +78,10 @@ export default function Dashboard() { refreshInterval: POLL_INTERVAL, }); + // Use optimistic data when available, fall back to SWR data const nodes = nodesData?.data ?? []; - const vms = vmsData?.data ?? []; - const lxc = lxcData?.data ?? []; + const vms = vmsOptimistic ?? (vmsData?.data ?? []); + const lxc = lxcOptimistic ?? (lxcData?.data ?? []); const summary = summaryData?.data ?? null; const tabs = [