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>
354 lines
14 KiB
JavaScript
354 lines
14 KiB
JavaScript
// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base.
|
||
Object.assign(TasksManager.prototype, {
|
||
// SSE-driven log streaming. Subscribes to `taskLog` events from the bus and
|
||
// appends incoming chunks. Initial backlog is fetched once via the API.
|
||
//
|
||
// Resilient to DOM replacement: the logs container can be wiped out by
|
||
// `renderTasks()` (the 30s auto-refresh) or by `loadTaskLogs()` (toggle
|
||
// re-open). Instead of capturing a `preElement` reference once, `render()`
|
||
// re-locates / re-creates it on every call from the cumulative `buffered`
|
||
// string. Idempotent: if already streaming, a second call just re-renders.
|
||
async startLogStreaming(taskId, task) {
|
||
if (!this.activeLogStreams) this.activeLogStreams = new Map();
|
||
|
||
if (this.activeLogStreams.has(taskId)) {
|
||
const existing = this.activeLogStreams.get(taskId);
|
||
if (existing && typeof existing.render === 'function') existing.render();
|
||
return;
|
||
}
|
||
|
||
const state = { buffered: '' };
|
||
|
||
const render = () => {
|
||
const logsContainer = document.getElementById(`logs-${taskId}`);
|
||
if (!logsContainer) return;
|
||
const overlay = logsContainer.querySelector('div[style*="position: absolute"]');
|
||
if (overlay) overlay.remove();
|
||
let preElement = logsContainer.querySelector('pre.output-content');
|
||
if (!preElement) {
|
||
logsContainer.innerHTML = '';
|
||
preElement = document.createElement('pre');
|
||
preElement.className = 'output-content terminal-style';
|
||
logsContainer.appendChild(preElement);
|
||
}
|
||
const atBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10;
|
||
if (state.buffered) {
|
||
preElement.innerHTML = this.taskManager.parseAnsiColors(state.buffered);
|
||
} else {
|
||
preElement.innerHTML = '<span style="color: #888;">Waiting for logs...</span>';
|
||
}
|
||
if (atBottom) logsContainer.scrollTop = logsContainer.scrollHeight;
|
||
};
|
||
|
||
// Initial backlog via the API.
|
||
try {
|
||
const initial = await this.taskManager.readFullTaskLog(taskId);
|
||
if (initial && initial.length) state.buffered = initial;
|
||
} catch { /* fall through to placeholder */ }
|
||
render();
|
||
|
||
const onLog = (event) => {
|
||
const detail = event.detail || {};
|
||
if (detail.id !== taskId || typeof detail.chunk !== 'string') return;
|
||
state.buffered += detail.chunk;
|
||
render();
|
||
};
|
||
window.addEventListener('taskLog', onLog);
|
||
|
||
// SSE catch-up: when the backend restarts mid-task (e.g., libreportal
|
||
// recreates itself during a CrowdSec install), the SSE event source
|
||
// drops. EventSource auto-reconnects, but task.log events emitted
|
||
// during the gap are lost. taskBusReady fires after every reconnect —
|
||
// pull the missed bytes via the existing /:id/log?position=N endpoint
|
||
// and splice them in. Skips on the initial connect (no gap).
|
||
let initialReadyFired = false;
|
||
const onBusReady = async () => {
|
||
if (!initialReadyFired) { initialReadyFired = true; return; }
|
||
try {
|
||
const res = await fetch(`/api/tasks/${encodeURIComponent(taskId)}/log?position=${state.buffered.length}`);
|
||
if (!res.ok) return;
|
||
const missed = await res.text();
|
||
if (missed) {
|
||
state.buffered += missed;
|
||
render();
|
||
}
|
||
} catch { /* network blip, next ready will retry */ }
|
||
};
|
||
window.addEventListener('taskBusReady', onBusReady);
|
||
|
||
this.activeLogStreams.set(taskId, {
|
||
stream: { stop: () => {
|
||
window.removeEventListener('taskLog', onLog);
|
||
window.removeEventListener('taskBusReady', onBusReady);
|
||
this.activeLogStreams.delete(taskId);
|
||
} },
|
||
render,
|
||
isPaused: false
|
||
});
|
||
},
|
||
// Stop log streaming for a task
|
||
stopLogStreaming(taskId) {
|
||
if (this.activeLogStreams && this.activeLogStreams.has(taskId)) {
|
||
const streamData = this.activeLogStreams.get(taskId);
|
||
streamData.stream.stop();
|
||
this.activeLogStreams.delete(taskId);
|
||
}
|
||
},
|
||
// Load task logs automatically
|
||
async loadTaskLogs(taskId) {
|
||
try {
|
||
const logsContainer = document.getElementById(`logs-${taskId}`);
|
||
if (!logsContainer) {
|
||
console.warn(`⚠️ Logs container not found for task ${taskId}`);
|
||
return;
|
||
}
|
||
|
||
const task = this.tasks.find(t => t.id === taskId);
|
||
const inMemoryLog = (task && Array.isArray(task.log) && task.log.length > 0) ? task.log : null;
|
||
|
||
const renderInMemory = () => {
|
||
if (!inMemoryLog) return false;
|
||
logsContainer.innerHTML = inMemoryLog
|
||
.map(line => `<div class="log-entry">${this.taskManager.parseAnsiColors(line)}</div>`)
|
||
.join('');
|
||
return true;
|
||
};
|
||
|
||
logsContainer.innerHTML = '<div class="log-entry">🔄 Loading logs...</div>';
|
||
const isScrolledToBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10;
|
||
|
||
const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`);
|
||
if (logResponse.ok) {
|
||
const logContent = await logResponse.text();
|
||
if (logContent.trim()) {
|
||
logsContainer.innerHTML = `<pre class="output-content terminal-style">${this.taskManager.parseAnsiColors(logContent)}</pre>`;
|
||
if (isScrolledToBottom) logsContainer.scrollTop = logsContainer.scrollHeight;
|
||
return;
|
||
}
|
||
}
|
||
|
||
if (renderInMemory()) return;
|
||
|
||
logsContainer.innerHTML = '<div class="log-entry">ℹ️ No logs available for this task.</div>';
|
||
|
||
} catch (error) {
|
||
console.error(`❌ Error loading logs for task ${taskId}:`, error);
|
||
const logsContainer = document.getElementById(`logs-${taskId}`);
|
||
if (logsContainer) {
|
||
logsContainer.innerHTML = '<div class="log-entry">❌ Failed to load logs.</div>';
|
||
}
|
||
}
|
||
},
|
||
// Load task output on demand
|
||
async loadTaskOutput(taskId) {
|
||
try {
|
||
// Read the task file to get output
|
||
const task = await this.taskManager.getTask(taskId);
|
||
if (!task) return;
|
||
|
||
const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`);
|
||
if (!outputElement) return;
|
||
|
||
// Show loading state
|
||
outputElement.innerHTML = `
|
||
<div class="loading-output">Loading output...</div>
|
||
`;
|
||
|
||
// Check if task has output
|
||
if (task.output && task.output.trim()) {
|
||
outputElement.innerHTML = `
|
||
<h4>📤 Output</h4>
|
||
<pre class="output-content terminal-style">${this.taskManager.parseAnsiColors(task.output)}</pre>
|
||
`;
|
||
} else if (task.error && task.error.trim()) {
|
||
outputElement.innerHTML = `
|
||
<h4>❌ Error</h4>
|
||
<pre class="error-content">${this.escapeHtml(task.error)}</pre>
|
||
`;
|
||
} else {
|
||
// Try to read from log file
|
||
const logResponse = await fetch(`/read-file?path=tasks/${taskId}.log`);
|
||
if (logResponse.ok) {
|
||
const logContent = await logResponse.text();
|
||
if (logContent.trim()) {
|
||
outputElement.innerHTML = `
|
||
<pre class="output-content terminal-style">${this.taskManager.parseAnsiColors(logContent)}</pre>
|
||
`;
|
||
} else {
|
||
outputElement.innerHTML = `
|
||
<h4>ℹ️ Information</h4>
|
||
<div class="info-content">No output available for this task.</div>
|
||
`;
|
||
}
|
||
} else {
|
||
outputElement.innerHTML = `
|
||
<h4>ℹ️ Information</h4>
|
||
<div class="info-content">No output available for this task.</div>
|
||
`;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error loading task output for ${taskId}:`, error);
|
||
const outputElement = document.querySelector(`[data-task-id="${taskId}"] .task-output`);
|
||
if (outputElement) {
|
||
outputElement.innerHTML = `
|
||
<h4>❌ Error</h4>
|
||
<div class="error-content">Failed to load task output: ${error.message}</div>
|
||
`;
|
||
}
|
||
}
|
||
},
|
||
// Start global live log updater - simple 2-second updates for all running tasks
|
||
startGlobalLiveLogUpdater() {
|
||
// Idempotent + keep the handle so unmount can clear it (it was discarded
|
||
// before — a permanent 2 s poll that outlived the page).
|
||
if (this.globalLiveLogInterval) return;
|
||
// Update every 2 seconds
|
||
this.globalLiveLogInterval = setInterval(async () => {
|
||
|
||
// Find all running tasks
|
||
const runningTasks = this.tasks.filter(task => task.status === 'running');
|
||
|
||
if (runningTasks.length > 0) {
|
||
|
||
// Update each running task's live logs
|
||
for (const task of runningTasks) {
|
||
await this.updateLiveLogsSimple(task.id);
|
||
}
|
||
} else {
|
||
}
|
||
}, 2000); // Every 2 seconds
|
||
},
|
||
// Simple live log update - no complex polling logic
|
||
async updateLiveLogsSimple(taskId) {
|
||
const liveLogsElement = document.getElementById(`live-logs-${taskId}`);
|
||
if (!liveLogsElement) {
|
||
return; // Silently skip if element not found
|
||
}
|
||
|
||
try {
|
||
|
||
// Read the log file content
|
||
const response = await fetch(`/read-file?path=tasks/${taskId}.log`);
|
||
|
||
if (response.ok) {
|
||
const logContent = await response.text();
|
||
|
||
if (logContent.trim()) {
|
||
// Split into lines and display
|
||
const lines = logContent.split('\n').filter(line => line.trim());
|
||
|
||
liveLogsElement.innerHTML = lines.map(line =>
|
||
`<div class="log-entry">${this.parseAnsiColors(line)}</div>`
|
||
).join('');
|
||
// Auto-scroll to bottom
|
||
liveLogsElement.scrollTop = liveLogsElement.scrollHeight;
|
||
} else {
|
||
liveLogsElement.innerHTML = '<div class="log-entry">🔄 Waiting for logs...</div>';
|
||
}
|
||
} else {
|
||
console.warn(`⚠️ Failed to read log file for task ${taskId}: ${response.status}`);
|
||
liveLogsElement.innerHTML = '<div class="log-entry">⚠️ Unable to read logs</div>';
|
||
}
|
||
} catch (error) {
|
||
// Silently handle errors
|
||
console.warn(`⚠️ Error reading live logs for task ${taskId}:`, error);
|
||
liveLogsElement.innerHTML = '<div class="log-entry">❌ Error loading logs</div>';
|
||
}
|
||
},
|
||
// Update task structure for live logs
|
||
updateTaskStructure(taskId, task) {
|
||
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
|
||
if (!taskElement) return;
|
||
|
||
const detailsElement = taskElement.querySelector('.task-details');
|
||
if (!detailsElement) return;
|
||
|
||
|
||
// Check if logs container already exists
|
||
const existingLogs = detailsElement.querySelector('.task-logs .log-container');
|
||
if (existingLogs) {
|
||
return; // Already exists, no need to update
|
||
}
|
||
|
||
// Add simplified logs section for running tasks
|
||
if (task.status === 'running') {
|
||
const logsHtml = `
|
||
<div class="task-logs">
|
||
<div class="log-container terminal-style" id="logs-${taskId}" style="height: 200px; overflow-y: auto; position: relative;">
|
||
<div style="position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 18, 36, 0.85); display: flex; align-items: center; justify-content: center; z-index: 10;"><div class="loading-spinner" style="width: 16px; height: 16px; border: 2px solid rgba(255,255,255,0.18); border-top: 2px solid #fff; border-radius: 50%; animation: spin 1s linear infinite; margin-right: 8px;"></div>Loading logs...</div></div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Insert logs section at the bottom of details
|
||
detailsElement.insertAdjacentHTML('beforeend', logsHtml);
|
||
|
||
// Auto-load logs
|
||
this.loadTaskLogs(taskId);
|
||
}
|
||
},
|
||
// Update task display in real-time
|
||
updateTaskDisplay(task) {
|
||
const taskElement = document.querySelector(`[data-task-id="${task.id}"]`);
|
||
if (!taskElement) {
|
||
// The monitored task isn't always rendered (different tab, list filtered out,
|
||
// task already removed). Silently skip — this is the normal case.
|
||
return;
|
||
}
|
||
|
||
|
||
// Update status and content
|
||
const statusElement = taskElement.querySelector('.task-status');
|
||
const contentElement = taskElement.querySelector('.task-content');
|
||
|
||
if (statusElement) {
|
||
const statusClass = `status-${task.status || 'unknown'}`;
|
||
statusElement.className = `task-status ${statusClass}`;
|
||
statusElement.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`;
|
||
} else {
|
||
console.warn(`⚠️ Status element not found for task ${task.id}`);
|
||
}
|
||
|
||
// Mirror the status into the details panel's metadata block too — that
|
||
// copy of the status was previously left stale until the page reloaded.
|
||
const detailsStatus = taskElement.querySelector(`#details-${task.id} .task-meta .status-running, #details-${task.id} .task-meta .status-queued, #details-${task.id} .task-meta .status-pending, #details-${task.id} .task-meta .status-completed, #details-${task.id} .task-meta .status-failed, #details-${task.id} .task-meta .status-cancelled, #details-${task.id} .task-meta [class^="status-"]`);
|
||
if (detailsStatus) {
|
||
detailsStatus.className = `status-${task.status || 'unknown'}`;
|
||
detailsStatus.innerHTML = `${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}`;
|
||
}
|
||
|
||
if (contentElement) {
|
||
contentElement.textContent = task.command;
|
||
}
|
||
|
||
},
|
||
// Update highlighted task status and UI
|
||
async updateHighlightedTaskStatus(taskId) {
|
||
try {
|
||
// Use lightweight summary for status updates
|
||
const task = await this.taskManager.getTaskSummary(taskId);
|
||
if (!task) return;
|
||
|
||
|
||
// Update task display
|
||
this.updateTaskDisplay(task);
|
||
|
||
// If task completed or failed, always load output
|
||
if ((task.status === 'completed' || task.status === 'failed')) {
|
||
|
||
const details = document.getElementById(`details-${taskId}`);
|
||
if (details && details.style.display === 'block') {
|
||
// Load output regardless of current content
|
||
this.loadTaskOutput(taskId);
|
||
} else if (details) {
|
||
// If details aren't open, mark for loading when opened
|
||
details.setAttribute('data-load-output-on-open', 'true');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`❌ Error updating highlighted task status for ${taskId}:`, error);
|
||
}
|
||
} ,
|
||
});
|