ws: migrate push stream to data streaming
- daemon: send full snapshot on connect; versions/tick now carry the full state of one subsystem (subsystem + data); no legacy updated/subsystems payloads; refresh_state and POST /status/refresh broadcast per-subsystem versions with data - client: modelSet() patches models in place; onMessage/topic refresh retired; 3s initial-load fallback via new POST /api/status/refresh - schema: lib/schema.py TypedDicts + hoover/schema.js defaults + docs/state-model.md as single source of truth for state shapes - system: poll at 1s, volatile metrics registered, dashboard uses a dedicated system model (status model removed) - firewall: refuse to strip both https and ssh from the default zone (409, force override via UI confirm); set_zone_services persists services to the declarative config; collector exposes default_zone - UI: pages migrate to flat state shapes; post-mutation modelFetch refreshes removed (WS delta covers it) - tests: ws snapshot/delta/broadcast, refresh-state, schema types, model-set/js ws handler and reconnect fallback
This commit is contained in:
+51
-116
@@ -1,4 +1,5 @@
|
||||
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js';
|
||||
import { h, render, Link, hComp, ToastContainer, connect, disconnect, apiFetch, modelRegister, modelFetch, getModel, reactive, createAuthModel, isAuthenticated, getAuthData } from '/static/hoover/index.js';
|
||||
import { SUBSYSTEMS } from '/static/hoover/schema.js';
|
||||
|
||||
import DashboardPage from '/static/pages/dashboard.js';
|
||||
import InterfacesPage from '/static/pages/interfaces.js';
|
||||
@@ -44,66 +45,47 @@ function getNav() {
|
||||
/* ── Auth model (silent topic — the daemon never broadcasts 'auth') ── */
|
||||
modelRegister('auth', createAuthModel());
|
||||
|
||||
modelRegister('firewall', {
|
||||
subsystem: 'firewall',
|
||||
fetch: async () => {
|
||||
const [cfg, zones, services, interfaces, state] = await Promise.allSettled([
|
||||
apiFetch('/api/firewall/config'),
|
||||
apiFetch('/api/firewall/zones'),
|
||||
apiFetch('/api/firewall/services'),
|
||||
apiFetch('/api/firewall/interfaces'),
|
||||
apiFetch('/api/firewall/state'),
|
||||
]);
|
||||
const result = {};
|
||||
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
||||
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
||||
if (zones.status === 'fulfilled' && zones.value.ok) result.zones = zones.value.data || {};
|
||||
else if (zones.status === 'rejected' || !zones.value.ok) throw new Error(zones.status === 'rejected' ? (zones.reason?.message || 'Failed') : (zones.value.error || 'Failed'));
|
||||
if (services.status === 'fulfilled' && services.value.ok) result.services = services.value.data || [];
|
||||
else if (services.status === 'rejected' || !services.value.ok) throw new Error(services.status === 'rejected' ? (services.reason?.message || 'Failed') : (services.value.error || 'Failed'));
|
||||
if (interfaces.status === 'fulfilled' && interfaces.value.ok) result.interfaces = interfaces.value.data || [];
|
||||
else if (interfaces.status === 'rejected' || !interfaces.value.ok) throw new Error(interfaces.status === 'rejected' ? (interfaces.reason?.message || 'Failed') : (interfaces.value.error || 'Failed'));
|
||||
if (state.status === 'fulfilled' && state.value.ok) result.state = state.value.data || {};
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
modelRegister('network', {
|
||||
subsystem: 'networkd',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/network/interfaces');
|
||||
/* ── State-backed models ──────────────────────────────────── */
|
||||
/* All state-backed models stream over the WS (snapshot on connect,
|
||||
* per-subsystem deltas). The fetch below is the HTTP fallback: it hits
|
||||
* POST /api/status/refresh with a subsystem filter and returns the
|
||||
* subsystem state verbatim — the exact shape the state store holds. */
|
||||
function _stateModelFetch(subsystem) {
|
||||
return async () => {
|
||||
const r = await apiFetch('/api/status/refresh', {
|
||||
method: 'POST',
|
||||
body: { subsystems: [subsystem] },
|
||||
});
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return r.data || { interfaces: {} };
|
||||
},
|
||||
});
|
||||
const payload = r.data?.[subsystem];
|
||||
// Collector failure: the daemon returns null for that subsystem.
|
||||
// Throw instead of returning {} so modelFetch keeps the current
|
||||
// data (schema defaults) and sets model.error rather than
|
||||
// clobbering it with an empty object.
|
||||
if (payload == null) throw new Error(subsystem + ': state not populated yet');
|
||||
return payload;
|
||||
};
|
||||
}
|
||||
|
||||
modelRegister('dnsmasq', {
|
||||
subsystem: 'dnsmasq',
|
||||
fetch: async () => {
|
||||
const [cfg, status, leases] = await Promise.allSettled([
|
||||
apiFetch('/api/dhcp/config'),
|
||||
apiFetch('/api/dhcp/status'),
|
||||
apiFetch('/api/dhcp/leases'),
|
||||
]);
|
||||
const result = {};
|
||||
if (cfg.status === 'fulfilled' && cfg.value.ok) result.config = cfg.value.data || {};
|
||||
else if (cfg.status === 'rejected' || !cfg.value.ok) throw new Error(cfg.status === 'rejected' ? (cfg.reason?.message || 'Failed') : (cfg.value.error || 'Failed'));
|
||||
if (status.status === 'fulfilled' && status.value.ok) result.status = status.value.data || {};
|
||||
else if (status.status === 'rejected' || !status.value.ok) throw new Error(status.status === 'rejected' ? (status.reason?.message || 'Failed') : (status.value.error || 'Failed'));
|
||||
if (leases.status === 'fulfilled' && leases.value.ok) result.leases = leases.value.data || [];
|
||||
else if (leases.status === 'rejected' || !leases.value.ok) throw new Error(leases.status === 'rejected' ? (leases.reason?.message || 'Failed') : (leases.value.error || 'Failed'));
|
||||
return result;
|
||||
},
|
||||
});
|
||||
// Each maps to one subsystem in the state store. Model name may differ
|
||||
// from subsystem name (e.g. `network` → `networkd`).
|
||||
const STATE_MODELS = [
|
||||
{ name: 'firewall', subsystem: 'firewall' },
|
||||
{ name: 'dnsmasq', subsystem: 'dnsmasq' },
|
||||
{ name: 'nginx', subsystem: 'nginx' },
|
||||
{ name: 'acme', subsystem: 'acme' },
|
||||
{ name: 'wireguard', subsystem: 'wireguard' },
|
||||
{ name: 'network', subsystem: 'networkd' },
|
||||
{ name: 'system', subsystem: 'system' },
|
||||
];
|
||||
|
||||
modelRegister('nginx', {
|
||||
subsystem: 'nginx',
|
||||
fetch: async () => {
|
||||
const r = await apiFetch('/api/proxy/domains');
|
||||
if (!r.ok) throw new Error(r.error);
|
||||
return { domains: r.data || [] };
|
||||
},
|
||||
});
|
||||
for (const { name, subsystem } of STATE_MODELS) {
|
||||
modelRegister(name, {
|
||||
subsystem,
|
||||
defaultData: SUBSYSTEMS[subsystem].defaults,
|
||||
fetch: _stateModelFetch(subsystem),
|
||||
});
|
||||
}
|
||||
|
||||
modelRegister('backends', {
|
||||
subsystem: 'nginx',
|
||||
@@ -114,44 +96,6 @@ modelRegister('backends', {
|
||||
},
|
||||
});
|
||||
|
||||
modelRegister('acme', {
|
||||
subsystem: 'acme',
|
||||
fetch: async () => {
|
||||
const [listR, acctR] = await Promise.allSettled([
|
||||
apiFetch('/api/certs/list'),
|
||||
apiFetch('/api/certs/account'),
|
||||
]);
|
||||
const result = {};
|
||||
if (listR.status === 'fulfilled' && listR.value.ok) {
|
||||
result.certs = listR.value.data || [];
|
||||
} else if (listR.status === 'rejected' || !listR.value.ok) {
|
||||
throw new Error(listR.status === 'rejected' ? (listR.reason?.message || 'Failed') : (listR.value.error || 'Failed'));
|
||||
}
|
||||
if (acctR.status === 'fulfilled' && acctR.value.ok) {
|
||||
result.account = acctR.value.data || { registered: false, email: '', ca: '' };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
modelRegister('wireguard', {
|
||||
subsystem: 'wireguard',
|
||||
fetch: async () => {
|
||||
const [stR, pR, cfgR] = await Promise.allSettled([
|
||||
apiFetch('/api/wireguard/status'),
|
||||
apiFetch('/api/wireguard/peers'),
|
||||
apiFetch('/api/wireguard/config'),
|
||||
]);
|
||||
const result = {};
|
||||
if (stR.status === 'fulfilled' && stR.value.ok) result.status = stR.value.data || {};
|
||||
else if (stR.status === 'rejected' || !stR.value.ok) throw new Error(stR.status === 'rejected' ? (stR.reason?.message || 'Failed') : (stR.value.error || 'Failed'));
|
||||
if (pR.status === 'fulfilled' && pR.value.ok) result.peers = pR.value.data || [];
|
||||
else if (pR.status === 'rejected' || !pR.value.ok) throw new Error(pR.status === 'rejected' ? (pR.reason?.message || 'Failed') : (pR.value.error || 'Failed'));
|
||||
if (cfgR.status === 'fulfilled' && cfgR.value.ok) result.config = cfgR.value.data || {};
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
const LOG_TABS = {
|
||||
journal: '/api/logs/journal',
|
||||
'nginx-access': '/api/logs/nginx/access',
|
||||
@@ -172,29 +116,20 @@ modelRegister('logs', {
|
||||
},
|
||||
});
|
||||
|
||||
modelRegister('status', {
|
||||
subsystem: '*',
|
||||
fetch: async () => {
|
||||
const [pendingR, metricsR] = await Promise.allSettled([
|
||||
apiFetch('/api/status/pending'),
|
||||
apiFetch('/api/status/system-metrics'),
|
||||
]);
|
||||
const result = {};
|
||||
if (pendingR.status === 'fulfilled' && pendingR.value.ok) {
|
||||
result.pending = pendingR.value.data || {};
|
||||
}
|
||||
if (metricsR.status === 'fulfilled' && metricsR.value.ok) {
|
||||
result.metrics = metricsR.value.data || {};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
/* ── Initial fetch (after auth check) ───────────────────────── */
|
||||
function fetchInitialData() {
|
||||
for (const name of ['firewall', 'network', 'dnsmasq', 'nginx', 'backends', 'wireguard', 'acme', 'status']) {
|
||||
modelFetch(name);
|
||||
// State-backed models: first data arrives via the WS snapshot.
|
||||
// If WS hasn't delivered data within 3s, fall back to HTTP.
|
||||
for (const { name } of STATE_MODELS) {
|
||||
setTimeout(() => {
|
||||
const model = getModel(name);
|
||||
if (model.loading) { // snapshot (or a prior fetch) hasn't completed
|
||||
modelFetch(name);
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
// Non-state models fetch immediately
|
||||
modelFetch('backends');
|
||||
modelFetch('logs', 'journal');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user