From 687fa8f52fb6560802ad632c7dfd23e14f059e37 Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Wed, 17 Jun 2026 03:41:08 +0000 Subject: [PATCH] pre-refactor --- .gitignore | 1 + restart-services.sh | 1 + webui/server.py | 2 +- webui/static/app.js | 26 +++++--------- webui/static/hoover/api.js | 3 ++ webui/static/hoover/component.js | 60 ++++++++++++++++++++++---------- webui/static/hoover/render.js | 6 +++- webui/static/hoover/websocket.js | 4 +-- webui/static/index.html | 9 +++-- webui/static/pages/certs.js | 17 ++++++--- webui/static/pages/dashboard.js | 18 ++++++---- webui/static/pages/dhcp.js | 23 ++++++++---- webui/static/pages/interfaces.js | 19 ++++++---- webui/static/pages/logs.js | 22 ++++++++---- webui/static/pages/nat.js | 20 +++++++---- webui/static/pages/proxy.js | 20 +++++++---- webui/static/pages/rules.js | 20 +++++++---- webui/static/pages/wireguard.js | 23 ++++++++---- webui/static/pages/zones.js | 26 +++++++++----- 19 files changed, 215 insertions(+), 105 deletions(-) diff --git a/.gitignore b/.gitignore index fe26a87..e6b49eb 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ __pycache__/ # Local AI tool config (contains internal hostnames) opencode.json opencode.json.pwenv +PLAN.md # Playwright MCP artifacts .playwright-mcp/ diff --git a/restart-services.sh b/restart-services.sh index f296481..6cb1e6b 100755 --- a/restart-services.sh +++ b/restart-services.sh @@ -4,6 +4,7 @@ systemctl restart nginx systemctl restart vacuum-wall systemctl restart vacuum-walld +systemctl status nginx systemctl status vacuum-wall systemctl status vacuum-walld diff --git a/webui/server.py b/webui/server.py index 4b6fb33..617aefd 100644 --- a/webui/server.py +++ b/webui/server.py @@ -197,7 +197,7 @@ def spa_page(path=""): scheme = "wss" if request.is_secure else "ws" ws_url = f"{scheme}://{request.host}/ws" html = (SPA_DIR / "index.html").read_text() - return html.replace("__WS_URL__", ws_url) + return html.replace("__WS_URL_PLACEHOLDER__", ws_url) if __name__ == "__main__": diff --git a/webui/static/app.js b/webui/static/app.js index f426c9e..fdd34a3 100644 --- a/webui/static/app.js +++ b/webui/static/app.js @@ -55,7 +55,7 @@ window.addEventListener('hashchange', () => { router.state.path = location.hash.slice(1) || '/dashboard'; }); -/* ── Sidebar component ─────────────────────────────────────── */ +/* ── Sidebar render root ───────────────────────────────────── */ function Sidebar() { const current = router.state.path; return h('div', { class: 'sidebar' }, @@ -72,29 +72,21 @@ function Sidebar() { ); } -/* ── Route component wrapper ───────────────────────────────── */ -function RouteComponent() { - return router.component(); -} - -/* ── App layout ────────────────────────────────────────────── */ -function AppLayout() { +/* ── Main content render root ──────────────────────────────── */ +function MainContent() { return [ - h('div', { class: 'layout' }, - Sidebar(), - h('div', { class: 'main' }, - RouteComponent(), - ), - ), + router.component(), ToastContainer(), ]; } /* ── Init ──────────────────────────────────────────────────── */ export function initApp() { - const appEl = document.getElementById('app'); - if (appEl) { - render(appEl, AppLayout); + const sidebarEl = document.getElementById('sidebar'); + const mainEl = document.getElementById('main'); + if (sidebarEl && mainEl) { + render(sidebarEl, Sidebar); + render(mainEl, MainContent); } // Defer connect() after the first render microtask settles to prevent // the initial requestUpdate() from triggering a second commit while diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index 32b4f6e..5cd06d1 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -29,6 +29,9 @@ export async function apiFetch(url, options = {}) { try { const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); + if (opts.signal?.aborted) { + return { ok: false, data: null, error: 'Aborted', status: 0 }; + } if (res.status === 401) { window.location.reload(); return { ok: false, data: null, error: 'Session expired', status: 401 }; diff --git a/webui/static/hoover/component.js b/webui/static/hoover/component.js index 056b1ed..073e257 100644 --- a/webui/static/hoover/component.js +++ b/webui/static/hoover/component.js @@ -20,13 +20,19 @@ import { reactive } from './reactivity.js'; import { h } from './vdom.js'; import { _compExpandedCache } from './render.js'; -/** - * Registry of mounted components: key → { state, subscriptions, loadAbort, entry } - */ +/** Registry of mounted components: key → { state, subscriptions, loadAbort, entry, isLoading } */ const _mounted = new Map(); -/** - * External subscribe function from websocket.js. +/** Check whether a state object belongs to a currently mounted component. + * Used by websocket.js to skip auto-refresh for unmounted pages. */ +export function isComponentStateMounted(state) { + for (const entry of _mounted.values()) { + if (entry.state === state) return true; + } + return false; +} + +/** External subscribe function from websocket.js. * Set via setSubscribeFn() when the websocket module initializes. */ let _subscribeFn = null; @@ -67,29 +73,45 @@ export function definePage(def) { * enters the tree for the first time. */ export function mountComponent(key, renderer) { - // Prevent duplicate mounts when normalization loses #comp tracking - if (_mounted.has(key)) return; - const pd = renderer._pageDef; if (!pd) return; - const entry = { - state: pd.state, - subscriptions: [], - loadAbort: null, - }; + let entry = _mounted.get(key); - _mounted.set(key, entry); + if (entry) { + // Re-mount of an already-mounted page: restart load with fresh AbortController + if (entry.loadAbort) { + entry.loadAbort.abort(); + } + entry.requestId++; + entry.loadAbort = null; + } else { + // Fresh mount + entry = { + state: pd.state, + subscriptions: [], + loadAbort: null, + requestId: 0, + }; + _mounted.set(key, entry); + } - // Fire load + // Clear error on re-mount; load() decides loading vs refreshing + pd.state.error = null; + + // Fire load with fresh AbortController if (pd.load) { const abortController = new AbortController(); entry.loadAbort = abortController; - pd.load(pd.state, abortController); + entry.requestId++; + entry.isLoading = true; + Promise.resolve() + .then(() => pd.load(pd.state, abortController, entry)) + .finally(() => { entry.isLoading = false; }); } - // Register WS subscriptions - if (_subscribeFn && pd.subscribe.length) { + // Register WS subscriptions (only on fresh mount) + if (!entry.subscriptions.length && _subscribeFn && pd.subscribe.length) { for (const topic of pd.subscribe) { const unsub = _subscribeFn(renderer, topic, pd.load, pd.state); if (unsub) entry.subscriptions.push(unsub); @@ -111,6 +133,8 @@ export function unmountComponent(key, renderer) { if (entry.loadAbort) { entry.loadAbort.abort(); } + // Invalidate any in-flight callbacks + entry.requestId++; // Unsubscribe from WS for (const unsub of entry.subscriptions) { diff --git a/webui/static/hoover/render.js b/webui/static/hoover/render.js index 8bb0cf4..22839b2 100644 --- a/webui/static/hoover/render.js +++ b/webui/static/hoover/render.js @@ -218,7 +218,11 @@ function diffContainer(container, prev, vnodes) { if (oldDom?.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom); const nd = createDom(newV); _vnodeDom.set(newV, nd); - if (oldDom?.parentNode) oldDom.parentNode.replaceChild(nd, oldDom); + if (oldDom?.parentNode) { + oldDom.parentNode.replaceChild(nd, oldDom); + } else if (nd.parentNode !== container) { + container.insertBefore(nd, lastDom ? lastDom.nextSibling : null); + } lastDom = nd; } } diff --git a/webui/static/hoover/websocket.js b/webui/static/hoover/websocket.js index 7049109..eb8b518 100644 --- a/webui/static/hoover/websocket.js +++ b/webui/static/hoover/websocket.js @@ -8,7 +8,7 @@ * auto-refresh messages from the backend can trigger page reloads. */ -import { setSubscribeFn } from './component.js'; +import { setSubscribeFn, isComponentStateMounted } from './component.js'; const _wsSubs = new Map(); let _wsConn = null; @@ -74,7 +74,7 @@ function handleMessage(msg) { } for (const s of _wsSubs.values()) { - if (s.unsubscribed) continue; + if (s.unsubscribed || !isComponentStateMounted(s.state)) continue; if (s.topic === '*') { s.loadFn(s.state); } else if (topics.some(t => t === s.topic || t === '*')) { diff --git a/webui/static/index.html b/webui/static/index.html index 8f766db..1c755dc 100644 --- a/webui/static/index.html +++ b/webui/static/index.html @@ -7,8 +7,13 @@ -
- +
+
+ +
+
+
+ diff --git a/webui/static/pages/certs.js b/webui/static/pages/certs.js index d654404..5ff37bd 100644 --- a/webui/static/pages/certs.js +++ b/webui/static/pages/certs.js @@ -57,29 +57,36 @@ async function pollCertIssue(rid, state) { }, 2000); } -async function load(state) { +async function load(state, abortController, entry) { + if (state.certs?.length) state.refreshing = true; + else state.loading = true; try { - const r = await apiFetch('/api/certs/list'); + const myId = entry ? entry.requestId : 0; + const sig = abortController?.signal; + const r = await apiFetch('/api/certs/list', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (r.ok) state.certs = r.data || []; else state.error = r.error; } catch (e) { + if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; } export default definePage({ init() { - return { certs: [], loading: true, error: null }; + return { certs: [], loading: true, refreshing: false, error: null }; }, subscribe: ['acme'], load, render(state) { - if (state.loading) { + if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'Certificates' }), h('div', { class: 'card', key: 'loading' }, - h('div', { class: 'card-body loading' }, 'Loading...'), + h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; } diff --git a/webui/static/pages/dashboard.js b/webui/static/pages/dashboard.js index 241af10..0d8a9b3 100644 --- a/webui/static/pages/dashboard.js +++ b/webui/static/pages/dashboard.js @@ -2,25 +2,31 @@ import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val, export default definePage({ init() { - return { data: null, loading: true, error: null }; + return { data: null, loading: true, refreshing: false, error: null }; }, subscribe: ['*'], - async load(state) { + async load(state, abortController, entry) { + const myId = entry ? entry.requestId : 0; + if (state.data) state.refreshing = true; + else state.loading = true; try { - const res = await apiFetch('/api/status/all'); + const res = await apiFetch('/api/status/all', { signal: abortController?.signal }); + if (abortController?.signal.aborted || entry.requestId !== myId) return; if (res.ok) state.data = res.data; else state.error = res.error; } catch (e) { + if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; }, render(state) { - if (state.loading) { + if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), - h('div', { class: 'card', key: 'loading' }, - h('div', { class: 'card-body loading' }, 'Loading...'), + h('div', { class: 'card', key: 'loading' }, + h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; } diff --git a/webui/static/pages/dhcp.js b/webui/static/pages/dhcp.js index caa3109..55df974 100644 --- a/webui/static/pages/dhcp.js +++ b/webui/static/pages/dhcp.js @@ -120,32 +120,41 @@ function addDnsModal(state) { }); } -async function load(state) { +async function load(state, abortController, entry) { + if (Object.keys(state.config || {}).length) state.refreshing = true; + else state.loading = true; try { - const cfgR = await apiFetch('/api/dhcp/config'); + const myId = entry ? entry.requestId : 0; + const sig = abortController?.signal; + const cfgR = await apiFetch('/api/dhcp/config', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (cfgR.ok) state.config = cfgR.data || {}; - const stR = await apiFetch('/api/dhcp/status'); + const stR = await apiFetch('/api/dhcp/status', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (stR.ok) state.status = stR.data || {}; - const lsR = await apiFetch('/api/dhcp/leases'); + const lsR = await apiFetch('/api/dhcp/leases', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (lsR.ok) state.leases = lsR.data || []; } catch (e) { + if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; } export default definePage({ init() { - return { config: {}, status: {}, leases: [], loading: true, error: null, activeTab: 'ranges' }; + return { config: {}, status: {}, leases: [], loading: true, refreshing: false, error: null, activeTab: 'ranges' }; }, subscribe: ['dnsmasq'], load, render(state) { - if (state.loading) { + if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'DHCP & DNS' }), h('div', { class: 'card', key: 'loading' }, - h('div', { class: 'card-body loading' }, 'Loading...'), + h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; } diff --git a/webui/static/pages/interfaces.js b/webui/static/pages/interfaces.js index a8f12ef..9b1812d 100644 --- a/webui/static/pages/interfaces.js +++ b/webui/static/pages/interfaces.js @@ -50,12 +50,17 @@ function cfgModal(name, state) { }); } -async function load(state) { +async function load(state, abortController, entry) { + if (state.ifaces?.length) state.refreshing = true; + else state.loading = true; try { + const myId = entry ? entry.requestId : 0; + const sig = abortController?.signal; const [fw, net] = await Promise.all([ - apiFetch('/api/firewall/zones'), - apiFetch('/api/network/interfaces'), + apiFetch('/api/firewall/zones', { signal: sig }), + apiFetch('/api/network/interfaces', { signal: sig }), ]); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; // Extract zone names from available zones (for the dropdown) state.zones = fw.ok ? (fw.data?.available || []) : []; if (net.ok) { @@ -78,23 +83,25 @@ async function load(state) { state.error = net.error; } } catch (e) { + if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; } export default definePage({ init() { - return { ifaces: [], zones: [], loading: true, error: null }; + return { ifaces: [], zones: [], loading: true, refreshing: false, error: null }; }, subscribe: ['firewall', 'networkd'], load, render(state) { - if (state.loading) { + if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'Interfaces' }), h('div', { class: 'card', key: 'loading' }, - h('div', { class: 'card-body loading' }, 'Loading...'), + h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; } diff --git a/webui/static/pages/logs.js b/webui/static/pages/logs.js index 75b4738..591b981 100644 --- a/webui/static/pages/logs.js +++ b/webui/static/pages/logs.js @@ -9,27 +9,35 @@ const logTabs = [ ]; -async function fetchLog(state, url) { +async function fetchLog(state, url, signal) { state.loading = true; state.error = null; try { - const res = await fetch(url); + const res = await fetch(url, { signal }); + if (signal?.aborted) return; const text = await res.text(); + if (signal?.aborted) return; state.lines = text.split('\n').filter(l => l.length > 0); } catch (e) { + if (signal?.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; } export default definePage({ init() { - return { activeTab: 'journal', lines: [], loading: false, error: null }; + return { activeTab: 'journal', lines: [], loading: false, refreshing: false, error: null }; }, subscribe: [], - async load(state) { + async load(state, abortController, entry) { + if (state.lines?.length) state.refreshing = true; + else state.loading = true; + const myId = entry ? entry.requestId : 0; const tab = logTabs.find(t => t.key === state.activeTab) || logTabs[0]; - await fetchLog(state, tab.url); + await fetchLog(state, tab.url, abortController?.signal); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; }, onUnmount(state) { state.lines = []; @@ -65,8 +73,8 @@ export default definePage({ }, '\u21BB') ), h('div', { class: 'card-body log-body' }, - state.loading - ? h('div', { class: 'loading' }, 'Loading...') + state.loading && !state.refreshing + ? h('div', { class: 'loading' }, state.refreshing ? 'Refreshing...' : 'Loading...') : state.error ? h('div', { class: 'error-msg' }, state.error) : lineVnodes.length > 0 diff --git a/webui/static/pages/nat.js b/webui/static/pages/nat.js index 21cfe02..a4718d3 100644 --- a/webui/static/pages/nat.js +++ b/webui/static/pages/nat.js @@ -44,30 +44,38 @@ function addFwdModal(zones, state) { }); } -async function load(state) { +async function load(state, abortController, entry) { + if (Object.keys(state.config || {}).length) state.refreshing = true; + else state.loading = true; try { - const r = await apiFetch('/api/firewall/config'); + const myId = entry ? entry.requestId : 0; + const sig = abortController?.signal; + const r = await apiFetch('/api/firewall/config', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (r.ok) state.config = r.data || {}; - const zr = await apiFetch('/api/firewall/zones'); + const zr = await apiFetch('/api/firewall/zones', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (zr.ok) state.activeZones = Object.keys(zr.data?.active || {}); } catch (e) { + if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; } export default definePage({ init() { - return { config: {}, activeZones: [], loading: true, error: null }; + return { config: {}, activeZones: [], loading: true, refreshing: false, error: null }; }, subscribe: ['firewall'], load, render(state) { - if (state.loading) { + if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }), h('div', { class: 'card', key: 'loading' }, - h('div', { class: 'card-body loading' }, 'Loading...'), + h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; } diff --git a/webui/static/pages/proxy.js b/webui/static/pages/proxy.js index f34fa2b..18f5cba 100644 --- a/webui/static/pages/proxy.js +++ b/webui/static/pages/proxy.js @@ -82,30 +82,38 @@ function editDomainModal(domain, state) { }); } -async function load(state) { +async function load(state, abortController, entry) { + if (state.domains?.length) state.refreshing = true; + else state.loading = true; try { - const domainsR = await apiFetch('/api/proxy/domains'); + const myId = entry ? entry.requestId : 0; + const sig = abortController?.signal; + const domainsR = await apiFetch('/api/proxy/domains', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (domainsR.ok) state.domains = domainsR.data || []; - const certsR = await apiFetch('/api/certs/list'); + const certsR = await apiFetch('/api/certs/list', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (certsR.ok) state.certs = certsR.data || []; } catch (e) { + if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; } export default definePage({ init() { - return { domains: [], certs: [], loading: true, error: null }; + return { domains: [], certs: [], loading: true, refreshing: false, error: null }; }, subscribe: ['nginx', 'acme'], load, render(state) { - if (state.loading) { + if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'Proxy' }), h('div', { class: 'card', key: 'loading' }, - h('div', { class: 'card-body loading' }, 'Loading...'), + h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; } diff --git a/webui/static/pages/rules.js b/webui/static/pages/rules.js index f421542..70c40ad 100644 --- a/webui/static/pages/rules.js +++ b/webui/static/pages/rules.js @@ -33,31 +33,39 @@ function addRuleModal(zones, state) { }); } -async function load(state) { +async function load(state, abortController, entry) { + if (Object.keys(state.config || {}).length) state.refreshing = true; + else state.loading = true; try { - const r = await apiFetch('/api/firewall/config'); + const myId = entry ? entry.requestId : 0; + const sig = abortController?.signal; + const r = await apiFetch('/api/firewall/config', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (r.ok) state.config = r.data || {}; else state.error = r.error; - const zr = await apiFetch('/api/firewall/zones'); + const zr = await apiFetch('/api/firewall/zones', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (zr.ok) state.zones = Object.keys(zr.data?.active || {}); } catch (e) { + if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; } export default definePage({ init() { - return { config: {}, loading: true, error: null, zones: [] }; + return { config: {}, loading: true, refreshing: false, error: null, zones: [] }; }, subscribe: ['firewall'], load, render(state) { - if (state.loading) { + if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'Rules', subtitle: 'Firewall rich rules' }), h('div', { class: 'card', key: 'loading' }, - h('div', { class: 'card-body loading' }, 'Loading...'), + h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; } diff --git a/webui/static/pages/wireguard.js b/webui/static/pages/wireguard.js index 3cd88a5..c4b1137 100644 --- a/webui/static/pages/wireguard.js +++ b/webui/static/pages/wireguard.js @@ -76,32 +76,41 @@ function downloadConfigModal(peerName, config, state) { }); } -async function load(state) { +async function load(state, abortController, entry) { + if (state.peers?.length) state.refreshing = true; + else state.loading = true; try { - const stR = await apiFetch('/api/wireguard/status'); + const myId = entry ? entry.requestId : 0; + const sig = abortController?.signal; + const stR = await apiFetch('/api/wireguard/status', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (stR.ok) state.status = stR.data || {}; - const pR = await apiFetch('/api/wireguard/peers'); + const pR = await apiFetch('/api/wireguard/peers', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (pR.ok) state.peers = pR.data || []; - const cfgR = await apiFetch('/api/wireguard/config'); + const cfgR = await apiFetch('/api/wireguard/config', { signal: sig }); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; if (cfgR.ok) state.config = cfgR.data || {}; } catch (e) { + if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; } export default definePage({ init() { - return { status: {}, peers: [], config: {}, loading: true, error: null }; + return { status: {}, peers: [], config: {}, loading: true, refreshing: false, error: null }; }, subscribe: ['wireguard'], load, render(state) { - if (state.loading) { + if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'WireGuard' }), h('div', { class: 'card', key: 'loading' }, - h('div', { class: 'card-body loading' }, 'Loading...'), + h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; } diff --git a/webui/static/pages/zones.js b/webui/static/pages/zones.js index aa7cb90..6bbc885 100644 --- a/webui/static/pages/zones.js +++ b/webui/static/pages/zones.js @@ -107,24 +107,32 @@ function zoneSvcModal(zoneName, state) { }); } -async function load(state) { +async function load(state, abortController, entry) { + if (Object.keys(state.zones || {}).length) state.refreshing = true; + else state.loading = true; try { + const myId = entry ? entry.requestId : 0; + const sig = abortController?.signal; const [zRes, svcRes, ifRes] = await Promise.all([ - apiFetch('/api/firewall/zones'), - apiFetch('/api/firewall/services'), - apiFetch('/api/firewall/interfaces'), + apiFetch('/api/firewall/zones', { signal: sig }), + apiFetch('/api/firewall/services', { signal: sig }), + apiFetch('/api/firewall/interfaces', { signal: sig }), ]); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; + if (zRes.ok) { const data = zRes.data || {}; const activeZones = data.active || {}; const availableZones = data.available || []; const detailPromises = availableZones.map(name => - apiFetch('/api/firewall/zones/' + enc(name)).catch(() => null) + apiFetch('/api/firewall/zones/' + enc(name), { signal: sig }).catch(() => null) ); const detailResults = await Promise.all(detailPromises); + if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return; + const zones = {}; for (let i = 0; i < availableZones.length; i++) { const name = availableZones[i]; @@ -143,23 +151,25 @@ async function load(state) { if (svcRes.ok) state.services = svcRes.data || []; if (ifRes.ok) state.interfaces = ifRes.data || []; } catch (e) { + if (abortController?.signal.aborted) return; state.error = String(e); } state.loading = false; + state.refreshing = false; } export default definePage({ init() { - return { zones: {}, services: [], interfaces: [], loading: true, error: null }; + return { zones: {}, services: [], interfaces: [], loading: true, refreshing: false, error: null }; }, subscribe: ['firewall'], load, render(state) { - if (state.loading) { + if (state.loading && !state.refreshing) { return [ PageHeader({ title: 'Zones', subtitle: 'Firewall zone management' }), h('div', { class: 'card', key: 'loading' }, - h('div', { class: 'card-body loading' }, 'Loading...'), + h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'), ), ]; }