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 -35
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, apiSubmit, refactorLoad, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=6';
import { h, PageHeader, Badge, StatusDot, Empty, Table, ServiceStatus, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionButton, ActionCell, MonoText, ActionGroup, QuickModal, downloadBlob } from '/static/hoover/index.js?v=7';
const addPeer = QuickModal({
title: 'Add WireGuard Peer',
@@ -19,7 +19,7 @@ const addPeer = QuickModal({
validate: (b) => !b.name ? 'Name is required' : null,
successMsg: 'Peer added',
},
reload: (s) => load(s),
refresh: 'wireguard',
});
function downloadConfigModal(peerName, config, state) {
@@ -50,42 +50,21 @@ function downloadConfigModal(peerName, config, state) {
});
}
async function load(state, abortController, entry) {
await refactorLoad(state,
s => s.peers?.length,
async (s, sig, isAborted) => {
const stR = await apiFetch('/api/wireguard/status', { signal: sig });
if (isAborted()) return;
if (stR.ok) s.status = stR.data || {};
else s.error = stR.error;
const pR = await apiFetch('/api/wireguard/peers', { signal: sig });
if (isAborted()) return;
if (pR.ok) s.peers = pR.data || [];
else if (!s.error) s.error = pR.error;
const cfgR = await apiFetch('/api/wireguard/config', { signal: sig });
if (isAborted()) return;
if (cfgR.ok) s.config = cfgR.data || {};
else if (!s.error) s.error = cfgR.error;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { status: {}, peers: [], config: {} };
return {
wireguard: getModel('wireguard'),
};
},
subscribe: ['wireguard'],
load,
render(state) {
const guard = renderGuard(state, 'WireGuard', 'Tunnel: ' + ((state.status || {}).state || 'unknown') + ', Listen: ' + ((state.config?.interface || {}).listen_port || '-'), state.peers);
const guard = renderGuard(state.wireguard, 'WireGuard', null, state.wireguard.data?.peers);
if (guard) return guard;
const st = state.status || {};
const st = state.wireguard.data?.status || {};
const isUp = st.state === 'up';
const listenPort = (state.config?.interface || {}).listen_port || '-';
const listenPort = (state.wireguard.data?.config?.interface || {}).listen_port || '-';
const peerRows = state.peers.map(p => {
const peerRows = (state.wireguard.data?.peers || []).map(p => {
const hasHandshake = !!p.latest_handshake;
return h('tr', { key: p.name },
h('td', null,
@@ -103,11 +82,11 @@ export default definePage({
),
ActionCell({
editLabel: 'Config',
editClick: () => downloadConfigModal(p.name, state.config, state),
editClick: () => downloadConfigModal(p.name, state.wireguard.data?.config, state),
removeUrl: '/api/wireguard/peers/' + enc(p.name),
removeMessage: 'Remove peer ' + p.name + '?',
removeSuccess: 'Peer removed',
removeReload: () => load(state),
removeRefresh: 'wireguard',
}),
);
});
@@ -118,13 +97,13 @@ export default definePage({
url: '/api/wireguard/' + (isUp ? 'down' : 'up'),
labelOn: 'Stop', labelOff: 'Start', condition: isUp,
successMsg: 'Tunnel ' + (isUp ? 'stopped' : 'started'),
reload: () => load(state),
refresh: 'wireguard',
}),
ActionButton({
url: '/api/wireguard/apply',
successMsg: 'Config applied',
label: 'Apply',
reload: () => load(state),
refresh: 'wireguard',
}),
);
@@ -143,4 +122,4 @@ export default definePage({
: Empty({ text: 'No peers configured. Add a peer above.' }),
];
},
});
});