refactor: introduce model layer for centralized data synchronization
Add hoover model.js as a central reactive store per subsystem, replacing per-component data fetching with a single source of truth. - Add hoover/model.js with modelRegister, modelFetch, and WS invalidation - Refactor websocket.js to route messages to model refresh (drop per-component subscribe/unsubscribe) - Simplify component.js by removing WS subscription management - Add refresh option to apiSubmit, deprecate refactorLoad and checkAbort - Rewrite all pages to use getModel() instead of inline data fetching - Bootstrap model registrations in app.js - Add GET /api/firewall/state endpoint - Fix restart-services.sh restart order and add service health verification - Update hoover.md docs with model layer architecture
This commit is contained in:
@@ -1,19 +1,19 @@
|
||||
/**
|
||||
* Hoover — websocket.js
|
||||
*
|
||||
* WebSocket connection manager with auto-reconnect, subscribe/unsubscribe
|
||||
* per component per topic, and version-track messages.
|
||||
*
|
||||
* The _wsSubs Map stores entries keyed by renderer function so that
|
||||
* auto-refresh messages from the backend can trigger page reloads.
|
||||
* WebSocket connection manager with auto-reconnect. WS messages are routed
|
||||
* to model-based refresh and direct onMessage handlers.
|
||||
* Page-level subscribe/unsubscribe is replaced by the model layer.
|
||||
*/
|
||||
|
||||
import { setSubscribeFn, isComponentStateMounted, getComponentEntry } from './component.js?v=6';
|
||||
import { refreshByTopic } from './model.js?v=7';
|
||||
|
||||
const _wsSubs = new Map();
|
||||
let _wsConn = null;
|
||||
let _wsReconnectMs = 0;
|
||||
|
||||
/** Direct onMessage handlers: { topics, handler, unsubscribed }[] */
|
||||
const _directHandlers = [];
|
||||
|
||||
/**
|
||||
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
|
||||
* (useful for proxy setups). Falls back to port 9091 when the current
|
||||
@@ -52,57 +52,13 @@ 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.
|
||||
* Route an incoming WS message to model refresh and direct handlers.
|
||||
*
|
||||
* Expected message shapes:
|
||||
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
|
||||
* { type: 'notify', topic: 'firewall' }
|
||||
* { 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 = [];
|
||||
@@ -115,88 +71,39 @@ 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();
|
||||
// Refresh models for each topic
|
||||
for (const topic of topics) {
|
||||
refreshByTopic(topic);
|
||||
}
|
||||
|
||||
for (const s of _wsSubs.values()) {
|
||||
if (s.unsubscribed || !isComponentStateMounted(s.state)) continue;
|
||||
|
||||
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);
|
||||
// Notify direct onMessage handlers
|
||||
for (const h of _directHandlers) {
|
||||
if (h.unsubscribed) continue;
|
||||
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
|
||||
try { h.handler(msg); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a component to WS topics.
|
||||
*
|
||||
* 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
|
||||
* @param {object} state – Reactive state passed to loadFn
|
||||
* @returns {function} unsubscribe
|
||||
*/
|
||||
function subscribe(componentFn, topic, loadFn, state) {
|
||||
const key = componentFn + ':' + topic;
|
||||
const entry = { componentFn, topic, loadFn, state, unsubscribed: false };
|
||||
_wsSubs.set(key, entry);
|
||||
|
||||
return () => {
|
||||
entry.unsubscribed = true;
|
||||
// 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);
|
||||
};
|
||||
}
|
||||
|
||||
/** Register the subscribe function with component.js and kick off connection. */
|
||||
setSubscribeFn(subscribe);
|
||||
|
||||
/** Start the WebSocket connection. */
|
||||
export function connect() {
|
||||
_wsConnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public subscribe API for direct one-off usage (e.g. from page code).
|
||||
* Handler receives the raw parsed message when a matching topic arrives.
|
||||
* @param {string|string[]} topics
|
||||
* @param {function} handler
|
||||
* @returns {function} unsubscribe
|
||||
*/
|
||||
export function onMessage(topics, handler) {
|
||||
const tArray = Array.isArray(topics) ? topics : [topics];
|
||||
const fns = [];
|
||||
for (const t of tArray) {
|
||||
const entry = {
|
||||
componentFn: handler, topic: t, loadFn: handler, state: {},
|
||||
unsubscribed: false
|
||||
};
|
||||
_wsSubs.set(handler + ':' + t, entry);
|
||||
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());
|
||||
const entry = { topics: tArray, handler, unsubscribed: false };
|
||||
_directHandlers.push(entry);
|
||||
return () => {
|
||||
entry.unsubscribed = true;
|
||||
const idx = _directHandlers.indexOf(entry);
|
||||
if (idx !== -1) _directHandlers.splice(idx, 1);
|
||||
};
|
||||
}
|
||||
|
||||
/** Start the WebSocket connection. */
|
||||
export function connect() {
|
||||
_wsConnect();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user