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
+18 -11
View File
@@ -4,9 +4,10 @@
* Data display components: Badge, StatusDot, Empty, Card.
*/
import { h } from '../vdom.js?v=6';
import { esc } from '../helpers.js?v=6';
import { apiFetch, toast } from '../api.js?v=6';
import { h } from '../vdom.js?v=7';
import { esc } from '../helpers.js?v=7';
import { apiFetch, toast } from '../api.js?v=7';
import { modelFetch } from '../model.js?v=7';
/**
* Colored badge/span.
@@ -62,13 +63,13 @@ export function Card(props = {}) {
}
/**
* A Remove button that confirms, deletes via API, toasts, and reloads.
* A Remove button that confirms, deletes via API, toasts, and refreshes models.
*
* @param {object} props
* @param {string} props.url - API DELETE URL
* @param {string} props.message - Confirmation prompt text
* @param {string} [props.success] - Success toast message (default: 'Removed')
* @param {function} [props.reload] - Function to call on success (e.g., load)
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [props.label] - Button text (default: 'Remove')
* @param {object} [props.body] - Optional JSON body to send with DELETE
*/
@@ -81,7 +82,10 @@ export function ConfirmDelete(props = {}) {
const r = await apiFetch(props.url, opts);
if (r.ok) {
toast(props.success || 'Removed', 'success');
if (props.reload) await props.reload();
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
}
} else {
toast(r.error || 'Failed', 'error');
}
@@ -90,7 +94,7 @@ export function ConfirmDelete(props = {}) {
/**
* An action button that POSTs to an API endpoint, toasts on result,
* and optionally reloads state. Supports toggle labels for on/off buttons.
* and optionally refreshes models. Supports toggle labels for on/off buttons.
*
* @param {object} props
* @param {string} props.url - API URL
@@ -102,7 +106,7 @@ export function ConfirmDelete(props = {}) {
* @param {boolean} [props.condition] - Toggle condition for labelOn/labelOff
* @param {string} [props.successMsg] - Success toast message
* @param {string} [props.errorType] - Toast type for errors (default: 'error')
* @param {function} [props.reload] - () => Promise, called on success
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
* @param {boolean} [props.disabled] - Disabled state
*/
@@ -122,7 +126,10 @@ export function ActionButton(props = {}) {
const resp = await apiFetch(props.url, opts);
if (resp.ok) {
if (props.successMsg) toast(props.successMsg, 'success');
if (props.reload) await props.reload();
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
}
} else {
toast(resp.error || 'Failed', props.errorType || 'error');
}
@@ -249,7 +256,7 @@ export function ServiceStatus(props = {}) {
* @param {string} props.removeUrl - API DELETE URL
* @param {string} props.removeMessage - Confirmation prompt text
* @param {string} [props.removeSuccess] - Success toast message
* @param {function} [props.removeReload] - Reload function
* @param {string|string[]} [props.removeRefresh] - Model name(s) to refresh
* @param {string} [props.removeLabel] - Delete button label (default: 'Remove')
* @param {object} [props.removeBody] - Optional JSON body to send with DELETE
* @param {string} [props.editCls] - Override classes for edit button (default: 'btn btn-sm btn-outline')
@@ -265,7 +272,7 @@ export function ActionCell(props = {}) {
url: props.removeUrl,
message: props.removeMessage,
success: props.removeSuccess,
reload: props.removeReload,
refresh: props.removeRefresh,
label: props.removeLabel || 'Remove',
body: props.removeBody,
}),
+36 -5
View File
@@ -4,8 +4,9 @@
* Layout components: PageHeader, Tabs, SectionTitle, DataTableSection, ActionGroup.
*/
import { h } from '../vdom.js?v=6';
import { Table } from './data.js?v=6';
import { h } from '../vdom.js?v=7';
import { Table } from './data.js?v=7';
import { collectLoadingModels } from '../model.js?v=7';
/**
* Page header with title, optional subtitle, and action buttons.
@@ -25,14 +26,17 @@ export function PageHeader(props = {}) {
);
}
/**
/**
* Handle loading/error/no-data states and return early if applicable.
* Returns null when data is ready for the page to render its content.
*
* Accepts a model object (with loading/refreshing/error/data properties) as
* the `data` parameter to check the model's data property directly.
*
* @param {object} state - Page state with loading/error flags
* @param {string} title - Page header title
* @param {string} [subtitle] - Page header subtitle
* @param {*} [data] - Data presence check for "no data" state
* @param {*} [data] - Data to check (or model object with .data property)
* @returns {VNode[]|null}
*/
export function renderGuard(state, title, subtitle, data) {
@@ -54,7 +58,7 @@ export function renderGuard(state, title, subtitle, data) {
),
];
}
if ((data === undefined || data === null) && !state.loading) {
if (isEmpty(data) && !state.loading) {
return [
PageHeader({ title, subtitle }),
h('div', { class: 'card', key: 'no-data' },
@@ -65,6 +69,33 @@ export function renderGuard(state, title, subtitle, data) {
return null;
}
/**
* Convenience wrapper for pages consuming multiple models.
* Internally calls collectLoadingModels then delegates to renderGuard.
*
* @param {string} title - Page header title
* @param {string} [subtitle] - Page header subtitle
* @param {...object} models - Model objects to combine
* @returns {VNode[]|null}
*/
export function renderGuardMulti(title, subtitle, ...models) {
const combined = collectLoadingModels(...models);
return renderGuard(combined, title, subtitle);
}
/**
* Check if a value is "empty" for renderGuard's no-data check.
* @param {*} data
* @returns {boolean}
*/
function isEmpty(data) {
if (data === null || data === undefined || data === '') return true;
if (Array.isArray(data)) return data.length === 0;
if (typeof data === 'object') return Object.keys(data).length === 0;
if (typeof data === 'number') return false;
return !data;
}
/**
* Tab bar component. Writes to state[prop] on tab click.
* The caller is responsible for rendering tab body content.
+7 -7
View File
@@ -6,9 +6,9 @@
* avoid fighting with the main render cycle.
*/
import { esc } from '../helpers.js?v=6';
import { att_esc } from '../helpers.js?v=6';
import { apiSubmit } from '../api.js?v=6';
import { esc } from '../helpers.js?v=7';
import { att_esc } from '../helpers.js?v=7';
import { apiSubmit } from '../api.js?v=7';
const _modalQueue = [];
@@ -114,7 +114,7 @@ export function formModal(inner, title, fields, actions) {
* @param {string[]} props.selected - Currently selected values
* @param {string} props.fieldKey - JSON key for the field
* @param {string} [props.successMsg] - Success toast message
* @param {function} [props.reload] - () => Promise, called on success
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @returns {function} () => void, calls openModal
*/
export function MultiSelectModal(props = {}) {
@@ -138,7 +138,7 @@ export function MultiSelectModal(props = {}) {
.map(o => o.value),
}),
successMsg: props.successMsg || 'Updated',
reload: props.reload,
refresh: props.refresh,
closeModal: () => closeModal(),
}),
],
@@ -160,7 +160,7 @@ export function MultiSelectModal(props = {}) {
* @param {function} [props.submit.body] - (data) => object
* @param {function} [props.submit.validate] - (body) => string|null
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
* @param {function} [props.reload] - (data) => Promise, called on success with the data argument
* @param {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
* @returns {function} (data) => void, calls openModal
@@ -194,7 +194,7 @@ export function QuickModal(props = {}) {
successMsg: typeof props.submit.successMsg === 'function'
? props.submit.successMsg(data)
: (props.submit.successMsg || 'Done'),
reload: props.reload ? () => props.reload(data) : undefined,
refresh: props.refresh || undefined,
closeModal: () => closeModal(),
}),
];
+2 -2
View File
@@ -5,8 +5,8 @@
* Uses the toast/dismissToast state from api.js.
*/
import { h } from '../vdom.js?v=6';
import { _toasts, dismissToast } from '../api.js?v=6';
import { h } from '../vdom.js?v=7';
import { _toasts, dismissToast } from '../api.js?v=7';
/**
* Render all pending toast notifications.