/** * 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 = `
info $ ${message} just now
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 `
${this.renderTaskIcons(task)} ${this.formatCommandForUser(task)} ${this.getStatusIcon(task.status)} ${task.status ? task.status.toUpperCase() : 'UNKNOWN'} ${timeAgo} ${executionTime ? `⏱️ ${executionTime}` : ''}
${isFailed ? ` ` : ''}
Task ID: ${task.id}
Type: ${task.type || 'unknown'}
App: ${task.app ? `${task.app}` : 'system'}
Created: ${new Date(task.createdAt).toLocaleString()}
${task.startedAt ? `
Started: ${new Date(task.startedAt).toLocaleString()}
` : ''} ${task.completedAt ? `
Completed: ${new Date(task.completedAt).toLocaleString()}
` : ''} ${executionTime ? `
Execution Time: ${executionTime}
` : ''}
${hasLogs ? task.log.map(log => `
${this.taskManager.parseAnsiColors(log)}
`).join('') : '
Loading logs...
' }
`; } 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 = ` `; 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}${task.app}`; } if (this.isLibrePortalSystemTask(task)) { return `${typeIcon}LibrePortal`; } 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 = '

Loading apps...

'; // 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 ` ${displayName} ${taskCount} `; }).join(''); container.innerHTML = appCategories; return; } const appCategories = installedApps.map(app => { const taskCount = this.tasks.filter(task => task.app === app.slug).length; return `
${app.title} ${taskCount}
`; }).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 ` ${displayName} ${taskCount} `; }).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 = `
Loading logs...
`; // 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 = `
$ ${message}
_
`; } } destroy() { if (this.refreshInterval) { clearInterval(this.refreshInterval); } } } // Export for use in other modules window.TasksManager = TasksManager;