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:
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user