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
+21 -49
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, refactorLoad, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=6';
import { h, PageHeader, Badge, ConfirmDelete, Tabs, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, definePage, getModel, modelFetch, ActionButton, ActionGroup, QuickModal } from '/static/hoover/index.js?v=7';
const addRange = QuickModal({
title: 'Add DHCP Range',
@@ -19,7 +19,7 @@ const addRange = QuickModal({
validate: (b) => !b.start || !b.end ? 'Start and end are required' : null,
successMsg: 'Range added',
},
reload: (s) => load(s),
refresh: 'dnsmasq',
});
const addLease = QuickModal({
@@ -39,7 +39,7 @@ const addLease = QuickModal({
validate: (b) => !b.mac || !b.ip ? 'MAC and IP are required' : null,
successMsg: 'Lease added',
},
reload: (s) => load(s),
refresh: 'dnsmasq',
});
const addDns = QuickModal({
@@ -54,55 +54,27 @@ const addDns = QuickModal({
validate: (b) => !b.name || !b.address ? 'Name and address are required' : null,
successMsg: 'DNS record added',
},
reload: (s) => load(s),
refresh: 'dnsmasq',
});
async function load(state, abortController, entry) {
await refactorLoad(state,
s => Object.keys(s.config || {}).length,
async (s, sig, isAborted) => {
const [cfgR, stR, lsR] = await Promise.allSettled([
apiFetch('/api/dhcp/config', { signal: sig }),
apiFetch('/api/dhcp/status', { signal: sig }),
apiFetch('/api/dhcp/leases', { signal: sig }),
]);
if (isAborted()) return;
const errors = [];
if (cfgR.status === 'rejected') errors.push(cfgR.reason?.message || 'Failed');
else if (!cfgR.value.ok) errors.push(cfgR.value.error || 'Failed');
if (stR.status === 'rejected') errors.push(stR.reason?.message || 'Failed');
else if (!stR.value.ok) errors.push(stR.value.error || 'Failed');
if (lsR.status === 'rejected') errors.push(lsR.reason?.message || 'Failed');
else if (!lsR.value.ok) errors.push(lsR.value.error || 'Failed');
if (errors.length) {
s.error = errors[0];
return;
}
s.config = cfgR.value.data || {};
s.status = stR.value.data || {};
s.leases = lsR.value.data || [];
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { config: {}, status: {}, leases: [], activeTab: 'ranges' };
return {
dnsmasq: getModel('dnsmasq'),
activeTab: 'ranges',
};
},
subscribe: ['dnsmasq'],
load,
render(state) {
const guard = renderGuard(state, 'DHCP & DNS', null, state.leases);
const guard = renderGuard(state.dnsmasq, 'DHCP & DNS', 'Dnsmasq management', state.dnsmasq.data);
if (guard) return guard;
const cfg = state.config || {};
const cfg = state.dnsmasq.data?.config || {};
const ranges = cfg.ranges || [];
const staticLeases = cfg.static_leases || [];
const dnsRecords = cfg.dns_records || [];
const statusUp = state.status || {};
const status = state.dnsmasq.data?.status || {};
const rangesRows = ranges.map((r, i) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
const rangesRows = ranges.map((r) => h('tr', { key: (r.interface || '_g') + '-' + r.start + '-' + r.end },
h('td', null, r.interface || '(global)'),
h('td', null, esc(r.start)),
h('td', null, esc(r.end)),
@@ -113,12 +85,12 @@ export default definePage({
message: 'Remove range ' + r.start + ' - ' + r.end + '?',
body: { interface: r.interface || '', start: r.start, end: r.end },
success: 'Range removed',
reload: () => load(state),
refresh: 'dnsmasq',
}),
),
));
const leaseRows = staticLeases.map((l, i) => h('tr', { key: l.mac },
const leaseRows = staticLeases.map((l) => h('tr', { key: l.mac },
h('td', null, esc(l.mac)),
h('td', null, esc(l.ip)),
h('td', null, l.hostname || '-'),
@@ -127,12 +99,12 @@ export default definePage({
url: '/api/dhcp/static-lease/' + enc(l.mac),
message: 'Remove lease ' + l.mac + '?',
success: 'Lease removed',
reload: () => load(state),
refresh: 'dnsmasq',
}),
),
));
const dnsRows = dnsRecords.map((rec, i) => h('tr', { key: rec.name },
const dnsRows = dnsRecords.map((rec) => h('tr', { key: rec.name },
h('td', null, h('strong', null, esc(rec.name || 'unnamed'))),
h('td', { class: 'text-sm' }, esc(rec.address || '-')),
h('td', null,
@@ -140,7 +112,7 @@ export default definePage({
url: '/api/dhcp/dns-record/' + enc(rec.name || ''),
message: 'Remove DNS record ' + (rec.name || 'unnamed') + '?',
success: 'Record removed',
reload: () => load(state),
refresh: 'dnsmasq',
}),
),
));
@@ -154,13 +126,13 @@ export default definePage({
url: '/api/dhcp/apply',
successMsg: 'dnsmasq applied',
label: 'Apply',
reload: () => load(state),
refresh: 'dnsmasq',
}),
);
return [
PageHeader({ title: 'DHCP & DNS', subtitle: 'Dnsmasq management', actions }),
ServiceStatus({ state: statusUp.state || 'down', label: 'Dnsmasq' }),
ServiceStatus({ state: status.state || 'down', label: 'Dnsmasq' }),
Tabs({ state, tabs: tabNames }),
state.activeTab === 'ranges'
? Table({ columns: ['Interface', 'Start', 'End', 'Lease', 'Action'], rows: rangesRows, emptyText: 'No DHCP ranges' }) : null,
@@ -169,7 +141,7 @@ export default definePage({
state.activeTab === 'dns'
? Table({ columns: ['Name', 'Address', 'Action'], rows: dnsRows, emptyText: 'No custom DNS records' }) : null,
state.activeTab === 'active'
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.leases || []).map((l, i) => h('tr', { key: l.mac || i },
? Table({ columns: ['MAC', 'IP', 'Hostname', 'Expires'], rows: (state.dnsmasq.data?.leases || []).map((l) => h('tr', { key: l.mac || l.ip },
h('td', null, esc(l.mac || '-')),
h('td', null, esc(l.ip || '-')),
h('td', null, esc(l.hostname || '-')),
@@ -177,4 +149,4 @@ export default definePage({
)), emptyText: 'No active leases' }) : null,
];
},
});
});