librelad e57d42ddf6 refactor(webui): path-based URLs for app tabs + config sub-tabs
The app-detail page was the last corner of the SPA still using query
parameters for navigation state. Two related complaints surfaced it:

  - `/app/adguard?tab=tasks` should mirror admin (`/admin/tools/peers`,
    `/admin/config/network`) and be `/app/adguard/tasks`.
  - The config sub-tab (general / advanced / features / network / …)
    had no URL representation at all — `showTab` was a pure visual
    swap with no history push, so refreshing a deep config sub-tab
    sent the user back to the default first category.

New URL shape:

  /app/<name>                          → config tab, default sub-tab
  /app/<name>/<tab>                    → non-config main tab (tasks, backups, …)
  /app/<name>/config/<category>        → config tab + specific sub-tab
  …?task=<id>                          → optional deep-link to a single task

Mirrors `adminPath` / `adminCategoryFromPath`. Two new helpers in
spa.js carry the convention:

  window.appPath(name, tab, sub, taskId) → URL
  window.appPartsFromPath(pathname)      → { app, tab, sub }

Every URL constructor in the WebUI was replaced with `window.appPath`:

  spa.js                               — handleAppDetail back-compat redirect
  app-tabbed-manager.js                — getTabFromURL + new getConfigSubFromURL
                                          (path first, ?tab= fallback for legacy)
                                          updateURL + updateApp use appPath
                                          the inline task-deep-link constructor
  apps-manager.js                      — showAppDetail + showAppDetailWithConfig
                                          showTab now pushes /app/<n>/config/<sub>
                                          renderAppDetail picks the sub-tab out of
                                          the URL on first load
                                          4 fallback task-URL constructors
  tasks-manager.js                     — completion-notification URL
  task-actions.js                      — start-notification URL
  notifications.js                     — 2 task deep-link URLs

Back-compat: handleAppDetail detects legacy `?tab=` / `?config=` /
`?task=` queries and replaceState()s the URL to the canonical path
shape BEFORE anything else reads URL state — old bookmarks land on
the right page and end up with a clean URL.

Verified by running every appPath / appPartsFromPath case (including
the `logs` → `tasks` legacy alias) and confirming the round-trip is
identity. JS syntax checks clean on all six files. No remaining
hardcoded `/app/<x>?tab=` strings outside the back-compat comment.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-27 01:40:05 +01:00

1096 lines
43 KiB
JavaScript
Executable File

// 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/<name> 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/<name>/<tab> (where <tab> defaults to "config" when absent)
// and falls back to the legacy `?tab=<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=<category>` 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/<name> — config tab, no sub
// /app/<name>/<tab> — non-config main tab
// /app/<name>/config/<sub> — config tab + sub-category
// …?task=<id> — 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 = `
<div class="loading-content" style="
text-align: center;
padding: 40px 20px;
color: #ffffff;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 200px;
background: rgba(0, 0, 0, 0.2);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
margin: 16px;
">
<div class="loading-spinner" style="
width: 24px;
height: 24px;
border: 3px solid rgba(52, 152, 219, 0.3);
border-top: 3px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
"></div>
<p>Loading tasks...</p>
</div>
`;
}
if (!this.currentApp) {
if (tasksContainer) {
tasksContainer.innerHTML = '<p style="color: #888;">No app selected.</p>';
}
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 = `<p style="color: #888;">No tasks found for ${this.currentApp}.</p>`;
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 = `<p style="color: #dc3545;">Error loading tasks: ${error.message}</p>`;
}
}
// 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
this.setupURLMonitoring();
// Listen for task creation events
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 ? `<span style="font-size:18px;line-height:1;">${typeIcon}</span>` : null;
window.notificationSystem.show(
`<strong>Task Already Running</strong><br>
<small>A ${action} task for ${appName} is already in progress.</small><br>
<small>Please wait for the current task to complete.</small>`,
'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;
}
// Logs toggle buttons stay clickable while a task runs — viewing
// log output is read-only and the whole point during a long task.
if (button.dataset.action === 'toggle-logs' ||
button.classList.contains('service-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 = `
<span class="spinner" style="
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 0px;
vertical-align: middle;
"></span>
<span style="vertical-align: middle;">${textContent.trim()}</span>
`;
}
});
// 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
});