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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user