From 1b0040dbf1d477070a23e78cbef906b030333aa1 Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 30 May 2026 14:53:19 +0100 Subject: [PATCH] refactor(tasks): decompose tasks-manager god-file into 8 responsibility files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Faithful brace-aware split of tasks-manager.js (2664->491 line base) into list-render, data-load, log-stream, row-expand, actions, modals, logs-modal, format — augmenting TasksManager.prototype. First removed 4 provably-dead duplicate defs (the earlier init/setupAutoRefresh/startLogStreaming/loadTaskLogs that the later defs already overrode — behavior-preserving). Methods relocated verbatim via a brace-aware Node extractor (handles strings/templates/comments/ regex, fixing the line-heuristic over-capture). Verified: all 60 (deduped) methods present exactly once, no dups, all 9 files node --check clean. Wired after the base in the task-system loader (async=false-ordered). Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- .../components/tasks/js/tasks-actions.js | 294 +++ .../components/tasks/js/tasks-data-load.js | 217 ++ .../components/tasks/js/tasks-format.js | 188 ++ .../components/tasks/js/tasks-list-render.js | 512 ++++ .../components/tasks/js/tasks-log-stream.js | 372 +++ .../components/tasks/js/tasks-logs-modal.js | 104 + .../components/tasks/js/tasks-manager.js | 2173 ----------------- .../components/tasks/js/tasks-modals.js | 177 ++ .../components/tasks/js/tasks-row-expand.js | 166 ++ .../frontend/core/boot/system-loader.js | 11 +- 10 files changed, 2040 insertions(+), 2174 deletions(-) create mode 100644 containers/libreportal/frontend/components/tasks/js/tasks-actions.js create mode 100644 containers/libreportal/frontend/components/tasks/js/tasks-data-load.js create mode 100644 containers/libreportal/frontend/components/tasks/js/tasks-format.js create mode 100644 containers/libreportal/frontend/components/tasks/js/tasks-list-render.js create mode 100644 containers/libreportal/frontend/components/tasks/js/tasks-log-stream.js create mode 100644 containers/libreportal/frontend/components/tasks/js/tasks-logs-modal.js create mode 100644 containers/libreportal/frontend/components/tasks/js/tasks-modals.js create mode 100644 containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-actions.js b/containers/libreportal/frontend/components/tasks/js/tasks-actions.js new file mode 100644 index 0000000..a6d07d4 --- /dev/null +++ b/containers/libreportal/frontend/components/tasks/js/tasks-actions.js @@ -0,0 +1,294 @@ +// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base. +Object.assign(TasksManager.prototype, { + 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}`); + } + } + }, + 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; + }, + 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 + ); + } + } + }, + // 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; + } + } + }, + // 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); + }); + }, +}); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-data-load.js b/containers/libreportal/frontend/components/tasks/js/tasks-data-load.js new file mode 100644 index 0000000..66629ed --- /dev/null +++ b/containers/libreportal/frontend/components/tasks/js/tasks-data-load.js @@ -0,0 +1,217 @@ +// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base. +Object.assign(TasksManager.prototype, { + 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); + } + } + }, +}); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-format.js b/containers/libreportal/frontend/components/tasks/js/tasks-format.js new file mode 100644 index 0000000..a942be5 --- /dev/null +++ b/containers/libreportal/frontend/components/tasks/js/tasks-format.js @@ -0,0 +1,188 @@ +// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base. +Object.assign(TasksManager.prototype, { + 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; + }, + // 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(' '); + }, + 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); + }, + 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`; + }, + extractTimestamp(logEntry) { + const match = logEntry.match(/\[([^\]]+)\]/); + return match ? match[1] : ''; + }, + extractLogMessage(logEntry) { + const match = logEntry.match(/\] (.+)$/); + return match ? match[1] : logEntry; + }, + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, +}); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js new file mode 100644 index 0000000..9c7ab38 --- /dev/null +++ b/containers/libreportal/frontend/components/tasks/js/tasks-list-render.js @@ -0,0 +1,512 @@ +// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base. +Object.assign(TasksManager.prototype, { + 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...
' + } +
+
+
+ + `; + }, + 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`; + }, + 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); + }, + 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']; + }, + getStatusIcon(status) { + const icons = { + queued: '⏳', + running: '🔄', + completed: '✅', + failed: '❌' + }; + return icons[status] || '📋'; + }, + 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; + } + }, + 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(); + }, + showError(message) { + const container = document.getElementById('tasks-list'); + if (container) { + container.innerHTML = ` +
+
$ ${message}
+
_
+
+ `; + } + }, +}); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-log-stream.js b/containers/libreportal/frontend/components/tasks/js/tasks-log-stream.js new file mode 100644 index 0000000..b2b236a --- /dev/null +++ b/containers/libreportal/frontend/components/tasks/js/tasks-log-stream.js @@ -0,0 +1,372 @@ +// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base. +Object.assign(TasksManager.prototype, { + // SSE-driven log streaming. Subscribes to `taskLog` events from the bus and + // appends incoming chunks. Initial backlog is fetched once via the API. + // + // Resilient to DOM replacement: the logs container can be wiped out by + // `renderTasks()` (the 30s auto-refresh) or by `loadTaskLogs()` (toggle + // re-open). Instead of capturing a `preElement` reference once, `render()` + // re-locates / re-creates it on every call from the cumulative `buffered` + // string. Idempotent: if already streaming, a second call just re-renders. + async startLogStreaming(taskId, task) { + if (!this.activeLogStreams) this.activeLogStreams = new Map(); + + if (this.activeLogStreams.has(taskId)) { + const existing = this.activeLogStreams.get(taskId); + if (existing && typeof existing.render === 'function') existing.render(); + return; + } + + const state = { buffered: '' }; + + const render = () => { + const logsContainer = document.getElementById(`logs-${taskId}`); + if (!logsContainer) return; + const overlay = logsContainer.querySelector('div[style*="position: absolute"]'); + if (overlay) overlay.remove(); + let preElement = logsContainer.querySelector('pre.output-content'); + if (!preElement) { + logsContainer.innerHTML = ''; + preElement = document.createElement('pre'); + preElement.className = 'output-content terminal-style'; + logsContainer.appendChild(preElement); + } + const atBottom = logsContainer.scrollHeight - logsContainer.scrollTop <= logsContainer.clientHeight + 10; + if (state.buffered) { + preElement.innerHTML = this.taskManager.parseAnsiColors(state.buffered); + } else { + preElement.innerHTML = '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}`); + } + }, + // 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}
+ `; + } + } + }, + // 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
'; + } + }, + // 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}`); + }, + // 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); + } + } , +}); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-logs-modal.js b/containers/libreportal/frontend/components/tasks/js/tasks-logs-modal.js new file mode 100644 index 0000000..1edb86e --- /dev/null +++ b/containers/libreportal/frontend/components/tasks/js/tasks-logs-modal.js @@ -0,0 +1,104 @@ +// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base. +Object.assign(TasksManager.prototype, { + 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(); + }, + 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'; + } + } + }, +}); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js index ef22945..a2d4673 100755 --- a/containers/libreportal/frontend/components/tasks/js/tasks-manager.js +++ b/containers/libreportal/frontend/components/tasks/js/tasks-manager.js @@ -59,29 +59,6 @@ class TasksManager { //// // 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 @@ -152,40 +129,6 @@ class TasksManager { } } - // 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); @@ -262,1039 +205,36 @@ class TasksManager { //// // 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) @@ -1309,22 +249,6 @@ class TasksManager { } } - 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 @@ -1530,1128 +454,31 @@ class TasksManager { }); } - // 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) { diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-modals.js b/containers/libreportal/frontend/components/tasks/js/tasks-modals.js new file mode 100644 index 0000000..eb7fd8e --- /dev/null +++ b/containers/libreportal/frontend/components/tasks/js/tasks-modals.js @@ -0,0 +1,177 @@ +// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base. +Object.assign(TasksManager.prototype, { + // 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) + }); + }); + }, + // 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(); + }); + }, +}); diff --git a/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js b/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js new file mode 100644 index 0000000..74e2967 --- /dev/null +++ b/containers/libreportal/frontend/components/tasks/js/tasks-row-expand.js @@ -0,0 +1,166 @@ +// Auto-extracted from tasks-manager.js (verbatim) — augments TasksManager.prototype. Loaded after the base. +Object.assign(TasksManager.prototype, { + toggleTaskDetails(taskId) { + const details = document.getElementById(`details-${taskId}`); + const toggleBtn = document.querySelector(`.task-btn.toggle-details[onclick*="toggleTaskDetails('${taskId}')"]`); + + if (details) { + const isOpen = details.style.display === 'block'; + + // Close all other task details and reset their buttons + if (isOpen) { + document.querySelectorAll('.task-details').forEach(otherDetails => { + if (otherDetails.id !== `details-${taskId}`) { + otherDetails.style.display = 'none'; + otherDetails.classList.remove('task-details-open'); + } + }); + + document.querySelectorAll('.task-btn.toggle-details').forEach(otherBtn => { + if (!otherBtn.getAttribute('onclick').includes(taskId)) { + otherBtn.classList.remove('expanded'); + } + }); + + // Close current + details.style.display = 'none'; + details.classList.remove('task-details-open'); + if (toggleBtn) toggleBtn.classList.remove('expanded'); + } else { + // Open current + details.style.display = 'block'; + details.classList.add('task-details-open'); + if (toggleBtn) toggleBtn.classList.add('expanded'); + + // Auto-load logs when opened. For active tasks, hand off to the live + // streamer so SSE chunks keep updating the panel; for terminal tasks + // a one-shot snapshot is enough. + const t = this.tasks.find(x => x.id === taskId); + if (t && (t.status === 'running' || t.status === 'queued' || t.status === 'pending')) { + this.startLogStreaming(taskId, t); + } else { + this.loadTaskLogs(taskId); + } + + // Scroll to task + const taskElement = document.querySelector(`[data-task-id="${taskId}"]`); + if (taskElement) { + taskElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + } + + // Update URL to include task parameter + this.updateURL(this.currentCategory, isOpen ? null : taskId); + this.highlightedTaskId = isOpen ? null : taskId; + } else { + // Remove task parameter from URL + this.updateURL(this.currentCategory); + this.highlightedTaskId = null; + } + }, + // Monitor a specific task. State changes now arrive via SSE through + // TaskEventBus, so this is mostly a hook for the UI to: + // - auto-expand the task if it's the one we just started + // - start log streaming when the task transitions to running + // - clean up local intervals when it terminates + // No more polling; the only fetches happen on-demand via TaskManager. + monitorTask(taskId, appName, action) { + if (!this.highlightedTaskId || this.highlightedTaskId === taskId) { + setTimeout(() => this.autoExpandTask(taskId), 1500); + } + + let statusUpdateInterval = null; + + const onUpdate = (event) => { + const t = event.detail && event.detail.task; + if (!t || t.id !== taskId) return; + + if (t.status === 'running') { + this.updateTaskStructure(taskId, t); + this.startLogStreaming(taskId, t); + if (this.highlightedTaskId === taskId && !statusUpdateInterval) { + statusUpdateInterval = setInterval(() => this.updateHighlightedTaskStatus(taskId), 2000); + } + } else { + this.updateTaskDisplay(t); + } + }; + + const onComplete = (event) => { + if (!event.detail || event.detail.taskId !== taskId) return; + if (statusUpdateInterval) { clearInterval(statusUpdateInterval); statusUpdateInterval = null; } + this.stopLogStreaming(taskId); + window.removeEventListener('taskUpdated', onUpdate); + window.removeEventListener('taskCompleted', onComplete); + }; + + window.addEventListener('taskUpdated', onUpdate); + window.addEventListener('taskCompleted', onComplete); + }, + // 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(); + }, +}); diff --git a/containers/libreportal/frontend/core/boot/system-loader.js b/containers/libreportal/frontend/core/boot/system-loader.js index 9c95e89..34ab91a 100755 --- a/containers/libreportal/frontend/core/boot/system-loader.js +++ b/containers/libreportal/frontend/core/boot/system-loader.js @@ -174,7 +174,16 @@ class SystemLoader { '/core/lib/task-global-functions.js', '/core/lib/task-manager.js', '/core/lib/task-parameter-preserve.js', - '/components/tasks/js/tasks-manager.js' + '/components/tasks/js/tasks-manager.js', // base: class + constructor + init + bus wiring + // prototype-augment clusters (load after base; ordered via async=false): + '/components/tasks/js/tasks-format.js', + '/components/tasks/js/tasks-list-render.js', + '/components/tasks/js/tasks-data-load.js', + '/components/tasks/js/tasks-log-stream.js', + '/components/tasks/js/tasks-row-expand.js', + '/components/tasks/js/tasks-actions.js', + '/components/tasks/js/tasks-modals.js', + '/components/tasks/js/tasks-logs-modal.js' ] });