ws: migrate push stream to data streaming

- daemon: send full snapshot on connect; versions/tick now carry the
  full state of one subsystem (subsystem + data); no legacy
  updated/subsystems payloads; refresh_state and POST /status/refresh
  broadcast per-subsystem versions with data
- client: modelSet() patches models in place; onMessage/topic refresh
  retired; 3s initial-load fallback via new POST /api/status/refresh
- schema: lib/schema.py TypedDicts + hoover/schema.js defaults +
  docs/state-model.md as single source of truth for state shapes
- system: poll at 1s, volatile metrics registered, dashboard uses a
  dedicated system model (status model removed)
- firewall: refuse to strip both https and ssh from the default zone
  (409, force override via UI confirm); set_zone_services persists
  services to the declarative config; collector exposes default_zone
- UI: pages migrate to flat state shapes; post-mutation modelFetch
  refreshes removed (WS delta covers it)
- tests: ws snapshot/delta/broadcast, refresh-state, schema types,
  model-set/js ws handler and reconnect fallback
This commit is contained in:
2026-08-20 01:38:00 +00:00
parent 9c9f92ad04
commit 332d14e37d
45 changed files with 2819 additions and 496 deletions
+13 -7
View File
@@ -268,8 +268,11 @@ export function formAction(fn) {
* @param {string} [opts.method] - HTTP method (default: 'POST')
* @param {function} [opts.body] - () => object, body builder
* @param {function} [opts.validate] - (body) => string|null, validation function
* @param {function} [opts.confirm] - (body) => string|null; if a message is
* returned, a native confirm() dialog gates
* the submit; on approval the body gains
* force=true (server-side guard override)
* @param {string} [opts.successMsg] - Success toast message
* @param {string|string[]} [opts.refresh] - Model name(s) to refresh via modelFetch
* @param {string} [opts.submitText] - Submit button text (default: 'Submit')
* @returns {object[]} Array of action descriptors
*/
@@ -279,8 +282,8 @@ export function apiSubmit(opts) {
method = 'POST',
body,
validate,
confirm,
successMsg = 'Saved',
refresh,
submitText = 'Submit',
closeModal,
} = opts;
@@ -300,6 +303,13 @@ export function apiSubmit(opts) {
const err = validate(b);
if (err) { toast(err, 'error'); return; }
}
if (confirm) {
const msg = confirm(b);
if (msg) {
if (!window.confirm(msg)) return;
b.force = true;
}
}
refreshModals();
const res = await apiFetch(url, { method, body: b });
if (res.ok) {
@@ -307,14 +317,10 @@ export function apiSubmit(opts) {
let msg = successMsg;
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
}
toast(msg, 'success');
if (closeModal) closeModal();
if (refresh) {
const models = Array.isArray(refresh) ? refresh : [refresh];
await Promise.all(models.map(m => modelFetch(m)));
}
// No modelFetch — WS delta updates all affected subsystems.
} else {
toast(res.error || 'Failed', 'error');
}
+9 -12
View File
@@ -10,7 +10,6 @@ import { h } from '../vdom.js';
import { html } from '../html.js';
import { reactive } from '../reactivity.js';
import { apiFetch, toast } from '../api.js';
import { modelFetch } from '../model.js';
import { openModal, closeModal, modalVNodes, isModalProcessing, setModalProcessing, refreshModals } from './modal.js';
export const SUBSYSTEM_LIST = [
@@ -56,9 +55,10 @@ ${hasPending ? html`<span class="apply-expand-icon${isExpanded ? ' expanded' : '
}
/**
* POST apply-all, toast result, close modal, refresh models.
* POST apply-all, toast result, close modal. State-store models update from
* the daemon's WS delta — no explicit refresh.
*/
async function doApply(successMsg, refreshTargets) {
async function doApply(successMsg) {
if (isModalProcessing()) return;
setModalProcessing(true);
try {
@@ -66,10 +66,7 @@ async function doApply(successMsg, refreshTargets) {
if (resp.ok) {
toast(successMsg, 'success');
closeModal();
if (refreshTargets) {
const names = Array.isArray(refreshTargets) ? refreshTargets : [refreshTargets];
names.forEach(n => modelFetch(n));
}
// No modelFetch — WS delta updates all affected subsystems.
} else {
toast(resp.error || 'Apply failed', 'error');
}
@@ -82,7 +79,7 @@ async function doApply(successMsg, refreshTargets) {
/**
* Fetch pending state, then open the confirmation modal.
*/
async function openApplyModal(successMsg, refreshTargets) {
async function openApplyModal(successMsg) {
const pendingResp = await apiFetch('/api/status/pending');
if (!pendingResp.ok) {
toast(pendingResp.error || 'Could not fetch pending changes', 'error');
@@ -109,7 +106,7 @@ async function openApplyModal(successMsg, refreshTargets) {
modalVNodes(inner, html`<div>
<h2 class="modal-title">Confirm: Apply All Changes</h2>
<div class="modal-body">${rows}</div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg, refreshTargets)}">Apply All</button></div>
<div class="apply-modal-actions"><button class="btn btn-outline" onClick="${() => closeModal()}">Cancel</button><button class="btn btn-primary" onClick="${() => doApply(successMsg)}">Apply All</button></div>
</div>`);
});
}
@@ -123,7 +120,7 @@ async function openApplyModal(successMsg, refreshTargets) {
* @param {string} [props.syncedLabel] - Synced button text (default: 'Synced')
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-primary' when pending, 'btn btn-outline' when synced)
* @param {string} [props.successMsg] - Success toast message (default: 'All changes applied')
* @param {string|string[]} [props.refresh] - Model name(s) to refresh after apply
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
*/
export function ApplyConfirm(props = {}) {
const label = props.label || 'Apply';
@@ -139,7 +136,7 @@ export function ApplyConfirm(props = {}) {
toast(successMsg || 'All synced', 'info');
return;
}
openApplyModal(successMsg, props.refresh);
openApplyModal(successMsg);
},
}, props.pending ? label : syncedLabel);
}
}
+22 -36
View File
@@ -7,7 +7,6 @@
import { h } from '../vdom.js';
import { esc } from '../helpers.js';
import { apiFetch, toast } from '../api.js';
import { modelFetch } from '../model.js';
import { requestUpdate } from '../reactivity.js';
const _actionPending = new Map();
@@ -69,16 +68,16 @@ export function Card(props = {}) {
}
/**
* A Remove button that confirms, deletes via API, toasts, and refreshes models.
* When the response includes a ``synced`` array (list of subsystem names
* that were auto-updated), shows a secondary toast and refreshes those
* models.
* A Remove button that confirms, deletes via API, and toasts. State-store
* models update from the daemon's WS delta — no explicit refresh. When the
* response includes a ``synced`` array (list of subsystem names that were
* auto-updated), appends them to the success toast.
*
* @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 {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
* @param {string} [props.label] - Button text (default: 'Remove')
* @param {object} [props.body] - Optional JSON body to send with DELETE
* @param {string} [props.deleteKey] - Unique ID for pending-delete row styling
@@ -104,31 +103,20 @@ export function ConfirmDelete(props = {}) {
let msg = props.success || 'Removed';
if (synced && synced.length) {
msg += ' (auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
}
toast(msg, 'success');
if (props.deleteKey) {
_deleting.add(props.deleteKey);
// The WS delta (~50ms) removes the deleted item from
// model.data and re-renders the row away. This timeout
// purges _deleting if the delta is slow or the row was
// already unmounted.
setTimeout(() => _deleting.delete(props.deleteKey), 2000);
}
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
const promises = names.map(n => modelFetch(n));
if (props.deleteKey && promises.length) {
Promise.all(promises).finally(() => {
_deleting.delete(props.deleteKey);
if (props.onComplete) props.onComplete();
});
}
} else if (props.deleteKey) {
_deleting.delete(props.deleteKey);
}
if (props.onComplete && !props.refresh) {
props.onComplete();
}
if (props.onComplete) props.onComplete();
// No modelFetch — WS delta updates state store models.
} else {
toast(r.error || 'Failed', 'error');
}
@@ -141,11 +129,11 @@ export function ConfirmDelete(props = {}) {
}
/**
* An action button that POSTs to an API endpoint, toasts on result,
* and optionally refreshes models. Supports toggle labels for on/off buttons.
* When the response includes a ``synced`` array (list of subsystem names
* that were auto-updated), shows a secondary toast and refreshes those
* models.
* An action button that POSTs to an API endpoint and toasts on result.
* Supports toggle labels for on/off buttons. State-store models update from
* the daemon's WS delta — no explicit refresh. When the response includes a
* ``synced`` array (list of subsystem names that were auto-updated), appends
* them to the success toast.
*
* @param {object} props
* @param {string} props.url - API URL
@@ -157,7 +145,8 @@ 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 {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
* @param {function} [props.onSuccess] - Callback after the success toast
* @param {string} [props.cls] - Button CSS classes (default: 'btn btn-outline')
* @param {boolean} [props.disabled] - Disabled state
*/
@@ -187,13 +176,10 @@ export function ActionButton(props = {}) {
if (synced && synced.length) {
if (msg) msg += ' ';
msg += '(auto-synced: ' + synced.join(', ') + ')';
synced.forEach(s => modelFetch(s));
}
if (msg) toast(msg, 'success');
if (props.refresh) {
const names = Array.isArray(props.refresh) ? props.refresh : [props.refresh];
names.forEach(n => modelFetch(n));
}
if (props.onSuccess) props.onSuccess();
// No modelFetch — WS delta updates state store models.
} else {
toast(resp.error || 'Failed', props.errorType || 'error');
}
@@ -324,7 +310,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 {string|string[]} [props.removeRefresh] - Model name(s) to refresh
* @param {string|string[]} [props.removeRefresh] - Legacy, ignored (accepted for backward compat)
* @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')
+4 -2
View File
@@ -217,7 +217,8 @@ 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 {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
* @param {function} [props.confirm] - (body) => string|null; confirm gate, see apiSubmit
* @returns {function} () => void, calls openModal
*/
export function MultiSelectModal(props = {}) {
@@ -242,6 +243,7 @@ export function MultiSelectModal(props = {}) {
}),
successMsg: props.successMsg || 'Updated',
refresh: props.refresh,
confirm: props.confirm,
closeModal: () => closeModal(),
}),
],
@@ -263,7 +265,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 {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
* @param {string|string[]} [props.refresh] - Legacy, ignored (accepted for backward compat)
* @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
+2 -2
View File
@@ -23,7 +23,7 @@ export { definePage, hComp } from './component.js';
export { createRouter, Link } from './router.js';
/* ── WebSocket ───────────────────────────────────────────────── */
export { connect, onMessage, disconnect } from './websocket.js';
export { connect, disconnect } from './websocket.js';
/* ── API & Toast ─────────────────────────────────────────────── */
export { apiFetch, toast, dismissToast, apiSubmit, checkAbort, poll, refactorLoad, formAction }
@@ -38,7 +38,7 @@ export { createAuthModel, getAuthToken, isAuthenticated, refreshAuth, getAuthDat
from './auth_model.js';
/* ── Model ───────────────────────────────────────────────────── */
export { modelRegister, getModel, modelFetch, collectLoadingModels } from './model.js';
export { modelRegister, getModel, modelFetch, modelSet, collectLoadingModels } from './model.js';
/* ── Helpers ─────────────────────────────────────────────────── */
export { esc, att_esc, enc, $val, parseZones, fmtBytes, csvToArr, downloadBlob } from './helpers.js';
+17
View File
@@ -135,6 +135,23 @@ export function modelFetch(name, signalOrParam, signal) {
return promise;
}
/**
* Set model data from WebSocket. Bypasses fetch cycle, no refreshing flag.
* Directly assigns to reactive proxy → triggers re-render.
* Clears loading unconditionally on arrival of real data.
*
* @param {string} name - Model name
* @param {any} data - State payload from the WS snapshot/delta
*/
export function modelSet(name, data) {
const entry = _models.get(name);
if (!entry) return;
const model = entry.model;
if (model.loading) model.loading = false; // real data ends the initial load
model.data = data;
model.error = null;
}
/**
* Refresh all models whose subsystem topic matches the given topic.
* Topic '*' matches every model. Model subsystem '*' matches every topic.
+80
View File
@@ -0,0 +1,80 @@
/**
* Hoover — schema.js
*
* State-store schema defaults — one module per subsystem.
*
* `defaults` initializes model.data so pages don't need null guards
* during the first render (before the WS snapshot or HTTP fallback
* delivers real data). The shapes match the daemon state store
* (docs/state-model.md); the WebSocket streams these exact shapes.
*
* `POLL_INTERVALS` is client-side awareness of the daemon's expected
* refresh cadence per subsystem (for "last updated" displays).
*/
export const SUBSYSTEMS = {
firewall: {
defaults: {
config: {},
active_zones: {},
interfaces: [],
available_services: [],
zones: {},
rich_rules: {},
pending: {},
},
},
dnsmasq: {
defaults: {
config: {},
status: { service_active: false, config_file_exists: false, active_leases: 0, pending_changes: false },
leases: [],
},
},
nginx: {
defaults: {
config: {},
domains: [],
status: { pending_changes: false },
},
},
acme: {
defaults: {
certs: [],
email: '',
account: { registered: false, email: '', ca: '', key_length: null },
},
},
wireguard: {
defaults: {
config: {},
status: { up: false, interface: {}, peers: [], classes: {}, pending_changes: false },
peers: [],
},
},
networkd: {
defaults: {
config: {},
interfaces: {},
status: { pending_changes: false },
},
},
system: {
defaults: {
load: { load1: 0, load5: 0, load15: 0 },
memory: { total: 0, available: 0, used: 0, used_pct: 0 },
swap: { total: 0, used: 0, used_pct: 0 },
traffic: {},
},
},
};
export const POLL_INTERVALS = {
firewall: 30,
wireguard: 10,
dnsmasq: 10,
networkd: 10,
system: 1, // Phase 5: 30 → 1 (real-time metrics)
nginx: 60, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal)
acme: 300, // matches _DEFAULT_POLL_INTERVALS (config-drift self-heal)
};
+46 -50
View File
@@ -1,9 +1,10 @@
/**
* Hoover — websocket.js
*
* WebSocket connection manager with auto-reconnect. WS messages are routed
* to model-based refresh and direct onMessage handlers.
* Page-level subscribe/unsubscribe is replaced by the model layer.
* WebSocket connection manager with auto-reconnect. WS messages carry
* state data directly: a full snapshot on connect, then per-subsystem
* deltas. handleMessage patches the matching models in place via
* modelSet — no HTTP round-trip for auto-refresh.
*
* The JWT is read from the auth model (single source of truth). After 3
* failed close attempts a token refresh is triggered through the auth
@@ -19,9 +20,21 @@
* reloaded; the UI keeps working via the REST API.
*/
import { refreshByTopic } from './model.js';
import { modelSet } from './model.js';
import { refreshAuth, getAuthToken } from './auth_model.js';
// Maps subsystem name → registered model name.
// Most subsystems use the same name. `networkd` maps to `network`.
const _SUBSYSTEM_TO_MODEL = {
firewall: 'firewall',
dnsmasq: 'dnsmasq',
nginx: 'nginx',
acme: 'acme',
wireguard: 'wireguard',
networkd: 'network',
system: 'system',
};
let _wsConn = null;
let _wsReconnectMs = 0;
let _wsFailCount = 0;
@@ -32,9 +45,6 @@ let _wsRefreshStreak = 0;
let _wsGivingUp = false;
let _wsClosingHandled = false;
/** Direct onMessage handlers — { topics, handler, unsubscribed }[] */
const _directHandlers = [];
/**
* Build the WebSocket URL from the current origin. nginx proxies /ws to
* the daemon's WebSocket port.
@@ -126,55 +136,41 @@ function _wsConnect() {
}
/**
* Route an incoming WS message to model refresh and direct handlers.
* Patch models in place from a data-carrying WS message.
*
* Expected message shapes:
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
* { type: 'tick', subsystems: ['firewall', 'wireguard', …] }
* { type: 'notify', topic: 'firewall' }
* { type: 'status', topic: 'firewall', … }
* Expected message shapes (daemon → client):
* { type: 'snapshot', data: {subsystem: state|null, …} } // on connect
* { type: 'versions', subsystem: 'firewall', data: state } // structural change
* { type: 'tick', subsystem: 'system', data: state } // volatile change
*
* The daemon only sends these three types after the WS push-stream
* migration; unknown / retired types (refresh/notify/status, legacy
* versions.updated, tick.subsystems) are ignored — no backward compat.
*/
function handleMessage(msg) {
const topics = [];
if (msg.type === 'versions' || msg.type === 'refresh' || msg.type === 'tick') {
topics.push(...(msg.updated || msg.subsystems || msg.topics || []));
} else if (msg.type === 'notify') {
topics.push(msg.topic);
} else if (msg.type === 'status') {
topics.push(msg.topic || '*');
}
// Refresh models for each topic
for (const topic of topics) {
refreshByTopic(topic);
}
// Notify direct onMessage handlers
for (const h of _directHandlers) {
if (h.unsubscribed) continue;
if (topics.some(t => h.topics.includes(t) || h.topics.includes('*'))) {
try { h.handler(msg); } catch (err) { console.warn('[WS] Handler error:', err); }
if (msg.type === 'snapshot') {
// Full state on connect — set all models (null = collector failed, skip)
for (const [subsystem, data] of Object.entries(msg.data)) {
if (data !== null) {
const modelName = _SUBSYSTEM_TO_MODEL[subsystem] || subsystem;
modelSet(modelName, data);
}
}
return;
}
}
/**
* Public subscribe API for direct one-off usage (e.g. from page code).
* Handler receives the raw parsed message when a matching topic arrives.
* @param {string|string[]} topics
* @param {function} handler
* @returns {function} unsubscribe
*/
export function onMessage(topics, handler) {
const tArray = Array.isArray(topics) ? topics : [topics];
const entry = { topics: tArray, handler, unsubscribed: false };
_directHandlers.push(entry);
return () => {
entry.unsubscribed = true;
const idx = _directHandlers.indexOf(entry);
if (idx !== -1) _directHandlers.splice(idx, 1);
};
if ((msg.type === 'versions' || msg.type === 'tick')
&& msg.subsystem && msg.data != null) {
// Delta for one subsystem — patch the corresponding model.
// Guard is `!= null` (not `!== undefined`): a null payload means the
// collector failed — never overwrite good model data (defense in depth;
// the daemon skips null broadcasts).
const modelName = _SUBSYSTEM_TO_MODEL[msg.subsystem] || msg.subsystem;
modelSet(modelName, msg.data);
return;
}
// Everything else is unknown / retired — ignored.
}
/** Start the WebSocket connection. */