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:
@@ -6,7 +6,8 @@
|
||||
* ToastContainer component for rendering queued toasts.
|
||||
*/
|
||||
|
||||
import { h } from './vdom.js?v=6';
|
||||
import { h } from './vdom.js?v=7';
|
||||
import { modelFetch } from './model.js?v=7';
|
||||
|
||||
/**
|
||||
* JSON-friendly fetch wrapper.
|
||||
@@ -100,6 +101,8 @@ export function ToastContainer() {
|
||||
/**
|
||||
* Create an abort-checking function from an AbortController.
|
||||
*
|
||||
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
|
||||
* fetching with abort handling and loading state management.
|
||||
* @param {AbortController} ac
|
||||
* @returns {function} () => boolean
|
||||
*/
|
||||
@@ -112,6 +115,8 @@ export function checkAbort(ac) {
|
||||
*
|
||||
* Sets loading=true before, loading=false after, tracks errors.
|
||||
*
|
||||
* @deprecated Use model layer (`modelRegister` / `modelFetch`) for data
|
||||
* fetching with abort handling and loading state management.
|
||||
* @param {object} state - Reactive state object
|
||||
* @param {function} dataKey - (s) => any, current data to compare for refresh detection
|
||||
* @param {function} fetchFn - (state, signal, isAborted) => Promise
|
||||
@@ -204,7 +209,7 @@ export async function poll(opts) {
|
||||
* @param {function} [opts.body] - () => object, body builder
|
||||
* @param {function} [opts.validate] - (body) => string|null, validation function
|
||||
* @param {string} [opts.successMsg] - Success toast message
|
||||
* @param {function} [opts.reload] - () => Promise, data reload function
|
||||
* @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
|
||||
* @returns {object[]} Array of action descriptors
|
||||
*/
|
||||
@@ -215,7 +220,7 @@ export function apiSubmit(opts) {
|
||||
body,
|
||||
validate,
|
||||
successMsg = 'Saved',
|
||||
reload,
|
||||
refresh,
|
||||
submitText = 'Submit',
|
||||
closeModal,
|
||||
} = opts;
|
||||
@@ -235,7 +240,10 @@ export function apiSubmit(opts) {
|
||||
if (res.ok) {
|
||||
toast(successMsg, 'success');
|
||||
if (closeModal) closeModal();
|
||||
if (reload) await reload();
|
||||
if (refresh) {
|
||||
const models = Array.isArray(refresh) ? refresh : [refresh];
|
||||
await Promise.all(models.map(m => modelFetch(m)));
|
||||
}
|
||||
} else {
|
||||
toast(res.error || 'Failed', 'error');
|
||||
}
|
||||
|
||||
@@ -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 (_) {}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* Data display components: Badge, StatusDot, Empty, Card.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=6';
|
||||
import { esc } from '../helpers.js?v=6';
|
||||
import { apiFetch, toast } from '../api.js?v=6';
|
||||
import { h } from '../vdom.js?v=7';
|
||||
import { esc } from '../helpers.js?v=7';
|
||||
import { apiFetch, toast } from '../api.js?v=7';
|
||||
import { modelFetch } from '../model.js?v=7';
|
||||
|
||||
/**
|
||||
* Colored badge/span.
|
||||
@@ -62,13 +63,13 @@ export function Card(props = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A Remove button that confirms, deletes via API, toasts, and reloads.
|
||||
* A Remove button that confirms, deletes via API, toasts, and refreshes models.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.url - API DELETE URL
|
||||
* @param {string} props.message - Confirmation prompt text
|
||||
* @param {string} [props.success] - Success toast message (default: 'Removed')
|
||||
* @param {function} [props.reload] - Function to call on success (e.g., load)
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string} [props.label] - Button text (default: 'Remove')
|
||||
* @param {object} [props.body] - Optional JSON body to send with DELETE
|
||||
*/
|
||||
@@ -81,7 +82,10 @@ export function ConfirmDelete(props = {}) {
|
||||
const r = await apiFetch(props.url, opts);
|
||||
if (r.ok) {
|
||||
toast(props.success || 'Removed', 'success');
|
||||
if (props.reload) await props.reload();
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
names.forEach(n => modelFetch(n));
|
||||
}
|
||||
} else {
|
||||
toast(r.error || 'Failed', 'error');
|
||||
}
|
||||
@@ -90,7 +94,7 @@ export function ConfirmDelete(props = {}) {
|
||||
|
||||
/**
|
||||
* An action button that POSTs to an API endpoint, toasts on result,
|
||||
* and optionally reloads state. Supports toggle labels for on/off buttons.
|
||||
* and optionally refreshes models. Supports toggle labels for on/off buttons.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {string} props.url - API URL
|
||||
@@ -102,7 +106,7 @@ export function ConfirmDelete(props = {}) {
|
||||
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
|
||||
* @param {string} [props.successMsg] - Success toast message
|
||||
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
|
||||
* @param {function} [props.reload] - () => Promise, called on success
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
|
||||
* @param {boolean} [props.disabled] - Disabled state
|
||||
*/
|
||||
@@ -122,7 +126,10 @@ export function ActionButton(props = {}) {
|
||||
const resp = await apiFetch(props.url, opts);
|
||||
if (resp.ok) {
|
||||
if (props.successMsg) toast(props.successMsg, 'success');
|
||||
if (props.reload) await props.reload();
|
||||
if (props.refresh) {
|
||||
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
|
||||
names.forEach(n => modelFetch(n));
|
||||
}
|
||||
} else {
|
||||
toast(resp.error || 'Failed', props.errorType || 'error');
|
||||
}
|
||||
@@ -249,7 +256,7 @@ export function ServiceStatus(props = {}) {
|
||||
* @param {string} props.removeUrl - API DELETE URL
|
||||
* @param {string} props.removeMessage - Confirmation prompt text
|
||||
* @param {string} [props.removeSuccess] - Success toast message
|
||||
* @param {function} [props.removeReload] - Reload function
|
||||
* @param {string|string[]} [props.removeRefresh] - Model name(s) to refresh
|
||||
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
|
||||
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
|
||||
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
|
||||
@@ -265,7 +272,7 @@ export function ActionCell(props = {}) {
|
||||
url: props.removeUrl,
|
||||
message: props.removeMessage,
|
||||
success: props.removeSuccess,
|
||||
reload: props.removeReload,
|
||||
refresh: props.removeRefresh,
|
||||
label: props.removeLabel || 'Remove',
|
||||
body: props.removeBody,
|
||||
}),
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=6';
|
||||
import { Table } from './data.js?v=6';
|
||||
import { h } from '../vdom.js?v=7';
|
||||
import { Table } from './data.js?v=7';
|
||||
import { collectLoadingModels } from '../model.js?v=7';
|
||||
|
||||
/**
|
||||
* Page header with title, optional subtitle, and action buttons.
|
||||
@@ -25,14 +26,17 @@ export function PageHeader(props = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
/**
|
||||
* Handle loading/error/no-data states and return early if applicable.
|
||||
* Returns null when data is ready for the page to render its content.
|
||||
*
|
||||
* Accepts a model object (with loading/refreshing/error/data properties) as
|
||||
* the `data` parameter to check the model's data property directly.
|
||||
*
|
||||
* @param {object} state - Page state with loading/error flags
|
||||
* @param {string} title - Page header title
|
||||
* @param {string} [subtitle] - Page header subtitle
|
||||
* @param {*} [data] - Data presence check for "no data" state
|
||||
* @param {*} [data] - Data to check (or model object with .data property)
|
||||
* @returns {VNode[]|null}
|
||||
*/
|
||||
export function renderGuard(state, title, subtitle, data) {
|
||||
@@ -54,7 +58,7 @@ export function renderGuard(state, title, subtitle, data) {
|
||||
),
|
||||
];
|
||||
}
|
||||
if ((data === undefined || data === null) && !state.loading) {
|
||||
if (isEmpty(data) && !state.loading) {
|
||||
return [
|
||||
PageHeader({ title, subtitle }),
|
||||
h('div', { class: 'card', key: 'no-data' },
|
||||
@@ -65,6 +69,33 @@ export function renderGuard(state, title, subtitle, data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience wrapper for pages consuming multiple models.
|
||||
* Internally calls collectLoadingModels then delegates to renderGuard.
|
||||
*
|
||||
* @param {string} title - Page header title
|
||||
* @param {string} [subtitle] - Page header subtitle
|
||||
* @param {...object} models - Model objects to combine
|
||||
* @returns {VNode[]|null}
|
||||
*/
|
||||
export function renderGuardMulti(title, subtitle, ...models) {
|
||||
const combined = collectLoadingModels(...models);
|
||||
return renderGuard(combined, title, subtitle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is "empty" for renderGuard's no-data check.
|
||||
* @param {*} data
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isEmpty(data) {
|
||||
if (data === null || data === undefined || data === '') return true;
|
||||
if (Array.isArray(data)) return data.length === 0;
|
||||
if (typeof data === 'object') return Object.keys(data).length === 0;
|
||||
if (typeof data === 'number') return false;
|
||||
return !data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab bar component. Writes to state[prop] on tab click.
|
||||
* The caller is responsible for rendering tab body content.
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
* avoid fighting with the main render cycle.
|
||||
*/
|
||||
|
||||
import { esc } from '../helpers.js?v=6';
|
||||
import { att_esc } from '../helpers.js?v=6';
|
||||
import { apiSubmit } from '../api.js?v=6';
|
||||
import { esc } from '../helpers.js?v=7';
|
||||
import { att_esc } from '../helpers.js?v=7';
|
||||
import { apiSubmit } from '../api.js?v=7';
|
||||
|
||||
const _modalQueue = [];
|
||||
|
||||
@@ -114,7 +114,7 @@ export function formModal(inner, title, fields, actions) {
|
||||
* @param {string[]} props.selected - Currently selected values
|
||||
* @param {string} props.fieldKey - JSON key for the field
|
||||
* @param {string} [props.successMsg] - Success toast message
|
||||
* @param {function} [props.reload] - () => Promise, called on success
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @returns {function} () => void, calls openModal
|
||||
*/
|
||||
export function MultiSelectModal(props = {}) {
|
||||
@@ -138,7 +138,7 @@ export function MultiSelectModal(props = {}) {
|
||||
.map(o => o.value),
|
||||
}),
|
||||
successMsg: props.successMsg || 'Updated',
|
||||
reload: props.reload,
|
||||
refresh: props.refresh,
|
||||
closeModal: () => closeModal(),
|
||||
}),
|
||||
],
|
||||
@@ -160,7 +160,7 @@ export function MultiSelectModal(props = {}) {
|
||||
* @param {function} [props.submit.body] - (data) => object
|
||||
* @param {function} [props.submit.validate] - (body) => string|null
|
||||
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
|
||||
* @param {function} [props.reload] - (data) => Promise, called on success with the data argument
|
||||
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||||
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
|
||||
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
|
||||
* @returns {function} (data) => void, calls openModal
|
||||
@@ -194,7 +194,7 @@ export function QuickModal(props = {}) {
|
||||
successMsg: typeof props.submit.successMsg === 'function'
|
||||
? props.submit.successMsg(data)
|
||||
: (props.submit.successMsg || 'Done'),
|
||||
reload: props.reload ? () => props.reload(data) : undefined,
|
||||
refresh: props.refresh || undefined,
|
||||
closeModal: () => closeModal(),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* Uses the toast/dismissToast state from api.js.
|
||||
*/
|
||||
|
||||
import { h } from '../vdom.js?v=6';
|
||||
import { _toasts, dismissToast } from '../api.js?v=6';
|
||||
import { h } from '../vdom.js?v=7';
|
||||
import { _toasts, dismissToast } from '../api.js?v=7';
|
||||
|
||||
/**
|
||||
* Render all pending toast notifications.
|
||||
|
||||
@@ -5,37 +5,40 @@
|
||||
*/
|
||||
|
||||
/* ── Reactivity ──────────────────────────────────────────────── */
|
||||
export { reactive, requestUpdate } from './reactivity.js?v=6';
|
||||
export { reactive, requestUpdate } from './reactivity.js?v=7';
|
||||
|
||||
/* ── VDOM ────────────────────────────────────────────────────── */
|
||||
export { h } from './vdom.js?v=6';
|
||||
export { h } from './vdom.js?v=7';
|
||||
|
||||
/* ── Render ──────────────────────────────────────────────────── */
|
||||
export { render } from './render.js?v=6';
|
||||
export { render } from './render.js?v=7';
|
||||
|
||||
/* ── Component ───────────────────────────────────────────────── */
|
||||
export { definePage, hComp } from './component.js?v=6';
|
||||
export { definePage, hComp } from './component.js?v=7';
|
||||
|
||||
/* ── Router ──────────────────────────────────────────────────── */
|
||||
export { createRouter, Link } from './router.js?v=6';
|
||||
export { createRouter, Link } from './router.js?v=7';
|
||||
|
||||
/* ── WebSocket ───────────────────────────────────────────────── */
|
||||
export { connect, onMessage } from './websocket.js?v=6';
|
||||
export { connect, onMessage } from './websocket.js?v=7';
|
||||
|
||||
/* ── API & Toast ─────────────────────────────────────────────── */
|
||||
export { apiFetch, toast, dismissToast, apiSubmit, refactorLoad, checkAbort, poll } from './api.js?v=6';
|
||||
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad } from './api.js?v=7';
|
||||
|
||||
/* ── Model ───────────────────────────────────────────────────── */
|
||||
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js?v=7';
|
||||
|
||||
/* ── Helpers ─────────────────────────────────────────────────── */
|
||||
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=6';
|
||||
export { esc, att_esc, enc, $val, parseZones, downloadBlob } from './helpers.js?v=7';
|
||||
|
||||
/* ── UI Components: Layout ───────────────────────────────────── */
|
||||
export { PageHeader, renderGuard, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=6';
|
||||
export { PageHeader, renderGuard, renderGuardMulti, Tabs, SectionTitle, ActionGroup, DataTableSection } from './components/layout.js?v=7';
|
||||
|
||||
/* ── UI Components: Data ─────────────────────────────────────── */
|
||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=6';
|
||||
export { Badge, StatusDot, Empty, Card, ConfirmDelete, Table, ActionButton, certStatusBadge, serviceStatusBadge, StatCard, StatusText, ServiceStatus, ActionCell, MonoText, ZoneSelect } from './components/data.js?v=7';
|
||||
|
||||
/* ── UI Components: Modal ────────────────────────────────────── */
|
||||
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=6';
|
||||
export { openModal, closeModal, closeAllModals, formModal, MultiSelectModal, QuickModal } from './components/modal.js?v=7';
|
||||
|
||||
/* ── UI Components: Toast ────────────────────────────────────── */
|
||||
export { ToastContainer } from './components/toast.js?v=6';
|
||||
export { ToastContainer } from './components/toast.js?v=7';
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Hoover — model.js
|
||||
*
|
||||
* Central reactive store for subsystem models. Each subsystem gets one
|
||||
* reactive model with { data, loading, refreshing, error }. Hoover handles
|
||||
* fetching, WS invalidation, loading states, and abort management.
|
||||
*
|
||||
* API:
|
||||
* modelRegister(name, definition) — register at app bootstrap
|
||||
* getModel(name) — return reactive model object
|
||||
* modelFetch(name, signal?, param?) — trigger fetch with in-flight dedup
|
||||
* refreshByTopic(topic) — WS callback: refresh all models matching topic
|
||||
* collectLoadingModels(...models) — combine loading/refreshing/error
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=7';
|
||||
|
||||
/** Registered models: name → { model, subsystem, fetch } */
|
||||
const _models = new Map();
|
||||
|
||||
/** In-flight fetch promises for dedup: name → Promise */
|
||||
const _fetchPromises = new Map();
|
||||
|
||||
/**
|
||||
* Register a subsystem model.
|
||||
*
|
||||
* @param {string} name - Model name (e.g. 'firewall', 'dnsmasq')
|
||||
* @param {object} definition
|
||||
* @param {string} definition.subsystem - WS topic to listen for ('*' = all)
|
||||
* @param {function} definition.fetch - async (signal?, param?) => Promise<data>
|
||||
* @param {any} [definition.defaultData] - Initial data value (default: null)
|
||||
* @returns {object} reactive model
|
||||
*/
|
||||
export function modelRegister(name, definition) {
|
||||
const model = reactive({
|
||||
data: definition.defaultData ?? null,
|
||||
loading: true,
|
||||
refreshing: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
_models.set(name, {
|
||||
model,
|
||||
subsystem: definition.subsystem,
|
||||
fetch: definition.fetch,
|
||||
});
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a reactive model by name. Throws if not registered.
|
||||
* @param {string} name
|
||||
* @returns {object} reactive model
|
||||
*/
|
||||
export function getModel(name) {
|
||||
const entry = _models.get(name);
|
||||
if (!entry) throw new Error('Model not registered: ' + name);
|
||||
return entry.model;
|
||||
}
|
||||
|
||||
/** Build dedup key from model name and optional param. */
|
||||
function _dedupKey(name, param) {
|
||||
return param !== undefined ? `${name}:${String(param)}` : name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a fetch for the named model.
|
||||
*
|
||||
* In-flight dedup: if a fetch is already running, returns the existing
|
||||
* promise. Models never abort in-progress fetches since other consumers
|
||||
* may still need the data.
|
||||
*
|
||||
* @param {string} name - Model name
|
||||
* @param {AbortSignal|*} [signalOrParam] - AbortSignal (backward compat) or param
|
||||
* @param {AbortSignal} [signal] - AbortSignal when a param was provided
|
||||
*/
|
||||
export function modelFetch(name, signalOrParam, signal) {
|
||||
const entry = _models.get(name);
|
||||
if (!entry) return;
|
||||
|
||||
const isSignal = signalOrParam instanceof AbortSignal || signalOrParam === undefined;
|
||||
const param = isSignal ? undefined : signalOrParam;
|
||||
const actualSignal = isSignal ? signalOrParam : signal;
|
||||
|
||||
const model = entry.model;
|
||||
const isInitial = model.loading && model.data === null;
|
||||
const key = _dedupKey(name, param);
|
||||
|
||||
if (_fetchPromises.has(key)) return _fetchPromises.get(key);
|
||||
|
||||
if (isInitial) model.loading = true;
|
||||
else model.refreshing = true;
|
||||
model.error = null;
|
||||
|
||||
const promise = (async () => {
|
||||
try {
|
||||
const data = await entry.fetch(actualSignal, param);
|
||||
model.data = data;
|
||||
} catch (e) {
|
||||
model.error = e.message || 'Fetch failed';
|
||||
} finally {
|
||||
model.loading = false;
|
||||
model.refreshing = false;
|
||||
}
|
||||
})();
|
||||
|
||||
_fetchPromises.set(key, promise);
|
||||
promise.finally(() => _fetchPromises.delete(key));
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh all models whose subsystem topic matches the given topic.
|
||||
* Topic '*' matches every model. Model subsystem '*' matches every topic.
|
||||
*/
|
||||
export function refreshByTopic(topic) {
|
||||
for (const [name, entry] of _models) {
|
||||
if (entry.subsystem === '*') {
|
||||
modelFetch(name);
|
||||
} else if (entry.subsystem === topic || topic === '*') {
|
||||
modelFetch(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine loading/refreshing/error from multiple models.
|
||||
* @param {...object} models
|
||||
* @returns {{loading: boolean, refreshing: boolean, error: string|null}}
|
||||
*/
|
||||
export function collectLoadingModels(...models) {
|
||||
return {
|
||||
loading: models.some(m => m.loading),
|
||||
refreshing: models.some(m => m.refreshing),
|
||||
error: models.find(m => m.error)?.error ?? null,
|
||||
};
|
||||
}
|
||||
@@ -5,12 +5,12 @@
|
||||
* batched re-render loop integration with reactivity.js.
|
||||
*/
|
||||
|
||||
import { requestUpdate, setCommitFn } from './reactivity.js?v=6';
|
||||
import { requestUpdate, setCommitFn } from './reactivity.js?v=7';
|
||||
import {
|
||||
_vnodeDom, createDom, getDom, patchNode, sweepDom,
|
||||
setMountFn, setUnmountFn,
|
||||
} from './vdom.js?v=6';
|
||||
import { mountComponent, unmountComponent } from './component.js?v=6';
|
||||
} from './vdom.js?v=7';
|
||||
import { mountComponent, unmountComponent } from './component.js?v=7';
|
||||
|
||||
/** Container → previous root vnodes */
|
||||
export const _renderSlots = new Map();
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* navigation). Link component for client-side navigation.
|
||||
*/
|
||||
|
||||
import { reactive } from './reactivity.js?v=6';
|
||||
import { h } from './vdom.js?v=6';
|
||||
import { reactive } from './reactivity.js?v=7';
|
||||
import { h } from './vdom.js?v=7';
|
||||
|
||||
/**
|
||||
* Hash-based router.
|
||||
|
||||
@@ -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