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>
140 lines
4.5 KiB
JavaScript
Executable File
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;
|