librelad 875a60f90f LibrePortal v0.1.0 — initial release
A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys,
Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun
VPN routing, and a web dashboard to manage it all.

Free & open forever to self-host; optional paid hosted services fund it.
See PROMISE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-21 20:37:54 +01:00

140 lines
4.5 KiB
JavaScript
Executable File

/**
* TaskEventBus — single SSE connection for the page.
*
* Connects to /api/tasks/events. Translates the server's SSE events
* (task.upsert, task.deleted, task.log) into the existing window-level
* CustomEvents that the rest of the UI already listens for:
* - taskCreated (when a brand-new task appears)
* - taskUpdated (status change while still active)
* - taskCompleted (status -> completed | failed | cancelled)
* - taskLog (new log lines for a running task)
* - taskDeleted (task removed)
*
* The bus also exposes a `tasks` Map keyed by id holding the latest known
* task object — components can read this synchronously instead of fetching.
*/
class TaskEventBus {
constructor() {
this.tasks = new Map(); // id -> latest task object
this.eventSource = null;
this.reconnectTimer = null;
this.connected = false;
// Track previous status per task so we can decide created vs updated vs completed.
this._lastStatus = new Map();
}
start() {
if (this.eventSource) return;
this._open();
}
stop() {
if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; }
if (this.eventSource) { this.eventSource.close(); this.eventSource = null; }
this.connected = false;
}
// Convenience accessors used by UI components.
getTask(id) { return this.tasks.get(id) || null; }
getRunningTasks() {
const out = [];
for (const t of this.tasks.values()) {
if (t.status === 'running' || t.status === 'queued' || t.status === 'pending') out.push(t);
}
return out;
}
getRunningForApp(appName) {
return this.getRunningTasks().filter(t => t.app === appName);
}
// ---- internals --------------------------------------------------------
_open() {
try {
this.eventSource = new EventSource('/api/tasks/events');
} catch (err) {
this._scheduleReconnect();
return;
}
this.eventSource.addEventListener('ready', () => {
this.connected = true;
window.dispatchEvent(new CustomEvent('taskBusReady'));
});
this.eventSource.addEventListener('task.upsert', (e) => {
let task; try { task = JSON.parse(e.data); } catch { return; }
if (!task || !task.id) return;
this._handleUpsert(task);
});
this.eventSource.addEventListener('task.deleted', (e) => {
let payload; try { payload = JSON.parse(e.data); } catch { return; }
if (!payload || !payload.id) return;
this.tasks.delete(payload.id);
this._lastStatus.delete(payload.id);
window.dispatchEvent(new CustomEvent('taskDeleted', { detail: { id: payload.id } }));
});
this.eventSource.addEventListener('task.log', (e) => {
let payload; try { payload = JSON.parse(e.data); } catch { return; }
if (!payload || !payload.id || typeof payload.chunk !== 'string') return;
window.dispatchEvent(new CustomEvent('taskLog', {
detail: { id: payload.id, chunk: payload.chunk }
}));
});
this.eventSource.onerror = () => {
// Browser will auto-retry, but we want a deterministic backoff on top
// so we don't hammer the server during a long outage.
this.connected = false;
this.eventSource && this.eventSource.close();
this.eventSource = null;
this._scheduleReconnect();
};
}
_scheduleReconnect() {
if (this.reconnectTimer) return;
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._open();
}, 3000);
}
_handleUpsert(task) {
const prevStatus = this._lastStatus.get(task.id);
const isNew = !this.tasks.has(task.id);
this.tasks.set(task.id, task);
this._lastStatus.set(task.id, task.status);
const detail = {
taskId: task.id,
appName: task.app || null,
action: task.type || 'unknown',
status: task.status,
task,
timestamp: Date.now()
};
if (isNew) {
window.dispatchEvent(new CustomEvent('taskCreated', { detail }));
}
const isTerminal = task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled';
const wasTerminal = prevStatus === 'completed' || prevStatus === 'failed' || prevStatus === 'cancelled';
if (isTerminal && !wasTerminal) {
window.dispatchEvent(new CustomEvent('taskCompleted', { detail }));
} else if (!isNew) {
window.dispatchEvent(new CustomEvent('taskUpdated', { detail }));
}
}
}
// One instance per page.
window.taskEventBus = window.taskEventBus || new TaskEventBus();
window.TaskEventBus = TaskEventBus;