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:
2026-06-22 22:54:29 +00:00
parent 633505e7dc
commit b673e87c9b
27 changed files with 952 additions and 838 deletions
+136 -12
View File
@@ -1,16 +1,16 @@
import { h, render, Link, hComp, ToastContainer, connect, requestUpdate, reactive } from '/static/hoover/index.js?v=6';
import { h, render, Link, hComp, ToastContainer, connect, apiFetch, modelRegister, modelFetch, reactive } from '/static/hoover/index.js?v=7';
import DashboardPage from '/static/pages/dashboard.js?v=6';
import InterfacesPage from '/static/pages/interfaces.js?v=6';
import ZonesPage from '/static/pages/zones.js?v=6';
import RulesPage from '/static/pages/rules.js?v=6';
import NatPage from '/static/pages/nat.js?v=6';
import DhcpPage from '/static/pages/dhcp.js?v=6';
import ProxyPage from '/static/pages/proxy.js?v=6';
import CertsPage from '/static/pages/certs.js?v=6';
import WireguardPage from '/static/pages/wireguard.js?v=6';
import LogsPage from '/static/pages/logs.js?v=6';
import NotFoundPage from '/static/pages/notfound.js?v=6';
import DashboardPage from '/static/pages/dashboard.js?v=7';
import InterfacesPage from '/static/pages/interfaces.js?v=7';
import ZonesPage from '/static/pages/zones.js?v=7';
import RulesPage from '/static/pages/rules.js?v=7';
import NatPage from '/static/pages/nat.js?v=7';
import DhcpPage from '/static/pages/dhcp.js?v=7';
import ProxyPage from '/static/pages/proxy.js?v=7';
import CertsPage from '/static/pages/certs.js?v=7';
import WireguardPage from '/static/pages/wireguard.js?v=7';
import LogsPage from '/static/pages/logs.js?v=7';
import NotFoundPage from '/static/pages/notfound.js?v=7';
/* ── Navigation items ──────────────────────────────────────── */
const Nav = [
@@ -26,6 +26,130 @@ const Nav = [
{ path: '/logs', label: 'Logs' },
];
/* ── Model registration ────────────────────────────────────── */
modelRegister('status', {
subsystem: 'status',
fetch: async () => {
const r = await apiFetch('/api/status/all');
if (!r.ok) throw new Error(r.error);
return r.data;
},
});
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');
if (!r.ok) throw new Error(r.error);
return r.data || { interfaces: {} };
},
});
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;
},
});
modelRegister('nginx', {
subsystem: 'nginx',
fetch: async () => {
const r = await apiFetch('/api/proxy/domains');
if (!r.ok) throw new Error(r.error);
return r.data || [];
},
});
modelRegister('acme', {
subsystem: 'acme',
fetch: async () => {
const r = await apiFetch('/api/certs/list');
if (!r.ok) throw new Error(r.error);
return r.data || [];
},
});
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',
'nginx-error': '/api/logs/nginx/error',
dnsmasq: '/api/logs/dnsmasq',
app: '/api/logs/app',
};
modelRegister('logs', {
subsystem: '*',
fetch: async (signal, tab) => {
const tabKey = tab || 'journal';
const url = LOG_TABS[tabKey];
if (!url) throw new Error('Unknown log tab: ' + tabKey);
const r = await apiFetch(url, { signal });
if (!r.ok) throw new Error(r.error);
return { data: (r.data || '').split('\n').filter(l => l.length > 0), tab: tabKey };
},
});
/* ── Initial fetch ─────────────────────────────────────────── */
for (const name of ['status', 'firewall', 'network', 'dnsmasq', 'nginx', 'wireguard', 'acme']) {
modelFetch(name);
}
modelFetch('logs', 'journal');
/* ── Page map ──────────────────────────────────────────────── */
const Pages = {
dashboard: DashboardPage,