// Enhanced App Manager with Tabbed Interface // Integrates app management with task history class AppTabbedManager { constructor() { // console.log('🔍 AppTabbedManager constructor called'); // console.log('🔍 URL in constructor:', window.location.href); // console.log('🔍 Search params in constructor:', window.location.search); // Store original URL for task parameter detection this.originalUrl = window.location.href; this.originalSearch = window.location.search; // Check sessionStorage for task parameter (fallback) const sessionTaskId = sessionStorage.getItem('pendingTaskId'); // console.log('🔍 Session storage task ID:', sessionTaskId); // Debug: Check if task parameter exists in original URL const originalParams = new URLSearchParams(this.originalSearch); const originalTaskId = originalParams.get('task'); // console.log('🔍 Original task ID in constructor:', originalTaskId); // Try to get task parameter from performance navigation if available if (performance && performance.getEntriesByType) { const navigationEntries = performance.getEntriesByType('navigation'); if (navigationEntries.length > 0) { const navEntry = navigationEntries[0]; // console.log('🔍 Navigation entry URL:', navEntry.name); const navParams = new URLSearchParams(new URL(navEntry.name).search); const navTaskId = navParams.get('task'); // console.log('🔍 Navigation task ID:', navTaskId); } } this.currentApp = this.getAppFromURL(); this.currentTab = this.getTabFromURL(); this.tasksManager = new TasksManager(); this.appsManager = new AppsManager(); this.initialized = false; // Button state management this.disabledButtons = new Set(); this.activeTaskId = null; // Track running tasks. Key is `${appName}|${action}` (using `|` so app names with // hyphens don't collide). Value is { taskId, appName, action } so callers never // have to parse the key. this.runningTasks = new Map(); } // Build the runningTasks key for an (app, action) pair. Use `|` since `-` and `_` // appear in app/action strings (e.g. 'delete_all'). taskKey(appName, action) { return `${appName}|${action}`; } // Find the most recent running task for the given app, or null. getRunningTaskForApp(appName) { if (!appName) return null; for (const info of this.runningTasks.values()) { if (info.appName === appName) return info; } return null; } // Switch the manager to a new app, clearing DOM-bound state from the previous app // and re-evaluating tab/button state for the new one. Callers (e.g. apps-manager) // should use this instead of mutating `currentApp` directly so disabled tabs from // app A don't bleed into app B's view. setCurrentApp(appName) { if (this.currentApp === appName) return; // console.log('🔄 setCurrentApp: switching from %s to %s', this.currentApp, appName); this.currentApp = appName; // Before clearing disabled button references, restore any static backup action buttons // that may have spinners/disabled state from a previous app const backupActions = document.querySelectorAll('.backup-actions button'); backupActions.forEach(button => { if (button && button.dataset.originalContent) { button.disabled = false; button.classList.remove('disabled', 'task-running'); button.innerHTML = button.dataset.originalContent; delete button.dataset.originalContent; } }); this.disabledButtons.clear(); this.activeTaskId = null; this.enableTabs(); const running = this.getRunningTaskForApp(appName); // console.log('🔍 setCurrentApp: running task for %s = %o', appName, running); if (running) { this.activeTaskId = running.taskId; } } // Get app name from URL parameter getAppFromURL() { // Path-based /app/ first, then legacy ?app= / ?=name. let appName = window.location.pathname.replace(/^\/app\/?/, '').split('/')[0]; appName = appName ? decodeURIComponent(appName) : ''; if (!appName) { const urlParams = new URLSearchParams(window.location.search); appName = urlParams.get('app'); if (!appName && window.location.search.includes('?=')) { appName = window.location.search.split('?=')[1].split('&')[0]; } } // console.log('🔍 Original app name from URL:', appName); // Convert full app name to slug for task filtering if (appName && window.apps) { const appData = window.apps.find(app => { // Extract slug from command const command = app.command || ''; const parts = command.split(' '); return parts[parts.length - 1] === appName; }); if (appData) { const command = appData.command || ''; const parts = command.split(' '); const slug = parts[parts.length - 1]; // Return the slug // console.log('🔄 Converted to slug:', slug, 'from appData:', appData.name); return slug; } else { // console.log('⚠️ No app data found for:', appName); } } // console.log('🔄 Returning original app name:', appName); return appName; } // Get the main tab from the URL. Prefers the path-based shape // /app// (where defaults to "config" when absent) // and falls back to the legacy `?tab=` query for older bookmarks / // links that haven't been migrated yet. Legacy `logs` is aliased to `tasks`. getTabFromURL() { if (window.appPartsFromPath) { const parts = window.appPartsFromPath(window.location.pathname); if (parts.app) return parts.tab; // path wins on app pages } const urlParams = new URLSearchParams(window.location.search); const tab = urlParams.get('tab') || 'config'; return tab === 'logs' ? 'tasks' : tab; } // Get the config sub-tab category from the URL. Only meaningful when the // main tab is "config" — returns null otherwise (and when no sub-tab is set). getConfigSubFromURL() { if (window.appPartsFromPath) { const parts = window.appPartsFromPath(window.location.pathname); if (parts.tab === 'config' && parts.sub) return parts.sub; } // Legacy `?config=` query support. const urlParams = new URLSearchParams(window.location.search); return urlParams.get('config') || null; } // Check if we're on an app page before doing anything isAppPage() { const pathname = window.location.pathname; const search = window.location.search; // Only individual app pages (/app?=appname), NOT the apps listing page (/apps) return (pathname.startsWith('/app') && !pathname.startsWith('/apps') || pathname.endsWith('/index.html') || pathname === '/index.html' || search.includes('app=') || search.includes('?=')); // Old format app pages } // Update URL with app and tab — path-based: // /app/ — config tab, no sub // /app// — non-config main tab // /app//config/ — config tab + sub-category // …?task= — optional deep-link updateURL(app = null, tab = null) { // Only update URLs on app pages - prevent interference with other pages. if (!this.isAppPage()) return; const currentParts = window.appPartsFromPath ? window.appPartsFromPath(window.location.pathname) : { app: '', tab: 'config', sub: null }; const currentApp = app || currentParts.app || this.currentApp; if (!currentApp) return; const finalTab = tab || currentParts.tab || 'config'; // Keep the config sub-tab only if we're STAYING on config (and on the same // app). Switching main tab or app drops it; staying on config preserves it. const finalSub = (!app && finalTab === 'config') ? currentParts.sub : null; // Keep a deep-linked task only when staying on the same app (tab-only update). const params = new URLSearchParams(window.location.search); const taskId = !app ? params.get('task') : null; const url = window.appPath(currentApp, finalTab, finalSub, taskId); window.history.replaceState({}, '', url); } // Update current app and refresh content updateApp(newAppName) { this.setCurrentApp(newAppName); // Reset to the config tab on the path-based app URL. history.replaceState({}, '', window.appPath(newAppName, 'config')); this.switchTab('config'); } // Switch between tabs switchTab(tabId) { // console.log('🔄 switchTab called with:', tabId); // console.log('🔍 Current currentApp before switch:', this.currentApp); // console.log('🔍 Current URL when switching:', window.location.href); // console.log('🔍 URL search when switching:', window.location.search); // Remove active class from all main navigation tabs document.querySelectorAll('.main-tab-button').forEach(btn => { btn.classList.remove('active'); }); // Hide all tab panes document.querySelectorAll('.tab-pane').forEach(pane => { pane.classList.remove('active'); }); // Add active class to selected main navigation tab const selectedTab = document.querySelector(`.main-tab-button[data-tab="${tabId}"]`); if (selectedTab) { selectedTab.classList.add('active'); //// // console.log('✅ Tab button activated:', tabId); } else { console.warn('⚠️ Main navigation tab button not found:', tabId); } // Add active class to selected tab pane const selectedPane = document.getElementById(`${tabId}-tab`); if (selectedPane) { selectedPane.classList.add('active'); //// // console.log('✅ Tab pane activated:', tabId); } else { console.warn('⚠️ Tab pane not found:', tabId); } // Update URL (only tab, not app) - but only on app pages if (this.isAppPage()) { // console.log('🔄 About to updateURL with tab:', tabId); this.updateURL(null, tabId); } // Load tab-specific content // console.log('🔄 About to load tab content for tab:', tabId, 'with currentApp:', this.currentApp); this.loadTabContent(tabId); } // Load content for specific tab async loadTabContent(tabId) { const actualTabId = tabId === 'logs' ? 'tasks' : tabId; const currentAppFromUrl = this.getAppFromURL(); // console.log('📂 loadTabContent: tabId=%s, currentApp=%s, fromUrl=%s', // tabId, this.currentApp, currentAppFromUrl); // Update currentApp if URL has different app name. Route through setCurrentApp // so any disable state from the previous app gets cleared before we render. if (currentAppFromUrl && currentAppFromUrl !== this.currentApp) { this.setCurrentApp(currentAppFromUrl); } // Ensure app detail view is shown and app is loaded before loading tab content if (!this.currentApp || this.currentApp === 'null') { console.warn('⚠️ No current app set, cannot load tab content'); return; } // Toggle the Tools tab button visibility based on whether this app has // any tools. Tools-less apps simply don't see the tab. If the user // landed on the tools tab via a deep link for such an app, redirect // them to config so they're not staring at an empty pane. if (window.toolsManager) { const toolsResult = await window.toolsManager.prepare(this.currentApp); if (actualTabId === 'tools' && (!toolsResult || toolsResult.tools.length === 0)) { return this.switchTab('config'); } } // Routing tab is Traefik-only — show on Traefik, hide everywhere else. const isTraefik = this.currentApp === 'traefik'; document.querySelectorAll('[data-tab="routing"]').forEach(btn => { btn.style.display = isTraefik ? '' : 'none'; }); if (actualTabId === 'routing' && !isTraefik) { return this.switchTab('config'); } // Make sure app detail view is visible and app is loaded if (window.appsManager) { // Use showAppDetail to ensure proper initialization (same as config tab) // console.log('🔄 Ensuring app detail is loaded for:', this.currentApp); window.appsManager.showAppDetail(this.currentApp); // Wait a bit for DOM to be ready after app detail is rendered await new Promise(resolve => setTimeout(resolve, 200)); } switch (actualTabId) { case 'tasks': // console.log('🔄 loadTabContent: Loading tasks for app:', this.currentApp); await this.loadAppTasks(); break; case 'backups': // console.log('🔄 loadTabContent: Loading backups for app:', this.currentApp); await this.loadAppBackups(); // IMPORTANT: Re-apply button state if there are running tasks this.restoreButtonState(); break; case 'services': if (window.servicesManager) { await window.servicesManager.load(this.currentApp); } this.restoreButtonState(); break; case 'tools': if (window.toolsManager) { await window.toolsManager.load(this.currentApp); } this.restoreButtonState(); break; case 'routing': if (window.routingManager) { await window.routingManager.load(this.currentApp); } this.restoreButtonState(); break; case 'config': // Config is already handled by showAppDetail above // console.log('🔧 Config content already loaded by showAppDetail'); // IMPORTANT: Re-apply button state if there are running tasks this.restoreButtonState(); break; default: // Config is handled by existing app management system break; } // Tear down the services tab (timers + SSE) when switching away. if (actualTabId !== 'services' && window.servicesManager) { window.servicesManager.unload(); } if (actualTabId !== 'tools' && window.toolsManager) { window.toolsManager.unload(); } } // Load tasks specific to current app async loadAppTasks() { // console.log('🔄 loadAppTasks called, currentApp:', this.currentApp); // Show loading spinner by showing the initial loading state const tasksContainer = document.getElementById('app-tasks'); if (tasksContainer) { tasksContainer.innerHTML = `

Loading tasks...

`; } if (!this.currentApp) { if (tasksContainer) { tasksContainer.innerHTML = '

No app selected.

'; } return; } try { // Load all tasks // console.log('🔄 Loading tasks...'); // console.log('🔍 Using currentApp for filtering:', this.currentApp); await this.tasksManager.loadTasks(); const allTasks = this.tasksManager.tasks || []; // console.log('📊 All tasks loaded:', allTasks.length); // console.log('📋 All tasks data:', allTasks); // console.log('📋 Sample task app names:', allTasks.slice(0, 3).map(t => t.app)); // Filter tasks for current app const appTasks = allTasks.filter(task => task.app === this.currentApp); // console.log('🎯 Filtering tasks for app:', this.currentApp); // console.log('📋 Available task.app values:', [...new Set(allTasks.map(t => t.app))]); // console.log('🎯 Filtered tasks for', this.currentApp, ':', appTasks.length); // Debug: Show what would match if we used different app names // console.log('🔍 Debug - Testing different app names:'); ['libreportal', 'fail2ban', 'LibrePortal', 'Fail2Ban'].forEach(testApp => { const testTasks = allTasks.filter(task => task.app === testApp); // console.log(` - "${testApp}": ${testTasks.length} tasks`); }); if (appTasks.length === 0) { // console.log('⚠️ No tasks found for', this.currentApp, '- checking if tasks have different app names'); // Show some task details for debugging if (allTasks.length > 0) { // console.log('📋 Sample tasks:', allTasks.slice(0, 3).map(t => ({ id: t.id, app: t.app, command: t.command }))); } tasksContainer.innerHTML = `

No tasks found for ${this.currentApp}.

`; return; } // Setup global functions for task interactions this.tasksManager.setupGlobalFunctions(); // Render app-specific tasks const tasksHtml = appTasks.map(task => this.tasksManager.renderTask(task)).join(''); tasksContainer.innerHTML = tasksHtml; // Setup app-specific task interactions (separate from main tasks system) this.setupAppTaskFunctions(); // Handle pending task ID from URL parameter if (this.pendingTaskId) { // console.log('🔍 Handling pending task ID after tasks loaded:', this.pendingTaskId); setTimeout(() => { if (typeof window.toggleAppTaskDetails === 'function') { // console.log('🔍 Opening task details for pending task:', this.pendingTaskId); window.toggleAppTaskDetails(this.pendingTaskId); // Scroll to the task element after opening details this.scrollToTask(this.pendingTaskId); this.pendingTaskId = null; // Clear pending task ID } }, 500); // Wait a bit for DOM to be ready } // Task events are handled by individual task components // No additional initialization needed } catch (error) { console.error('AppTabbedManager: Error loading app tasks:', error); tasksContainer.innerHTML = `

Error loading tasks: ${error.message}

`; } } // Scroll to specific task element with smooth animation scrollToTask(taskId) { // console.log('🔍 Scrolling to task:', taskId); // Find the task element by ID or data attribute let taskElement = document.getElementById(`task-${taskId}`); // If not found by ID, try to find by data-task-id attribute if (!taskElement) { taskElement = document.querySelector(`[data-task-id="${taskId}"]`); } // If still not found, try to find the task details element if (!taskElement) { const detailsElement = document.getElementById(`details-${taskId}`); if (detailsElement) { taskElement = detailsElement.closest('.task-item'); } } if (taskElement) { // console.log('🔍 Found task element, scrolling to it:', taskElement); // Smooth scroll to the task element taskElement.scrollIntoView({ behavior: 'smooth', block: 'center', // Center the task in the viewport inline: 'nearest' }); // Add a highlight effect to make the task more visible taskElement.classList.add('task-highlighted'); // Remove the highlight after 3 seconds setTimeout(() => { taskElement.classList.remove('task-highlighted'); }, 3000); } else { console.warn('⚠️ Task element not found for scrolling:', taskId); } } // Setup app-specific task functions to avoid conflicts with main tasks page setupAppTaskFunctions() { // Create app-specific toggleTaskDetails function window.toggleAppTaskDetails = (taskId) => { // console.log('🔍 App-specific toggleTaskDetails called for:', 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 document.querySelectorAll('.task-details').forEach(d => { if (d.id !== `details-${taskId}`) { d.style.display = 'none'; d.classList.remove('task-details-open'); } }); document.querySelectorAll('.task-btn.toggle-details').forEach(btn => { btn.classList.remove('expanded'); }); if (isOpen) { details.style.display = 'none'; details.classList.remove('task-details-open'); if (toggleBtn) toggleBtn.classList.remove('expanded'); } else { details.style.display = 'block'; details.classList.add('task-details-open'); if (toggleBtn) toggleBtn.classList.add('expanded'); // Auto-load logs when opened if (this.tasksManager && this.tasksManager.loadTaskLogs) { this.tasksManager.loadTaskLogs(taskId); } // Update URL to include task parameter const currentUrl = window.location.href; const urlParams = new URLSearchParams(currentUrl.search); // Get current app from AppTabbedManager const currentApp = this.currentApp || ''; history.pushState({}, '', window.appPath(currentApp, 'tasks', null, taskId)); } } else { console.warn('⚠️ App task details not found for:', taskId); } }; // Override global toggleTaskDetails to use app-specific version when on app page const originalToggleTaskDetails = window.toggleTaskDetails; window.toggleTaskDetails = (taskId) => { if (window.appTabbedManager && window.location.pathname.includes('/app')) { // Use app-specific version window.toggleAppTaskDetails(taskId); } else { // Use original version for main tasks page originalToggleTaskDetails(taskId); } }; } // Load app backups async loadAppBackups() { const backupAppNameElement = document.getElementById('backup-app-name'); if (backupAppNameElement) { const formattedAppName = this.currentApp ? (window.getAppDisplayName ? window.getAppDisplayName(this.currentApp) : (this.currentApp.charAt(0).toUpperCase() + this.currentApp.slice(1))) : 'Unknown App'; backupAppNameElement.textContent = formattedAppName; } if (!this.currentApp || typeof BackupAppCard === 'undefined') { const status = document.getElementById('backup-app-card-status'); if (status) status.textContent = 'No app selected.'; return; } if (!this.backupAppCard || this.backupAppCard.appName !== this.currentApp) { this.backupAppCard = new BackupAppCard(this.currentApp); window.backupAppCard = this.backupAppCard; } await this.backupAppCard.render(); } // Initialize the tabbed manager async initialize() { // Prevent double initialization if (this.initialized) { // console.log('⚠️ AppTabbedManager already initialized, skipping'); return; } // console.log('🚀 AppTabbedManager initializing, currentApp:', this.currentApp); // Initialize task system if not already done (with retry) if (this.tasksManager && !this.tasksManager.commands) { let initialized = false; let attempts = 0; const maxAttempts = 5; while (!initialized && attempts < maxAttempts) { // console.log(`🔄 Attempting to initialize task system (${attempts + 1}/${maxAttempts})...`); try { initialized = this.tasksManager.initializeTaskSystem(); if (initialized) { // console.log('✅ Task system initialized successfully'); } } catch (error) { console.error('❌ Task system initialization error:', error); } if (!initialized) { attempts++; await new Promise(resolve => setTimeout(resolve, 200)); // Wait 200ms } } if (!initialized) { console.warn('⚠️ Task system initialization failed after retries'); } } // Stale .task-running from a prior session won't survive a reload, but the // DOM might still carry it from a re-render — clear it up front. this.enableTabs(); document.querySelectorAll('button.task-running, .tab-button.task-running, .main-tab-button.task-running') .forEach(button => this.restoreButton(button)); // SSE drives task lifecycle events; the reconcile pass below is a // safety net for the cases where they don't reach us — bus disconnect, // missed event during reconnect, throttled background tab. Several // triggers so a stuck "running" state self-heals quickly: // * a faster periodic tick (5s) instead of the previous 30s // * whenever the SSE bus reconnects (`taskBusReady`) // * whenever the page becomes visible again (`visibilitychange`) // * whenever the window regains focus if (!this._watchdogStarted) { this._watchdogStarted = true; const reconcile = () => this.reconcileRunningTasks().catch(() => {}); // Adaptive cadence: when a task is actively running, poll every // 1.5s so a missed SSE event surfaces quickly. When idle, fall back // to 5s. Net: worst-case lag drops from ~10s to ~1.5s while keeping // background load minimal. const tick = () => { reconcile(); const next = (this.runningTasks && this.runningTasks.size > 0) ? 1500 : 5000; clearTimeout(this._reconcileTimer); this._reconcileTimer = setTimeout(tick, next); }; this._reconcileTimer = setTimeout(tick, 1500); window.addEventListener('taskBusReady', reconcile); window.addEventListener('focus', reconcile); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') reconcile(); }); } // Set current app from URL BEFORE setting up URL monitoring const urlAppName = this.getAppFromURL(); // console.log('🔍 Setting initial currentApp from URL:', urlAppName); this.currentApp = urlAppName; // Check for running tasks for this app and auto-switch to tasks tab if found if (this.currentApp) { const running = this.getRunningTaskForApp(this.currentApp); if (running) { this.switchTab('tasks'); this.disableTabs(); this.activeTaskId = running.taskId; } } // Check for task parameter and handle it AFTER tasks are loaded // Use original URL since the current URL might have been modified const urlParams = new URLSearchParams(this.originalSearch); // console.log('🔍 Original URL search during init:', this.originalSearch); // console.log('🔍 Original URL params during init:', Object.fromEntries(urlParams.entries())); let taskId = urlParams.get('task'); // console.log('🔍 Task ID from original params:', taskId); // Fallback: Check sessionStorage if URL doesn't have task parameter if (!taskId) { taskId = sessionStorage.getItem('pendingTaskId'); // console.log('🔍 Task ID from sessionStorage fallback:', taskId); // Clear sessionStorage after using it if (taskId) { sessionStorage.removeItem('pendingTaskId'); } } if (taskId) { // console.log('🔍 Task parameter found:', taskId); // Store the task ID to handle after tasks are loaded this.pendingTaskId = taskId; // Force tasks tab this.switchTab('tasks'); } // Monitor URL changes for app navigation + listen for task events. These // add window-level listeners (popstate, taskCreated/Completed/Updated), so // bind them ONCE for the lifetime of this singleton — initialize() re-runs // on every /app navigation (the `initialized` flag is never set true), and // without this guard each visit stacked another set of window listeners. if (!this._listenersWired) { this._listenersWired = true; this.setupURLMonitoring(); this.setupTaskEventListeners(); } // Set initial active tab (only if no task parameter) if (!taskId) { const initialTab = this.getTabFromURL(); // console.log('🔄 Setting initial tab:', initialTab, 'with currentApp:', this.currentApp); this.switchTab(initialTab); } // Set global reference for other components window.appTabbedManager = this; } // Monitor URL changes for app navigation setupURLMonitoring() { // Listen for popstate events (browser back/forward) window.addEventListener('popstate', () => { if (!this.isAppPage()) return; // Only monitor on app pages const newAppName = this.getAppFromURL(); // Only update if currentApp is already set and app actually changed if (this.currentApp && newAppName !== this.currentApp) { // console.log('🔄 URL changed, updating app from', this.currentApp, 'to', newAppName); this.updateApp(newAppName); } }); } // Watch for new tasks and switch to logs tab watchForTaskCreation() { // Auto-switch to Tasks tab when a fresh task appears for the current app. setInterval(async () => { if (this.currentApp && this.currentTab !== 'tasks') { // Skip if a recent uninstall asked us not to auto-switch. const until = this._suppressTaskAutoSwitch?.get(this.currentApp); if (until && Date.now() < until) return; if (until) this._suppressTaskAutoSwitch.delete(this.currentApp); try { await this.tasksManager.loadTasks(); const allTasks = this.tasksManager.tasks || []; // Only switch on RUNNING/QUEUED tasks created recently — completed // ones don't need watching, and would otherwise bounce the user // back to Tasks right after they've been switched away. const recentTasks = allTasks.filter(task => task.app === this.currentApp && (task.status === 'running' || task.status === 'queued' || task.status === 'pending') && new Date(task.createdAt) > new Date(Date.now() - 5000) ); if (recentTasks.length > 0) this.switchTab('tasks'); } catch (error) { console.error('Error watching for tasks:', error); } } }, 5000); } // Create backup (placeholder function) async createBackup(appName) { // Placeholder - will be implemented with actual backup logic // console.log(`Creating backup for ${appName}...`); } // Setup task event listeners for button state management setupTaskEventListeners() { window.addEventListener('taskCreated', (event) => { const { taskId, appName, action } = event.detail; const key = this.taskKey(appName, action); // console.log('📌 taskCreated: appName=%s, currentApp=%s, action=%s, key=%s', // appName, this.currentApp, action, key); if (this.runningTasks.has(key)) { const existing = this.runningTasks.get(key); // Same task firing twice — executeTask dispatches taskCreated // synchronously, and the SSE bus also dispatches it when the new task // file shows up. Both events carry the same taskId; treat as a no-op. if (existing && existing.taskId === taskId) return; if (window.notificationSystem) { // Match the per-task-type icon used everywhere else (install ✅, // backup 💾, etc.) so the user sees *what kind* of task is in // progress, not just a generic warning triangle. const typeIcon = (window.tasksManager && window.tasksManager.getTaskTypeIcon ? window.tasksManager.getTaskTypeIcon({ type: action }) : null)?.icon || ''; const customIcon = typeIcon ? `${typeIcon}` : null; window.notificationSystem.show( `Task Already Running
A ${action} task for ${appName} is already in progress.
Please wait for the current task to complete.`, 'warning', null, null, null, customIcon ); } return; } this.runningTasks.set(key, { taskId, appName, action }); // console.log('📌 taskCreated: stored in runningTasks, will disable=%s', appName === this.currentApp); if (appName === this.currentApp) { this.disableAppButtons(appName, action); this.activeTaskId = taskId; } }); window.addEventListener('taskCompleted', (event) => { const { taskId, appName, action } = event.detail; const key = this.taskKey(appName, action); // Primary path: delete by exact (app, action) key. this.runningTasks.delete(key); // Belt-and-braces: also remove any entry that matches the taskId. If // `action` ever differs between the original `taskCreated` and this // `taskCompleted` event (different code paths produce slightly // different action strings), the key-based delete above is a no-op // and the row would stay "running" forever. Match on id too. for (const [k, info] of this.runningTasks) { if (info && info.taskId === taskId) this.runningTasks.delete(k); } const stillRunning = this.getRunningTaskForApp(appName); if (stillRunning) { if (appName === this.currentApp) this.activeTaskId = stillRunning.taskId; return; } // Always run the DOM cleanup; enableAppButtons is idempotent. this.enableAppButtons(appName); if (appName === this.currentApp) this.activeTaskId = null; if ((action === 'backup' || action === 'delete' || action === 'delete_all') && this.backupAppCard) { this.backupAppCard.render(); } }); // Extra safety net: any `taskUpdated` whose status is terminal should // also clear our local tracking. The bus normally dispatches a // dedicated `taskCompleted` instead — but if a single task file write // jumps a status straight from queued/pending to completed in a way // that the bus classifies as "updated, !isNew", we'd miss it otherwise. window.addEventListener('taskUpdated', (event) => { const t = event.detail && event.detail.task; if (!t || !t.id) return; const terminal = t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled'; if (!terminal) return; let removed = false; for (const [k, info] of this.runningTasks) { if (info && info.taskId === t.id) { this.runningTasks.delete(k); removed = true; } } if (!removed) return; const appName = t.app || null; if (appName && !this.getRunningTaskForApp(appName)) { this.enableAppButtons(appName); if (appName === this.currentApp) this.activeTaskId = null; } }); } // Helper method to get action for a task (used for duplicate detection) getActionForTask(taskId) { // Prefer our own runningTasks map — it knows the action by source of truth. for (const info of this.runningTasks.values()) { if (info.taskId === taskId) return info.action; } if (this.tasksManager && this.tasksManager.tasks) { const task = this.tasksManager.tasks.find(t => t.id === taskId); return task ? task.type : 'unknown'; } return 'unknown'; } // Disable config, services and backup tabs when task is running disableTabs() { const tabs = ['config', 'services', 'tools', 'backups'] .map(name => document.querySelector(`.main-tab-button[data-tab="${name}"], .tab-button[data-tab="${name}"]`)) .filter(Boolean); for (const tab of tabs) { tab.disabled = true; tab.classList.add('disabled', 'task-running'); tab.style.opacity = '0.5'; tab.style.pointerEvents = 'none'; tab.title = 'Disabled due to task running'; } } // Enable config, services and backup tabs when task completes enableTabs() { const tabs = ['config', 'services', 'tools', 'backups'] .map(name => document.querySelector(`.main-tab-button[data-tab="${name}"], .tab-button[data-tab="${name}"]`)) .filter(Boolean); for (const tab of tabs) { tab.disabled = false; tab.classList.remove('disabled', 'task-running'); tab.style.opacity = ''; tab.style.pointerEvents = ''; tab.title = ''; } } // Disable app buttons during task execution disableAppButtons(appName, action) { // console.log('🚫 disableAppButtons called: appName=%s, action=%s, currentApp=%s', // appName, action, this.currentApp); // Also disable config and backup tabs this.disableTabs(); // Find ALL action buttons in the app content section (config, backup, etc.) // This includes install, uninstall, update, backup, and any other action buttons const appContent = document.querySelector('.app-content, .config-section, .backup-section, .tab-content'); if (!appContent) { console.warn('⚠️ App content section not found'); return; } // Disable ALL buttons in the app content section const allButtons = appContent.querySelectorAll('button:not([disabled]):not(.tab-button)'); // console.log('🚫 disableAppButtons found %d buttons to disable', allButtons.length); allButtons.forEach(button => { // Skip tab buttons (config, backup, tasks tabs) if (button.classList.contains('tab-button')) { return; } // Details + log-stream toggles stay clickable while a task runs — // viewing service details and tailing logs is read-only and the // whole point during a long task. if (button.dataset.action === 'toggle-details' || button.dataset.action === 'toggle-log-stream' || button.classList.contains('service-details') || button.classList.contains('service-show-logs') || button.classList.contains('toggle-details')) { return; } // Hide download buttons permanently as they're not needed if (button.textContent && button.textContent.toLowerCase().includes('download')) { button.style.display = 'none'; return; } button.disabled = true; button.classList.add('disabled', 'task-running'); // Add loading spinner to buttons that don't already have one if (!button.querySelector('.spinner')) { const originalContent = button.innerHTML; button.dataset.originalContent = originalContent; // Replace entire content with spinner + text (no icons) // Extract text content from original HTML (remove icons/SVGs) const tempDiv = document.createElement('div'); tempDiv.innerHTML = originalContent; const textContent = tempDiv.textContent || tempDiv.innerText || originalContent; // console.log('🔃 Adding spinner to button:', button.textContent.trim(), 'for app:', appName); button.innerHTML = ` ${textContent.trim()} `; } }); // Track which buttons were disabled allButtons.forEach(button => { this.disabledButtons.add(button); }); // console.log(`🔍 Disabled ${allButtons.length} buttons for ${appName} during ${action}`); } // Restore button state when switching tabs. Only disable if the *current* app // has a running task — without this check, switching tabs on app B while a task // is running for app A would disable app B's buttons and tabs. restoreButtonState() { const running = this.getRunningTaskForApp(this.currentApp); if (running) { this.activeTaskId = running.taskId; this.disableAppButtons(this.currentApp, running.action); } else { this.enableTabs(); this.enableAppButtons(this.currentApp); } } // SSE safety-net. The bus normally delivers terminal transitions in // milliseconds; this re-syncs from the API if the bus has been disconnected. async reconcileRunningTasks() { if (this.runningTasks.size === 0) { // Nothing tracked but the DOM still says disabled — that's a stale // leftover from an earlier run whose `taskCompleted` we missed. Just // force-enable so the user isn't stuck. const configTab = document.querySelector('.main-tab-button[data-tab="config"], .tab-button[data-tab="config"]'); if (configTab && (configTab.classList.contains('task-running') || configTab.disabled)) { this.enableTabs(); if (this.currentApp) this.enableAppButtons(this.currentApp); } return; } for (const info of Array.from(this.runningTasks.values())) { let task = null; if (window.taskEventBus) task = window.taskEventBus.getTask(info.taskId); if (!task) { try { const res = await fetch(`/api/tasks/${info.taskId}`); if (res.ok) task = await res.json(); else if (res.status === 404) { // Task file gone — fire one synthetic completion + stop polling // so we don't loop forever on a deleted task. this.runningTasks.delete(info.taskId); window.dispatchEvent(new CustomEvent('taskCompleted', { detail: { taskId: info.taskId, appName: info.appName, action: info.action, status: 'completed', task: null, timestamp: Date.now() } })); continue; } } catch { continue; } } if (!task) continue; if (task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled') { window.dispatchEvent(new CustomEvent('taskCompleted', { detail: { taskId: task.id, appName: info.appName, action: info.action, status: task.status, task, timestamp: Date.now() } })); } } } restoreButton(button) { if (!button) return; button.disabled = false; button.classList.remove('disabled', 'task-running'); if (button.dataset.originalContent) { button.innerHTML = button.dataset.originalContent; delete button.dataset.originalContent; } button.querySelectorAll('.spinner').forEach(s => s.remove()); } enableAppButtons(appName) { this.enableTabs(); this.disabledButtons.forEach(button => this.restoreButton(button)); this.disabledButtons.clear(); document.querySelectorAll('button.task-running, .tab-button.task-running, .main-tab-button.task-running') .forEach(button => this.restoreButton(button)); const appContent = document.querySelector('.app-content, .config-section, .backup-section, .tab-content'); if (appContent) { appContent.querySelectorAll('button.disabled, button[disabled]:not(.tab-button)') .forEach(button => this.restoreButton(button)); } } } // Export for use window.AppTabbedManager = AppTabbedManager; // Initialize when DOM is loaded document.addEventListener('DOMContentLoaded', async () => { // console.log('🔍 DOMContentLoaded: Skipping automatic initialization - SPA will handle it'); // Don't initialize here - let SPA handle it }); // Also initialize when scripts are loaded (for SPA navigation) window.addEventListener('load', async () => { // console.log('🔍 Window load: Skipping automatic initialization - SPA will handle it'); // Don't initialize here - let SPA handle it });