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
+14 -33
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete, ZoneSelect } from '/static/hoover/index.js?v=6';
import { h, PageHeader, Badge, StatusDot, Card, Table, renderGuard, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, DataTableSection, SectionTitle, ActionGroup, QuickModal, ConfirmDelete } from '/static/hoover/index.js?v=7';
const addFwd = QuickModal({
title: 'Add Port Forward',
@@ -11,7 +11,7 @@ const addFwd = QuickModal({
],
submit: {
url: '/api/firewall/forward-port',
body: (s) => ({
body: () => ({
zone: $val('fwd-zone'),
port: parseInt($val('fwd-port')),
proto: ($val('fwd-proto') || 'tcp').trim(),
@@ -21,43 +21,23 @@ const addFwd = QuickModal({
validate: (b) => !b.zone || !b.port || !b.proto ? 'Zone, port, and proto are required' : null,
successMsg: 'Forward rule added',
},
reload: (s) => load(s._s),
refresh: 'firewall',
});
async function load(state, abortController, entry) {
await refactorLoad(state,
s => Object.keys(s.config || {}).length,
async (s, sig, isAborted) => {
const r = await apiFetch('/api/firewall/config', { signal: sig });
if (isAborted()) return;
if (r.ok) s.config = r.data || {};
else s.error = r.error;
const zr = await apiFetch('/api/firewall/zones', { signal: sig });
if (isAborted()) return;
if (zr.ok) s.activeZones = Object.keys(zr.data?.active || {});
else if (!s.error) s.error = zr.error;
const sr = await apiFetch('/api/firewall/state', { signal: sig });
if (isAborted()) return;
if (sr.ok) s.stateData = sr.data;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { config: {}, activeZones: [], stateData: null };
return {
firewall: getModel('firewall'),
};
},
subscribe: ['firewall'],
load,
render(state) {
const guard = renderGuard(state, 'NAT', 'Masquerade & port forwarding', state.config);
const guard = renderGuard(state.firewall, 'NAT', 'Masquerade & port forwarding', state.firewall.data?.config);
if (guard) return guard;
const cfg = state.config || {};
const cfg = state.firewall.data?.config || {};
const zoneData = cfg.zones || {};
const sIface = (state.stateData || {}).interfaces || [];
const sIface = (state.firewall.data?.state || {}).interfaces || [];
const masqZones = new Set(
Object.entries(zoneData)
.filter(([, zcfg]) => !!zcfg.masquerade)
@@ -94,7 +74,7 @@ export default definePage({
labelOn: 'Disable', labelOff: 'Enable', condition: masq,
body: () => ({ zone, enable: !masq }),
successMsg: 'Masquerade ' + (masq ? 'disabled' : 'enabled') + ' on ' + zone,
reload: () => load(state),
refresh: 'firewall',
}),
),
);
@@ -117,7 +97,7 @@ export default definePage({
url: '/api/firewall/forward-port/' + enc(zone) + '/' + port + '/' + enc(proto),
message: 'Remove forward ' + zone + ':' + port + '/' + proto + '?',
success: 'Rule removed',
reload: () => load(state),
refresh: 'firewall',
}),
),
));
@@ -148,7 +128,8 @@ export default definePage({
Card({ children: [
ActionGroup(
h('button', { class: 'btn btn-sm btn-primary',
'on:click': () => addFwd({ zones: state.activeZones, _s: state }) }, 'Add Forward'),
'on:click': () => addFwd({ zones: Object.keys(zoneData) })
}, 'Add Forward'),
),
Table({
columns: ['Zone', 'Proto', 'Port', 'To Addr', 'To Port', 'Action'],
@@ -159,4 +140,4 @@ export default definePage({
]}),
];
},
});
});