Add entity CRUD events over WebSocket with auto-refresh

Broadcast entity_changed and device_health_changed events via the event
bus so the frontend can auto-refresh cards without polling. Adds
exponential backoff on WS reconnect.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 11:09:09 +03:00
parent 1ce25caa35
commit 73562cd525
18 changed files with 169 additions and 10 deletions

View File

@@ -0,0 +1,61 @@
/**
* Entity event listeners — reacts to server-pushed entity_changed and
* device_health_changed WebSocket events by invalidating the relevant
* DataCache and dispatching an `entity:reload` DOM event so active
* feature modules can refresh their UI.
*/
import {
devicesCache, outputTargetsCache, colorStripSourcesCache,
streamsCache, audioSourcesCache, valueSourcesCache,
syncClocksCache, automationsCacheObj, scenePresetsCache,
captureTemplatesCache, audioTemplatesCache, ppTemplatesCache,
patternTemplatesCache,
} from './state.js';
/** Maps entity_type string from the server event to its DataCache instance. */
const ENTITY_CACHE_MAP = {
device: devicesCache,
output_target: outputTargetsCache,
color_strip_source: colorStripSourcesCache,
picture_source: streamsCache,
audio_source: audioSourcesCache,
value_source: valueSourcesCache,
sync_clock: syncClocksCache,
automation: automationsCacheObj,
scene_preset: scenePresetsCache,
capture_template: captureTemplatesCache,
audio_template: audioTemplatesCache,
pp_template: ppTemplatesCache,
pattern_template: patternTemplatesCache,
};
function _invalidateAndReload(entityType) {
const cache = ENTITY_CACHE_MAP[entityType];
if (cache) {
cache.invalidate();
cache.fetch();
}
document.dispatchEvent(new CustomEvent('entity:reload', {
detail: { entity_type: entityType },
}));
}
function _onEntityChanged(e) {
const { entity_type } = e.detail || {};
if (!entity_type) return;
_invalidateAndReload(entity_type);
}
function _onDeviceHealthChanged() {
_invalidateAndReload('device');
}
/**
* Register listeners for server-pushed entity events.
* Call once during app initialization, after startEventsWS().
*/
export function startEntityEventListeners() {
document.addEventListener('server:entity_changed', _onEntityChanged);
document.addEventListener('server:device_health_changed', _onDeviceHealthChanged);
}

View File

@@ -2,13 +2,20 @@
* Global events WebSocket — stays connected while logged in,
* dispatches DOM custom events that feature modules can listen to.
*
* Events dispatched: server:state_change, server:automation_state_changed
* Events dispatched:
* server:state_change — target processing start/stop/crash
* server:automation_state_changed — automation activated/deactivated
* server:entity_changed — entity CRUD (create/update/delete)
* server:device_health_changed — device online/offline status change
*/
import { apiKey } from './state.js';
let _ws = null;
let _reconnectTimer = null;
let _reconnectDelay = 1000; // start at 1s, exponential backoff to 30s
const _RECONNECT_MIN = 1000;
const _RECONNECT_MAX = 30000;
export function startEventsWS() {
stopEventsWS();
@@ -19,6 +26,9 @@ export function startEventsWS() {
try {
_ws = new WebSocket(url);
_ws.onopen = () => {
_reconnectDelay = _RECONNECT_MIN; // reset backoff on successful connection
};
_ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
@@ -27,7 +37,8 @@ export function startEventsWS() {
};
_ws.onclose = () => {
_ws = null;
_reconnectTimer = setTimeout(startEventsWS, 3000);
_reconnectTimer = setTimeout(startEventsWS, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, _RECONNECT_MAX);
};
_ws.onerror = () => {};
} catch {
@@ -45,4 +56,5 @@ export function stopEventsWS() {
_ws.close();
_ws = null;
}
_reconnectDelay = _RECONNECT_MIN;
}

View File

@@ -840,6 +840,13 @@ function _debouncedDashboardReload(forceFullRender = false) {
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
document.addEventListener('server:automation_state_changed', () => _debouncedDashboardReload(true));
document.addEventListener('server:device_health_changed', () => _debouncedDashboardReload());
const _DASHBOARD_ENTITY_TYPES = new Set(['output_target', 'automation', 'scene_preset', 'sync_clock', 'device']);
document.addEventListener('server:entity_changed', (e) => {
const { entity_type } = e.detail || {};
if (_DASHBOARD_ENTITY_TYPES.has(entity_type)) _debouncedDashboardReload(true);
});
// Re-render dashboard when language changes
document.addEventListener('languageChanged', () => {