refactor: modernize frontend with hoover framework components and docs

- Add quick modal, table, service status, and confirmation dialog components
- Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns
- Introduce refactor load utility and render guard for consistent UX
- Add hoover documentation and update AGENTS.md, architecture, overview
This commit is contained in:
2026-06-21 04:29:27 +00:00
parent b8f20e99d9
commit 633505e7dc
29 changed files with 2558 additions and 1414 deletions
+77 -9
View File
@@ -8,7 +8,7 @@
* auto-refresh messages from the backend can trigger page reloads.
*/
import { setSubscribeFn, isComponentStateMounted } from './component.js';
import { setSubscribeFn, isComponentStateMounted, getComponentEntry } from './component.js?v=6';
const _wsSubs = new Map();
let _wsConn = null;
@@ -52,6 +52,43 @@ function _wsConnect() {
};
}
/** Per-state debounce timer (shared across all subscriptions for that state). */
const _wsDebounceTimers = new Map();
/**
* Fire the debounced load for a component state.
*
* Only one load fires per state regardless of how many subscriptions
* matched. Passes the mount entry so refactorLoad can toggle
* loading / refreshing flags correctly.
*/
function debouncedLoad(state, entry) {
if (!isComponentStateMounted(state)) return;
// Abort any in-flight load for this component
if (entry && entry.loadAbort) entry.loadAbort.abort();
const ac = new AbortController();
const firstSub = [..._wsSubs.values()]
.find(s => !s.unsubscribed && s.state === state);
if (firstSub) {
firstSub.loadFn(state, ac, entry);
}
}
/**
* Debounce helper: coalesces all matching subscriptions for the same
* component state into a single reload, keyed by state object.
*/
function scheduleReload(state) {
if (_wsDebounceTimers.has(state)) {
clearTimeout(_wsDebounceTimers.get(state));
}
_wsDebounceTimers.set(state, setTimeout(() => {
_wsDebounceTimers.delete(state);
const entry = getComponentEntry(state);
debouncedLoad(state, entry);
}, 300));
}
/**
* Route an incoming WS message to subscribed components.
*
@@ -61,6 +98,11 @@ function _wsConnect() {
* { type: 'status', topic: 'firewall', … }
*
* Components subscribed to wildcard ('*') match every topic.
*
* Uses per-component-state debouncing (300ms) to prevent a burst of WS
* messages or multiple matching topics from triggering overlapping
* loads. All subscriptions that share the same state object are
* coalesced into a single debounced reload.
*/
function handleMessage(msg) {
const topics = [];
@@ -73,13 +115,20 @@ function handleMessage(msg) {
topics.push(msg.topic || '*');
}
// Track which states have already been scheduled to avoid
// double-scheduling when multiple subscriptions of the same
// component match the same message.
const scheduled = new Set();
for (const s of _wsSubs.values()) {
if (s.unsubscribed || !isComponentStateMounted(s.state)) continue;
if (s.topic === '*') {
s.loadFn(s.state);
} else if (topics.some(t => t === s.topic || t === '*')) {
s.loadFn(s.state);
}
const matched = s.topic === '*' || topics.some(t => t === s.topic || t === '*');
if (!matched) continue;
if (scheduled.has(s.state)) continue;
scheduled.add(s.state);
scheduleReload(s.state);
}
}
@@ -89,6 +138,9 @@ function handleMessage(msg) {
* Called by component.js on mount. Returns an unsubscribe function
* called by component.js on unmount.
*
* Key is `componentFn + ':' + topic` so a component can subscribe to
* multiple topics without overwriting previous subscriptions.
*
* @param {function} componentFn The page renderer function (used as map key)
* @param {string} topic Topic to listen for ('*' = all)
* @param {function} loadFn Function to call when topic updates
@@ -96,12 +148,21 @@ function handleMessage(msg) {
* @returns {function} unsubscribe
*/
function subscribe(componentFn, topic, loadFn, state) {
const key = componentFn + ':' + topic;
const entry = { componentFn, topic, loadFn, state, unsubscribed: false };
_wsSubs.set(componentFn, entry);
_wsSubs.set(key, entry);
return () => {
entry.unsubscribed = true;
_wsSubs.delete(componentFn);
// Clear per-state debounce timer if this was the last active
// subscription for that state
const remaining = [..._wsSubs.values()]
.some(s => !s.unsubscribed && s.state === entry.state);
if (!remaining && _wsDebounceTimers.has(entry.state)) {
clearTimeout(_wsDebounceTimers.get(entry.state));
_wsDebounceTimers.delete(entry.state);
}
_wsSubs.delete(key);
};
}
@@ -128,7 +189,14 @@ export function onMessage(topics, handler) {
unsubscribed: false
};
_wsSubs.set(handler + ':' + t, entry);
fns.push(() => { entry.unsubscribed = true; _wsSubs.delete(handler + ':' + t); });
fns.push(() => {
entry.unsubscribed = true;
if (_wsDebounceTimers.has(entry.state)) {
clearTimeout(_wsDebounceTimers.get(entry.state));
_wsDebounceTimers.delete(entry.state);
}
_wsSubs.delete(handler + ':' + t);
});
}
return () => fns.forEach(f => f());
}