5135de0921
- lib/system_import: new module to import system configs into JSON at daemon startup - daemon/server.py: call import_all() during startup for config reconciliation - daemon/handlers/nginx.py: simplify add_domain auth handling, remove duplicate code - scripts/install.sh: replace inline Python setup with curl-based daemon API calls; apply IP forwarding at runtime - hoover: bump internal asset versions to v=8 - pages: bump asset versions to v=9
139 lines
4.3 KiB
JavaScript
139 lines
4.3 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?v=8';
|
|
|
|
/** 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,
|
|
};
|
|
}
|