Convert the remaining sections off the legacy ?= query form to clean paths, matching the Admin area: /apps/<category> (was /apps?=<category>) /app/<name>?tab=&task= (was /app?=<name>&tab=&task=) /tasks/<category>?task= (was /tasks?=<category>&task=) /backup/<tab> (was /backup?=<tab>) Builders updated everywhere (sidebar, dashboard, notifications, tasks, apps, app tabs, task-actions, setup watcher); parsers now read the resource from the path with the legacy ?= kept as a fallback so old links/bookmarks still work (server already serves index.html at any depth). Route table gains /apps* and orders it before /app* (since '/apps' startsWith '/app'); active-nav and config/apps data-loading recognise the new paths. Tab/task remain ordinary query params (modifiers, not the primary resource). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
1085 lines
42 KiB
JavaScript
Executable File
1085 lines
42 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 tab from URL parameter
|
|
getTabFromURL() {
|
|
const currentUrl = window.location.href;
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const tab = urlParams.get('tab') || 'config';
|
|
// console.log('🔍 getTabFromURL debug:', {
|
|
//currentUrl: currentUrl,
|
|
//search: window.location.search,
|
|
//tabParam: urlParams.get('tab'),
|
|
//defaultTab: 'config',
|
|
//finalTab: tab === 'logs' ? 'tasks' : tab
|
|
//});
|
|
// Convert "logs" to "tasks" for backward compatibility
|
|
return tab === 'logs' ? 'tasks' : tab;
|
|
}
|
|
|
|
// 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/<app>?tab=<tab>&task=<task>.
|
|
updateURL(app = null, tab = null) {
|
|
// Only update URLs on app pages - prevent interference with other pages.
|
|
if (!this.isAppPage()) return;
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
const fromPath = window.location.pathname.replace(/^\/app\/?/, '').split('/')[0];
|
|
const currentApp = app || (fromPath ? decodeURIComponent(fromPath) : '') || this.currentApp;
|
|
if (!currentApp) return;
|
|
|
|
const q = new URLSearchParams();
|
|
const finalTab = tab || params.get('tab');
|
|
if (finalTab) q.set('tab', finalTab);
|
|
// Keep a deep-linked task only when staying on the same app (tab-only update).
|
|
if (!app) {
|
|
const task = params.get('task');
|
|
if (task) q.set('task', task);
|
|
}
|
|
|
|
const qs = q.toString();
|
|
window.history.replaceState({}, '', `/app/${encodeURIComponent(currentApp)}${qs ? '?' + qs : ''}`);
|
|
}
|
|
|
|
// Update current app and refresh content
|
|
updateApp(newAppName) {
|
|
this.setCurrentApp(newAppName);
|
|
// Reset to the config tab on the path-based app URL.
|
|
history.replaceState({}, '', `/app/${encodeURIComponent(newAppName)}?tab=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 || '';
|
|
|
|
// Construct proper URL with correct parameter order
|
|
const newUrl = `/app/${currentApp}?tab=tasks&task=${taskId}`;
|
|
// console.log('🔍 Updating URL with task parameter:', newUrl);
|
|
history.pushState({}, '', newUrl);
|
|
}
|
|
} 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 — `createAndExecuteTask` 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
|
|
});
|