/** * 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=9'; /** 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 * @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, }; }