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:
@@ -4,72 +4,53 @@
|
||||
* Component wrapper: definePage, lifecycle hooks, state caching.
|
||||
*
|
||||
* definePage wraps a page definition into a renderer function compatible
|
||||
* with hoover's render engine. Handles reactive state creation, WS
|
||||
* subscription registration on mount, and cleanup on unmount.
|
||||
* with hoover's render engine. Handles reactive state creation and
|
||||
* lifecycle management. Data loading is handled by the model layer.
|
||||
*
|
||||
* Usage:
|
||||
* export default definePage({
|
||||
* init() { return { data: null, loading: true, error: null }; },
|
||||
* subscribe: ['*'], // WS topics to subscribe to
|
||||
* async load(state) { ... }, // called on mount
|
||||
* init() { return { firewall: getModel('firewall') }; },
|
||||
* async load(state) { ... }, // optional, for one-time setup
|
||||
* render(state) { return [vnodes],
|
||||
* });
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=6';
|
||||
import { h } from './vdom.js?v=6';
|
||||
import { _compExpandedCache } from './render.js?v=6';
|
||||
import { reactive } from './reactivity.js?v=7';
|
||||
import { h } from './vdom.js?v=7';
|
||||
import { _compExpandedCache } from './render.js?v=7';
|
||||
|
||||
/** Registry of mounted components: key → { state, subscriptions, loadAbort, entry, isLoading } */
|
||||
/** Registry of mounted components: key → { state } */
|
||||
const _mounted = new Map();
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Get the full mounted entry for a state object.
|
||||
* Used by websocket.js to abort in-flight loads before triggering a refresh. */
|
||||
export function getComponentEntry(state) {
|
||||
for (const entry of _mounted.values()) {
|
||||
if (entry.state === state) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** External subscribe function from websocket.js.
|
||||
* Set via setSubscribeFn() when the websocket module initializes.
|
||||
*/
|
||||
let _subscribeFn = null;
|
||||
|
||||
export function setSubscribeFn(fn) {
|
||||
_subscribeFn = fn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Define a page component.
|
||||
*
|
||||
* @param {object} def — Page definition
|
||||
* @param {function} def.init — Return initial state object
|
||||
* @param {string[]} [def.subscribe] — WS topics to subscribe to on mount
|
||||
* @param {function} def.load — Async function to load data into state
|
||||
* @param {function} [def.load] — Optional one-time setup called on mount
|
||||
* @param {function} def.render — Render function that returns vnodes
|
||||
* @returns {object} — Component renderer compatible with h('#comp', ...)
|
||||
*/
|
||||
export function definePage(def) {
|
||||
const state = reactive(def.init());
|
||||
let state = null;
|
||||
let stateInitialized = false;
|
||||
|
||||
const renderer = () => {
|
||||
if (!stateInitialized) {
|
||||
state = reactive(def.init());
|
||||
stateInitialized = true;
|
||||
}
|
||||
return def.render(state);
|
||||
};
|
||||
|
||||
renderer._pageDef = {
|
||||
state,
|
||||
subscribe: def.subscribe || [],
|
||||
get state() {
|
||||
if (!stateInitialized) {
|
||||
state = reactive(def.init());
|
||||
stateInitialized = true;
|
||||
}
|
||||
return state;
|
||||
},
|
||||
load: def.load || null,
|
||||
onUnmount: def.onUnmount || null,
|
||||
};
|
||||
@@ -88,43 +69,18 @@ export function mountComponent(key, renderer) {
|
||||
let entry = _mounted.get(key);
|
||||
|
||||
if (entry) {
|
||||
// Re-mount: component already exists with its data and subscriptions.
|
||||
// Don't abort or restart loads — that re-render was triggered by a
|
||||
// state change (load completion, reactive update, etc). Let existing
|
||||
// in-flight loads complete naturally. WS handles auto-refresh.
|
||||
// Re-mount: component already exists with its state.
|
||||
// Don't re-run load — that re-render was triggered by a reactive update.
|
||||
return;
|
||||
} else {
|
||||
// Fresh mount
|
||||
entry = {
|
||||
state: pd.state,
|
||||
subscriptions: [],
|
||||
loadAbort: null,
|
||||
requestId: 0,
|
||||
};
|
||||
_mounted.set(key, entry);
|
||||
}
|
||||
|
||||
// Clear error on re-mount; load() decides loading vs refreshing
|
||||
entry = { state: pd.state };
|
||||
_mounted.set(key, entry);
|
||||
|
||||
pd.state.error = null;
|
||||
|
||||
// Fire load with fresh AbortController
|
||||
if (pd.load) {
|
||||
if (entry.isLoading) return;
|
||||
const abortController = new AbortController();
|
||||
entry.loadAbort = abortController;
|
||||
entry.requestId++;
|
||||
entry.isLoading = true;
|
||||
Promise.resolve()
|
||||
.then(() => pd.load(pd.state, abortController, entry))
|
||||
.finally(() => { entry.isLoading = false; });
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
Promise.resolve().then(() => pd.load(pd.state));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,19 +94,6 @@ export function unmountComponent(key, renderer) {
|
||||
|
||||
const pd = renderer._pageDef;
|
||||
|
||||
// Cancel load
|
||||
if (entry.loadAbort) {
|
||||
entry.loadAbort.abort();
|
||||
}
|
||||
// Invalidate any in-flight callbacks
|
||||
entry.requestId++;
|
||||
|
||||
// Unsubscribe from WS
|
||||
for (const unsub of entry.subscriptions) {
|
||||
try { unsub(); } catch (_) {}
|
||||
}
|
||||
|
||||
// Fire custom onUnmount
|
||||
if (pd.onUnmount) {
|
||||
try { pd.onUnmount(entry.state); } catch (_) {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user