Files
vacuum-wall/webui/static/hoover/model.js
T

164 lines
5.6 KiB
JavaScript

/**
* 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';
/** Registered models: name → { model, subsystem, fetch, onSuccess?, onFailure? } */
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)
* @param {function} [definition.onSuccess] - (name, data, param?) => void.
* Called after model.data is assigned. Also fires when data === null —
* only a thrown fetch error skips it. Hook errors are caught and logged
* (console.warn) and never affect the fetch promise or model state.
* @param {function} [definition.onFailure] - (name, error) => void.
* Called after model.error is assigned, only on a real throw from fetch.
* Hook errors are caught and logged and never affect the fetch promise.
* @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,
onSuccess: definition.onSuccess,
onFailure: definition.onFailure,
});
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.
* Objects (e.g. `{ action: 'refresh' }`) are JSON-serialized so distinct
* param objects get distinct keys; strings/undefined keep bare behavior.
*/
function _dedupKey(name, param) {
return param !== undefined ? name + ':' + JSON.stringify(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;
// Fires for any resolved result, including data === null. Hook
// errors must not clobber model state or the returned promise.
try {
entry.onSuccess?.(name, data, param);
} catch (err) {
console.warn('Model onSuccess hook (' + name + ') threw:', err);
}
} catch (e) {
model.error = e.message || 'Fetch failed';
try {
entry.onFailure?.(name, e);
} catch (err) {
console.warn('Model onFailure hook (' + name + ') threw:', err);
}
} 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,
};
}