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
+9 -22
View File
@@ -1,4 +1,4 @@
import { h, PageHeader, Badge, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, refactorLoad, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=6';
import { h, PageHeader, Empty, Table, renderGuard, esc, enc, $val, apiFetch, toast, openModal, closeModal, formModal, definePage, getModel, modelFetch, ActionCell, certStatusBadge, poll } from '/static/hoover/index.js?v=7';
function issueCertModal(state) {
openModal((inner, idx) => {
@@ -40,7 +40,7 @@ async function pollCertIssue(rid, state) {
onErrorKey: (d) => d.status === 'failed',
onComplete: (d) => {
toast('Certificate issued for ' + (d.domain || rid), 'success');
load(state);
modelFetch('acme');
},
onError: (d) => {
toast('Issuance failed: ' + (d?.error || 'unknown'), 'error');
@@ -48,30 +48,17 @@ async function pollCertIssue(rid, state) {
});
}
async function load(state, abortController, entry) {
await refactorLoad(state,
s => s.certs?.length,
async (s, sig, isAborted) => {
const r = await apiFetch('/api/certs/list', { signal: sig });
if (isAborted()) return;
if (r.ok) s.certs = r.data || [];
else s.error = r.error;
},
{ entry, abortController },
);
}
export default definePage({
init() {
return { certs: [] };
return {
acme: getModel('acme'),
};
},
subscribe: ['acme'],
load,
render(state) {
const guard = renderGuard(state, 'Certificates', 'ACME certificate management', state.certs);
const guard = renderGuard(state.acme, 'Certificates', 'ACME certificate management', state.acme.data);
if (guard) return guard;
const rows = state.certs.map(c => {
const rows = (state.acme.data || []).map(c => {
const badge = certStatusBadge({ expired: c.expired, daysRemaining: c.days_remaining });
return h('tr', { key: c.domain },
@@ -89,7 +76,7 @@ export default definePage({
removeUrl: '/api/certs/' + enc(c.domain),
removeMessage: 'Remove certificate for ' + c.domain + '?',
removeSuccess: 'Certificate removed',
removeReload: () => load(state),
removeRefresh: 'acme',
}),
);
});
@@ -109,4 +96,4 @@ export default definePage({
: Empty({ text: 'No certificates found. Issue a certificate to get started.' }),
];
},
});
});