librelad 306e6223c0 fix(webui): release leaked listeners/intervals/streams on unmount (all modules)
The teardown audit found the backup-stacking leak class across 4 more feature
modules (12 confirmed leaks); unmount() left document/window listeners, intervals,
and SSE subscriptions firing on stale controllers after navigation:

- admin: overview/ssh/peers/system each leaked a document click listener ->
  AbortController + dispose() per page; admin unmount() aborts each.
- dashboard: the 1 Hz update-countdown interval + the LiveSystem view sub ->
  stopUpdateCountdown()/detachDashboardLive(), registered via ctx.sub().
- tasks: constructor-started global live-log poller (discarded handle) -> stored
  + idempotent + cleared on unmount + re-armed on mount; per-task monitorTask
  window listeners + interval -> tracked in a map, released on unmount.
- apps: app-tabbed reconcile setTimeout loop + watchdog window/document listeners
  + popstate -> per-instance AbortController + dispose() that clears the timer,
  resets the guards, and unloads the active tab's Services intervals + log SSE.

All mirror the kernel's MountContext teardown discipline. 12 files, all pass
node --check. Backup (fixed earlier) re-confirmed clean by the audit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-31 15:27:29 +01:00

169 lines
6.3 KiB
JavaScript

// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
Object.assign(TasksManager.prototype, {
toggleTaskDetails(taskId) {
const details = document.getElementById(`details-${taskId}`);
const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
if (details) {
const isOpen = details.style.display === 'block';
// Close all other task details and reset their buttons
if (isOpen) {
document.querySelectorAll('.task-details').forEach(otherDetails => {
if (otherDetails.id !== `details-${taskId}`) {
otherDetails.style.display = 'none';
otherDetails.classList.remove('task-details-open');
}
});
document.querySelectorAll('.task-btn.toggle-details').forEach(otherBtn => {
if (!otherBtn.getAttribute('onclick').includes(taskId)) {
otherBtn.classList.remove('expanded');
}
});
// Close current
details.style.display = 'none';
details.classList.remove('task-details-open');
if (toggleBtn) toggleBtn.classList.remove('expanded');
} else {
// Open current
details.style.display = 'block';
details.classList.add('task-details-open');
if (toggleBtn) toggleBtn.classList.add('expanded');
// Auto-load logs when opened. For active tasks, hand off to the live
// streamer so SSE chunks keep updating the panel; for terminal tasks
// a one-shot snapshot is enough.
const t = this.tasks.find(x => x.id === taskId);
if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) {
this.startLogStreaming(taskId, t);
} else {
this.loadTaskLogs(taskId);
}
// Scroll to task
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (taskElement) {
taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
// Update URL to include task parameter
this.updateURL(this.currentCategory, isOpen ? null : taskId);
this.highlightedTaskId = isOpen ? null : taskId;
} else {
// Remove task parameter from URL
this.updateURL(this.currentCategory);
this.highlightedTaskId = null;
}
},
// Monitor a specific task. State changes now arrive via SSE through
// TaskEventBus, so this is mostly a hook for the UI to:
// - auto-expand the task if it's the one we just started
// - start log streaming when the task transitions to running
// - clean up local intervals when it terminates
// No more polling; the only fetches happen on-demand via TaskManager.
monitorTask(taskId, appName, action) {
if (!this.highlightedTaskId || this.highlightedTaskId === taskId) {
setTimeout(() => this.autoExpandTask(taskId), 1500);
}
let statusUpdateInterval = null;
const onUpdate = (event) => {
const t = event.detail && event.detail.task;
if (!t || t.id !== taskId) return;
if (t.status === 'running') {
this.updateTaskStructure(taskId, t);
this.startLogStreaming(taskId, t);
if (this.highlightedTaskId === taskId && !statusUpdateInterval) {
statusUpdateInterval = setInterval(() => this.updateHighlightedTaskStatus(taskId), 2000);
}
} else {
this.updateTaskDisplay(t);
}
};
const onComplete = (event) => {
if (!event.detail || event.detail.taskId !== taskId) return;
if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; }
this.stopLogStreaming(taskId);
window.removeEventListener('taskUpdated', onUpdate);
window.removeEventListener('taskCompleted', onComplete);
if (this.taskMonitors) this.taskMonitors.delete(taskId);
};
window.addEventListener('taskUpdated', onUpdate);
window.addEventListener('taskCompleted', onComplete);
// Track this monitor so the tasks module's unmount() can release it if the
// user navigates away while the task is still running (these window
// listeners + interval otherwise outlive the page until the task completes).
(this.taskMonitors = this.taskMonitors || new Map()).set(taskId, () => {
if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; }
window.removeEventListener('taskUpdated', onUpdate);
window.removeEventListener('taskCompleted', onComplete);
});
},
// Auto-expand a task when it's created
async autoExpandTask(taskId) {
// Wait for task to be rendered
let attempts = 0;
const maxAttempts = 10;
const tryExpand = async () => {
attempts++;
// Check if task element exists
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (!taskElement) {
if (attempts < maxAttempts) {
setTimeout(tryExpand, 500); // Try again in 500ms
} else {
console.warn(`⚠️ Could not find task element for ${taskId} after ${maxAttempts} attempts`);
}
return;
}
// Get the details element
const details = document.getElementById(`details-${taskId}`);
if (!details) {
if (attempts < maxAttempts) {
setTimeout(tryExpand, 500);
} else {
console.warn(`⚠️ Could not find details element for ${taskId}`);
}
return;
}
// Expand the task details
details.style.display = 'block';
details.classList.add('task-details-open');
// Update toggle button
const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
if (toggleBtn) {
toggleBtn.classList.add('expanded');
}
// Scroll to the task
taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Load the output if task is completed
const task = await this.taskManager.getTask(taskId);
if (task && (task.status === 'completed' || task.status === 'failed')) {
setTimeout(() => {
this.loadTaskOutput(taskId);
}, 1000);
}
};
tryExpand();
},
});