/** * Tasks Manager - Tasks Page UI Management * Handles the tasks page display, filtering, and UI interactions * Uses individual task operations from the task/ folder */ class TasksManager { constructor() { this.tasks = []; this.currentCategory = 'all'; this.highlightedTaskId = null; // When true, the open row tracks whichever task is currently running // (queue handoffs, setup installs). Cleared the moment the user manually // toggles a row or switches category — their selection then wins. this.followRunning = false; // Multi-select: ids of tasks the user has ticked. When non-empty the // "Clear All" button morphs into "Delete Selected (N)". Both paths // share _showClearAllModal — same UX, different filter. this.selectedTaskIds = new Set(); this.taskManager = new TaskManager(); this.init(); // Start global live log updater this.startGlobalLiveLogUpdater(); this.refreshInterval = null; this.activeLogStreams = new Map(); // Track active log streams this.autoRefreshIntervals = new Map(); // Track auto-refresh intervals for running tasks // Initialize modular components for individual task operations (only if available) try { this.commands = new TaskCommands(); this.actions = new TaskActions(this, this.commands); this.router = new TaskRouter(this, this.actions); this.taskManager = new TaskManager(); } catch (error) { console.warn('⚠️ Task system components not yet loaded, will initialize later:', error.message); this.commands = null; this.actions = null; this.router = null; this.taskManager = null; } // Initialize from URL parameters this.initializeFromURL(); // Load tasks and setup (only if task system is available) if (this.commands) { this.loadTasks(); } else { //// // console.log('⏳ Task system will be initialized later'); } // Setup global functions this.setupGlobalFunctions(); // Subscribe to the SSE bus once for the page so every visible task row // reacts to status changes, not just ones spawned in this session. this.setupTaskBusListeners(); // Setup mobile menu this.setupMobileMenu(); //// // console.log('✅ TasksManager initialized with modular architecture'); } // Shared "task → notification payload" resolver. Every task surface // (started/completed toast, delete modal, delete confirmation) builds // the same identity (display name, app icon, friendly action title, // emoji type-icon) — keep it in one place so they read consistently. _taskNotificationDescriptor(task) { const appName = (task && task.app) || null; const action = (task && task.type) || ''; const command = (task && task.command) || ''; // System-level backups carry no app slug, so they'd otherwise resolve to a // blank subject + no icon. Give them the LibrePortal identity and a // distinct subject ("Configs" vs the whole-fleet "All apps"). const sysBackup = command.match(/^libreportal backup (system|all)\b/); const sysBackupSubject = sysBackup ? (sysBackup[1] === 'system' ? 'Configs' : 'All apps') : null; const isSystemTask = action.startsWith('setup-') || appName === 'system' || !!sysBackup; let actionTitle = this.formatActionTitle(action); // Tool tasks: prefer the catalog-defined label. const toolCmdMatch = ((task && task.command) || '').match(/libreportal app tool (\S+) (\S+)/); if (toolCmdMatch) { const toolId = toolCmdMatch[2]; let toolLabel = null; const cat = window.toolsCatalog; if (cat && cat.apps && cat.apps[toolCmdMatch[1]] && Array.isArray(cat.apps[toolCmdMatch[1]].tools)) { const t = cat.apps[toolCmdMatch[1]].tools.find(x => x.id === toolId); if (t && t.label) toolLabel = t.label; } if (!toolLabel) toolLabel = toolId.split(/[_-]/).map(w => w ? w.charAt(0).toUpperCase() + w.slice(1) : '').join(' '); actionTitle = toolLabel; } const displayName = sysBackupSubject ? sysBackupSubject : (isSystemTask ? 'LibrePortal' : ((appName && window.getAppDisplayName) ? window.getAppDisplayName(appName) : (appName || ''))); const icon = isSystemTask ? '/core/icons/libreportal.svg' : (appName ? `/core/icons/apps/${encodeURIComponent(appName)}.svg` : null); const typeIcon = (this.getTaskTypeIcon ? this.getTaskTypeIcon(task)?.icon : '') || ''; return { appName, isSystemTask, actionTitle, displayName, icon, typeIcon }; } // Initialize task system after scripts are loaded initializeTaskSystem() { try { //// // console.log('🔧 Initializing task system components...'); // Check if TaskManager is available if (typeof TaskManager === 'undefined') { console.warn('⚠️ TaskManager not available yet, deferring initialization'); return false; } this.commands = new TaskCommands(); this.actions = new TaskActions(this, this.commands); this.router = new TaskRouter(this, this.actions); this.taskManager = new TaskManager(); // Add TaskManager for task operations // Now load tasks since system is ready this.loadTasks(); //// // console.log('✅ Task system initialized successfully'); return true; } catch (error) { console.error('❌ Failed to initialize task system:', error); return false; } } initializeFromURL() { const currentUrl = new URL(window.location.href); const searchParams = currentUrl.searchParams; // Check if we're on the main tasks page (not app page) const isMainTasksPage = currentUrl.pathname === '/tasks' || currentUrl.pathname.startsWith('/tasks/') || currentUrl.pathname === '/tasks.html'; if (isMainTasksPage) { // Category + single-task deep link from the path (/tasks//), // with legacy ?= and ?task= queries still honoured. const parts = (typeof window.taskPartsFromPath === 'function') ? window.taskPartsFromPath(currentUrl.pathname, currentUrl.search) : { category: '', taskId: searchParams.get('task') || '' }; this.currentCategory = parts.category || searchParams.get('') || 'all'; this.highlightedTaskId = parts.taskId || null; } else { // Not on main tasks page, get default filter from localStorage this.currentCategory = localStorage.getItem('tasksDefaultFilter') || 'all'; this.highlightedTaskId = null; // Always clear when not on tasks page } } updateURL(category, taskId = null) { // Update URL without page reload and without hash. The task deep link is a // path segment (/tasks//) — see window.taskPath. const newURL = (typeof window.taskPath === 'function') ? window.taskPath(category, taskId) : `/tasks/${category || 'all'}${taskId ? '/' + String(taskId).replace(/^task_/, '') : ''}`; // Prevent the SPA from interfering if (window.librePortalSPA) { window.librePortalSPA.currentRoute = newURL; } // Use a timeout to avoid conflicts with SPA routing setTimeout(() => { window.history.pushState({ category, taskId }, '', newURL); }, 0); } async init() { //// // console.log('🔧 Initializing TasksManager...'); // Re-read the URL on every (re)mount. The SPA reuses this singleton, so a // navigation to /tasks// must refresh the category + deep-link // state — a constructor-only read goes stale after the first visit. this.initializeFromURL(); // Load initial tasks and refresh sidebar counts await this.loadTasks(); // Force a refresh to ensure latest data await this.loadTasks(); // Open the row this visit is about (deep link, or the live task) now that // the first render is in the DOM, and arm running-task auto-follow. if (typeof this.applyInitialSelection === 'function') this.applyInitialSelection(); // Setup auto-refresh this.setupAutoRefresh(); // Setup global functions this.setupGlobalFunctions(); // Subscribe to the SSE bus once for the page so every visible task row // reacts to status changes, not just ones spawned in this session. this.setupTaskBusListeners(); // Setup mobile menu this.setupMobileMenu(); //// // console.log('✅ TasksManager initialized'); } /* 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. */ /* 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. */ setupMobileMenu() { // Setup mobile menu toggle (if needed) const mobileOverlay = document.getElementById('mobile-overlay'); const sidebar = document.getElementById('sidebar'); if (mobileOverlay && sidebar) { mobileOverlay.addEventListener('click', () => { sidebar.classList.remove('mobile-open'); mobileOverlay.classList.remove('active'); }); } } setupAutoRefresh() { // Only refresh when tasks page is visible and every 30 seconds this.refreshInterval = setInterval(() => { // Only refresh if we're on the tasks page if (window.location.pathname === '/tasks' || window.location.hash.includes('tasks')) { this.loadTasks(); } }, 30000); // 30 seconds instead of 5 } setupGlobalFunctions() { // Make functions available globally for onclick handlers window.filterTasksByCategory = (category) => { event.preventDefault(); this.filterTasksByCategoryHandler(category); }; // Delegated click handler for the Task ID and App links rendered inside // each task row. Behaviour: // * Task ID always lands on the global /tasks page with that task open. // If we're already there, just push the URL and toggle the row open // (no content reload); otherwise SPA-navigate to /tasks. // * App link goes to that app's /app page on whatever its default tab // is (we don't pin tab=… so the app's own logic picks one). if (!window.__taskMetaLinksBound) { window.__taskMetaLinksBound = true; document.addEventListener('click', (e) => { const link = e.target.closest('a.task-id-link, a.task-app-link'); if (!link) return; const href = link.getAttribute('href'); if (!href) return; e.preventDefault(); e.stopPropagation(); const navigate = (url) => { if (window.spaClean && typeof window.spaClean.navigate === 'function') { window.spaClean.navigate(url); } else { window.location.href = url; } }; if (link.classList.contains('task-id-link')) { const taskId = link.dataset.taskId; const onTasksPage = window.location.pathname.startsWith('/tasks'); if (onTasksPage && taskId && typeof window.toggleTaskDetails === 'function') { // Already on /tasks — soft-update the URL and open the row. window.history.pushState({}, '', href); if (window.tasksManager) window.tasksManager.highlightedTaskId = taskId; window.toggleTaskDetails(taskId); } else { // Coming from /app or anywhere else — go to the tasks page; its // initializeFromURL picks up `task=` and auto-expands. navigate(href); } } else { // App link — go to the app page on its default tab. // // `task-parameter-preserve.js` stashes any `task=…` from the // initial page URL into sessionStorage.pendingTaskId, and // app-tabbed-manager's init reads that as a fallback when the // current URL has no task param. Without clearing it here, an old // task id from /tasks would hijack the app page, force-switching // to the Tasks tab and opening that task. Clear it so the app // page lands on its actual default tab. try { sessionStorage.removeItem('pendingTaskId'); } catch {} navigate(href); } }); } window.refreshTasks = () => this.refreshTasks(); window.toggleTaskDetails = (taskId) => this.toggleTaskDetails(taskId); window.retryTask = (taskId) => this.retryTask(taskId); window.deleteTask = (taskId) => this.deleteTask(taskId); window.clearAllTasks = () => this.clearAllTasks(); window.viewTaskLogs = (taskId) => this.viewTaskLogs(taskId); window.toggleLogStreaming = (taskId) => this.toggleLogStreaming(taskId); window.closeTaskLogsModal = () => { const modal = document.querySelector('.task-logs-modal'); if (modal) { // Stop all active streaming for this modal this.activeLogStreams?.forEach((streamData, streamTaskId) => { if (streamData.modal === modal) { streamData.stream.stop(); this.activeLogStreams.delete(streamTaskId); } }); modal.remove(); } }; } // Single page-wide subscription to the TaskEventBus. This complements // monitorTask (which only fires for tasks created in this session) and // ensures any visible row updates when its status changes — including the // running -> completed/failed/cancelled transition that previously only // refreshed when the user switched tabs and came back. // // The guard is on `window`, not `this`, because TasksManager is constructed // in several places (system-loader, app-tabbed-manager, …). Without a // global guard each instance would attach its own listener and the user // would see N notifications for one task completion. setupTaskBusListeners() { if (window.__tasksManagerBusBound) return; window.__tasksManagerBusBound = true; const upsertLocal = (task) => { if (!task || !task.id) return; const idx = this.tasks.findIndex(t => t.id === task.id); if (idx >= 0) this.tasks[idx] = task; else this.tasks.unshift(task); }; window.addEventListener('taskCreated', (e) => { const task = e.detail && e.detail.task; if (!task) return; upsertLocal(task); // Don't re-render the entire list here — `renderTasks` would blow away // any open dropdown's DOM. A targeted update is enough; the next // category switch / refresh will pull the new row in. this.updateTaskDisplay(task); this.maybeFollowRunningTask(task); }); window.addEventListener('taskUpdated', (e) => { const task = e.detail && e.detail.task; if (!task) return; upsertLocal(task); if (task.status === 'running') { this.updateTaskStructure(task.id, task); this.startLogStreaming(task.id, task); this.maybeFollowRunningTask(task); } this.updateTaskDisplay(task); }); window.addEventListener('taskCompleted', (e) => { const task = e.detail && e.detail.task; const taskId = (task && task.id) || (e.detail && e.detail.taskId); if (!taskId) return; if (task) upsertLocal(task); if (task) this.updateTaskDisplay(task); // Final render of any buffered log content, then stop the SSE listener. const stream = this.activeLogStreams && this.activeLogStreams.get(taskId); if (stream && typeof stream.render === 'function') stream.render(); this.stopLogStreaming(taskId); // User-visible notification so they know without staring at the page. // Layout matches the " task started!" format used by task-actions // and backup-manager so started/completed look like a matched pair. if (window.notificationSystem && task) { const { appName, actionTitle, displayName, icon, typeIcon } = this._taskNotificationDescriptor(task); const onAppPage = window.location.pathname.startsWith('/app') && !window.location.pathname.startsWith('/apps'); const url = (onAppPage && appName) ? window.appPath(appName, 'tasks', null, taskId) : window.taskPath('all', taskId); // Per-action emoji (install ✅, backup 💾, restore 📦, …) in the // notification's leftmost icon slot, mirroring task-list rows. const customIcon = typeIcon ? `${typeIcon}` : null; let body; let level; if (task.status === 'completed') { body = `${displayName}
${actionTitle} task completed!`; level = 'success'; } else if (task.status === 'failed') { body = `${displayName}
${actionTitle} task failed.`; level = 'error'; } else if (task.status === 'cancelled') { body = `${displayName}
${actionTitle} task cancelled.`; level = 'warning'; } if (body) window.notificationSystem.show(body, level, appName, url, icon, customIcon); // Belt-and-braces: when the completion notification fires we also // tell the app-tabbed manager to re-enable that app's tabs and // buttons. The taskCompleted listener inside app-tabbed-manager // does this too, plus the 5s reconcile sweep — but routing it // through here as well means any one path being broken or // de-bound still leaves the user with a usable UI rather than // permanently-disabled tabs. if (appName && window.appTabbedManager && typeof window.appTabbedManager.enableAppButtons === 'function') { try { window.appTabbedManager.enableAppButtons(appName); } catch {} } } }); window.addEventListener('taskDeleted', (e) => { const id = e.detail && e.detail.id; if (!id) return; this.tasks = this.tasks.filter(t => t.id !== id); const el = document.querySelector(`.task-item[data-task-id="${id}"]`); if (!el) return; const parent = el.parentElement; el.remove(); if (!parent || parent.querySelector('.task-item')) return; if (parent.id === 'tasks-list') { this.renderTasks(); } else if (parent.id === 'app-tasks') { const appName = (window.appTabbedManager && window.appTabbedManager.currentApp) || ''; parent.innerHTML = `

No tasks found for ${appName}.

`; } }); } destroy() { if (this.refreshInterval) { clearInterval(this.refreshInterval); } } } // Export for use in other modules window.TasksManager = TasksManager;