pre-refactor

This commit is contained in:
2026-06-17 03:41:08 +00:00
parent 318d7169f7
commit 687fa8f52f
19 changed files with 215 additions and 105 deletions
+1
View File
@@ -15,6 +15,7 @@ __pycache__/
# Local AI tool config (contains internal hostnames) # Local AI tool config (contains internal hostnames)
opencode.json opencode.json
opencode.json.pwenv opencode.json.pwenv
PLAN.md
# Playwright MCP artifacts # Playwright MCP artifacts
.playwright-mcp/ .playwright-mcp/
+1
View File
@@ -4,6 +4,7 @@ systemctl restart nginx
systemctl restart vacuum-wall systemctl restart vacuum-wall
systemctl restart vacuum-walld systemctl restart vacuum-walld
systemctl status nginx
systemctl status vacuum-wall systemctl status vacuum-wall
systemctl status vacuum-walld systemctl status vacuum-walld
+1 -1
View File
@@ -197,7 +197,7 @@ def spa_page(path=""):
scheme = "wss" if request.is_secure else "ws" scheme = "wss" if request.is_secure else "ws"
ws_url = f"{scheme}://{request.host}/ws" ws_url = f"{scheme}://{request.host}/ws"
html = (SPA_DIR / "index.html").read_text() 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__": if __name__ == "__main__":
+9 -17
View File
@@ -55,7 +55,7 @@ window.addEventListener('hashchange', () => {
router.state.path = location.hash.slice(1) || '/dashboard'; router.state.path = location.hash.slice(1) || '/dashboard';
}); });
/* ── Sidebar component ─────────────────────────────────────── */ /* ── Sidebar render root ───────────────────────────────────── */
function Sidebar() { function Sidebar() {
const current = router.state.path; const current = router.state.path;
return h('div', { class: 'sidebar' }, return h('div', { class: 'sidebar' },
@@ -72,29 +72,21 @@ function Sidebar() {
); );
} }
/* ── Route component wrapper ───────────────────────────────── */ /* ── Main content render root ──────────────────────────────── */
function RouteComponent() { function MainContent() {
return router.component();
}
/* ── App layout ────────────────────────────────────────────── */
function AppLayout() {
return [ return [
h('div', { class: 'layout' }, router.component(),
Sidebar(),
h('div', { class: 'main' },
RouteComponent(),
),
),
ToastContainer(), ToastContainer(),
]; ];
} }
/* ── Init ──────────────────────────────────────────────────── */ /* ── Init ──────────────────────────────────────────────────── */
export function initApp() { export function initApp() {
const appEl = document.getElementById('app'); const sidebarEl = document.getElementById('sidebar');
if (appEl) { const mainEl = document.getElementById('main');
render(appEl, AppLayout); if (sidebarEl && mainEl) {
render(sidebarEl, Sidebar);
render(mainEl, MainContent);
} }
// Defer connect() after the first render microtask settles to prevent // Defer connect() after the first render microtask settles to prevent
// the initial requestUpdate() from triggering a second commit while // the initial requestUpdate() from triggering a second commit while
+3
View File
@@ -29,6 +29,9 @@ export async function apiFetch(url, options = {}) {
try { try {
const res = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); 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) { if (res.status === 401) {
window.location.reload(); window.location.reload();
return { ok: false, data: null, error: 'Session expired', status: 401 }; return { ok: false, data: null, error: 'Session expired', status: 401 };
+38 -14
View File
@@ -20,13 +20,19 @@ import { reactive } from './reactivity.js';
import { h } from './vdom.js'; import { h } from './vdom.js';
import { _compExpandedCache } from './render.js'; import { _compExpandedCache } from './render.js';
/** /** Registry of mounted components: key → { state, subscriptions, loadAbort, entry, isLoading } */
* Registry of mounted components: key → { state, subscriptions, loadAbort, entry }
*/
const _mounted = new Map(); const _mounted = new Map();
/** /** Check whether a state object belongs to a currently mounted component.
* External subscribe function from websocket.js. * 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. * Set via setSubscribeFn() when the websocket module initializes.
*/ */
let _subscribeFn = null; let _subscribeFn = null;
@@ -67,29 +73,45 @@ export function definePage(def) {
* enters the tree for the first time. * enters the tree for the first time.
*/ */
export function mountComponent(key, renderer) { export function mountComponent(key, renderer) {
// Prevent duplicate mounts when normalization loses #comp tracking
if (_mounted.has(key)) return;
const pd = renderer._pageDef; const pd = renderer._pageDef;
if (!pd) return; if (!pd) return;
const entry = { let entry = _mounted.get(key);
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, state: pd.state,
subscriptions: [], subscriptions: [],
loadAbort: null, loadAbort: null,
requestId: 0,
}; };
_mounted.set(key, entry); _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) { if (pd.load) {
const abortController = new AbortController(); const abortController = new AbortController();
entry.loadAbort = 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 // Register WS subscriptions (only on fresh mount)
if (_subscribeFn && pd.subscribe.length) { if (!entry.subscriptions.length && _subscribeFn && pd.subscribe.length) {
for (const topic of pd.subscribe) { for (const topic of pd.subscribe) {
const unsub = _subscribeFn(renderer, topic, pd.load, pd.state); const unsub = _subscribeFn(renderer, topic, pd.load, pd.state);
if (unsub) entry.subscriptions.push(unsub); if (unsub) entry.subscriptions.push(unsub);
@@ -111,6 +133,8 @@ export function unmountComponent(key, renderer) {
if (entry.loadAbort) { if (entry.loadAbort) {
entry.loadAbort.abort(); entry.loadAbort.abort();
} }
// Invalidate any in-flight callbacks
entry.requestId++;
// Unsubscribe from WS // Unsubscribe from WS
for (const unsub of entry.subscriptions) { for (const unsub of entry.subscriptions) {
+5 -1
View File
@@ -218,7 +218,11 @@ function diffContainer(container, prev, vnodes) {
if (oldDom?.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom); if (oldDom?.nodeType === Node.ELEMENT_NODE) sweepDom(oldDom);
const nd = createDom(newV); const nd = createDom(newV);
_vnodeDom.set(newV, nd); _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; lastDom = nd;
} }
} }
+2 -2
View File
@@ -8,7 +8,7 @@
* auto-refresh messages from the backend can trigger page reloads. * 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(); const _wsSubs = new Map();
let _wsConn = null; let _wsConn = null;
@@ -74,7 +74,7 @@ function handleMessage(msg) {
} }
for (const s of _wsSubs.values()) { for (const s of _wsSubs.values()) {
if (s.unsubscribed) continue; if (s.unsubscribed || !isComponentStateMounted(s.state)) continue;
if (s.topic === '*') { if (s.topic === '*') {
s.loadFn(s.state); s.loadFn(s.state);
} else if (topics.some(t => t === s.topic || t === '*')) { } else if (topics.some(t => t === s.topic || t === '*')) {
+7 -2
View File
@@ -7,8 +7,13 @@
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
</head> </head>
<body> <body>
<div id="app"></div> <div id="app">
<script>window.__WS_URL__ = "__WS_URL__"</script> <div class="layout">
<div id="sidebar"></div>
<div class="main" id="main"></div>
</div>
</div>
<script>window.__WS_URL__ = "__WS_URL_PLACEHOLDER__"</script>
<script type="module" src="/static/app.js?v=4"></script> <script type="module" src="/static/app.js?v=4"></script>
</body> </body>
</html> </html>
+12 -5
View File
@@ -57,29 +57,36 @@ async function pollCertIssue(rid, state) {
}, 2000); }, 2000);
} }
async function load(state) { async function load(state, abortController, entry) {
if (state.certs?.length) state.refreshing = true;
else state.loading = true;
try { 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 || []; if (r.ok) state.certs = r.data || [];
else state.error = r.error; else state.error = r.error;
} catch (e) { } catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { certs: [], loading: true, error: null }; return { certs: [], loading: true, refreshing: false, error: null };
}, },
subscribe: ['acme'], subscribe: ['acme'],
load, load,
render(state) { render(state) {
if (state.loading) { if (state.loading && !state.refreshing) {
return [ return [
PageHeader({ title: 'Certificates' }), PageHeader({ title: 'Certificates' }),
h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'), h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
), ),
]; ];
} }
+11 -5
View File
@@ -2,25 +2,31 @@ import { h, PageHeader, Badge, StatusDot, Empty, Card, esc, enc, att_esc, $val,
export default definePage({ export default definePage({
init() { init() {
return { data: null, loading: true, error: null }; return { data: null, loading: true, refreshing: false, error: null };
}, },
subscribe: ['*'], 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 { 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; if (res.ok) state.data = res.data;
else state.error = res.error; else state.error = res.error;
} catch (e) { } catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
}, },
render(state) { render(state) {
if (state.loading) { if (state.loading && !state.refreshing) {
return [ return [
PageHeader({ title: 'Dashboard', subtitle: 'System overview' }), PageHeader({ title: 'Dashboard', subtitle: 'System overview' }),
h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'), h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
), ),
]; ];
} }
+16 -7
View File
@@ -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 { 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 || {}; 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 || {}; 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 || []; if (lsR.ok) state.leases = lsR.data || [];
} catch (e) { } catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { 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'], subscribe: ['dnsmasq'],
load, load,
render(state) { render(state) {
if (state.loading) { if (state.loading && !state.refreshing) {
return [ return [
PageHeader({ title: 'DHCP & DNS' }), PageHeader({ title: 'DHCP & DNS' }),
h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'), h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
), ),
]; ];
} }
+13 -6
View File
@@ -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 { try {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const [fw, net] = await Promise.all([ const [fw, net] = await Promise.all([
apiFetch('/api/firewall/zones'), apiFetch('/api/firewall/zones', { signal: sig }),
apiFetch('/api/network/interfaces'), apiFetch('/api/network/interfaces', { signal: sig }),
]); ]);
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
// Extract zone names from available zones (for the dropdown) // Extract zone names from available zones (for the dropdown)
state.zones = fw.ok ? (fw.data?.available || []) : []; state.zones = fw.ok ? (fw.data?.available || []) : [];
if (net.ok) { if (net.ok) {
@@ -78,23 +83,25 @@ async function load(state) {
state.error = net.error; state.error = net.error;
} }
} catch (e) { } catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { ifaces: [], zones: [], loading: true, error: null }; return { ifaces: [], zones: [], loading: true, refreshing: false, error: null };
}, },
subscribe: ['firewall', 'networkd'], subscribe: ['firewall', 'networkd'],
load, load,
render(state) { render(state) {
if (state.loading) { if (state.loading && !state.refreshing) {
return [ return [
PageHeader({ title: 'Interfaces' }), PageHeader({ title: 'Interfaces' }),
h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'), h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
), ),
]; ];
} }
+15 -7
View File
@@ -9,27 +9,35 @@ const logTabs = [
]; ];
async function fetchLog(state, url) { async function fetchLog(state, url, signal) {
state.loading = true; state.loading = true;
state.error = null; state.error = null;
try { try {
const res = await fetch(url); const res = await fetch(url, { signal });
if (signal?.aborted) return;
const text = await res.text(); const text = await res.text();
if (signal?.aborted) return;
state.lines = text.split('\n').filter(l => l.length > 0); state.lines = text.split('\n').filter(l => l.length > 0);
} catch (e) { } catch (e) {
if (signal?.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { activeTab: 'journal', lines: [], loading: false, error: null }; return { activeTab: 'journal', lines: [], loading: false, refreshing: false, error: null };
}, },
subscribe: [], 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]; 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) { onUnmount(state) {
state.lines = []; state.lines = [];
@@ -65,8 +73,8 @@ export default definePage({
}, '\u21BB') }, '\u21BB')
), ),
h('div', { class: 'card-body log-body' }, h('div', { class: 'card-body log-body' },
state.loading state.loading && !state.refreshing
? h('div', { class: 'loading' }, 'Loading...') ? h('div', { class: 'loading' }, state.refreshing ? 'Refreshing...' : 'Loading...')
: state.error : state.error
? h('div', { class: 'error-msg' }, state.error) ? h('div', { class: 'error-msg' }, state.error)
: lineVnodes.length > 0 : lineVnodes.length > 0
+14 -6
View File
@@ -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 { 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 || {}; 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 || {}); if (zr.ok) state.activeZones = Object.keys(zr.data?.active || {});
} catch (e) { } catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { config: {}, activeZones: [], loading: true, error: null }; return { config: {}, activeZones: [], loading: true, refreshing: false, error: null };
}, },
subscribe: ['firewall'], subscribe: ['firewall'],
load, load,
render(state) { render(state) {
if (state.loading) { if (state.loading && !state.refreshing) {
return [ return [
PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }), PageHeader({ title: 'NAT', subtitle: 'Masquerade & port forwarding' }),
h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'), h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
), ),
]; ];
} }
+14 -6
View File
@@ -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 { 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 || []; 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 || []; if (certsR.ok) state.certs = certsR.data || [];
} catch (e) { } catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { domains: [], certs: [], loading: true, error: null }; return { domains: [], certs: [], loading: true, refreshing: false, error: null };
}, },
subscribe: ['nginx', 'acme'], subscribe: ['nginx', 'acme'],
load, load,
render(state) { render(state) {
if (state.loading) { if (state.loading && !state.refreshing) {
return [ return [
PageHeader({ title: 'Proxy' }), PageHeader({ title: 'Proxy' }),
h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'), h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
), ),
]; ];
} }
+14 -6
View File
@@ -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 { 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 || {}; if (r.ok) state.config = r.data || {};
else state.error = r.error; 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 || {}); if (zr.ok) state.zones = Object.keys(zr.data?.active || {});
} catch (e) { } catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { config: {}, loading: true, error: null, zones: [] }; return { config: {}, loading: true, refreshing: false, error: null, zones: [] };
}, },
subscribe: ['firewall'], subscribe: ['firewall'],
load, load,
render(state) { render(state) {
if (state.loading) { if (state.loading && !state.refreshing) {
return [ return [
PageHeader({ title: 'Rules', subtitle: 'Firewall rich rules' }), PageHeader({ title: 'Rules', subtitle: 'Firewall rich rules' }),
h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'), h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
), ),
]; ];
} }
+16 -7
View File
@@ -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 { 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 || {}; 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 || []; 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 || {}; if (cfgR.ok) state.config = cfgR.data || {};
} catch (e) { } catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { status: {}, peers: [], config: {}, loading: true, error: null }; return { status: {}, peers: [], config: {}, loading: true, refreshing: false, error: null };
}, },
subscribe: ['wireguard'], subscribe: ['wireguard'],
load, load,
render(state) { render(state) {
if (state.loading) { if (state.loading && !state.refreshing) {
return [ return [
PageHeader({ title: 'WireGuard' }), PageHeader({ title: 'WireGuard' }),
h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'), h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
), ),
]; ];
} }
+18 -8
View File
@@ -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 { try {
const myId = entry ? entry.requestId : 0;
const sig = abortController?.signal;
const [zRes, svcRes, ifRes] = await Promise.all([ const [zRes, svcRes, ifRes] = await Promise.all([
apiFetch('/api/firewall/zones'), apiFetch('/api/firewall/zones', { signal: sig }),
apiFetch('/api/firewall/services'), apiFetch('/api/firewall/services', { signal: sig }),
apiFetch('/api/firewall/interfaces'), apiFetch('/api/firewall/interfaces', { signal: sig }),
]); ]);
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
if (zRes.ok) { if (zRes.ok) {
const data = zRes.data || {}; const data = zRes.data || {};
const activeZones = data.active || {}; const activeZones = data.active || {};
const availableZones = data.available || []; const availableZones = data.available || [];
const detailPromises = availableZones.map(name => 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); const detailResults = await Promise.all(detailPromises);
if (abortController?.signal.aborted || (entry && entry.requestId !== myId)) return;
const zones = {}; const zones = {};
for (let i = 0; i < availableZones.length; i++) { for (let i = 0; i < availableZones.length; i++) {
const name = availableZones[i]; const name = availableZones[i];
@@ -143,23 +151,25 @@ async function load(state) {
if (svcRes.ok) state.services = svcRes.data || []; if (svcRes.ok) state.services = svcRes.data || [];
if (ifRes.ok) state.interfaces = ifRes.data || []; if (ifRes.ok) state.interfaces = ifRes.data || [];
} catch (e) { } catch (e) {
if (abortController?.signal.aborted) return;
state.error = String(e); state.error = String(e);
} }
state.loading = false; state.loading = false;
state.refreshing = false;
} }
export default definePage({ export default definePage({
init() { init() {
return { zones: {}, services: [], interfaces: [], loading: true, error: null }; return { zones: {}, services: [], interfaces: [], loading: true, refreshing: false, error: null };
}, },
subscribe: ['firewall'], subscribe: ['firewall'],
load, load,
render(state) { render(state) {
if (state.loading) { if (state.loading && !state.refreshing) {
return [ return [
PageHeader({ title: 'Zones', subtitle: 'Firewall zone management' }), PageHeader({ title: 'Zones', subtitle: 'Firewall zone management' }),
h('div', { class: 'card', key: 'loading' }, h('div', { class: 'card', key: 'loading' },
h('div', { class: 'card-body loading' }, 'Loading...'), h('div', { class: 'card-body loading' }, state.refreshing ? 'Refreshing...' : 'Loading...'),
), ),
]; ];
} }