Files
vacuum-wall/webui/static/hoover/websocket.js
T
mteehan b673e87c9b 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
2026-06-22 22:54:29 +00:00

110 lines
3.1 KiB
JavaScript

/**
* 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.
*/
import { refreshByTopic } from './model.js?v=7';
let _wsConn = null;
let _wsReconnectMs = 0;
/** Direct onMessage handlers: { topics, handler, unsubscribed }[] */
const _directHandlers = [];
/**
* Build the WebSocket URL. Supports an override via `window.__WS_URL__`
* (useful for proxy setups). Falls back to port 9091 when the current
* origin has no port (nginx fronting the WS on a different port).
*/
function _wsUrl() {
if (window.__WS_URL__) return window.__WS_URL__;
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
return proto + '//' + location.host + '/ws';
}
/** Attempt a WebSocket connection. */
function _wsConnect() {
if (_wsConn && _wsConn.readyState <= 1) return;
_wsConn = new WebSocket(_wsUrl());
_wsConn.onopen = () => {
_wsReconnectMs = 0;
};
_wsConn.onclose = () => {
_wsReconnectMs = Math.min(_wsReconnectMs * 2 + 1000, 15000);
setTimeout(_wsConnect, _wsReconnectMs);
};
_wsConn.onerror = () => {
_wsConn.close();
};
_wsConn.onmessage = (ev) => {
try {
const msg = typeof ev.data === 'string' ? JSON.parse(ev.data) : ev.data;
handleMessage(msg);
} catch (_) {}
};
}
/**
* Route an incoming WS message to model refresh and direct handlers.
*
* Expected message shapes:
* { type: 'versions', updated: ['firewall', 'dnsmasq', …] }
* { type: 'notify', topic: 'firewall' }
* { type: 'status', topic: 'firewall', … }
*/
function handleMessage(msg) {
const topics = [];
if (msg.type === 'versions' || msg.type === 'refresh') {
topics.push(...(msg.updated || 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 (_) {}
}
}
}
/**
* 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);
};
}
/** Start the WebSocket connection. */
export function connect() {
_wsConnect();
}