This commit is contained in:
2026-03-27 03:58:57 +08:00
commit 86eba27a24
38 changed files with 4074 additions and 0 deletions

119
static/js/events.js Normal file
View File

@@ -0,0 +1,119 @@
/**
* static/js/events.js
* Server-Sent Events client connects to /api/events and dispatches
* updates into the central state store.
* Uses full item payloads embedded in events to avoid extra re-fetch round-trips.
*/
import { state } from './state.js';
import { api } from './api.js';
let _es = null;
let _reconnectTimer = null;
let _reconnecting = false;
const RECONNECT_DELAY_MS = 3000;
export function connectSSE() {
if (_es) return;
_connect();
}
function _connect() {
_es = new EventSource('/api/events');
_es.onopen = () => {
console.debug('[SSE] connected');
if (_reconnecting) {
_reconnecting = false;
state.set('sseReconnecting', false);
// Full refresh after reconnect to catch anything we missed
_fullRefresh();
}
state.set('serverOnline', true);
};
_es.onmessage = (e) => {
try {
const event = JSON.parse(e.data);
_handleEvent(event);
} catch (err) {
console.warn('[SSE] parse error', err);
}
};
_es.onerror = () => {
console.warn('[SSE] connection lost reconnecting in', RECONNECT_DELAY_MS, 'ms');
state.set('serverOnline', false);
_reconnecting = true;
state.set('sseReconnecting', true);
_es.close();
_es = null;
clearTimeout(_reconnectTimer);
_reconnectTimer = setTimeout(_connect, RECONNECT_DELAY_MS);
};
}
async function _fullRefresh() {
try {
const [instructions, status, config] = await Promise.all([
api.listInstructions('all'),
api.status(),
api.getConfig(),
]);
state.set('instructions', instructions.items);
state.set('status', status);
state.set('config', config);
} catch (e) {
console.error('[SSE] full refresh failed', e);
}
}
function _applyInstructionPatch(item) {
const current = state.get('instructions') || [];
const idx = current.findIndex(i => i.id === item.id);
if (idx === -1) {
// New item append and re-sort by position
const next = [...current, item].sort((a, b) => a.position - b.position);
state.set('instructions', next);
} else {
// Replace in-place, preserving order
const next = [...current];
next[idx] = item;
state.set('instructions', next.sort((a, b) => a.position - b.position));
}
}
function _handleEvent(event) {
switch (event.type) {
case 'instruction.created':
case 'instruction.updated':
case 'instruction.consumed': {
if (event.data?.item) {
_applyInstructionPatch(event.data.item);
} else {
// Fallback: full refresh
api.listInstructions('all').then(d => state.set('instructions', d.items)).catch(console.error);
}
break;
}
case 'instruction.deleted': {
const id = event.data?.id;
if (id) {
const next = (state.get('instructions') || []).filter(i => i.id !== id);
state.set('instructions', next);
}
break;
}
case 'status.changed': {
api.status().then(s => state.set('status', s)).catch(console.error);
break;
}
case 'config.updated': {
api.getConfig().then(c => state.set('config', c)).catch(console.error);
break;
}
default:
console.debug('[SSE] unknown event type', event.type);
}
}