/**
* Tasks Manager - Tasks Page UI Management
* Handles the tasks page display, filtering, and UI interactions
* Uses individual task operations from the task/ folder
*/
class TasksManager {
constructor() {
this.tasks = [];
this.currentCategory = 'all';
this.highlightedTaskId = null;
this.taskManager = new TaskManager();
this.init();
// Start global live log updater
this.startGlobalLiveLogUpdater();
this.refreshInterval = null;
this.activeLogStreams = new Map(); // Track active log streams
this.autoRefreshIntervals = new Map(); // Track auto-refresh intervals for running tasks
// Initialize modular components for individual task operations (only if available)
try {
this.commands = new TaskCommands();
this.actions = new TaskActions(this, this.commands);
this.router = new TaskRouter(this, this.actions);
this.taskManager = new TaskManager();
} catch (error) {
console.warn('β οΈ Task system components not yet loaded, will initialize later:', error.message);
this.commands = null;
this.actions = null;
this.router = null;
this.taskManager = null;
}
// Initialize from URL parameters
this.initializeFromURL();
// Load tasks and setup (only if task system is available)
if (this.commands) {
this.loadTasks();
} else {
//// // console.log('β³ Task system will be initialized later');
}
// Setup global functions
this.setupGlobalFunctions();
// Subscribe to the SSE bus once for the page so every visible task row
// reacts to status changes, not just ones spawned in this session.
this.setupTaskBusListeners();
// Setup mobile menu
this.setupMobileMenu();
//// // console.log('β
TasksManager initialized with modular architecture');
}
// Initialize task system after scripts are loaded
initializeTaskSystem() {
try {
//// // console.log('π§ Initializing task system components...');
// Check if TaskManager is available
if (typeof TaskManager === 'undefined') {
console.warn('β οΈ TaskManager not available yet, deferring initialization');
return false;
}
this.commands = new TaskCommands();
this.actions = new TaskActions(this, this.commands);
this.router = new TaskRouter(this, this.actions);
this.taskManager = new TaskManager(); // Add TaskManager for task operations
// Now load tasks since system is ready
this.loadTasks();
//// // console.log('β
Task system initialized successfully');
return true;
} catch (error) {
console.error('β Failed to initialize task system:', error);
return false;
}
}
// Main initialization method for the tasks page
async init() {
//// // console.log('π§ Initializing TasksManager...');
// Initialize task system if not already done
if (!this.taskManager) {
let initialized = false;
let attempts = 0;
const maxAttempts = 5;
while (!initialized && attempts < maxAttempts) {
//// // console.log(`π Attempting to initialize task system (${attempts + 1}/${maxAttempts})...`);
initialized = this.initializeTaskSystem();
if (!initialized) {
attempts++;
await new Promise(resolve => setTimeout(resolve, 200)); // Wait 200ms
}
}
if (!initialized) {
console.warn('β οΈ Task system initialization failed after retries');
}
}
// Setup refresh interval
this.setupRefreshInterval();
// Reconstructor() {
this.tasks = [];
this.taskManager = new TaskManager();
this.activeLogStreams = new Map(); // Track active log streams
// console.log('π TasksManager initialized');
}
initializeFromURL() {
const currentUrl = new URL(window.location.href);
const searchParams = currentUrl.searchParams;
// Check if we're on the main tasks page (not app page)
const isMainTasksPage = currentUrl.pathname === '/tasks' || currentUrl.pathname === '/tasks.html';
if (isMainTasksPage) {
// On main tasks page, get category from URL
this.currentCategory = searchParams.get('') || 'all';
// Only check for specific task parameter if we're not coming from an app page
const taskParam = searchParams.get('task');
if (taskParam) {
// console.log(`π― Found task parameter in URL: ${taskParam} on main tasks page`);
this.highlightedTaskId = taskParam;
} else {
// Clear any existing highlighted task when on main tasks page without task param
this.highlightedTaskId = null;
// console.log(`π― Clearing highlighted task on main tasks page`);
}
} else {
// Not on main tasks page, get default filter from localStorage
this.currentCategory = localStorage.getItem('tasksDefaultFilter') || 'all';
this.highlightedTaskId = null; // Always clear when not on tasks page
}
// console.log(`π― Tasks category from URL: ${this.currentCategory}`);
}
updateURL(category, taskId = null) {
// Update URL without page reload and without hash
let newURL = `/tasks?=${category}`;
if (taskId) {
newURL += `&task=${taskId}`;
}
// Prevent the SPA from interfering
if (window.librePortalSPA) {
window.librePortalSPA.currentRoute = newURL;
}
// Use a timeout to avoid conflicts with SPA routing
setTimeout(() => {
window.history.pushState({ category, taskId }, '', newURL);
}, 0);
}
async init() {
//// // console.log('π§ Initializing TasksManager...');
// Load initial tasks and refresh sidebar counts
await this.loadTasks();
// Force a refresh to ensure latest data
// console.log('π Refreshing tasks data on initialization...');
await this.loadTasks();
// Setup auto-refresh
this.setupAutoRefresh();
// Setup global functions
this.setupGlobalFunctions();
// Subscribe to the SSE bus once for the page so every visible task row
// reacts to status changes, not just ones spawned in this session.
this.setupTaskBusListeners();
// Setup mobile menu
this.setupMobileMenu();
//// // console.log('β
TasksManager initialized');
}
async refreshTasks() {
// Show refresh notification
const refreshNotification = window.notificationSystem.info(
'π Refreshing tasks...',
'Tasks',
null,
null
);
try {
await this.loadTasks();
// Remove refresh notification and show success
if (refreshNotification && refreshNotification.remove) {
refreshNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.success(
'π Tasks refreshed successfully',
'Tasks',
null,
null
);
}
} catch (error) {
console.error('Error refreshing tasks:', error);
// Remove refresh notification and show error
if (refreshNotification && refreshNotification.remove) {
refreshNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.error(
`β οΈ Failed to refresh tasks: ${error.message}`,
'Tasks',
null,
null
);
}
}
}
async loadTasks() {
try {
//// // console.log('π Loading tasks from file system...');
// Check if task system is available
if (!this.taskManager) {
console.warn('β οΈ Task system not yet initialized, skipping task loading');
this.tasks = [];
return;
}
// Get tasks using new system
// console.log('π₯ Getting tasks using new queue system...');
// Get queue and current status
let queue = [];
let current = {};
try {
const queueResponse = await fetch('/read-file?path=tasks/queue.json');
if (queueResponse.ok) {
const queueText = await queueResponse.text();
if (queueText.trim()) { // Only parse if not empty
try {
queue = JSON.parse(queueText);
} catch (parseError) {
console.warn('β οΈ Invalid queue.json format, starting with empty queue');
queue = [];
}
}
}
} catch (error) {
// console.log('π Queue file not found, starting with empty queue');
}
try {
const currentResponse = await fetch('/read-file?path=tasks/current.json');
if (currentResponse.ok) {
const currentText = await currentResponse.text();
if (currentText.trim()) { // Only parse if not empty
try {
current = JSON.parse(currentText);
} catch (parseError) {
console.warn('β οΈ Invalid current.json format, treating as empty');
current = {};
}
}
}
} catch (error) {
// console.log('π Current file not found, no current task');
}
// Load individual task files
const allTasks = [];
// Add queued tasks
for (const taskId of queue) {
try {
const task = await this.taskManager.getTask(taskId);
if (task) allTasks.push(task);
} catch (error) {
console.warn(`β οΈ Failed to load queued task ${taskId}:`, error);
}
}
// Add current task if different from queue
if (current.id && !queue.includes(current.id)) {
try {
const task = await this.taskManager.getTask(current.id);
if (task) allTasks.push(task);
} catch (error) {
console.warn(`β οΈ Failed to load current task ${current.id}:`, error);
}
}
// Scan tasks folder for all task files (including completed ones) - OPTIMIZED
try {
// console.log('π Scanning tasks folder for all task files...');
const tasksResponse = await fetch('/read-directory?path=tasks');
if (tasksResponse.ok) {
const files = await tasksResponse.json();
const taskFiles = files.filter(file =>
file.endsWith('.json') &&
file !== 'queue.json' &&
file !== 'current.json'
);
// console.log(`π Found ${taskFiles.length} task files in folder`);
// OPTIMIZATION: Batch load tasks instead of individual calls
const missingTaskIds = taskFiles
.map(file => file.replace('.json', ''))
.filter(taskId => !allTasks.find(task => task.id === taskId));
if (missingTaskIds.length > 0) {
// console.log(`π¦ Batch loading ${missingTaskIds.length} missing tasks...`);
try {
const batchResponse = await fetch('/read-tasks-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ taskIds: missingTaskIds })
});
if (batchResponse.ok) {
const batchTasks = await batchResponse.json();
batchTasks.forEach(task => {
if (task) {
allTasks.push(task);
// console.log(`β
Added completed task ${task.id} from batch load`);
}
});
} else {
// Fallback to individual loading if batch endpoint not available
// console.log('β οΈ Batch endpoint not available, falling back to individual loading');
await this.loadTasksIndividually(missingTaskIds, allTasks);
}
} catch (error) {
console.warn('β οΈ Batch loading failed, falling back to individual loading:', error);
await this.loadTasksIndividually(missingTaskIds, allTasks);
}
}
}
} catch (error) {
console.warn('β οΈ Failed to scan tasks folder:', error);
}
//// // console.log('π Task counts:', {
//queued: queuedTasks.length,
//processing: processingTasks.length,
//completed: completedTasks.length
//});
// Combine all tasks
this.tasks = allTasks;
// Sort by creation time (newest first)
this.tasks.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
// console.log(`β
Loaded ${this.tasks.length} tasks`);
//// // console.log('π All tasks:', this.tasks);
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
} catch (error) {
console.error('β Failed to load tasks:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to load tasks: ${error.message}`);
}
this.tasks = [];
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
}
}
async loadTasksIndividually(taskIds, allTasks) {
// Fallback method for individual task loading
for (const taskId of taskIds) {
try {
const task = await this.taskManager.getTask(taskId);
if (task) {
allTasks.push(task);
// console.log(`β
Added completed task ${taskId} from individual load`);
}
} catch (error) {
console.warn(`β οΈ Failed to load task ${taskId}:`, error);
}
}
}
renderTasks() {
const container = document.getElementById('tasks-list');
if (!container) return;
// Capture which task panels are currently open and where the user
// is scrolled to *before* the innerHTML rebuild. Without this,
// every refresh slams every expanded log shut and snaps to the top
// β which makes watching a live task feel hostile.
const expandedIds = new Set();
container.querySelectorAll('.task-details.task-details-open').forEach(el => {
const taskId = (el.id || '').replace(/^details-/, '');
if (taskId) expandedIds.add(taskId);
});
const scrollParent = container.closest('.main') || document.scrollingElement || document.documentElement;
const savedScrollTop = scrollParent ? scrollParent.scrollTop : window.scrollY;
// Filter tasks based on current category and specific task
let filteredTasks = this.filterTasksByCategory(this.tasks, this.currentCategory);
// Note: Don't filter out other tasks when one is highlighted
// highlightedTaskId is only for auto-expansion, not for filtering
if (filteredTasks.length === 0) {
let message;
if (this.highlightedTaskId) {
message = `Task ${this.highlightedTaskId} not found`;
} else {
const categoryName = this.getCategoryDisplayName(this.currentCategory);
message = `No ${categoryName.toLowerCase()} tasks found`;
}
container.innerHTML = `
Run a task or install an application to see your task list here
`;
return;
}
// Sort tasks by creation time (newest first)
const sortedTasks = filteredTasks.sort((a, b) =>
new Date(b.createdAt) - new Date(a.createdAt)
);
const html = sortedTasks.map(task => this.renderTask(task)).join('');
container.innerHTML = html;
// Re-open everything that was open before the rebuild and re-attach
// log streaming so live tasks keep updating without an extra click.
expandedIds.forEach(taskId => {
const details = document.getElementById(`details-${taskId}`);
if (!details) return;
details.style.display = 'block';
details.classList.add('task-details-open');
const btn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`);
if (btn) btn.classList.add('expanded');
const t = this.tasks.find(x => x.id === taskId);
if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) {
if (typeof this.startLogStreaming === 'function') this.startLogStreaming(taskId, t);
} else {
if (typeof this.loadTaskLogs === 'function') this.loadTaskLogs(taskId);
}
});
// Restore scroll. Defer one frame so the browser has laid out the
// new content height before we scroll back into it.
if (scrollParent) {
requestAnimationFrame(() => { scrollParent.scrollTop = savedScrollTop; });
}
}
filterTasksByCategory(tasks, category) {
switch (category) {
case 'all':
return tasks;
case 'queued':
case 'running':
case 'completed':
case 'failed':
return tasks.filter(task => task.status === category);
case 'install':
case 'uninstall':
return tasks.filter(task => task.type === category);
case 'management':
return tasks.filter(task =>
['restart', 'start', 'stop'].includes(task.type)
);
case 'backup':
return tasks.filter(task =>
['backup', 'restore', 'delete'].includes(task.type)
);
case 'config':
return tasks.filter(task => task.type === 'update_config');
default:
// Assume it's an app name
return tasks.filter(task => task.app === category);
}
}
getCategoryDisplayName(category) {
const displayNames = {
'all': 'All Tasks',
'queued': 'Queued',
'running': 'Running',
'completed': 'Completed',
'failed': 'Failed',
'install': 'Install',
'uninstall': 'Uninstall',
'management': 'Management',
'backup': 'Backups',
'config': 'Configuration',
'libreportal': 'LibrePortal'
};
return displayNames[category] || category.charAt(0).toUpperCase() + category.slice(1);
}
renderTask(task) {
// console.log(`π renderTask called with task:`, task);
// Debug undefined status
if (!task.status) {
console.warn(`β οΈ Task ${task.id} has undefined status:`, task);
}
const statusClass = `status-${task.status || 'unknown'}`;
const timeAgo = this.getTimeAgo(task.createdAt);
const isRunning = task.status === 'running';
const isFailed = task.status === 'failed';
const hasOutput = task.output && task.output.length > 0;
const hasError = task.error && task.error.length > 0;
const hasLogs = task.log && Array.isArray(task.log) && task.log.length > 0;
// console.log(`π Task fields check:`, {
//hasOutput: hasOutput,
//hasError: hasError,
//hasLogs: hasLogs,
//isRunning: isRunning,
//outputLength: task.output ? task.output.length : 0,
//error: task.error,
//logCount: task.log ? task.log.length : 0
//});
const executionTime = task.startedAt && task.completedAt ?
this.calculateExecutionTime(task.startedAt, task.completedAt) : null;
// console.log('π renderTask debug:', {
//taskStatus: task.status,
//statusClass: statusClass,
//statusDisplay: task.status ? task.status.toUpperCase() : 'UNKNOWN'
//});
return `
${hasLogs ?
task.log.map(log => `
${this.taskManager.parseAnsiColors(log)}
`).join('') :
'
'
}
`;
}
getStatusIcon(status) {
const icons = {
queued: 'β³',
running: 'π',
completed: 'β
',
failed: 'β'
};
return icons[status] || 'π';
}
formatDuration(seconds) {
if (seconds < 60) {
return `${seconds}s`;
} else if (seconds < 3600) {
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${hours}h ${minutes}m`;
}
}
calculateExecutionTime(startedAt, completedAt) {
if (!startedAt || !completedAt) return null;
const start = new Date(startedAt);
const end = new Date(completedAt);
const durationMs = end - start;
const durationSeconds = Math.floor(durationMs / 1000);
return this.formatDuration(durationSeconds);
}
extractTimestamp(logEntry) {
const match = logEntry.match(/\[([^\]]+)\]/);
return match ? match[1] : '';
}
extractLogMessage(logEntry) {
const match = logEntry.match(/\] (.+)$/);
return match ? match[1] : logEntry;
}
async viewTaskLogs(taskId) {
// Open logs in a modal or new view
const task = this.tasks.find(t => t.id === taskId);
if (!task) return;
// Load full logs for modal
const fullLogs = await this.taskManager.readFullTaskLog(taskId);
// Create modal with streaming logs
const modal = document.createElement('div');
modal.className = 'task-logs-modal';
modal.innerHTML = `
Command: ${task.command}
Status: ${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'}
Created: ${new Date(task.createdAt).toLocaleString()}
Execution Logs (0 lines)
${fullLogs.split('\n').map(log => `
${this.taskManager.parseAnsiColors(log)}
`).join('')}
${task.output ? `
Command Output
${this.taskManager.parseAnsiColors(task.output)}
` : ''}
${task.error ? `
Error Details
${this.escapeHtml(task.error)}
` : ''}
`;
document.body.appendChild(modal);
// Update line count
const lineCount = fullLogs.split('\n').length;
const lineCountElement = modal.querySelector('.log-line-count');
if (lineCountElement) {
lineCountElement.textContent = `(${lineCount} lines)`;
}
// Start streaming if task is running
if (task.status === 'running' || task.status === 'queued') {
this.startLogStreaming(taskId, modal);
}
// Store streaming controller
this.activeLogStreams = this.activeLogStreams || new Map();
}
startLogStreaming(taskId, modal) {
const statusIndicator = document.getElementById(`log-status-${taskId}`);
const toggleButton = document.getElementById(`log-toggle-${taskId}`);
const logViewer = document.getElementById(`log-viewer-${taskId}`);
const lineCountElement = modal.querySelector('.log-line-count');
if (!logViewer) return;
let lineCount = logViewer.children.length;
const stream = this.taskManager.streamTaskLog(
taskId,
(newLines) => {
// Add new lines to existing content
const preElement = logViewer.querySelector('pre');
let currentContent = preElement ? preElement.textContent : '';
const separator = currentContent.trim() && !currentContent.includes('Waiting for logs...') ? '\n' : '';
let cleanedContent = currentContent.replace('Waiting for logs...', '').trim();
const newContent = cleanedContent + separator + newLines.join('\n');
preElement.innerHTML = this.taskManager.parseAnsiColors(newContent);
logViewer.appendChild(lineElement);
lineCount++;
if (lineCountElement) {
lineCountElement.textContent = `(${lineCount} lines)`;
}
// Auto-scroll to bottom
logViewer.scrollTop = logViewer.scrollHeight;
// Update status
if (statusIndicator) {
statusIndicator.textContent = 'π΄ Live';
statusIndicator.className = 'status-indicator live';
}
// Remove new-line highlighting after a moment
setTimeout(() => {
const newLines = logViewer.querySelectorAll('.new-line');
newLines.forEach(line => line.classList.remove('new-line'));
}, 2000);
},
(error) => {
console.error('Log streaming error:', error);
if (statusIndicator) {
statusIndicator.textContent = 'β Error';
statusIndicator.className = 'status-indicator error';
}
}
);
// Store stream controller
this.activeLogStreams.set(taskId, { stream, modal, isPaused: false });
// Update toggle button
if (toggleButton) {
toggleButton.textContent = 'βΈοΈ Pause';
}
}
toggleLogStreaming(taskId) {
const streamData = this.activeLogStreams?.get(taskId);
if (!streamData) return;
const toggleButton = document.getElementById(`log-toggle-${taskId}`);
const statusIndicator = document.getElementById(`log-status-${taskId}`);
if (streamData.isPaused) {
// Resume streaming
streamData.isPaused = false;
if (toggleButton) toggleButton.textContent = 'βΈοΈ Pause';
if (statusIndicator) {
statusIndicator.textContent = 'π΄ Live';
statusIndicator.className = 'status-indicator live';
}
} else {
// Pause streaming
streamData.isPaused = true;
if (toggleButton) toggleButton.textContent = 'βΆοΈ Resume';
if (statusIndicator) {
statusIndicator.textContent = 'βΈοΈ Paused';
statusIndicator.className = 'status-indicator paused';
}
}
}
formatCommandForUser(task) {
if (!task.command) return 'Unknown Task';
const displayName = (slug) => window.getAppDisplayName ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1));
if (/^libreportal setup config\b/.test(task.command)) return 'LibrePortal - Apply Configuration';
if (/^libreportal setup finalize\b/.test(task.command)) return 'LibrePortal - Finalize Setup';
if (/^libreportal setup apply\b/.test(task.command)) return 'LibrePortal - Setup Wizard';
// Self-update actions (WebUI "Update now" / "Check for updates").
if (/^libreportal update (apply|now)\b/.test(task.command)) return 'LibrePortal - Update';
if (/^libreportal update check\b/.test(task.command)) return 'LibrePortal - Check for Updates';
// Backup engine β per-app actions.
const backupDeleteAllMatch = task.command.match(/libreportal backup app delete_all (\w+)/);
if (backupDeleteAllMatch) return `${displayName(backupDeleteAllMatch[1])} - Delete All Backups`;
const backupDeleteMatch = task.command.match(/libreportal backup app delete (\w+) (.+)/);
if (backupDeleteMatch) return `${displayName(backupDeleteMatch[1])} - Delete Backup`;
const backupCreateMatch = task.command.match(/^libreportal backup app create (\w+)/);
if (backupCreateMatch) return `${displayName(backupCreateMatch[1])} - Create Backup`;
const backupScheduleMatch = task.command.match(/^libreportal backup app schedule (\w+)/);
if (backupScheduleMatch) return `${displayName(backupScheduleMatch[1])} - Scheduled Backup`;
const backupListMatch = task.command.match(/^libreportal backup app list (\w+)/);
if (backupListMatch) return `${displayName(backupListMatch[1])} - List Backups`;
// Backup engine β restore actions.
const restoreStartMatch = task.command.match(/^libreportal restore app start (\w+)/);
if (restoreStartMatch) return `${displayName(restoreStartMatch[1])} - Restore Backup`;
const restoreListMatch = task.command.match(/^libreportal restore app list (\w+)/);
if (restoreListMatch) return `${displayName(restoreListMatch[1])} - List Backups`;
const migrateAppMatch = task.command.match(/^libreportal restore migrate app (\w+)/);
if (migrateAppMatch) return `${displayName(migrateAppMatch[1])} - Migrate from Host`;
// Backup engine β system-level actions (no per-app target).
if (/^libreportal backup all\b/.test(task.command)) return 'LibrePortal - Backup All Apps';
if (/^libreportal backup verify\b/.test(task.command)) return 'LibrePortal - Verify Backups';
if (/^libreportal backup location add\b/.test(task.command)) return 'LibrePortal - Add Backup Location';
if (/^libreportal backup location remove\b/.test(task.command)) return 'LibrePortal - Remove Backup Location';
if (/^libreportal backup location init\b/.test(task.command)) return 'LibrePortal - Initialise Backup Locations';
if (/^libreportal backup location check\b/.test(task.command)) return 'LibrePortal - Check Backup Locations';
if (/^libreportal backup location list\b/.test(task.command)) return 'LibrePortal - List Backup Locations';
if (/^libreportal backup location stats\b/.test(task.command)) return 'LibrePortal - Backup Location Stats';
if (/^libreportal restore migrate system\b/.test(task.command)) return 'LibrePortal - Migrate System';
if (/^libreportal restore migrate discover\b/.test(task.command)) return 'LibrePortal - Discover Backups';
if (/^libreportal restore first-run\b/.test(task.command)) return 'LibrePortal - First-Run Restore';
// `libreportal app tool ['']` β show the
// tool's friendly label instead of "Tool Application". Pull the
// label from window.toolsCatalog if loaded; otherwise titlecase
// the snake_case tool id.
const toolMatch = task.command.match(/libreportal app tool (\S+) (\S+)/);
if (toolMatch) {
const appName = toolMatch[1];
const toolId = toolMatch[2];
let label = null;
const cat = window.toolsCatalog;
if (cat && cat.apps && cat.apps[appName] && Array.isArray(cat.apps[appName].tools)) {
const t = cat.apps[appName].tools.find(x => x.id === toolId);
if (t && t.label) label = t.label;
}
if (!label) {
label = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' ');
}
return `${displayName(appName)} - ${label}`;
}
// Parse libreportal commands. Capture only the app name token β anything after
// (e.g. config overrides like `CFG_FOO=bar|...`) is for the CLI, not the title.
const libreportalMatch = task.command.match(/libreportal app (\w+) (\S+)/);
if (libreportalMatch) {
const action = libreportalMatch[1];
const appName = libreportalMatch[2];
const actionMap = {
'install': 'Install Application',
'uninstall': 'Uninstall Application',
'restart': 'Restart Application',
'start': 'Start Application',
'stop': 'Stop Application',
'update': 'Update Application',
'rebuild': 'Rebuild Application',
'delete': 'Delete Backup',
'backup': 'Backup Application'
};
const formattedAction = actionMap[action] || `${action.charAt(0).toUpperCase() + action.slice(1)} Application`;
return `${displayName(appName)} - ${formattedAction}`;
}
// Handle other common patterns
if (task.command.includes('docker-compose')) {
return 'Docker Compose Operation';
}
if (task.command.includes('docker')) {
return 'Docker Operation';
}
// Return first 50 chars of command as fallback
return task.command.length > 50 ? task.command.substring(0, 47) + '...' : task.command;
}
getTaskTypeIcon(task) {
if (!task.type) return { icon: 'βοΈ', class: 'custom' };
const iconMap = {
'install': { icon: 'β
', class: 'install' },
'app-install': { icon: 'β
', class: 'install' },
'uninstall': { icon: 'β', class: 'uninstall' },
'restart': { icon: 'π', class: 'restart' },
'start': { icon: 'βΆοΈ', class: 'start' },
'stop': { icon: 'βΉοΈ', class: 'stop' },
'update': { icon: 'β¬οΈ', class: 'update' },
'rebuild': { icon: 'π¨', class: 'rebuild' },
'backup': { icon: 'πΎ', class: 'backup' },
'restore': { icon: 'π¦', class: 'restore' },
'delete': { icon: 'ποΈ', class: 'delete' },
'delete_all': { icon: 'ποΈ', class: 'delete' },
'setup-config': { icon: 'π οΈ', class: 'setup' },
'setup-finalize': { icon: 'π', class: 'setup' },
'custom': { icon: 'βοΈ', class: 'custom' }
};
return iconMap[task.type] || iconMap['custom'];
}
/* Detect a task that's an LibrePortal system action (no specific app) so
the row can show the LibrePortal logo instead of a blank icon slot. */
isLibrePortalSystemTask(task) {
if (!task || !task.command || task.app) return false;
return /^libreportal (setup|backup\s+all|backup\s+verify|backup\s+location|backup\s+repo|restore\s+migrate\s+system|restore\s+migrate\s+discover|restore\s+first-run|webui|config|update)\b/.test(task.command);
}
/* Render the leading icon(s) on a task row:
- Per-app task β emoji type icon + app icon
- System task β emoji type icon + LibrePortal logo
- Anything else β emoji type icon only
Keeps the layout consistent across every row regardless of source. */
renderTaskIcons(task) {
const typeIcon = `${this.getTaskTypeIcon(task).icon}`;
if (task.app) {
const appIconPath = this.getAppIconPath(task);
return `${typeIcon}
`;
}
if (this.isLibrePortalSystemTask(task)) {
return `${typeIcon}
`;
}
return typeIcon;
}
getAppIconPath(task) {
if (!task.app) return null;
// Try to get icon from commands if available
if (this.commands && this.commands.getAppData) {
const appData = this.commands.getAppData(task.app);
if (appData && appData.icon) {
return appData.icon;
}
}
// Default icon path
return `icons/apps/${task.app}.svg`;
}
updateStats() {
const stats = {
queued: this.tasks.filter(t => t.status === 'queued').length,
running: this.tasks.filter(t => t.status === 'running').length,
completed: this.tasks.filter(t => t.status === 'completed').length,
failed: this.tasks.filter(t => t.status === 'failed').length
};
Object.keys(stats).forEach(status => {
const element = document.getElementById(`${status}-count`);
if (element) element.textContent = stats[status];
});
}
updateSidebarCounts() {
// Update all sidebar category counts
const categories = ['all', 'queued', 'running', 'completed', 'failed', 'install', 'uninstall', 'management', 'backup', 'config'];
categories.forEach(category => {
const count = this.filterTasksByCategory(this.tasks, category).length;
const element = document.getElementById(`count-${category}`);
if (element) element.textContent = count;
});
// Update app-specific counts
const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))];
apps.forEach(app => {
const count = this.tasks.filter(task => task.app === app).length;
const element = document.getElementById(`count-app-${app}`);
if (element) element.textContent = count;
});
}
async generateAppCategories() {
const container = document.getElementById('app-categories');
if (!container) return;
try {
// Show loading state
container.innerHTML = '';
// Try to load apps data if not already available
let appsData = window.apps || [];
// If window.apps is not available, try to load it
if (!appsData || appsData.length === 0) {
try {
const appsResponse = await fetch('/read-file?path=apps.json');
if (appsResponse.ok) {
const appsText = await appsResponse.text();
if (appsText.trim()) {
appsData = JSON.parse(appsText);
window.apps = appsData; // Cache for future use
}
}
} catch (error) {
// console.log('Could not load apps.json, will use fallback');
}
}
// Filter for installed apps only and extract slugs
const installedApps = appsData
.filter(app => app.installed === true || app.status === 'installed')
.map(app => {
// Extract slug from command like "libreportal app install adguard"
const command = app.command || '';
const slugMatch = command.match(/libreportal app install\s+(.+)$/);
const slug = slugMatch ? slugMatch[1].trim() : '';
// Extract title from "Title - Description" format
const fullName = app.name || '';
const title = fullName.split(' - ')[0].trim();
return { slug, title };
})
.filter(app => app.slug && app.title);
// If no installed apps found from apps data, fall back to task-based apps
if (installedApps.length === 0) {
// console.log('No installed apps found, using task-based app list');
const taskApps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))];
if (taskApps.length === 0) {
container.innerHTML = 'No apps found
';
return;
}
const appCategories = taskApps.map(app => {
const taskCount = this.tasks.filter(task => task.app === app).length;
const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1));
return `
`;
}).join('');
container.innerHTML = appCategories;
return;
}
const appCategories = installedApps.map(app => {
const taskCount = this.tasks.filter(task => task.app === app.slug).length;
return `
`;
}).join('');
container.innerHTML = appCategories;
} catch (error) {
console.error('Error generating app categories:', error);
// Final fallback to task-based app list
const apps = [...new Set(this.tasks.map(task => task.app).filter(Boolean))];
if (apps.length === 0) {
container.innerHTML = 'No apps found
';
return;
}
const appCategories = apps.map(app => {
const taskCount = this.tasks.filter(task => task.app === app).length;
const displayName = window.getAppDisplayName ? window.getAppDisplayName(app) : (app.charAt(0).toUpperCase() + app.slice(1));
return `
`;
}).join('');
container.innerHTML = appCategories;
}
}
setupAutoRefresh() {
// Refresh every 5 seconds
this.refreshInterval = setInterval(() => {
this.loadTasks();
}, 5000);
}
setupMobileMenu() {
// Setup mobile menu toggle (if needed)
const mobileOverlay = document.getElementById('mobile-overlay');
const sidebar = document.getElementById('sidebar');
if (mobileOverlay && sidebar) {
mobileOverlay.addEventListener('click', () => {
sidebar.classList.remove('mobile-open');
mobileOverlay.classList.remove('active');
});
}
}
filterTasksByCategoryHandler(category) {
this.currentCategory = category;
// Clear specific task filter when switching categories
this.highlightedTaskId = null;
// Update URL
this.updateURL(category);
// Update sidebar active state
document.querySelectorAll('.sidebar-item').forEach(item => {
item.classList.toggle('active', item.dataset.category === category);
});
this.renderTasks();
}
setupAutoRefresh() {
// Only refresh when tasks page is visible and every 30 seconds
this.refreshInterval = setInterval(() => {
// Only refresh if we're on the tasks page
if (window.location.pathname === '/tasks' || window.location.hash.includes('tasks')) {
this.loadTasks();
}
}, 30000); // 30 seconds instead of 5
}
setupGlobalFunctions() {
// Make functions available globally for onclick handlers
window.filterTasksByCategory = (category) => {
event.preventDefault();
this.filterTasksByCategoryHandler(category);
};
// Delegated click handler for the Task ID and App links rendered inside
// each task row. Behaviour:
// * Task ID always lands on the global /tasks page with that task open.
// If we're already there, just push the URL and toggle the row open
// (no content reload); otherwise SPA-navigate to /tasks.
// * App link goes to that app's /app page on whatever its default tab
// is (we don't pin tab=β¦ so the app's own logic picks one).
if (!window.__taskMetaLinksBound) {
window.__taskMetaLinksBound = true;
document.addEventListener('click', (e) => {
const link = e.target.closest('a.task-id-link, a.task-app-link');
if (!link) return;
const href = link.getAttribute('href');
if (!href) return;
e.preventDefault();
e.stopPropagation();
const navigate = (url) => {
if (window.spaClean && typeof window.spaClean.navigate === 'function') {
window.spaClean.navigate(url);
} else {
window.location.href = url;
}
};
if (link.classList.contains('task-id-link')) {
const taskId = link.dataset.taskId;
const onTasksPage = window.location.pathname.startsWith('/tasks');
if (onTasksPage && taskId && typeof window.toggleTaskDetails === 'function') {
// Already on /tasks β soft-update the URL and open the row.
window.history.pushState({}, '', href);
if (window.tasksManager) window.tasksManager.highlightedTaskId = taskId;
window.toggleTaskDetails(taskId);
} else {
// Coming from /app or anywhere else β go to the tasks page; its
// initializeFromURL picks up `task=` and auto-expands.
navigate(href);
}
} else {
// App link β go to the app page on its default tab.
//
// `task-parameter-preserve.js` stashes any `task=β¦` from the
// initial page URL into sessionStorage.pendingTaskId, and
// app-tabbed-manager's init reads that as a fallback when the
// current URL has no task param. Without clearing it here, an old
// task id from /tasks would hijack the app page, force-switching
// to the Tasks tab and opening that task. Clear it so the app
// page lands on its actual default tab.
try { sessionStorage.removeItem('pendingTaskId'); } catch {}
navigate(href);
}
});
}
window.refreshTasks = () => this.refreshTasks();
window.toggleTaskDetails = (taskId) => this.toggleTaskDetails(taskId);
window.retryTask = (taskId) => this.retryTask(taskId);
window.deleteTask = (taskId) => this.deleteTask(taskId);
window.clearAllTasks = () => this.clearAllTasks();
window.viewTaskLogs = (taskId) => this.viewTaskLogs(taskId);
window.toggleLogStreaming = (taskId) => this.toggleLogStreaming(taskId);
window.closeTaskLogsModal = () => {
const modal = document.querySelector('.task-logs-modal');
if (modal) {
// Stop all active streaming for this modal
this.activeLogStreams?.forEach((streamData, streamTaskId) => {
if (streamData.modal === modal) {
streamData.stream.stop();
this.activeLogStreams.delete(streamTaskId);
}
});
modal.remove();
}
};
window.createAndExecuteTask = async (action, appName, config = '') => {
try {
// Create task using NEW signature
const task = await this.taskManager.createTask(`libreportal app ${action} ${appName}`, action, appName, config);
// Emit task creation event for AppTabbedManager
window.dispatchEvent(new CustomEvent('taskCreated', {
detail: {
taskId: task.id,
appName: appName,
action: action,
timestamp: Date.now()
}
}));
// Set up monitoring for this specific task
this.monitorTask(task.id, appName, action);
// Show success notification with app icon and direct link
if (window.notificationSystem) {
const taskUrl = `/tasks?=all&task=${task.id}`;
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon({ type: action })?.icon : '';
const customIcon = typeIcon ? `${typeIcon}` : null;
window.notificationSystem.show(
`Task created: ${action} ${appName}`,
'info',
appName,
taskUrl,
`icons/apps/${appName}.svg`,
customIcon
);
}
return task;
} catch (error) {
console.error(`β Failed to create ${action} task for ${appName}:`, error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to start ${action} task for ${appName}: ${error.message}`);
}
throw error;
}
};
}
// Single page-wide subscription to the TaskEventBus. This complements
// monitorTask (which only fires for tasks created in this session) and
// ensures any visible row updates when its status changes β including the
// running -> completed/failed/cancelled transition that previously only
// refreshed when the user switched tabs and came back.
//
// The guard is on `window`, not `this`, because TasksManager is constructed
// in several places (system-loader, app-tabbed-manager, β¦). Without a
// global guard each instance would attach its own listener and the user
// would see N notifications for one task completion.
setupTaskBusListeners() {
if (window.__tasksManagerBusBound) return;
window.__tasksManagerBusBound = true;
const upsertLocal = (task) => {
if (!task || !task.id) return;
const idx = this.tasks.findIndex(t => t.id === task.id);
if (idx >= 0) this.tasks[idx] = task; else this.tasks.unshift(task);
};
window.addEventListener('taskCreated', (e) => {
const task = e.detail && e.detail.task;
if (!task) return;
upsertLocal(task);
// Don't re-render the entire list here β `renderTasks` would blow away
// any open dropdown's DOM. A targeted update is enough; the next
// category switch / refresh will pull the new row in.
this.updateTaskDisplay(task);
});
window.addEventListener('taskUpdated', (e) => {
const task = e.detail && e.detail.task;
if (!task) return;
upsertLocal(task);
if (task.status === 'running') {
this.updateTaskStructure(task.id, task);
this.startLogStreaming(task.id, task);
}
this.updateTaskDisplay(task);
});
window.addEventListener('taskCompleted', (e) => {
const task = e.detail && e.detail.task;
const taskId = (task && task.id) || (e.detail && e.detail.taskId);
if (!taskId) return;
if (task) upsertLocal(task);
if (task) this.updateTaskDisplay(task);
// Final render of any buffered log content, then stop the SSE listener.
const stream = this.activeLogStreams && this.activeLogStreams.get(taskId);
if (stream && typeof stream.render === 'function') stream.render();
this.stopLogStreaming(taskId);
// User-visible notification so they know without staring at the page.
// Layout matches the " task started!" format used by task-actions
// and backup-manager so started/completed look like a matched pair.
if (window.notificationSystem && task) {
const appName = task.app || null;
const action = task.type || 'task';
const friendlyActionMap = {
'install': 'Install', 'app-install': 'Install',
'uninstall': 'Uninstall', 'restart': 'Restart',
'start': 'Start', 'stop': 'Stop',
'update': 'Update', 'rebuild': 'Rebuild',
'backup': 'Backup', 'restore': 'Restore',
'delete': 'Delete Backup', 'delete_all': 'Delete All Backups',
'setup-config': 'Apply Configuration',
'setup-finalize': 'Finalize Setup'
};
let actionTitle = friendlyActionMap[action] || (action.charAt(0).toUpperCase() + action.slice(1));
// Tool tasks: override the generic "Tool" label with the tool's
// friendly name (e.g. "Manage Shortcuts") so completion toasts
// match what the user clicked.
const toolCmdMatch = (task.command || '').match(/libreportal app tool (\S+) (\S+)/);
if (toolCmdMatch) {
const toolApp = toolCmdMatch[1];
const toolId = toolCmdMatch[2];
let toolLabel = null;
const cat = window.toolsCatalog;
if (cat && cat.apps && cat.apps[toolApp] && Array.isArray(cat.apps[toolApp].tools)) {
const t = cat.apps[toolApp].tools.find(x => x.id === toolId);
if (t && t.label) toolLabel = t.label;
}
if (!toolLabel) {
toolLabel = toolId.split('_').map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' ');
}
actionTitle = toolLabel;
}
const isSystemTask = action.startsWith('setup-');
const displayName = isSystemTask
? 'LibrePortal'
: ((appName && window.getAppDisplayName)
? window.getAppDisplayName(appName)
: (appName || (task.command || `Task ${taskId}`)));
const subjectLabel = isSystemTask ? 'System' : 'App';
const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps');
const url = (onAppPage && appName)
? `/app?=${appName}&tab=tasks&task=${taskId}`
: `/tasks?=all&task=${taskId}`;
const icon = appName ? `icons/apps/${appName}.svg` : null;
// Match the per-action emoji used in the task list rows (see
// `getTaskTypeIcon`). Passed as the 6th `customIcon` arg so the
// notification's leftmost icon slot shows the task *type* (install
// β
, backup πΎ, restore π¦, β¦) instead of the generic level tick.
const typeIcon = (this.getTaskTypeIcon ? this.getTaskTypeIcon(task) : null)?.icon || '';
const customIcon = typeIcon ? `${typeIcon}` : null;
let body;
let level;
if (task.status === 'completed') {
body = `${subjectLabel}: ${displayName}
${actionTitle} task completed!`;
level = 'success';
} else if (task.status === 'failed') {
body = `${subjectLabel}: ${displayName}
${actionTitle} task failed.`;
level = 'error';
} else if (task.status === 'cancelled') {
body = `${subjectLabel}: ${displayName}
${actionTitle} task cancelled.`;
level = 'warning';
}
if (body) window.notificationSystem.show(body, level, appName, url, icon, customIcon);
// Belt-and-braces: when the completion notification fires we also
// tell the app-tabbed manager to re-enable that app's tabs and
// buttons. The taskCompleted listener inside app-tabbed-manager
// does this too, plus the 5s reconcile sweep β but routing it
// through here as well means any one path being broken or
// de-bound still leaves the user with a usable UI rather than
// permanently-disabled tabs.
if (appName && window.appTabbedManager && typeof window.appTabbedManager.enableAppButtons === 'function') {
try { window.appTabbedManager.enableAppButtons(appName); } catch {}
}
}
});
window.addEventListener('taskDeleted', (e) => {
const id = e.detail && e.detail.id;
if (!id) return;
this.tasks = this.tasks.filter(t => t.id !== id);
const el = document.querySelector(`.task-item[data-task-id="${id}"]`);
if (!el) return;
const parent = el.parentElement;
el.remove();
if (!parent || parent.querySelector('.task-item')) return;
if (parent.id === 'tasks-list') {
this.renderTasks();
} else if (parent.id === 'app-tasks') {
const appName = (window.appTabbedManager && window.appTabbedManager.currentApp) || '';
parent.innerHTML = `No tasks found for ${appName}.
`;
}
});
}
// Load task logs on demand
async loadTaskLogs(taskId) {
try {
// console.log(`π Loading logs for task ${taskId}...`);
// Show loading state
const detailsElement = document.getElementById(`details-${taskId}`);
if (detailsElement) {
const logsContainer = detailsElement.querySelector('.task-logs');
if (logsContainer) {
logsContainer.innerHTML = 'π Loading logs...
';
}
}
// Load task data directly from task file
const task = this.tasks.find(t => t.id === taskId);
let output = '';
if (task) {
output = task.output || '';
} else {
// Try to fetch task data directly
try {
const response = await fetch(`/api/tasks/${taskId}`);
if (response.ok) {
const taskData = await response.json();
output = taskData.output || '';
}
} catch (error) {
console.warn('Failed to fetch task data:', error);
}
}
if (output && output.trim().length > 0) {
// Display the output
const logsHtml = output.split('\n').map(log => `${this.parseAnsiColors(log)}
`).join('');
if (detailsElement) {
const logsContainer = detailsElement.querySelector('.task-logs');
if (logsContainer) {
logsContainer.innerHTML = `
π Execution Logs
${logsHtml}
`;
}
}
// console.log(`β
Loaded logs for task ${taskId}`);
} else {
// No logs available
if (detailsElement) {
const logsContainer = detailsElement.querySelector('.task-logs');
if (logsContainer) {
logsContainer.innerHTML = 'π No output available for this task.
';
}
}
// console.log(`βΉοΈ No logs available for task ${taskId}`);
}
return output;
} catch (error) {
console.error(`β Failed to load logs for task ${taskId}:`, error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to load logs: ${error.message}`);
}
return '';
}
}
// 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);
};
window.addEventListener('taskUpdated', onUpdate);
window.addEventListener('taskCompleted', onComplete);
}
// 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 = 'Waiting for logs...';
}
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);
// console.log(`βΉοΈ Stopped log streaming for task ${taskId}`);
}
}
// 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;
// console.log(`π Updating task structure for ${taskId} to show simplified logs`);
// Check if logs container already exists
const existingLogs = detailsElement.querySelector('.task-logs .log-container');
if (existingLogs) {
// console.log(`π Logs container already exists for ${taskId}`);
return; // Already exists, no need to update
}
// Add simplified logs section for running tasks
if (task.status === 'running') {
const logsHtml = `
`;
// Insert logs section at the bottom of details
detailsElement.insertAdjacentHTML('beforeend', logsHtml);
// console.log(`β
Added simplified logs section for task ${taskId}`);
// 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;
}
// console.log(`π Found task element:`, taskElement);
// 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;
}
// console.log(`π Updated task ${task.id} display: ${task.status}`);
}
// Start global live log updater - simple 2-second updates for all running tasks
startGlobalLiveLogUpdater() {
// console.log(`π Starting global live log updater`);
// Update every 2 seconds
setInterval(async () => {
// console.log(`π Global updater running - checking tasks...`);
// Find all running tasks
const runningTasks = this.tasks.filter(task => task.status === 'running');
// console.log(`π Found ${runningTasks.length} running tasks:`, runningTasks.map(t => t.id));
if (runningTasks.length > 0) {
// console.log(`π Updating live logs for ${runningTasks.length} running tasks`);
// Update each running task's live logs
for (const task of runningTasks) {
// console.log(`π About to update live logs for task ${task.id}`);
await this.updateLiveLogsSimple(task.id);
}
} else {
// console.log(`π No running tasks found, skipping live log updates`);
}
}, 2000); // Every 2 seconds
}
// Simple live log update - no complex polling logic
async updateLiveLogsSimple(taskId) {
const liveLogsElement = document.getElementById(`live-logs-${taskId}`);
if (!liveLogsElement) {
// console.log(`β οΈ Live logs element not found for task ${taskId}`);
return; // Silently skip if element not found
}
try {
// console.log(`π Reading log file for task ${taskId}`);
// Read the log file content
const response = await fetch(`/read-file?path=tasks/${taskId}.log`);
// console.log(`π Log file response status: ${response.status} for task ${taskId}`);
if (response.ok) {
const logContent = await response.text();
// console.log(`π Log file content length: ${logContent.length} chars for task ${taskId}`);
// console.log(`π First 100 chars of log content: "${logContent.substring(0, 100)}..."`);
if (logContent.trim()) {
// Split into lines and display
const lines = logContent.split('\n').filter(line => line.trim());
// console.log(`π Displaying ${lines.length} log lines for task ${taskId}`);
liveLogsElement.innerHTML = lines.map(line =>
`${this.parseAnsiColors(line)}
`
).join('');
// Auto-scroll to bottom
liveLogsElement.scrollTop = liveLogsElement.scrollHeight;
} else {
// console.log(`π Log file is empty for task ${taskId}`);
liveLogsElement.innerHTML = 'π Waiting for logs...
';
}
} else {
console.warn(`β οΈ Failed to read log file for task ${taskId}: ${response.status}`);
liveLogsElement.innerHTML = 'β οΈ Unable to read logs
';
}
} catch (error) {
// Silently handle errors
console.warn(`β οΈ Error reading live logs for task ${taskId}:`, error);
liveLogsElement.innerHTML = 'β Error loading logs
';
}
}
// 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 => `${this.taskManager.parseAnsiColors(line)}
`)
.join('');
return true;
};
logsContainer.innerHTML = 'π Loading logs...
';
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 = `${this.taskManager.parseAnsiColors(logContent)}`;
if (isScrolledToBottom) logsContainer.scrollTop = logsContainer.scrollHeight;
return;
}
}
if (renderInMemory()) return;
logsContainer.innerHTML = 'βΉοΈ No logs available for this task.
';
} catch (error) {
console.error(`β Error loading logs for task ${taskId}:`, error);
const logsContainer = document.getElementById(`logs-${taskId}`);
if (logsContainer) {
logsContainer.innerHTML = 'β Failed to load logs.
';
}
}
}
// 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 = `
Loading output...
`;
// Check if task has output
if (task.output && task.output.trim()) {
outputElement.innerHTML = `
π€ Output
${this.taskManager.parseAnsiColors(task.output)}
`;
} else if (task.error && task.error.trim()) {
outputElement.innerHTML = `
β Error
${this.escapeHtml(task.error)}
`;
} 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 = `
${this.taskManager.parseAnsiColors(logContent)}
`;
} else {
outputElement.innerHTML = `
βΉοΈ Information
No output available for this task.
`;
}
} else {
outputElement.innerHTML = `
βΉοΈ Information
No output available for this task.
`;
}
}
} 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 = `
β Error
Failed to load task output: ${error.message}
`;
}
}
}
// Auto-expand a task when it's created
async autoExpandTask(taskId) {
// console.log(`π Auto-expanding task ${taskId}`);
// Wait for task to be rendered
let attempts = 0;
const maxAttempts = 10;
const tryExpand = async () => {
attempts++;
// console.log(`π Auto-expand attempt ${attempts}/${maxAttempts} for task ${taskId}`);
// Check if task element exists
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
if (!taskElement) {
// console.log(`β οΈ Task element not found for ${taskId}, attempt ${attempts}`);
if (attempts < maxAttempts) {
setTimeout(tryExpand, 500); // Try again in 500ms
} else {
console.warn(`β οΈ Could not find task element for ${taskId} after ${maxAttempts} attempts`);
}
return;
}
// console.log(`β
Found task element for ${taskId}`);
// Get the details element
const details = document.getElementById(`details-${taskId}`);
if (!details) {
// console.log(`β οΈ Details element not found for ${taskId}, attempt ${attempts}`);
if (attempts < maxAttempts) {
setTimeout(tryExpand, 500);
} else {
console.warn(`β οΈ Could not find details element for ${taskId}`);
}
return;
}
// console.log(`β
Found details element for ${taskId}`);
// 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);
}
// console.log(`β
Auto-expanded task ${taskId}`);
};
tryExpand();
}
// 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;
// console.log(`π Updating highlighted task ${taskId} status: ${task.status}`);
// Update task display
this.updateTaskDisplay(task);
// If task completed or failed, always load output
if ((task.status === 'completed' || task.status === 'failed')) {
// console.log(`π Task ${taskId} is ${task.status}, loading output...`);
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);
}
}
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;
}
}
async retryTask(taskId) {
if (!confirm('Are you sure you want to retry this task?')) return;
try {
const task = this.tasks.find(t => t.id === taskId);
if (!task) {
throw new Error('Task not found');
}
// Create a new task with the same command
const newTask = await this.taskManager.createTask(
task.command,
task.type,
task.app,
task.config
);
//// // console.log(`β
Task retried: ${newTask.id}`);
// Refresh tasks to show the new one
await this.loadTasks();
if (window.notificationSystem) {
// Use the source task's type icon β retrying a backup shows πΎ etc.
const typeIcon = this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '';
const customIcon = typeIcon ? `${typeIcon}` : null;
window.notificationSystem.show('Task retried successfully', 'success', null, null, null, customIcon);
}
} catch (error) {
console.error('Error retrying task:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to retry task: ${error.message}`);
}
}
}
async deleteTask(taskId) {
// Get the latest known status before deciding what to do. The server
// refuses to delete tasks that are still running or queued (HTTP 409),
// and we used to just propagate that as a red banner β but the user is
// almost always trying to clean up a row that looked finished, or a
// genuinely active task they want gone. Either way the right answer is
// "cancel first, then delete", which is what we do here.
const cached = (window.taskEventBus && window.taskEventBus.getTask)
? window.taskEventBus.getTask(taskId)
: null;
let task = cached || (this.tasks && this.tasks.find(t => t.id === taskId));
if (!task && this.taskManager && this.taskManager.getTask) {
try { task = await this.taskManager.getTask(taskId); } catch {}
}
const isActive = task && (task.status === 'running' || task.status === 'queued' || task.status === 'pending');
const confirmed = await this._showDeleteTaskModal(task, isActive);
if (!confirmed) return;
try {
if (isActive) {
// Ask the server to cancel. POST /cancel either flips status straight
// to `cancelled` (queued -> cancelled is synchronous) or drops a
// `.cancel` marker the bash processor picks up within ~1s
// (running -> cancelled). We then wait for the terminal status via
// SSE before issuing the delete.
try { await this.taskManager.cancelTask(taskId); } catch {
// If cancel itself failed (e.g. already terminal), fall through
// and try the delete anyway.
}
await this._waitForTaskTerminal(taskId, 15_000);
}
// Try the polite delete first. If the task is still flagged as
// running/queued server-side (e.g. the bash processor is dead and
// never picked up the cancel marker), fall back to the force-delete
// override so the user isn't stuck with a permanently-undeletable row.
try {
await this.taskManager.deleteTask(taskId);
} catch (err) {
const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : '');
if (!looks409) throw err;
await this.taskManager.deleteTask(taskId, { force: true });
}
// Remove from local array and re-render
this.tasks = this.tasks.filter(t => t.id !== taskId);
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
if (window.notificationSystem) {
// Type icon from whatever the task was, with a trash fallback
// because deletion is conceptually a ποΈ action.
const typeIcon = (task && this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '') || 'ποΈ';
const customIcon = `${typeIcon}`;
window.notificationSystem.show('Task deleted successfully', 'info', null, null, null, customIcon);
}
} catch (error) {
console.error('Error deleting task:', error);
if (window.notificationSystem) {
window.notificationSystem.error(`Failed to delete task: ${error.message}`);
}
}
}
// Confirmation modal for deleteTask. Uses the shared openEoModal so it
// visually matches every other destructive confirmation on the app
// (Uninstall, Apply Configuration, etc). Resolves true if the user
// confirms Delete, false on Cancel / backdrop click / close.
_showDeleteTaskModal(task, isActive) {
return new Promise((resolve) => {
const escHtml = (s) => String(s == null ? '' : s)
.replace(/&/g, '&').replace(//g, '>');
const taskLabel = (task && (task.command || task.id)) || 'Unknown task';
const taskStatus = (task && task.status) || 'unknown';
const warningTitle = isActive ? 'Active task' : 'This cannot be undone';
const warningText = isActive
? 'This task is still running or queued. It will be cancelled first, then deleted.'
: 'The task and its logs will be permanently removed.';
const bodyHtml = `
${escHtml(warningTitle)}
${escHtml(warningText)}
${window.eoBadgeRow ? window.eoBadgeRow([
{ label: `Status: ${taskStatus}`, variant: isActive ? 'warning' : 'info' }
]) : ''}
`;
let decided = false;
const finish = (val, modal) => {
if (decided) return;
decided = true;
if (modal) modal.close();
resolve(val);
};
window.openEoModal({
id: 'delete-task-modal',
size: 'sm',
eyebrow: 'Delete Task',
title: taskLabel,
desc: 'Confirm to delete this task.',
body: bodyHtml,
actions: [
{ label: 'Delete Task', variant: 'danger', onClick: (m) => finish(true, m) },
{ label: 'Cancel', variant: 'secondary', onClick: (m) => finish(false, m) }
],
onClose: () => finish(false, null)
});
});
}
// Resolves once the task reaches completed/failed/cancelled, or after the
// timeout. Used by deleteTask so a cancel request has time to take effect
// before we issue the DELETE that would otherwise 409.
_waitForTaskTerminal(taskId, timeoutMs = 15_000) {
return new Promise((resolve) => {
// Already terminal in the bus cache? No need to wait.
const cached = (window.taskEventBus && window.taskEventBus.getTask) ? window.taskEventBus.getTask(taskId) : null;
if (cached && (cached.status === 'completed' || cached.status === 'failed' || cached.status === 'cancelled')) {
return resolve(cached);
}
let timer;
const cleanup = () => {
window.removeEventListener('taskCompleted', onComplete);
window.removeEventListener('taskUpdated', onUpdate);
if (timer) clearTimeout(timer);
};
const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled';
const onComplete = (e) => {
const t = e.detail && (e.detail.task || (e.detail.taskId === taskId ? { id: taskId, status: e.detail.status } : null));
const id = (t && t.id) || (e.detail && e.detail.taskId);
if (id !== taskId) return;
cleanup();
resolve(t);
};
const onUpdate = (e) => {
const t = e.detail && e.detail.task;
if (!t || t.id !== taskId) return;
if (isTerminal(t.status)) { cleanup(); resolve(t); }
};
window.addEventListener('taskCompleted', onComplete);
window.addEventListener('taskUpdated', onUpdate);
timer = setTimeout(() => { cleanup(); resolve(null); }, timeoutMs);
});
}
async clearAllTasks() {
// Use the confirmation dialog system if available, otherwise fallback to confirm
return new Promise((resolve) => {
if (window.showConfirmation) {
window.showConfirmation(
'Clear All Tasks',
'Are you sure you want to clear all tasks? This will delete all task history and cannot be undone.',
() => {
this.performClearAll();
resolve(true);
},
'Yes, Clear All',
'Cancel',
'clear',
false
);
} else {
// Fallback to native confirm
const confirmed = confirm('Are you sure you want to clear all tasks? This will delete all task history.');
if (confirmed) {
this.performClearAll();
}
resolve(confirmed);
}
});
}
async performClearAll() {
// Show progress notification with the trash type icon in the left slot
// (this is conceptually a delete-all action, so ποΈ matches the row icon).
const customIcon = 'ποΈ';
const progressNotification = window.notificationSystem.show(
'Clearing all tasks...',
'info',
null,
null,
null,
customIcon
);
try {
// Delete all tasks using the task manager
const deletePromises = this.tasks.map(task =>
this.taskManager.deleteTask(task.id)
);
await Promise.all(deletePromises);
// Clear local array and re-render
this.tasks = [];
this.renderTasks();
this.updateStats();
this.updateSidebarCounts();
this.generateAppCategories();
// Remove progress notification and show success
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.show(
'All tasks cleared successfully',
'success',
null,
null,
null,
customIcon
);
}
} catch (error) {
console.error('Error clearing tasks:', error);
// Remove progress notification and show error
if (progressNotification && progressNotification.remove) {
progressNotification.remove();
}
if (window.notificationSystem) {
window.notificationSystem.show(
`Failed to clear tasks: ${error.message}`,
'error',
null,
null,
null,
customIcon
);
}
}
}
getTimeAgo(dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
showError(message) {
const container = document.getElementById('tasks-list');
if (container) {
container.innerHTML = `
`;
}
}
destroy() {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
}
}
// Export for use in other modules
window.TasksManager = TasksManager;