/** * 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; // Multi-select: ids of tasks the user has ticked. When non-empty the // "Clear All" button morphs into "Delete Selected (N)". Both paths // share _showClearAllModal — same UX, different filter. this.selectedTaskIds = new Set(); 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'); } // User-facing label for a task action. Explicit map first (so // 'config_update' reads as 'Update Config', not 'Config Update'); // anything not listed falls back to snake/kebab → Title Case so a // future task type still renders cleanly instead of leaking the // literal id like "Config_update". formatActionTitle(action) { if (!action) return 'Task'; const map = { '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', 'config_update': 'Update Config', 'update_config': 'Update Config', 'system_update': 'Update System', 'system_reclaim': 'Reclaim Space', 'system_image_rm': 'Remove Images', 'verify': 'Verify System', 'setup-config': 'Apply Configuration', 'setup-finalize': 'Finalize Setup' }; if (map[action]) return map[action]; return action.split(/[_-]/).map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' '); } // Shared "task → notification payload" resolver. Every task surface // (started/completed toast, delete modal, delete confirmation) builds // the same identity (display name, app icon, friendly action title, // emoji type-icon) — keep it in one place so they read consistently. _taskNotificationDescriptor(task) { const appName = (task && task.app) || null; const action = (task && task.type) || ''; const command = (task && task.command) || ''; // System-level backups carry no app slug, so they'd otherwise resolve to a // blank subject + no icon. Give them the LibrePortal identity and a // distinct subject ("Configs" vs the whole-fleet "All apps"). const sysBackup = command.match(/^libreportal backup (system|all)\b/); const sysBackupSubject = sysBackup ? (sysBackup[1] === 'system' ? 'Configs' : 'All apps') : null; const isSystemTask = action.startsWith('setup-') || appName === 'system' || !!sysBackup; let actionTitle = this.formatActionTitle(action); // Tool tasks: prefer the catalog-defined label. const toolCmdMatch = ((task && task.command) || '').match(/libreportal app tool (\S+) (\S+)/); if (toolCmdMatch) { const toolId = toolCmdMatch[2]; let toolLabel = null; const cat = window.toolsCatalog; if (cat && cat.apps && cat.apps[toolCmdMatch[1]] && Array.isArray(cat.apps[toolCmdMatch[1]].tools)) { const t = cat.apps[toolCmdMatch[1]].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 displayName = sysBackupSubject ? sysBackupSubject : (isSystemTask ? 'LibrePortal' : ((appName && window.getAppDisplayName) ? window.getAppDisplayName(appName) : (appName || ''))); const icon = isSystemTask ? '/core/icons/libreportal.svg' : (appName ? `/core/icons/apps/${encodeURIComponent(appName)}.svg` : null); const typeIcon = (this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '') || ''; return { appName, isSystemTask, actionTitle, displayName, icon, typeIcon }; } // 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.startsWith('/tasks/') || currentUrl.pathname === '/tasks.html'; if (isMainTasksPage) { // Category from the path (/tasks/), else legacy ?=. const seg = currentUrl.pathname.replace(/^\/tasks\/?/, '').split('/')[0]; this.currentCategory = seg || 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 || 'all'}`; 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 { // Category display names sometimes already end in "Tasks" (e.g. // "All Tasks", "Running Tasks") — naive interpolation produced // "No all tasks tasks found". Strip the trailing word when // present, and special-case the "all" bucket to read naturally. const catLow = this.getCategoryDisplayName(this.currentCategory).toLowerCase(); if (catLow === 'all tasks' || catLow === 'all') { message = 'No tasks found'; } else if (catLow.endsWith(' tasks')) { message = `No ${catLow} found`; } else { message = `No ${catLow} 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; }); } // Resync the multi-select UI after the render. The Clear All label // + master-checkbox tri-state need to reflect the just-rendered row // set (selections can become stale across category switches). if (typeof this._updateSelectionUI === 'function') this._updateSelectionUI(); } 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'; // Declarative pattern table — order matters (first match wins, so put // specific patterns ahead of catch-alls). `title` is either a literal // string or a function taking the regex match and returning the title; // the latter is used for per-app patterns that extract the app slug. // // Adding a new task: just append one row here. Add the matching command // shape in the WebUI submission site and you're done — no new branch // needed in this function. const displayName = (slug) => window.getAppDisplayName ? window.getAppDisplayName(slug) : (slug.charAt(0).toUpperCase() + slug.slice(1)); const PATTERNS = [ // -- System / setup ---------------------------------------------------- { match: /^libreportal setup config\b/, title: 'LibrePortal - Apply Configuration' }, { match: /^libreportal setup finalize\b/, title: 'LibrePortal - Finalize Setup' }, { match: /^libreportal setup apply\b/, title: 'LibrePortal - Setup Wizard' }, { match: /^libreportal config update\b/, title: 'LibrePortal - Apply Configuration' }, // -- Self-update ------------------------------------------------------- { match: /^libreportal update (apply|now)\b/, title: 'LibrePortal - Update' }, { match: /^libreportal update check\b/, title: 'LibrePortal - Check for Updates' }, // -- Peers ------------------------------------------------------------- { match: /^libreportal peer add\b/, title: 'LibrePortal - Add Peer' }, { match: /^libreportal peer remove\b/, title: 'LibrePortal - Remove Peer' }, { match: /^libreportal peer pair\b/, title: 'LibrePortal - Pair with Peer' }, // -- Regen ------------------------------------------------------------- { match: /^libreportal regen\b/, title: 'LibrePortal - Regenerate WebUI Data' }, // -- System maintenance ------------------------------------------------ { match: /^libreportal system reclaim\b/, title: 'LibrePortal - Reclaim Space' }, { match: /^libreportal system image rm\b/, title: 'LibrePortal - Remove Images' }, { match: /^libreportal verify\b/, title: 'LibrePortal - Verify System' }, // -- Backup: per-app (these capture the app slug) ---------------------- { match: /^libreportal backup app create (\w+)/, title: (m) => `${displayName(m[1])} - Create Backup` }, { match: /^libreportal backup app schedule (\w+)/, title: (m) => `${displayName(m[1])} - Scheduled Backup` }, { match: /^libreportal backup app list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` }, { match: /^libreportal backup app delete_all (\w+)/, title: (m) => `${displayName(m[1])} - Delete All Backups` }, { match: /^libreportal backup app delete (\w+)/, title: (m) => `${displayName(m[1])} - Delete Backup` }, // -- Backup: system / locations ---------------------------------------- { match: /^libreportal backup all\b/, title: 'LibrePortal - Backup All Apps' }, { match: /^libreportal backup verify\b/, title: 'LibrePortal - Verify Backups' }, { match: /^libreportal backup system\b/, title: 'LibrePortal - Backup Configs' }, { match: /^libreportal backup location add\b/, title: 'LibrePortal - Add Backup Location' }, { match: /^libreportal backup location remove\b/, title: 'LibrePortal - Remove Backup Location' }, { match: /^libreportal backup location init\b/, title: 'LibrePortal - Initialise Backup Locations' }, { match: /^libreportal backup location check\b/, title: 'LibrePortal - Check Backup Locations' }, { match: /^libreportal backup location list\b/, title: 'LibrePortal - List Backup Locations' }, { match: /^libreportal backup location stats\b/, title: 'LibrePortal - Backup Location Stats' }, // -- Restore / migrate ------------------------------------------------- { match: /^libreportal restore app start (\w+)/, title: (m) => `${displayName(m[1])} - Restore Backup` }, { match: /^libreportal restore app list (\w+)/, title: (m) => `${displayName(m[1])} - List Backups` }, { match: /^libreportal restore migrate app (\w+)/, title: (m) => `${displayName(m[1])} - Migrate from Host` }, { match: /^libreportal restore migrate system\b/, title: 'LibrePortal - Migrate System' }, { match: /^libreportal restore migrate discover\b/, title: 'LibrePortal - Discover Backups' }, { match: /^libreportal restore first-run\b/, title: 'LibrePortal - First-Run Restore' }, ]; for (const p of PATTERNS) { const m = task.command.match(p.match); if (m) return typeof p.title === 'function' ? p.title(m) : p.title; } // `libreportal app tool ['']` — needs the tools // catalog to resolve the friendly label, so it lives outside the table. 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}`; } // Generic `libreportal app ` — capture the app token only; // anything after (e.g. config overrides `CFG_FOO=bar|…`) is for the CLI. 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}`; } if (task.command.includes('docker-compose')) return 'Docker Compose Operation'; if (task.command.includes('docker')) return 'Docker Operation'; 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' }, 'system_image_rm': { icon: '🗑️', class: 'delete' }, 'verify': { icon: '🛡️', class: 'verify' }, '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+system|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|verify|system\s+(reclaim|image))\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}`; // `app: 'system'` is a category sentinel (config_update, system_update, …), // not a real app slug, so it has no /core/icons/apps/system.svg — fall through // to the LibrePortal-system branch so those tasks still get a logo. const isSystemSentinel = task.app === 'system'; if (task.app && !isSystemSentinel) { const appIconPath = this.getAppIconPath(task); return `${typeIcon}${task.app}`; } if (isSystemSentinel || 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 `/core/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(); } }; } // 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, actionTitle, displayName, icon, typeIcon } = this._taskNotificationDescriptor(task); const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps'); const url = (onAppPage && appName) ? window.appPath(appName, 'tasks', null, taskId) : `/tasks/all?task=${taskId}`; // Per-action emoji (install ✅, backup 💾, restore 📦, …) in the // notification's leftmost icon slot, mirroring task-list rows. const customIcon = typeIcon ? `${typeIcon}` : null; let body; let level; if (task.status === 'completed') { body = `${displayName}
${actionTitle} task completed!`; level = 'success'; } else if (task.status === 'failed') { body = `${displayName}
${actionTitle} task failed.`; level = 'error'; } else if (task.status === 'cancelled') { body = `${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) { // Match the " task " two-line shape used by started/ // completed/failed/cancelled so deletion reads as part of the same // family. Type emoji falls back to 🗑️ since deletion is a 🗑️ action. const { appName, actionTitle, displayName, icon, typeIcon } = this._taskNotificationDescriptor(task); const emoji = typeIcon || '🗑️'; const customIcon = `${emoji}`; const body = displayName ? `${displayName}
${actionTitle} task deleted.` : `${actionTitle || 'Task'} deleted.`; window.notificationSystem.show(body, 'info', appName, null, icon, 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, '>'); // Friendly title + app icon — mirrors the completion-toast format so // the modal's identity matches every other surface. const { isSystemTask, actionTitle, displayName, icon: taskIcon } = this._taskNotificationDescriptor(task); // For the modal we omit the "LibrePortal" subject (the eyebrow already // says "Delete Task") and just lead with the action: "Update Config". const modalSubject = isSystemTask ? '' : displayName; const taskLabel = modalSubject ? `${actionTitle} ${modalSubject}` : (actionTitle || (task && 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', icon: taskIcon || undefined, iconAlt: displayName || 'Task', 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() { // Routes to one of two modes depending on whether any rows are ticked. // Both paths share _showClearAllModal — same UX, same modal, different // input list + title. const hasSelection = this.selectedTaskIds.size > 0; const targetTasks = hasSelection ? this.tasks.filter(t => this.selectedTaskIds.has(t.id)) : this.tasks; const result = await this._showClearAllModal(targetTasks, hasSelection ? 'selected' : 'all'); if (!result || !result.confirmed) return false; await this.performClearAll({ cancelRunning: result.cancelRunning, targets: targetTasks }); if (hasSelection) { // Drop any ids we just deleted from the selection set, then refresh // the button label + master-checkbox state. this.selectedTaskIds = new Set([...this.selectedTaskIds].filter(id => this.tasks.find(t => t.id === id))); this._updateSelectionUI(); } return true; } // Selection helpers wired by the row + master checkboxes. toggleTaskSelection(taskId, checked) { if (checked) this.selectedTaskIds.add(taskId); else this.selectedTaskIds.delete(taskId); this._updateSelectionUI(); } toggleSelectAll(checked) { if (checked) { // Tick every CURRENTLY VISIBLE row. We use the rendered checkboxes // rather than this.tasks so category-filtered views only select // what the user can see. const boxes = document.querySelectorAll('#tasks-list [data-task-select]'); boxes.forEach((cb) => { this.selectedTaskIds.add(cb.dataset.taskSelect); cb.checked = true; }); } else { this.selectedTaskIds.clear(); document.querySelectorAll('#tasks-list [data-task-select]').forEach((cb) => { cb.checked = false; }); } this._updateSelectionUI(); } // Rewrites the Clear All button label + the master-checkbox indeterminate // state to reflect the current selection. Cheap — only touches a few // DOM nodes, safe to call from any selection-change path. _updateSelectionUI() { const n = this.selectedTaskIds.size; const btn = document.getElementById('tasks-clear-btn'); const btnLabel = btn && btn.querySelector('.clear-btn-label'); if (btnLabel) btnLabel.textContent = n > 0 ? `Delete Selected (${n})` : 'Clear All'; if (btn) btn.title = n > 0 ? `Delete ${n} selected task${n === 1 ? '' : 's'}` : 'Clear All Tasks'; // Master checkbox: checked when ALL visible are picked, indeterminate // when SOME are, unchecked when none. const master = document.getElementById('tasks-select-all'); const visible = document.querySelectorAll('#tasks-list [data-task-select]'); if (master) { if (n === 0 || visible.length === 0) { master.checked = false; master.indeterminate = false; } else if (n >= visible.length) { master.checked = true; master.indeterminate = false; } else { master.checked = false; master.indeterminate = true; } } } // Confirmation modal for clearAllTasks. Same openEoModal shape as // _showDeleteTaskModal so visual identity matches every other destructive // confirmation (Uninstall, Delete Task, …). Adds a "Cancel running tasks // too" toggle (off by default) — when off, running/queued tasks are // skipped; when on, they're cancelled first then deleted. // Resolves {confirmed, cancelRunning}. Cancel/backdrop/close → confirmed=false. // mode: 'all' (everything) or 'selected' (user-picked subset) — just // shapes the title/copy. _showClearAllModal(tasks, mode) { return new Promise((resolve) => { const escHtml = (s) => String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>'); const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending'); const total = (tasks || []).length; const runningCount = (tasks || []).filter(isActive).length; const terminalCount = total - runningCount; const bodyHtml = `

This cannot be undone

All selected tasks and their logs will be permanently removed.

${window.eoBadgeRow ? window.eoBadgeRow([ { label: `Total: ${total}`, variant: 'info' }, ...(runningCount > 0 ? [{ label: `Running: ${runningCount}`, variant: 'warning' }] : []), ...(terminalCount > 0 ? [{ label: `Terminal: ${terminalCount}`, variant: 'success' }] : []), ]) : ''} ${runningCount > 0 ? ` ` : ''} `; let decided = false; const finish = (val, modal) => { if (decided) return; decided = true; if (modal) modal.close(); resolve(val); }; // Title shape depends on which path got us here: // selected → "Delete N selected task(s)?" (multi-select chip) // all → "Delete all N tasks?" (legacy Clear All) const isSelectedMode = mode === 'selected'; const titleText = isSelectedMode ? (total === 1 ? 'Delete 1 selected task?' : `Delete ${total} selected tasks?`) : (total === 1 ? 'Delete 1 task?' : `Delete all ${total} tasks?`); const m = window.openEoModal({ id: 'clear-all-tasks-modal', size: 'sm', eyebrow: isSelectedMode ? 'Delete Selected' : 'Delete Tasks', title: titleText, desc: isSelectedMode ? 'Confirm to delete the ticked tasks.' : 'Confirm to delete every task on the list.', body: bodyHtml, actions: [ { label: 'Delete', variant: 'danger', onClick: (modal) => { const cb = modal.bodyEl.querySelector('#clear-all-cancel-running'); finish({ confirmed: true, cancelRunning: !!(cb && cb.checked) }, modal); } }, { label: 'Cancel', variant: 'secondary', onClick: (modal) => finish({ confirmed: false }, modal) } ], onClose: () => finish({ confirmed: false }, null) }); // Live-update the danger button's label so the user knows exactly // what will happen as they flip the toggle. No-op when no running // tasks (no toggle in the body in that case). const cb = m.bodyEl.querySelector('#clear-all-cancel-running'); const deleteBtn = m.contentEl.querySelector('.btn-danger'); const updateLabel = () => { if (!deleteBtn) return; const cancelRunning = !!(cb && cb.checked); if (runningCount > 0 && !cancelRunning) { deleteBtn.textContent = `Delete ${terminalCount} (skip ${runningCount} running)`; } else { deleteBtn.textContent = total === 1 ? 'Delete Task' : `Delete ${total} Tasks`; } }; if (cb) cb.addEventListener('change', updateLabel); updateLabel(); }); } async performClearAll(opts) { opts = opts || {}; const cancelRunning = !!opts.cancelRunning; // Caller (clearAllTasks) passes the subset to operate on. Falls back // to this.tasks for backward compatibility — old direct callers // continue to mean "everything". const universe = Array.isArray(opts.targets) ? opts.targets : (this.tasks || []); // 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 tasks...', 'info', null, null, null, customIcon ); const isActive = (t) => t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending'); // Partition: which tasks we attempt now, which we skip silently. const targets = universe.filter(t => cancelRunning || !isActive(t)); const skipped = universe.filter(t => !targets.includes(t)); try { // For active tasks (only reached when the toggle is on): cancel first, // wait for terminal status, then delete. Mirrors the single-row // deleteTask flow so the bash processor has a chance to honour the // cancel marker before the DELETE arrives (otherwise we'd 409). await Promise.all(targets.map(async (task) => { if (cancelRunning && isActive(task)) { try { await this.taskManager.cancelTask(task.id); } catch {} await this._waitForTaskTerminal(task.id, 15_000); } try { await this.taskManager.deleteTask(task.id); } catch (err) { const looks409 = /\bHTTP 409\b/.test(err && err.message ? err.message : ''); if (!looks409) throw err; await this.taskManager.deleteTask(task.id, { force: true }); } })); // Re-sync local state: keep anything we intentionally skipped. const deletedIds = new Set(targets.map(t => t.id)); this.tasks = this.tasks.filter(t => !deletedIds.has(t.id)); this.renderTasks(); this.updateStats(); this.updateSidebarCounts(); this.generateAppCategories(); if (progressNotification && progressNotification.remove) { progressNotification.remove(); } if (window.notificationSystem) { const msg = skipped.length > 0 ? `Deleted ${targets.length} tasks (skipped ${skipped.length} still running)` : (targets.length === 1 ? 'Task deleted' : `Deleted ${targets.length} tasks`); window.notificationSystem.show(msg, 'success', null, null, null, customIcon); } } catch (error) { console.error('Error clearing tasks:', 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;