// Single source of truth for "given an apps-services.json entry, what // clickable links should we render for it?" Returns an array of // { url, label } pairs. // // New schema: each service entry carries a `links: [{label, externalURL, // internalURL}, ...]` array (built from the comma-separated label/path // pairs in CFG__PORT_N cols 9 and 10). // // Legacy fallback: if `links` is missing, build a single entry from // externalURL (or serverIP+externalPort) + buttonText, matching the // original single-button behaviour for snapshots that pre-date the // multi-button generator. // // Used by every UI surface that renders Open buttons: // - dashboard.js (dashboard app-card hover overlay) // - apps-manager.js (apps list popup + app-header buttons) // - services-manager.js (Services tab Open buttons) window.expandServiceLinks = function(s) { const proto = ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http'; if (Array.isArray(s.links) && s.links.length > 0) { return s.links .map(l => ({ url: l.externalURL || l.internalURL, label: l.label || s.buttonText || s.name })) .filter(l => !!l.url); } const fallbackUrl = s.externalURL || (s.serverIP ? `${proto}://${s.serverIP}:${s.externalPort}` : `${proto}://localhost:${s.externalPort}`); return fallbackUrl ? [{ url: fallbackUrl, label: s.buttonText || s.name }] : []; }; // Apps Manager - Manages the apps list page and app details class AppsManager { constructor() { this.cache = new Map(); this.setupTaskCompletionListener(); } setupTaskCompletionListener() { // App-data refresh is registered with the task-refresh coordinator — the // single source of truth for "task finished → reload this". Two entries: // - lifecycle: install/uninstall/tool/config_update carry their own UX // (welcome modal, post-uninstall task cleanup + tab bounce), so they run // the full handler with no debounce (every task must be handled). // - state: restore/update/rebuild just need the app + service data // repainted so version/config/restored state shows. if (!window.taskRefresh) return; window.taskRefresh.register({ id: 'apps-lifecycle', run: (d) => this._onAppTaskCompleted(d), }); window.taskRefresh.register({ id: 'apps-state', match: (d) => d.status === 'completed' && (['restore', 'update', 'rebuild'].includes(d.action) || /^libreportal\s+(app\s+(update|rebuild)|restore\s+app)\b/.test((d.task && d.task.command) || '')), run: () => this.refreshAppsAndView(), debounceMs: 400, }); } // Full handler for app lifecycle tasks. Carries UX side-effects (first-install // welcome modal, post-uninstall task cleanup + tab bounce) alongside the data // reload. `detail` is the taskCompleted event detail; self-gates by action so // it's safe to run on every completed task. async _onAppTaskCompleted(detail) { const { action, appName, status } = detail; // Tool tasks mutate per-app config — refresh cache silently for next read. if (action === 'tool' && status === 'completed') { this.clearCache(); await this.reloadAppsData(); // If the user is viewing this app's detail page, re-render the // config section in place so updated CFG_* values (e.g. a freshly // reset password) show without needing a page refresh. Don't // switch tabs — they may be reading the tool's task log. const url = new URL(window.location.href); const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || url.searchParams.get('app') || url.searchParams.get(''); const onAppDetail = window.location.pathname === '/app' || window.location.pathname.startsWith('/app/'); if (onAppDetail && appName && currentAppFromUrl === appName) { this.displayConfigForm?.((window.apps || []).find(a => (a.command || '').endsWith(' ' + appName) )); } return; } // Config apply re-deploys apps (ports/subdomains/URLs/routing). Reload // app + service data and repaint so the UI reflects the new config // instead of showing stale URLs/routing until a manual refresh. if (action === 'config_update' && status === 'completed') { await this.refreshAppsAndView(); return; } // First-install welcome modal — only on the very first successful install per app per browser. if (action === 'install' && status === 'completed' && appName) { const key = `libreportal.welcomeShown.${String(appName).toLowerCase()}`; try { if (!localStorage.getItem(key)) { setTimeout(() => this.showInstallWelcome(appName), 600); } } catch (_) {} } // Only reload on successful install or uninstall if ((action === 'install' || action === 'uninstall') && status === 'completed') { // Skip duplicate events for the same task id — the reconcile // loop can synthesise a 404-fallback completed event after we've // already handled the real one, which would re-trigger the // heavy re-render + tab switch and visually flash the page. const _taskId = detail.taskId || detail.id; this._handledTaskIds = this._handledTaskIds || new Set(); if (_taskId && this._handledTaskIds.has(_taskId)) return; if (_taskId) this._handledTaskIds.add(_taskId); try { // If this uninstall asked to delete its own task, do it now — // the bash side skipped the in-flight task on purpose to // avoid racing with the processor's final status write. const taskId = detail.taskId || detail.id; if (action === 'uninstall' && taskId && this._pendingTaskCleanup?.has(taskId)) { this._pendingTaskCleanup.delete(taskId); try { if (window.tasksManager?.taskManager?.deleteTask) { await window.tasksManager.taskManager.deleteTask(taskId, { force: true }); } // Re-render Tasks tab so the empty state ("No tasks found for X") shows. if (window.tasksManager?.loadTasks) await window.tasksManager.loadTasks(); if (window.tasksManager?.renderTasks) window.tasksManager.renderTasks(); // If the user is parked on the Tasks tab and now there's // nothing to look at, bounce them to Config. if (window.appTabbedManager?.currentTab === 'tasks') { window.appTabbedManager.switchTab('config'); } } catch (e) { console.error('post-uninstall task cleanup failed:', e); } } this.clearCache(); await this.reloadAppsData(); if (window.serviceButtons) { try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); } } const currentUrl = new URL(window.location.href); const currentAppFromUrl = decodeURIComponent((window.location.pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || currentUrl.searchParams.get('app') || currentUrl.searchParams.get(''); const pathname = window.location.pathname; const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/'); const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/'); if (isAppsPage && !isAppDetailPage) { const category = window.appsCategory || 'all'; this.renderApps(category); } else if (isAppDetailPage && currentAppFromUrl === appName) { // Defer + isolate the heavy re-render so a throw inside // displayConfigForm / port-manager init can't lock up the // post-task UI cleanup. Fires on the next tick — gives the // task spinners + button enables a chance to repaint first. setTimeout(() => { // _skipReload flag tells renderAppDetail not to re-fetch // apps.json again (we already just did, line above). this.renderAppDetail(appName, null, true, { skipReload: true }) .catch(err => console.error('renderAppDetail failed:', err)); }, 0); } // After uninstall, bounce off the Tasks tab — there's nothing // to watch any more. Mark the app as "recently uninstalled" // so the 5s watchForTaskCreation poll doesn't bounce back. if (action === 'uninstall' && isAppDetailPage && currentAppFromUrl === appName) { window.appTabbedManager = window.appTabbedManager || null; if (window.appTabbedManager) { window.appTabbedManager._suppressTaskAutoSwitch = window.appTabbedManager._suppressTaskAutoSwitch || new Map(); window.appTabbedManager._suppressTaskAutoSwitch.set(appName, Date.now() + 10_000); setTimeout(() => { if (window.appTabbedManager?.currentTab === 'tasks') { window.appTabbedManager.switchTab('config'); } }, 50); } } if (typeof window.renderInstalledApps === 'function') { window.renderInstalledApps(); } } catch (err) { console.error('Post-task handler failed for', action, appName, ':', err); } } } clearCache() { this.cache.clear(); console.log('🗑️ Apps cache cleared'); } async reloadAppsData() { try { // Reload global apps data const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); if (response.ok) { const appsData = await response.json(); window.apps = appsData.apps || []; // console.log(`✅ Reloaded ${window.apps.length} apps`); } } catch (error) { console.error('❌ Failed to reload apps data:', error); } } // Reload apps + service data and repaint whatever app surface is on screen // (grid or detail), with no manual page reload. Used after any task that // changes app config/state — config_update, restore, start/stop/restart, // update, rebuild — so the UI reflects the result. async refreshAppsAndView(appName) { this.clearCache(); await this.reloadAppsData(); if (window.serviceButtons) { try { await window.serviceButtons.loadServices(); } catch (e) { console.error('loadServices failed:', e); } } const url = new URL(window.location.href); const pathname = window.location.pathname; const currentApp = decodeURIComponent((pathname.match(/^\/app\/([^/?]+)/) || [])[1] || '') || url.searchParams.get('app') || url.searchParams.get('') || ''; const isAppsPage = pathname === '/apps' || pathname.startsWith('/apps/'); const isAppDetailPage = pathname === '/app' || pathname.startsWith('/app/'); if (isAppsPage && !isAppDetailPage) { this.renderApps(window.appsCategory || 'all'); } else if (isAppDetailPage && currentApp && (!appName || currentApp === appName)) { setTimeout(() => { this.renderAppDetail(currentApp, null, true, { skipReload: true }) .catch(err => console.error('renderAppDetail failed:', err)); }, 0); } if (typeof window.renderInstalledApps === 'function') window.renderInstalledApps(); } async loadApps(category = 'all') { // Check cache first if (this.cache.has(category)) { return this.cache.get(category); } try { // Load apps data directly const response = await fetch('/data/apps/generated/apps.json', { cache: 'no-store' }); if (!response.ok) { throw new Error(`Failed to load apps.json: ${response.status}`); } const appsData = await response.json(); // Filter apps by category let filteredApps = appsData.apps || []; if (category === 'installed') { filteredApps = filteredApps.filter(app => app.installed); } else if (category !== 'all') { // Apps may live in multiple categories (e.g. "Security,Recommended" // in their .config). apps.json emits BOTH `categories[]` and a // singular `category` for back-compat; prefer the array. filteredApps = filteredApps.filter(app => { if (Array.isArray(app.categories)) return app.categories.includes(category); return app.category === category; }); } // Sort installed apps first filteredApps.sort((a, b) => { if (a.installed && !b.installed) return -1; if (!a.installed && b.installed) return 1; return 0; }); // Cache the result this.cache.set(category, filteredApps); return filteredApps; } catch (error) { console.error(`AppsManager: Error loading ${category} apps:`, error); return []; } } async loadCategories() { try { const response = await fetch('/data/apps/apps-categories.json'); if (!response.ok) { throw new Error(`Failed to load apps-categories.json: ${response.status}`); } const categoriesData = await response.json(); return categoriesData.categories || []; } catch (error) { console.error('AppsManager: Error loading categories:', error); return []; } } async initialize() { // Don't load data here - SPA handles it // Just setup page based on URL const path = window.location.pathname; const searchParams = new URLSearchParams(window.location.search); if (path === '/app' || path.startsWith('/app/') || searchParams.has('app')) { const appName = decodeURIComponent((path.match(/^\/app\/([^/?]+)/) || [])[1] || '') || searchParams.get('app') || window.appName || ''; this.showAppDetail(appName); } else { // Use the category parsed by SPA let category = window.appsCategory || 'all'; this.showAppsList(category); } } showAppsList(category) { this.currentView = 'apps'; this.currentApp = null; // Update URL only for specific categories, not for 'all' if (category && category !== 'all') { history.pushState({}, '', `/apps/${category}`); } // For 'all' category, keep URL as /apps to avoid redirect loops // Switch to apps view this.showView('apps'); // Render apps this.renderApps(category); // Setup sidebar this.setupSidebar(category); } showAppDetail(appName, forceConfigTab = false) { // console.log('🔍 showAppDetail called with:', { appName, forceConfigTab }); //// // console.log(`AppsManager: Showing app detail: ${appName}`); // Don't proceed if appName is empty - redirect to apps list instead if (!appName || appName.trim() === '') { //// // console.log('AppsManager: Empty app name, redirecting to apps list'); this.showAppsList('all'); return; } // Check if app has changed - only re-render header if app changed const appChanged = this.currentApp !== appName; // Set current view first this.currentView = 'app-detail'; this.currentApp = appName; // Update URL to reflect current state let targetTab; if (forceConfigTab) { // Force config tab for install/manage buttons targetTab = 'config'; // console.log('🔍 Forcing config tab due to forceConfigTab=true'); } else { // Preserve existing tab or default to config for direct navigation const currentUrl = new URL(window.location.href); targetTab = currentUrl.searchParams.get('tab') || 'config'; // console.log('🔍 Preserving existing tab:', targetTab); } const newUrl = window.appPath(appName, targetTab); history.pushState({}, '', newUrl); // Update app-tabbed-manager BEFORE rendering the DOM. If renderAppDetail or // any code it triggers calls switchTab → loadTabContent → restoreButtonState, // we need this.currentApp to already be updated so restoreButtonState checks // the right app for running tasks. if (window.appTabbedManager) { if (typeof window.appTabbedManager.setCurrentApp === 'function') { window.appTabbedManager.setCurrentApp(appName); } else { window.appTabbedManager.currentApp = appName; } } // Switch to app detail view this.showView('app-detail'); // Render app detail (async) - only re-render header if app changed this.renderAppDetail(appName, null, appChanged); // Find app category and setup sidebar const app = window.apps?.find(a => a.name === appName || a.id === appName || a.slug === appName ); if (app && app.category) { this.setupSidebar(app.category); } else { this.setupSidebar('all'); } const appCategory = app ? app.category : 'all'; //// // console.log(`🎯 App category: "${appCategory}"`); this.setupSidebar(appCategory); } // Show app detail with config tab (for install/manage buttons) showAppDetailWithConfig(appName) { // console.log('🔍 showAppDetailWithConfig called with:', appName); // console.log('🔍 Forcing config tab for button click'); // Check if there's a running task for this app — switch straight to the tasks // tab if so, instead of landing on config (whose buttons would be disabled). let targetTab = 'config'; let runningTaskId = null; if (window.appTabbedManager && typeof window.appTabbedManager.getRunningTaskForApp === 'function') { const running = window.appTabbedManager.getRunningTaskForApp(appName); if (running) { targetTab = 'tasks'; runningTaskId = running.taskId; } } // Set URL to target tab (config or tasks) const newUrl = window.appPath(appName, targetTab); history.pushState({}, '', newUrl); // Update app-tabbed-manager. setCurrentApp clears stale disable state from // whichever app the user came from before showing the new app's content. if (window.appTabbedManager) { if (typeof window.appTabbedManager.setCurrentApp === 'function') { window.appTabbedManager.setCurrentApp(appName); } else { window.appTabbedManager.currentApp = appName; } // Simulate clicking target tab functionally // console.log('🔄 Simulating target tab click:', targetTab); setTimeout(() => { window.appTabbedManager.switchTab(targetTab); // Highlight the running task if switching to tasks tab if (targetTab === 'tasks' && runningTaskId && window.appTabbedManager.tasksManager) { window.appTabbedManager.tasksManager.highlightedTaskId = runningTaskId; window.appTabbedManager.tasksManager.renderTasks(); } }, 100); // Small delay to ensure app is loaded } // Continue with normal app detail loading this.showAppDetail(appName, true); } showView(viewType) { // Get both view containers const appsView = document.getElementById('apps-view'); const appDetailView = document.getElementById('app-detail-view'); if (viewType === 'apps') { // Show apps view, hide app detail view if (appsView) appsView.style.display = 'block'; if (appDetailView) appDetailView.style.display = 'none'; } else if (viewType === 'app-detail') { // Show app detail view, hide apps view if (appsView) appsView.style.display = 'none'; if (appDetailView) appDetailView.style.display = 'block'; } } setupSidebar(activeCategory = 'all') { const sidebar = document.getElementById('sidebar'); if (!sidebar) return; // Clear sidebar const container = document.getElementById('dynamic-categories'); if (!container) return; container.innerHTML = ''; // Hide loading const loading = document.querySelector('.loading-categories'); if (loading) loading.style.display = 'none'; // Add categories this.addCategory('All', 'all'); this.addCategory('Installed', 'installed'); // Add dynamic categories if (window.sidebarCategories) { const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : Object.entries(window.sidebarCategories).map(([key, value]) => ({ id: key, ...value })); categoriesArray.forEach(cat => { this.addCategory(cat.name, cat.id, cat.icon); }); } // Add back button for app detail if (this.currentView === 'app-detail') { this.addBackButton(); } // Set active category this.setActiveCategory(activeCategory); } addCategory(name, id, icon) { const container = document.getElementById('dynamic-categories'); if (!container) return; const div = document.createElement('div'); div.className = 'category'; div.setAttribute('data-category', id); let iconHtml; if (!icon && id === 'all') { iconHtml = ''; } else if (!icon && id === 'installed') { iconHtml = ''; } else { let iconPath = icon || `/core/icons/categories/${id}.svg`; if (!iconPath.startsWith('/')) iconPath = '/' + iconPath; iconHtml = `${name}`; } div.innerHTML = `${iconHtml} ${name}`; div.addEventListener('click', (e) => { e.preventDefault(); this.switchCategory(id); }); container.appendChild(div); } addBackButton() { const container = document.getElementById('dynamic-categories'); if (!container) return; const div = document.createElement('div'); div.className = 'category'; div.innerHTML = '← Back to Apps'; div.addEventListener('click', () => { this.showAppsList('all'); }); container.appendChild(div); } setActiveCategory(categoryId) { document.querySelectorAll('.category').forEach(cat => { cat.classList.remove('active'); }); const active = document.querySelector(`[data-category="${categoryId}"]`); if (active) active.classList.add('active'); } switchCategory(categoryId) { //// // console.log(`AppsManager: Switching to category: ${categoryId}`); // Update URL to reflect current state if (categoryId === 'all') { history.pushState({}, '', '/apps'); } else { history.pushState({}, '', `/apps/${categoryId}`); } // Direct view update without URL change to avoid conflicts this.currentView = 'apps'; this.currentApp = null; // Switch to apps view this.showView('apps'); // Render apps for new category this.renderApps(categoryId); // Update sidebar for new category this.setupSidebar(categoryId); } renderApps(category) { const container = document.getElementById('apps-section'); if (!container) return; container.innerHTML = ''; // Load and render apps this.loadApps(category).then(apps => { apps.forEach(app => { const card = this.createAppCard(app); container.appendChild(card); }); this.populateInlineServiceButtons(); // Re-apply any active sidebar search so changing category // doesn't reveal apps that should be filtered out. if (this.appsSearchQuery) this.filterAppsByQuery(this.appsSearchQuery); }).catch(error => { console.error('Error rendering apps:', error); }); } // Client-side substring filter wired to the sidebar search box. // Cards carry data-search (built in createAppCard) so this stays // a single querySelectorAll + display toggle. filterAppsByQuery(query) { const q = (query || '').trim().toLowerCase(); this.appsSearchQuery = q; const wrap = document.querySelector('.apps-search'); if (wrap) wrap.classList.toggle('has-value', !!q); const cards = document.querySelectorAll('#apps-section .app-card'); cards.forEach(card => { const hay = card.dataset.search || ''; card.style.display = (!q || hay.includes(q)) ? '' : 'none'; }); } clearAppsSearch() { const input = document.getElementById('apps-search-input'); if (input) input.value = ''; this.filterAppsByQuery(''); if (input) input.focus(); } async renderAppDetail(appName, preferredCategory = null, appChanged = true, opts = {}) { //// // console.log(`🎯 renderAppDetail called with: "${appName}", appChanged: ${appChanged}`); //// // console.log(`🎯 Available apps:`, window.apps?.map(a => ({ name: a.name, command: a.command }))); // Use global preferred category if not provided if (!preferredCategory && window.preferredConfigCategory) { preferredCategory = window.preferredConfigCategory; } // …or pick it out of the path (/app//config/) so a refresh / // deep-link lands on the sub-tab encoded in the URL. if (!preferredCategory && window.appPartsFromPath) { const parts = window.appPartsFromPath(window.location.pathname); if (parts.tab === 'config' && parts.sub) preferredCategory = parts.sub; } const container = document.getElementById('app-detail-view'); if (!container) return; // Don't clear the entire container - update specific sections // This preserves the original console-section styling // Get current installed status from DOM before reloading const serviceButtonsContainer = document.getElementById('service-buttons-container'); const wasInstalled = serviceButtonsContainer !== null; // Reload fresh app data if app has changed (ensures installed status is current after uninstall). // Caller can pass skipReload when they've just refreshed (post-task listener). if (appChanged && !opts.skipReload) { await this.reloadAppsData(); } // Find app data const app = window.apps?.find(a => a.name === appName || a.command === `libreportal app install ${appName}` || a.command.endsWith(` ${appName}`) ); //// // console.log(`🎯 Found app in renderAppDetail:`, app); if (!app) { container.innerHTML = '
App not found
'; return; } // Check if installed status changed (e.g., after uninstall) and force header re-render const isNowInstalled = app.installed; const installedStatusChanged = wasInstalled !== isNowInstalled; const shouldRenderHeader = appChanged || installedStatusChanged; //// // console.log(`🔍 Installed status: was=${wasInstalled}, now=${isNowInstalled}, changed=${installedStatusChanged}`); // Get app details - extract only the app name (before any dash) const shortName = app.name.split(' - ')[0].trim(); // Only take text before the first dash const cleanAppName = app.command.split(' ').pop(); document.title = `${shortName} - LibrePortal`; // Hide the Backups + Services tabs for not-yet-installed apps — there's // nothing to back up and no docker compose services running yet. const backupTab = document.querySelector('.main-tab-button[data-tab="backups"], .tab-button[data-tab="backups"]'); const backupPane = document.getElementById('backups-tab'); if (backupTab) backupTab.style.display = isNowInstalled ? '' : 'none'; if (backupPane && !isNowInstalled) backupPane.classList.remove('active'); const servicesTab = document.querySelector('.main-tab-button[data-tab="services"], .tab-button[data-tab="services"]'); const servicesPane = document.getElementById('services-tab'); if (servicesTab) servicesTab.style.display = isNowInstalled ? '' : 'none'; if (servicesPane && !isNowInstalled) servicesPane.classList.remove('active'); // Tools tab: hide for not-installed AND for installed-but-has-no-tools // apps. Without the second check, clicking the visible tab triggers // app-tabbed-manager's "no tools → bounce to config" path which // looks like a broken redirect. const toolsTab = document.querySelector('.main-tab-button[data-tab="tools"], .tab-button[data-tab="tools"]'); const toolsPane = document.getElementById('tools-tab'); const catalogEntry = window.toolsCatalog?.apps?.[cleanAppName]; const hasTools = Array.isArray(catalogEntry?.tools) && catalogEntry.tools.length > 0; const showToolsTab = isNowInstalled && hasTools; if (toolsTab) toolsTab.style.display = showToolsTab ? '' : 'none'; if (toolsPane && !showToolsTab) toolsPane.classList.remove('active'); const onHiddenTab = (!isNowInstalled && (window.appTabbedManager?.currentTab === 'backups' || window.appTabbedManager?.currentTab === 'services' || window.appTabbedManager?.currentTab === 'tools')) || (window.appTabbedManager?.currentTab === 'tools' && !showToolsTab); if (onHiddenTab) { window.appTabbedManager.switchTab('config'); } let icon = app.icon || '/core/icons/apps/default.svg'; // Ensure absolute path from root if (!icon.startsWith('/')) { icon = '/' + icon; } const status = app.installed ? 'Installed' : 'Not Installed'; const categoryName = this.getCategoryName(app.category); const categoryIcon = this.getCategoryIcon(app.category); // Create tags matching app center style const installedTag = app.installed ? `${status}` : `${status}`; const categoryTag = ` ${categoryName}`; // Render app header section (always define, but only update DOM if app changed) const headerHTML = `
${shortName}

${shortName}

${app.description || 'No description available'}

${app.longDescription ? `

${app.longDescription}

` : ''}
${categoryTag} ${installedTag}
${app.installed ? `
` : ''} `; // Render config section with working app-config-original.js approach // Use the working displayConfigForm from app-config-original.js await this.displayConfigForm(app, preferredCategory); const configHTML = document.getElementById('config-section')?.innerHTML || ''; // Initialize port managers after config form is rendered setTimeout(async () => { await this.initializePortManagers(); if (typeof ConfigOptions !== 'undefined') { ConfigOptions.refreshGluetunCredentialVisibility?.(); } this.wireShowWhenListeners(); this.wireConfigDirtyTracking(cleanAppName); // Only update service buttons if app has changed or installed status changed if (shouldRenderHeader) { this.updateServiceButtonsSidebar(app, cleanAppName); } }, 100); // Render console section with original styling const consoleHTML = `

Installation Console

Monitor the installation and configuration process

[${new Date().toLocaleTimeString()}] Ready to install ${app.name}
`; // Update specific sections instead of overwriting entire container // This preserves the original console-section styling // Only update app header if app has changed or installed status changed if (shouldRenderHeader) { const appHeader = document.getElementById('app-header'); //// // console.log('app-header element found:', !!appHeader); if (appHeader) { appHeader.innerHTML = headerHTML; // Explicitly remove service-buttons-container if app is not installed (prevents space) if (!app.installed) { const serviceButtonsContainer = document.getElementById('service-buttons-container'); if (serviceButtonsContainer) { serviceButtonsContainer.remove(); } } //// // console.log('App header updated successfully'); } } // Update config section const configSection = document.getElementById('config-section'); //// // console.log('config-section element found:', !!configSection); if (configSection) { configSection.innerHTML = configHTML; //// // console.log('Config section updated successfully, innerHTML length:', configHTML.length); } else { console.error('config-section element not found in DOM'); } // Update console section (preserve original structure) const consoleSection = container.querySelector('.console-section'); if (consoleSection) { // Update console output within the existing console-section const messageLog = consoleSection.querySelector('#message-log'); if (messageLog) { messageLog.innerHTML = `
[${new Date().toLocaleTimeString()}] Ready to install ${app.name}
`; } } // Initialize console to start at top this.initializeConsole(); // Load actual app configuration if available this.loadAppConfig(cleanAppName); } async loadAppConfig(appName) { //// // console.log(`AppsManager: Loading config for ${appName}...`); try { // Get app data from global apps array (like original app-config-original.js) const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName)); if (appData && appData.config) { //// // console.log(`AppsManager: Loaded config for ${appName}:`, appData.config); // Update form with actual configuration values from app.config this.updateConfigForm(appName, appData.config); } else { //// // console.log(`AppsManager: No config found for ${appName}, showing default configuration`); // Show default configuration when no config exists this.updateConfigForm(appName, { CFG_APP_NAME: appData?.name || appName, CFG_VERSION: appData?.version || '1.0.0', CFG_PORT: '8080', CFG_DOMAIN: '', CFG_USERNAME: 'admin', CFG_PASSWORD: '', CFG_DEBUG: 'false', CFG_LOG_LEVEL: 'INFO' }); } } catch (error) { //// // console.log(`AppsManager: Error loading config for ${appName}:`, error); // Get app data for defaults const appData = window.apps?.find(a => a.name === appName || a.command.includes(appName)); // Show default configuration on error this.updateConfigForm(appName, { CFG_APP_NAME: appData?.name || appName, CFG_VERSION: appData?.version || '1.0.0', CFG_PORT: '8080', CFG_DOMAIN: '', CFG_USERNAME: 'admin', CFG_PASSWORD: '', CFG_DEBUG: 'false', CFG_LOG_LEVEL: 'INFO' }); } } updateConfigForm(appName, appConfig) { const form = document.getElementById(`app-form-${appName}`); if (!form) return; const appData = window.apps?.find(a => a.name === appName || a.command?.includes(appName)); Object.entries(appConfig).forEach(([key, value]) => { const field = form.querySelector(`[name="${key}"]`); if (!field) return; let nextValue = value; if (key.endsWith('_NETWORK')) { nextValue = this.applyContextualDefault('NETWORK', value, appData); } if (field.type === 'checkbox') { field.checked = nextValue === 'true' || nextValue === 'yes'; } else { field.value = nextValue; } }); } // Display configuration form (working method from app-config-original.js) async displayConfigForm(appData, preferredCategory = null) { //// // console.log('displayConfigForm called with:', appData, 'preferredCategory:', preferredCategory); const configSection = document.getElementById('config-section'); if (!configSection) { return; } const cleanAppName = appData.command.split(' ').pop(); const requiresKey = Object.keys(appData.config || {}).find(k => k.endsWith('_REQUIRES_SERVICE')); const requiredService = requiresKey ? (appData.config[requiresKey] || '').trim() : ''; if (requiredService && !this.checkServiceInstalled(requiredService) && !appData.installed) { const slug = requiredService.toLowerCase(); const serviceLabel = slug.charAt(0).toUpperCase() + slug.slice(1); const iconUrl = `/core/icons/apps/${encodeURIComponent(slug)}.svg`; configSection.innerHTML = `

🛠️ Configuration Settings

Configure ${this.escHtml(appData.name)} to match your requirements

${this.escHtml(serviceLabel)} required
${this.escHtml(serviceLabel)} needs to be installed before you can configure ${this.escHtml(appData.name)}.
`; return; } //// // console.log('Setting config form HTML for:', appData.name); // Generate simple tabbed interface with preferred category //// // console.log('🎨 Generating config HTML with working approach...'); const tabsContent = await this.generateSimpleTabsAndContent(appData, preferredCategory); const configHTML = `

🛠️ Configuration Settings

Configure ${appData.name} to match your requirements

${tabsContent.tabsHTML}
${tabsContent.contentHTML}
${appData.installed && cleanAppName !== 'libreportal' ? ` ` : ''} ${appData.installed && cleanAppName === 'gluetun' ? ` ` : ''}
`; configSection.innerHTML = configHTML; // Initialize tab functionality this.initializeSimpleTabs(); // Enhance scrollbar dynamically this.enhanceTabsScrollbar(); //// // console.log('Config form HTML set successfully'); } // Generate simple tabs and content together (clean, reliable approach) async generateSimpleTabsAndContent(appData, preferredCategory = null) { //// // console.log('🏷️📄 generateSimpleTabsAndContent called'); const categories = await this.getConfigCategories(); const fieldMappings = await this.getFieldMappings(); const appConfig = appData.config || {}; //// // console.log(`🏷️ Config categories loaded:`, categories); //// // console.log(`🏷️ Field mappings loaded:`, fieldMappings); //// // console.log(`🏷️ App config:`, appConfig); //// // console.log(`🏷️ Preferred category:`, preferredCategory); //// // console.log('📂 Available categories:', Object.keys(categories)); //// // console.log('🗂️ Available field mappings:', Object.keys(fieldMappings)); //// // console.log('🔍 Looking for PORT_MANAGER in field mappings:', 'PORT_MANAGER' in fieldMappings); if ('PORT_MANAGER' in fieldMappings) { //// // console.log('🔍 PORT_MANAGER field config:', fieldMappings['PORT_MANAGER']); } let tabsHTML = ''; let contentHTML = ''; // Sort categories by order const sortedCategories = Object.entries(categories) .sort(([,a], [,b]) => a.order - b.order); // Render each category's fields up front and keep only the ones that // actually produced fields. A "hasFields" heuristic used to gate the tabs // separately, but it drifted from what generateConfigFields really emits — // so a category like Network could pass the check yet render empty, leaving // a blank tab whose body just reads "No configuration options available". // Trusting the rendered output keeps tabs and content in lockstep. const renderedCategories = []; for (const [key, category] of sortedCategories) { const content = await this.generateConfigFields(key, appData); if (!content || content.includes('class="no-fields"')) continue; renderedCategories.push({ key, category, content }); } // Use the preferred category if it's one that has fields, else the first. const activeTab = (preferredCategory && renderedCategories.some(c => c.key === preferredCategory)) ? preferredCategory : (renderedCategories[0] ? renderedCategories[0].key : null); for (const { key, category, content } of renderedCategories) { const isActive = key === activeTab ? 'active' : ''; tabsHTML += ` `; contentHTML += `

${category.icon} ${category.name}

${category.description}

${content}
`; } return { tabsHTML, contentHTML }; } // Initialize tab functionality initializeSimpleTabs() { //// // console.log('Simple tabs initialized'); } // Generate simple fields (working method from app-config-original.js) async generateSimpleFields(categoryKey, appData) { //// // console.log(`🔧 Generating fields for category: ${categoryKey}`); const fieldMappings = await this.getFieldMappings(); const appConfig = appData.config || {}; let fieldsHTML = ''; let hiddenFieldsHTML = ''; // Find fields that belong to this category for (const [fieldKey, fieldConfig] of Object.entries(fieldMappings)) { if (fieldConfig.category === categoryKey) { //// // console.log(`🔧 Processing field: ${fieldKey} with config:`, fieldConfig); const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); // Skip generic mappings when a longer/more-specific one binds to the same cfgKey. if (cfgKey) { const moreSpecific = Object.keys(fieldMappings).some(otherKey => otherKey !== fieldKey && otherKey.length > fieldKey.length && this.findMatchingCFGKey(otherKey, appConfig) === cfgKey ); if (moreSpecific) continue; } //// // console.log(`🔧 Found CFG key: ${cfgKey} with value:`, appConfig[cfgKey]); // Special debug for PORT_1 if (fieldKey === 'PORT_1') { //// // console.log(`🔧 PORT_1 DEBUG: fieldKey=${fieldKey}, cfgKey=${cfgKey}, hasValue=${!!appConfig[cfgKey]}, value="${appConfig[cfgKey]}"`); //// // console.log(`🔧 PORT_1 DEBUG: All app config keys:`, Object.keys(appConfig)); } // For advanced tab, only show advanced fields if (categoryKey === 'advanced' && !fieldConfig.advanced) { continue; // Skip non-advanced fields in advanced tab } // For regular tabs, skip advanced fields if (categoryKey !== 'advanced' && fieldConfig.advanced) { continue; // Skip advanced fields in regular tabs } // Skip fields gated by category allowlist when this app's category // isn't in the list AND the override requirement isn't enabled. if (Array.isArray(fieldConfig.categoryAllowlist)) { const appCategory = String(appData?.category || '').toLowerCase(); const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory); const override = fieldConfig.requirementOverride ? this.checkRequirementEnabled(fieldConfig.requirementOverride) : false; if (!inList && !override) continue; } // Generic requiresService gating from field-mapping JSON. if (fieldConfig.requiresService) { if (!this.checkServiceInstalled(fieldConfig.requiresService)) { const value = this.unmetDependencyValue(fieldConfig); fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`); continue; } } // Gate fields that depend on the global mail config being on. // Used for per-app email-notification toggles so a user can't // enable Email here without configuring SMTP under General first. if (fieldConfig.requiresGlobalMail) { const mailEnabled = await this.isGlobalMailEnabled(); if (!mailEnabled) { const value = this.unmetDependencyValue(fieldConfig); fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, fieldConfig.disabledReason || 'Configure mail in General settings first (CFG_MAIL_ENABLED=true).'); continue; } } // Check conditional requirements for certain fields if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') { let serviceName; let isServiceInstalled; let disabledReason; if (fieldKey === 'AUTHELIA') { serviceName = 'authelia'; isServiceInstalled = this.checkServiceInstalled(serviceName); disabledReason = 'Authelia needs to be installed'; } else if (fieldKey === 'HEADSCALE') { serviceName = 'headscale'; isServiceInstalled = this.checkServiceInstalled(serviceName); disabledReason = 'Headscale needs to be installed'; } else if (fieldKey === 'WHITELIST') { serviceName = 'traefik'; isServiceInstalled = this.checkServiceInstalled(serviceName); disabledReason = 'Traefik needs to be installed.'; } if (!isServiceInstalled) { // Force off-state so a stored "true" can't render checked when the dep is missing. const value = this.unmetDependencyValue(fieldConfig); fieldsHTML += this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason); continue; } } // Get current value or use default let fieldValue = cfgKey && appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData); const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig); if (fieldConfig.hideByDefault) { hiddenFieldsHTML += fieldHTML; } else { fieldsHTML += fieldHTML; } } } if (hiddenFieldsHTML) { fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML); } if (!fieldsHTML) { fieldsHTML = '
No configuration options available for this category.
'; } return fieldsHTML; } // Wrap each hidden field as a direct grid sibling tagged .advanced-field so // they participate in the parent .panel-fields grid (continuing on the right // of the toggle) rather than reflowing into a nested grid. applyContextualDefault(fieldKey, value, appData) { if (fieldKey === 'NETWORK' && !appData?.installed && (value === 'default' || value === '') && this.checkServiceInstalled('gluetun')) { return 'gluetun'; } return value; } renderAdvancedToggleAndFields(hiddenFieldsHTML) { const tagged = hiddenFieldsHTML.replace(/
Reveal less-common configuration options for power users.
${tagged} `; } // Generate field (working method from app-config-original.js) async generateField(fieldKey, cfgKey, value, fieldConfig) { const fieldId = fieldKey; // Use fieldKey to ensure unique IDs const required = fieldConfig.required ? '*' : ''; const helpIcon = fieldConfig.tooltip ? `?` : ''; let inputHTML = ''; // Special handling for DOMAIN fields - show domain dropdown if (fieldKey === 'DOMAIN') { //// // console.log('🎯 DOMAIN field detected, generating dropdown...'); try { const domainOptions = await this.getDomainOptions(); //// // console.log('📊 Domain options received:', domainOptions); let optionsHTML = ''; domainOptions.forEach(option => { const isSelected = option.value === value.toString() ? 'selected' : ''; optionsHTML += ``; }); inputHTML = ``; //// // console.log('✅ Domain dropdown generated successfully'); } catch (error) { console.error('❌ Error loading domain options, falling back to number input:', error); // Fallback to regular number input if domain loading fails inputHTML = ``; } } else if (fieldKey === 'GLUETUN_VPN_COUNTRIES') { const selected = (typeof value === 'string' ? value : '').split(',').map(s => s.trim()).filter(Boolean); const chips = selected.length ? selected.map(c => `${this.countryFlagEmoji(c)}${c}`).join('') : `Any`; inputHTML = `
${chips}
`; } else { // Regular field handling for all other types // Auto-detect PORT fields and use port manager if (fieldKey.startsWith('PORT_') || fieldConfig.type === 'port-manager') { // Special handling for port manager - will be initialized after DOM is ready //// // console.log(`🔌 Creating port-manager field: ${fieldKey} for app: ${this.getCurrentAppName()}`); inputHTML = `
Loading port manager...
`; } else { switch (fieldConfig.type) { case 'text': inputHTML = ``; break; case 'password': { const randomMatch = typeof value === 'string' && /^RANDOMIZEDPASSWORD\d+$/.test(value); if (randomMatch) { const placeholderToken = value; inputHTML = `
`; } else { inputHTML = `
`; } break; } case 'number': inputHTML = ``; break; case 'select': let optionsHTML = ''; let selectOptions = fieldConfig.options; if (!selectOptions && typeof ConfigOptions !== 'undefined' && ConfigOptions.isDropdownKey?.(cfgKey)) { selectOptions = ConfigOptions.getSelectOptions(cfgKey); } // Backup strategy: "live" is only valid for apps we can snapshot // consistently. Hide it elsewhere so we never offer a choice that // would just fall back to stopping the app. if (selectOptions && cfgKey.endsWith('_BACKUP_STRATEGY') && !this.isCurrentAppLiveCapable()) { selectOptions = selectOptions.filter(o => String(o.value) !== 'live'); } // Fall back to default if stored value isn't in the option list. let effectiveValue = value; if (selectOptions && selectOptions.length > 0) { const hasMatch = selectOptions.some(o => String(o.value) === String(value)); if (!hasMatch) { effectiveValue = (fieldConfig.default !== undefined && fieldConfig.default !== null) ? fieldConfig.default : selectOptions[0].value; } } if (selectOptions) { selectOptions.forEach(option => { const isSelected = String(option.value) === String(effectiveValue) ? 'selected' : ''; optionsHTML += ``; }); } inputHTML = ``; break; case 'checkbox': const isChecked = value === 'true' || value === true ? 'checked' : ''; inputHTML = ` `; break; case 'textarea': inputHTML = ``; break; default: inputHTML = ``; } } } // Generic conditional field: only render-visible when another field's // current value matches. The post-render `wireShowWhenListeners` keeps // visibility in sync as the watched field changes. Schema: // showWhen: { "": "" } // can be either a full CFG_ name or a bare suffix like // "NOTIFY_EMAIL"; bare keys auto-resolve against the current field's // app prefix so the same field-mapping is reusable across apps. // For checkboxes the expected value is "true" or "false". let showWhenAttrs = ''; let showWhenStyle = ''; if (fieldConfig.showWhen && typeof fieldConfig.showWhen === 'object') { const entries = Object.entries(fieldConfig.showWhen); if (entries.length > 0) { let [watchKey, expected] = entries[0]; // Auto-prefix bare keys with the current field's CFG__ prefix. if (cfgKey && !String(watchKey).startsWith('CFG_')) { const m = String(cfgKey).match(/^(CFG_[A-Z0-9]+_)/); if (m) watchKey = `${m[1]}${watchKey}`; } const currentValue = this._readWatchedValue(watchKey); const visible = String(currentValue) === String(expected); showWhenAttrs = ` data-show-when-key="${watchKey}" data-show-when-equals="${String(expected)}"`; if (!visible) showWhenStyle = ' style="display: none;"'; } } return `
${inputHTML} ${fieldConfig.tooltip ? `${this.escHtml(fieldConfig.tooltip)}` : ''}
`; } // Best-effort lookup of a watched field's current value during render. // Reads from the in-flight form (already-rendered fields above this one) // OR from the cached app config so the initial visibility is right even // for forward references. _readWatchedValue(cfgKey) { const live = document.querySelector(`[name="${cfgKey}"]`); if (live) { if (live.type === 'checkbox') return live.checked ? 'true' : 'false'; return live.value; } const cached = this.currentAppConfig || {}; if (Object.prototype.hasOwnProperty.call(cached, cfgKey)) { const v = cached[cfgKey]; if (typeof v === 'boolean') return v ? 'true' : 'false'; return String(v); } return ''; } // Hook change events on every watched CFG_KEY and toggle dependent // .form-field[data-show-when-key=...] elements when the watched value // changes. Called after the config form is rendered. wireShowWhenListeners() { const dependents = document.querySelectorAll('.form-field[data-show-when-key]'); if (dependents.length === 0) return; // Build a map: watchedKey -> [{element, expected}] const watch = new Map(); dependents.forEach((el) => { const key = el.getAttribute('data-show-when-key'); const expected = el.getAttribute('data-show-when-equals'); if (!key) return; if (!watch.has(key)) watch.set(key, []); watch.get(key).push({ element: el, expected }); }); const evalKey = (key) => { const entry = watch.get(key); if (!entry) return; const input = document.querySelector(`[name="${key}"]`); let val = ''; if (input) { val = input.type === 'checkbox' ? (input.checked ? 'true' : 'false') : input.value; } entry.forEach(({ element, expected }) => { element.style.display = String(val) === String(expected) ? '' : 'none'; }); }; watch.forEach((_v, key) => { const input = document.querySelector(`[name="${key}"]`); if (!input || input.dataset.showWhenWired === '1') return; input.dataset.showWhenWired = '1'; input.addEventListener('change', () => evalKey(key)); input.addEventListener('input', () => evalKey(key)); // Run once on init so any forward-reference defaults reconcile. evalKey(key); }); // showWhen dependents render as the grid cell immediately after their // controller (generateConfigFields reorders them there), so revealing one // drops the input in the slot right next to its toggle. } // Generate configuration field HTML (from old file - needed for tab content) // Are any CFG_DOMAIN_N configured? The per-app DOMAIN field is just an // index into that list, so showing it when the list is empty is noise. // Refetched on every form render so changes on the config page are // reflected the next time an app's config tab opens. async hasConfiguredDomains() { try { const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' }); if (!res.ok) return false; const json = await res.json(); const flat = JSON.stringify(json); for (let i = 1; i <= 9; i++) { const m = flat.match(new RegExp(`"CFG_DOMAIN_${i}"\\s*:\\s*\\{[^}]*"value"\\s*:\\s*"([^"]*)"`)); if (m && m[1].trim()) return true; } return false; } catch { return false; } } // Returns true if the user has switched on the global mail config. // Used by `requiresGlobalMail` field gating so per-app email-notification // toggles can refuse to enable until SMTP is configured once globally. async isGlobalMailEnabled() { try { const res = await fetch('/data/config/generated/configs.json', { cache: 'no-store' }); if (!res.ok) return false; const json = await res.json(); const v = json?.config?.CFG_MAIL_ENABLED?.value; return String(v).toLowerCase() === 'true'; } catch { return false; } } async generateConfigFields(categoryKey, appData) { const fieldMappings = await this.getFieldMappings(); const appConfig = appData.config || {}; const domainsAvailable = await this.hasConfiguredDomains(); let fieldsHTML = ''; let hiddenFieldsHTML = ''; // Collect every field that belongs to this category. const categoryFields = []; Object.entries(fieldMappings).forEach(([fieldKey, fieldConfig]) => { if (fieldConfig.category !== categoryKey) return; const cfgKey = this.findMatchingCFGKey(fieldKey, appConfig); // Advanced fields only on the advanced tab, and vice versa. if (categoryKey === 'advanced' && !fieldConfig.advanced) return; if (categoryKey !== 'advanced' && fieldConfig.advanced) return; // Only show a field if this app actually has the CFG_ variable. if (!cfgKey || !appConfig.hasOwnProperty(cfgKey)) return; // The DOMAIN selector is just an index into the domain list — hide it // entirely when no CFG_DOMAIN_N is configured. if (fieldKey === 'DOMAIN' && !domainsAvailable) return; // BACKUP gets priority -1 so "Enable Backups?" is always first; other // inputs are 0, remaining checkboxes 1. const isBackup = fieldKey === 'BACKUP'; categoryFields.push({ fieldKey, fieldConfig, cfgKey, priority: isBackup ? -1 : (fieldConfig.type === 'checkbox' ? 1 : 0) }); }); categoryFields.sort((a, b) => a.priority - b.priority); // The sort above orders by type (inputs before checkboxes), which can // separate a showWhen field from its controlling toggle. Reorder so each // dependent sits immediately after its controller — then its conditional // input reveals in the grid cell right next to the toggle. const byCfgKey = new Map(categoryFields.map(f => [f.cfgKey, f])); const resolveWatchKey = (entry) => { const sw = entry.fieldConfig.showWhen; if (!sw || typeof sw !== 'object') return null; const swEntries = Object.entries(sw); if (!swEntries.length) return null; let [watchKey] = swEntries[0]; if (!String(watchKey).startsWith('CFG_')) { const m = String(entry.cfgKey).match(/^(CFG_[A-Z0-9]+_)/); if (m) watchKey = `${m[1]}${watchKey}`; } return watchKey; }; const ordered = []; const placed = new Set(); for (const entry of categoryFields) { if (placed.has(entry.cfgKey)) continue; // Dependents whose controller is in this category are placed alongside // their controller below — skip them in this outer pass. const watchKey = resolveWatchKey(entry); if (watchKey && byCfgKey.has(watchKey)) continue; ordered.push(entry); placed.add(entry.cfgKey); for (const dep of categoryFields) { if (placed.has(dep.cfgKey)) continue; if (resolveWatchKey(dep) === entry.cfgKey) { ordered.push(dep); placed.add(dep.cfgKey); } } } // Safety net: anything still unplaced (e.g. a dependent whose controller // lives in another category) keeps its original sorted position. for (const entry of categoryFields) { if (!placed.has(entry.cfgKey)) { ordered.push(entry); placed.add(entry.cfgKey); } } for (const entry of ordered) { const rendered = await this._renderCategoryField(entry, appData, appConfig); if (!rendered) continue; if (rendered.hidden) hiddenFieldsHTML += rendered.html; else fieldsHTML += rendered.html; } if (hiddenFieldsHTML) { fieldsHTML += this.renderAdvancedToggleAndFields(hiddenFieldsHTML); } if (!fieldsHTML) { fieldsHTML = '
No configuration options available for this category.
'; } return fieldsHTML; } // Render a single collected category field: runs the dependency/service // gating, then produces the .form-field HTML. Returns { html, hidden } or // null when the field should be skipped entirely. async _renderCategoryField(entry, appData, appConfig) { const { fieldKey, fieldConfig, cfgKey } = entry; // Skip categoryAllowlist fields when this app's category isn't listed // AND the override requirement isn't enabled. if (Array.isArray(fieldConfig.categoryAllowlist)) { const appCategory = String(appData?.category || '').toLowerCase(); const inList = fieldConfig.categoryAllowlist.map(c => c.toLowerCase()).includes(appCategory); const override = fieldConfig.requirementOverride ? this.checkRequirementEnabled(fieldConfig.requirementOverride) : false; if (!inList && !override) return null; } // Generic requiresService gating from the field-mapping JSON. if (fieldConfig.requiresService && !this.checkServiceInstalled(fieldConfig.requiresService)) { const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); return { html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, fieldConfig.disabledReason || `${fieldConfig.requiresService} needs to be installed.`), hidden: false }; } // requiresServices: ALL listed services must be installed (e.g. the // MONITORING toggle needs both prometheus and grafana). if (Array.isArray(fieldConfig.requiresServices)) { const missing = fieldConfig.requiresServices.filter(s => !this.checkServiceInstalled(s)); if (missing.length) { const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); return { html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, fieldConfig.disabledReason || `${missing.join(' + ')} needs to be installed.`), hidden: false }; } } // Legacy hardcoded service checks for fields not yet migrated. if (fieldKey === 'AUTHELIA' || fieldKey === 'HEADSCALE' || fieldKey === 'WHITELIST') { let serviceName, disabledReason; if (fieldKey === 'AUTHELIA') { serviceName = 'authelia'; disabledReason = 'Authelia needs to be installed'; } else if (fieldKey === 'HEADSCALE') { serviceName = 'headscale'; disabledReason = 'Headscale needs to be installed'; } else { serviceName = 'traefik'; disabledReason = 'Traefik needs to be installed.'; } if (!this.checkServiceInstalled(serviceName)) { const value = appConfig.hasOwnProperty(cfgKey) ? appConfig[cfgKey] : (fieldConfig.default || ''); return { html: this.generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason), hidden: false }; } } let fieldValue = appConfig[cfgKey] || (fieldConfig.default || ''); fieldValue = this.applyContextualDefault(fieldKey, fieldValue, appData); const fieldHTML = await this.generateField(fieldKey, cfgKey, fieldValue, fieldConfig); return { html: fieldHTML, hidden: !!fieldConfig.hideByDefault }; } // Generate configuration field HTML generateConfigField(cfgKey, value, fieldConfig) { const description = fieldConfig.description || ''; let fieldHTML = `
`; const type = fieldConfig.type || 'text'; const options = fieldConfig.options; switch (type) { case 'text': fieldHTML += ``; break; case 'number': fieldHTML += ``; break; case 'password': fieldHTML += `
`; break; case 'checkbox': const checked = value === 'true' || value === 'yes' ? 'checked' : ''; fieldHTML += ``; break; case 'select': fieldHTML += ``; break; default: fieldHTML += ``; } fieldHTML += `
${description ? `

${description}

` : ''} `; return fieldHTML; } createAppCard(app) { const card = document.createElement('div'); card.className = 'app-card'; if (app.installed) card.classList.add('installed'); // Searchable text for the sidebar search box. Combined name + // description + long description + category, lowercased once here // so filterAppsByQuery is a cheap substring match. const searchHaystack = [ app.name, app.description, app.longDescription, app.category, this.getCategoryName ? this.getCategoryName(app.category) : '' ].filter(Boolean).join(' ').toLowerCase(); card.dataset.search = searchHaystack; const appName = app.command.split(' ').pop(); let icon = app.icon || '/core/icons/apps/default.svg'; // Ensure absolute path from root if (!icon.startsWith('/')) { icon = '/' + icon; } const status = app.installed ? 'Installed' : 'Not Installed'; // Get category icon and name const categoryIcon = this.getCategoryIcon(app.category); const categoryName = this.getCategoryName(app.category); // Create rich tags like original const descriptionTag = app.description ? ` ${app.description}` : ''; const categoryTag = ` ${categoryName}`; // Format long description with period if missing let formattedLongDescription = ''; if (app.longDescription) { formattedLongDescription = app.longDescription; if (!formattedLongDescription.endsWith('.') && !formattedLongDescription.endsWith('?') && !formattedLongDescription.endsWith('!')) { formattedLongDescription += '.'; } } // Service trigger icon (only for installed apps - visibility controlled after services load) const serviceTrigger = app.installed ? ` ` : ''; card.innerHTML = `
${app.name}
${app.name.split(' - ')[0].trim()}
${descriptionTag} ${categoryTag} ${status}
${formattedLongDescription ? `
${formattedLongDescription}
` : ''}
${serviceTrigger}
`; return card; } // Populate inline service trigger popups for installed apps async populateInlineServiceButtons() { if (!window.serviceButtons) return; if (window.serviceButtons.services.length === 0) { await window.serviceButtons.loadServices(); } const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http'; const popupContents = document.querySelectorAll('[id^="service-popup-content-"]'); for (const content of popupContents) { const appName = content.id.replace('service-popup-content-', ''); const trigger = document.getElementById(`service-trigger-${appName}`); const appServices = window.serviceButtons.services.filter(s => s.app === appName && s.buttonEnabled === true); if (appServices.length === 0) continue; // Multi-button render via the shared expandServiceLinks() helper. const buttons = appServices.flatMap(s => { const protectedClass = s.loginRequired ? ' protected' : ''; const lockIcon = s.loginRequired ? `` : ''; return window.expandServiceLinks(s).map(({ url, label }) => ` ${label} ${lockIcon} `); }).filter(Boolean).join(''); if (buttons) { content.innerHTML = buttons; if (trigger) trigger.style.display = ''; } } } getCategoryIcon(categoryId) { if (!categoryId || categoryId === 'all') return null; // Convert sidebar categories object to array with id field const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value })); // Find category in categories array (case-insensitive) const category = categoriesArray.find(cat => cat.id === categoryId.toLowerCase() || cat.name.toLowerCase() === categoryId.toLowerCase() ); let iconPath = category ? category.icon : `/core/icons/categories/${categoryId}.svg`; // Ensure absolute path from root if (iconPath && !iconPath.startsWith('/')) { iconPath = '/' + iconPath; } return iconPath; } getCategoryName(categoryId) { //// // console.log(`🏷️ Getting category name for: ${categoryId}`); //// // console.log(`🏷️ window.sidebarCategories type: ${typeof window.sidebarCategories}`, window.sidebarCategories); if (!categoryId || categoryId === 'all') return 'All'; // Check if sidebar categories is available if (!window.sidebarCategories) { console.warn(`🏷️ window.sidebarCategories is not available, returning categoryId: ${categoryId}`); return categoryId; } // Convert sidebar categories object to array with id field const categoriesArray = Array.isArray(window.sidebarCategories) ? window.sidebarCategories : Object.entries(window.sidebarCategories || {}).map(([key, value]) => ({ id: key, ...value })); //// // console.log(`🏷️ Categories array:`, categoriesArray); // Find category in categories array (case-insensitive) const category = categoriesArray.find(cat => cat.id === categoryId.toLowerCase() || cat.name.toLowerCase() === categoryId.toLowerCase() ); //// // console.log(`🏷️ Found category:`, category); return category ? category.name : categoryId; } // Show tab (working method from app-config-original.js) showTab(tabKey) { // Hide all panels const allPanels = document.querySelectorAll('.tab-panel'); allPanels.forEach(panel => panel.classList.remove('active')); // Remove active from all config category tabs (not main navigation tabs) const allButtons = document.querySelectorAll('.tab-panel:has(.config-section) .tab-button, .config-section .tab-button'); allButtons.forEach(button => button.classList.remove('active')); // Show selected panel const targetPanel = document.getElementById(`panel-${tabKey}`); if (targetPanel) { targetPanel.classList.add('active'); } // Add active to clicked config category button const targetButton = document.querySelector(`.config-section [data-tab="${tabKey}"], .tab-panel:has(.config-section) [data-tab="${tabKey}"]`); if (targetButton) { targetButton.classList.add('active'); } // Push the path-based URL so this sub-tab is shareable + back-buttonable — // /app//config/. Skipped when there's no current app (e.g. when // the form is rendered outside of the per-app context). const currentApp = window.appTabbedManager?.currentApp; if (currentApp && window.appPath) { const newUrl = window.appPath(currentApp, 'config', tabKey); if (window.location.pathname + window.location.search !== newUrl) { history.pushState({}, '', newUrl); } } } // Initialize simple tabs (working method from app-config-original.js) initializeSimpleTabs() { //// // console.log('Simple tabs initialized'); } // Check if a service is installed checkServiceInstalled(serviceName) { if (!window.apps || window.apps.length === 0) { return false; } const serviceApp = window.apps.find(app => app.command && app.command.endsWith(`libreportal app install ${serviceName}`) ); return serviceApp && serviceApp.installed === true; } checkRequirementEnabled(suffix) { if (!suffix) return false; const sysCfg = window.systemConfig || window.configs || {}; const v = sysCfg[`CFG_REQUIREMENT_${suffix}`]; return v === true || v === 'true'; } // Get navigation button for installing required services getNavigationButton(fieldKey) { const servicePages = { 'AUTHELIA': '/app/authelia', 'HEADSCALE': '/app/headscale', 'WHITELIST': '/app/traefik', 'TRAEFIK': '/app/traefik' }; let serviceName; if (fieldKey === 'WHITELIST') { serviceName = 'Traefik'; } else if (fieldKey === 'AUTHELIA') { serviceName = 'Authelia'; } else if (fieldKey === 'HEADSCALE') { serviceName = 'Headscale'; } else { serviceName = fieldKey.charAt(0) + fieldKey.slice(1).toLowerCase(); } const pageUrl = servicePages[fieldKey] || '#'; return ` `; } // Handle navigation with unsaved changes check handleNavigation(url, serviceName) { // SPA in-app nav (path-based routes), with an absolute-path full-load // fallback. A relative window.location.href here resolved wrong from the // /admin/config/* pages these buttons render on. if (typeof window.navigateToRoute === 'function' && window.spaClean) { window.navigateToRoute(url); } else { window.location.href = url; } } // Generate disabled field with navigation button serviceForField(fieldKey, fieldConfig) { const map = { AUTHELIA: 'authelia', HEADSCALE: 'headscale', WHITELIST: 'traefik' }; return (map[fieldKey] || fieldConfig.requiresService || '').toLowerCase(); } generateDisabledField(fieldKey, fieldConfig, cfgKey, value, disabledReason) { const fieldId = fieldKey; const slug = this.serviceForField(fieldKey, fieldConfig); const serviceName = slug ? slug.charAt(0).toUpperCase() + slug.slice(1) : ''; const iconUrl = slug ? `/core/icons/apps/${encodeURIComponent(slug)}.svg` : '/core/icons/apps/default.svg'; const isCheckbox = fieldConfig.type === 'checkbox'; const hiddenInput = isCheckbox ? `` : ``; return `
${hiddenInput}
${this.escHtml(fieldConfig.label)}
${this.escHtml(disabledReason)}
${slug ? `` : ''}
`; } // First-install welcome modal — also openable from the app-header button. async showInstallWelcome(appName, opts = {}) { const slug = String(appName || '').toLowerCase(); if (!slug) return; const app = (window.apps || []).find(a => ((a.command || '').split(' ').pop() || '').toLowerCase() === slug); if (!app) return; let services = []; try { const r = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); if (r.ok) { const d = await r.json(); services = Array.isArray(d?.services) ? d.services.filter(s => s.app === slug) : []; } } catch (_) {} const traefikInstalled = this.checkServiceInstalled('traefik'); const cfg = app.config || {}; const upper = slug.toUpperCase(); const isPublic = services.some(s => s.traefikManaged); const isAuth = String(cfg[`CFG_${upper}_AUTHELIA`] || '').toLowerCase() === 'true' && this.checkServiceInstalled('authelia'); const isVpn = String(cfg[`CFG_${upper}_NETWORK`] || '').toLowerCase() === 'gluetun'; const anyTraefikLogin = services.some(s => s.traefikManaged && s.loginRequired); const badges = []; if (isPublic) badges.push({ icon: '🌍', label: 'Public', cls: 'public' }); if (services.some(s => s.traefikManaged) && traefikInstalled) badges.push({ icon: '🛡️', label: 'Traefik', cls: 'traefik' }); if (isAuth) badges.push({ icon: '🔒', label: 'Authelia', cls: 'authelia' }); if (isVpn) badges.push({ icon: '🌐', label: 'Gluetun VPN', cls: 'gluetun' }); if (anyTraefikLogin && traefikInstalled) badges.push({ icon: '🔑', label: 'Login required', cls: 'login' }); const expand = typeof window.expandServiceLinks === 'function' ? window.expandServiceLinks : null; const urls = []; services.forEach(s => { if (s.buttonEnabled === false) return; const links = expand ? expand(s) : [{ url: s.externalURL, label: s.buttonText || s.name }]; links.forEach(l => { if (l?.url) urls.push({ url: l.url, label: l.label || s.buttonText || s.name }); }); }); const creds = []; if (anyTraefikLogin && traefikInstalled) { creds.push({ title: 'Website Authentication (Traefik basic-auth)', username: window.globalConfig?.CFG_TRAEFIK_USER || '(see CFG_TRAEFIK_USER)', password: window.globalConfig?.CFG_TRAEFIK_PASS || '(see CFG_TRAEFIK_PASS)' }); } // App-login keys are the app's OWN admin credentials, anchored to the // app's CFG prefix. Without the anchor, loose suffix matches pull in // notification-channel / upstream creds that aren't logins at all // (e.g. CFG_GLUETUN_OPENVPN_PASSWORD). const loginKey = (kind) => new RegExp(`^CFG_${upper}_(ADMIN_)?(${kind})$`); const emailKeys = Object.keys(cfg).filter(k => loginKey('EMAIL').test(k)); const userKeys = Object.keys(cfg).filter(k => loginKey('USER(NAME)?').test(k)); const passKeys = Object.keys(cfg).filter(k => loginKey('PASSWORD').test(k)); const emailVal = emailKeys[0] ? cfg[emailKeys[0]] : ''; const userVal = userKeys[0] ? cfg[userKeys[0]] : ''; const identifier = emailVal || userVal || 'admin'; const userLabel = (emailVal || (typeof identifier === 'string' && identifier.includes('@'))) ? 'Email' : 'User'; if (userKeys[0] || emailKeys[0] || passKeys[0]) { creds.push({ title: `${app.name.split(' - ')[0]} Login`, username: identifier, userLabel, password: cfg[passKeys[0]] || '(not generated)' }); } const iconUrl = app.icon ? (app.icon.startsWith('/') ? app.icon : '/' + app.icon) : `/core/icons/apps/${slug}.svg`; const shortName = app.name.split(' - ')[0]; const eoBadges = badges.map(b => ({ icon: b.icon, label: b.label, variant: ({public:'success', traefik:'info', authelia:'purple', gluetun:'warning', login:'danger'})[b.cls] })); const bodyParts = []; bodyParts.push(window.eoBadgeRow(eoBadges)); if (urls.length) bodyParts.push(window.eoSection(`Open ${shortName}`, window.eoUrlList(urls))); if (creds.length) bodyParts.push(window.eoSection('Login Details', window.eoCredList(creds))); if (!urls.length && !creds.length) bodyParts.push(window.eoEmpty('All set up — head to the Services tab for details.')); window.openEoModal({ id: 'install-welcome-modal', size: 'sm', icon: iconUrl, iconAlt: shortName, eyebrow: `🎉 ${opts.replay ? 'Welcome back to' : 'Installed'}`, title: shortName, desc: app.description || '', body: bodyParts.join(''), actions: [{ label: 'Done', variant: 'primary' }] }); try { localStorage.setItem(`libreportal.welcomeShown.${slug}`, '1'); } catch (_) {} } navigateToServiceApp(slug) { if (typeof window.navigateToApp === 'function') return window.navigateToApp(slug); if (window.librePortalSPA?.navigate) return window.librePortalSPA.navigate(`/app/${slug}`); if (typeof window.navigateToRoute === 'function') return window.navigateToRoute(`app/${slug}`); window.location.href = `/app/${slug}`; } // Find matching CFG_ key for a field (working method from app-config-original.js) togglePasswordVisibility(fieldId) { const input = document.getElementById(fieldId); const icon = document.getElementById(`${fieldId}-icon`); if (!input) return; const showing = input.type === 'text'; input.type = showing ? 'password' : 'text'; if (icon) icon.textContent = showing ? '👁' : '🙈'; } countryFlagEmoji(name) { const map = { 'Albania':'AL','Algeria':'DZ','Andorra':'AD','Angola':'AO','Argentina':'AR','Armenia':'AM','Australia':'AU','Austria':'AT','Azerbaijan':'AZ', 'Bahamas':'BS','Bahrain':'BH','Bangladesh':'BD','Belarus':'BY','Belgium':'BE','Belize':'BZ','Bermuda':'BM','Bhutan':'BT','Bolivia':'BO', 'Bosnia and Herzegovina':'BA','Brazil':'BR','Brunei':'BN','Brunei Darussalam':'BN','Bulgaria':'BG','Cambodia':'KH','Canada':'CA','Chile':'CL', 'China':'CN','Colombia':'CO','Costa Rica':'CR','Croatia':'HR','Cyprus':'CY','Czech Republic':'CZ','Czechia':'CZ', 'Denmark':'DK','Dominican Republic':'DO','Ecuador':'EC','Egypt':'EG','El Salvador':'SV','Estonia':'EE','Ethiopia':'ET', 'Finland':'FI','France':'FR','Georgia':'GE','Germany':'DE','Ghana':'GH','Greece':'GR','Greenland':'GL','Guatemala':'GT', 'Honduras':'HN','Hong Kong':'HK','Hungary':'HU','Iceland':'IS','India':'IN','Indonesia':'ID','Iran':'IR','Iraq':'IQ', 'Ireland':'IE','Isle of Man':'IM','Israel':'IL','Italy':'IT','Jamaica':'JM','Japan':'JP','Jordan':'JO','Kazakhstan':'KZ', 'Kenya':'KE','Kuwait':'KW','Kyrgyzstan':'KG','Laos':'LA','Latvia':'LV','Lebanon':'LB','Liechtenstein':'LI','Lithuania':'LT', 'Luxembourg':'LU','Macao':'MO','Macau':'MO','North Macedonia':'MK','Macedonia':'MK','Madagascar':'MG','Malaysia':'MY','Malta':'MT', 'Mexico':'MX','Moldova':'MD','Monaco':'MC','Mongolia':'MN','Montenegro':'ME','Morocco':'MA','Myanmar':'MM','Nepal':'NP', 'Netherlands':'NL','New Zealand':'NZ','Nicaragua':'NI','Nigeria':'NG','Norway':'NO','Oman':'OM','Pakistan':'PK','Panama':'PA', 'Papua New Guinea':'PG','Paraguay':'PY','Peru':'PE','Philippines':'PH','Poland':'PL','Portugal':'PT','Puerto Rico':'PR','Qatar':'QA', 'Romania':'RO','Russia':'RU','Russian Federation':'RU','Saudi Arabia':'SA','Senegal':'SN','Serbia':'RS','Singapore':'SG', 'Slovakia':'SK','Slovenia':'SI','South Africa':'ZA','South Korea':'KR','Korea, Republic of':'KR','Spain':'ES','Sri Lanka':'LK', 'Sweden':'SE','Switzerland':'CH','Taiwan':'TW','Tajikistan':'TJ','Thailand':'TH','Trinidad and Tobago':'TT','Tunisia':'TN', 'Turkey':'TR','Türkiye':'TR','Turkmenistan':'TM','Ukraine':'UA','United Arab Emirates':'AE','UAE':'AE','United Kingdom':'GB','UK':'GB', 'United States':'US','USA':'US','United States of America':'US','Uruguay':'UY','Uzbekistan':'UZ','Venezuela':'VE','Vietnam':'VN','Viet Nam':'VN' }; const code = map[name]; if (!code) return '🏳️'; return String.fromCodePoint(...[...code].map(c => 0x1F1E6 + (c.charCodeAt(0) - 65))); } openGluetunCountriesModal(fieldId) { const hidden = document.getElementById(fieldId); const chips = document.getElementById(`${fieldId}-chips`); if (!hidden) return; const providerEl = (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunProviderEl) ? ConfigOptions.findGluetunProviderEl() : null; const provider = providerEl ? providerEl.value : ''; const providers = window.gluetunProviders || {}; const countries = (provider && providers[provider] && Array.isArray(providers[provider].countries)) ? [...providers[provider].countries].sort((a, b) => a.localeCompare(b)) : []; const current = new Set((hidden.value || '').split(',').map(s => s.trim()).filter(Boolean)); const existing = document.getElementById('gluetun-countries-modal'); if (existing) existing.remove(); const flag = (n) => this.countryFlagEmoji(n); const renderChips = (list) => list.length ? list.map(c => `${flag(c)}${c}`).join('') : `Any`; const fallbackProviderIcon = `data:image/svg+xml;utf8,`; const providerLabel = provider ? provider.replace(/\b\w/g, (c) => c.toUpperCase()) : '— none selected —'; const iconManifest = window.gluetunProviderIcons || {}; const providerIconUrl = (provider && iconManifest[provider]) || fallbackProviderIcon; const bodyHtml = `
${provider}

Provider

${providerLabel}

${countries.length === 0 ? `

No country list available for this provider. Pick a provider first or wait for the snapshot to load.

` : countries.map(c => ` `).join('')}
`; const m = window.openEoModal({ id: 'gluetun-countries-modal', title: '🌍 Select VPN Countries', body: bodyHtml, actions: [ { label: 'Save', variant: 'primary', onClick: (modal) => { const picked = Array.from(modal.contentEl.querySelectorAll('.gluetun-country-item input:checked')).map(cb => cb.value); hidden.value = picked.join(','); hidden.dispatchEvent(new Event('change', { bubbles: true })); if (chips) chips.innerHTML = renderChips(picked); modal.close(); }}, { label: 'Cancel', variant: 'secondary' } ] }); const root = m.contentEl; root.querySelector('.gluetun-country-search').addEventListener('input', (e) => { const q = e.target.value.toLowerCase(); root.querySelectorAll('.gluetun-country-item').forEach(item => { const label = item.querySelector('.gluetun-country-name').textContent.toLowerCase(); item.style.display = label.includes(q) ? '' : 'none'; }); }); root.querySelector('.gluetun-country-all').addEventListener('click', () => { root.querySelectorAll('.gluetun-country-item').forEach(item => { if (item.style.display !== 'none') item.querySelector('input').checked = true; }); }); root.querySelector('.gluetun-country-none').addEventListener('click', () => { root.querySelectorAll('.gluetun-country-item input').forEach(cb => cb.checked = false); }); } async openGluetunRouteAppsModal() { const existing = document.getElementById('gluetun-route-apps-modal'); if (existing) existing.remove(); let allow = []; try { const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' }); if (r.ok) allow = (await r.json()).categories || []; } catch {} const allowSet = new Set(allow.map((c) => String(c).toLowerCase())); const overrideOn = (typeof ConfigOptions !== 'undefined') && this.checkRequirementEnabled('GLUETUN_FOR_ALL'); const skip = new Set(['gluetun', 'libreportal', 'traefik', 'fail2ban']); const apps = (window.apps || []) .filter((a) => a.installed) .map((a) => { const slug = (a.command || '').split(' ').pop(); return { ...a, slug }; }) .filter((a) => a.slug && !skip.has(a.slug)) .filter((a) => overrideOn || allowSet.has(String(a.category || '').toLowerCase())) .sort((a, b) => (a.name || a.slug).localeCompare(b.name || b.slug)); const bodyHtml = `

Tick an app to send its outbound traffic through the Gluetun VPN. Untick to restore the default network. Each change re-runs that app's install task to apply the new compose.

${apps.length === 0 ? `

No eligible installed apps

Install an app from the curated categories first, or enable the Gluetun For All Apps requirement to expose every app.

` : `
${apps.map((a) => { const cfgKey = `CFG_${a.slug.toUpperCase()}_NETWORK`; const current = (a.config && a.config[cfgKey]) || 'default'; const checked = current === 'gluetun' ? 'checked' : ''; const icon = a.icon ? (a.icon.startsWith('/') ? a.icon : '/' + a.icon) : '/core/icons/apps/default.svg'; return ` `; }).join('')}
`}`; const m = window.openEoModal({ id: 'gluetun-route-apps-modal', title: '🛡️ Route apps through Gluetun', body: bodyHtml, actions: [ { label: 'Apply', variant: 'primary', onClick: async (modal) => { if (apps.length === 0) { modal.close(); return; } const root = modal.contentEl; const applyBtn = root.querySelectorAll('.eo-modal-footer .btn')[0]; const changes = []; root.querySelectorAll('.gluetun-country-item input[type=checkbox]').forEach((cb) => { const desired = cb.checked ? 'gluetun' : 'default'; if (desired !== cb.dataset.current) changes.push({ slug: cb.dataset.slug, value: desired }); }); if (changes.length === 0) { modal.close(); return; } applyBtn.disabled = true; applyBtn.textContent = 'Applying…'; try { if (!window.tasksManager?.router) await this.loadTaskSystem?.(); for (const { slug, value } of changes) { const cfgKey = `CFG_${slug.toUpperCase()}_NETWORK`; await window.tasksManager.router.routeAction('install', { appName: slug, config: { [cfgKey]: value } }); } this.addSuccessLog?.(`Queued ${changes.length} gluetun routing task(s).`); modal.close(); if (window.appTabbedManager) window.appTabbedManager.switchTab('tasks'); } catch (err) { applyBtn.disabled = false; applyBtn.textContent = 'Apply'; console.error('Failed to queue gluetun routing tasks', err); } }}, { label: 'Cancel', variant: 'secondary' } ] }); if (apps.length === 0) { const applyBtn = m.contentEl.querySelectorAll('.eo-modal-footer .btn')[0]; if (applyBtn) applyBtn.disabled = true; } } openMullvadGenerateModal() { const existing = document.getElementById('mullvad-generate-modal'); if (existing) existing.remove(); const mullvadIcon = (window.gluetunProviderIcons && window.gluetunProviderIcons.mullvad) || '/core/icons/vpn/mullvad.svg'; const bodyHtml = `

Enter your 16-digit Mullvad account number. A new WireGuard key will be generated locally and registered with Mullvad — this consumes one of your 5 device slots.

`; const m = window.openEoModal({ id: 'mullvad-generate-modal', size: 'sm', icon: mullvadIcon, iconAlt: 'Mullvad', eyebrow: 'Provider', title: 'Generate Mullvad Config', body: bodyHtml, actions: [ { label: 'Generate', variant: 'primary', onClick: async (modal) => { const root = modal.contentEl; const errEl = root.querySelector('.mullvad-error'); const confirmBtn = root.querySelectorAll('.eo-modal-footer .btn')[0]; const acctEl = root.querySelector('#mullvad-acct'); const setError = (msg) => { errEl.textContent = msg || ''; errEl.style.display = msg ? '' : 'none'; }; const account = (acctEl.value || '').replace(/\s+/g, ''); if (!/^\d{16}$/.test(account)) { setError('Account number must be 16 digits.'); return; } setError(''); confirmBtn.disabled = true; confirmBtn.textContent = 'Generating…'; try { const res = await fetch('/api/gluetun/mullvad-wireguard', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountNumber: account }) }); const data = await res.json(); if (!res.ok || !data.success) { setError(data.error || `Request failed (${res.status}).`); confirmBtn.disabled = false; confirmBtn.textContent = 'Generate'; return; } const findField = (suffix) => (typeof ConfigOptions !== 'undefined' && ConfigOptions.findGluetunFieldEl) ? ConfigOptions.findGluetunFieldEl(suffix) : null; const setField = (suffix, value) => { const el = findField(suffix); if (!el) return; el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); }; setField('WIREGUARD_PRIVATE_KEY', data.privateKey); setField('WIREGUARD_ADDRESSES', data.addresses); modal.close(); } catch (err) { setError(err.message || 'Network error.'); confirmBtn.disabled = false; confirmBtn.textContent = 'Generate'; } }}, { label: 'Cancel', variant: 'secondary' } ] }); m.contentEl.querySelector('#mullvad-acct').focus(); } setPasswordMode(fieldId, mode) { const wrapper = document.querySelector(`.password-mode-wrapper[data-field-id="${fieldId}"]`); const input = document.getElementById(fieldId); const tokenInput = document.getElementById(`${fieldId}-token`); if (!wrapper || !input || !tokenInput) return; const key = wrapper.dataset.fieldKey; if (mode === 'random') { input.dataset.previousCustom = input.value || ''; input.value = ''; input.readOnly = true; input.type = 'password'; input.setAttribute('placeholder', 'Will generate on save'); input.removeAttribute('name'); tokenInput.setAttribute('name', key); const icon = document.getElementById(`${fieldId}-icon`); if (icon) icon.textContent = '👁'; } else { input.readOnly = false; input.removeAttribute('placeholder'); input.value = input.dataset.previousCustom || ''; input.setAttribute('name', key); tokenInput.removeAttribute('name'); input.focus(); } } // Off-value for a checkbox whose dependency isn't installed. unmetDependencyValue(fieldConfig) { return fieldConfig.type === 'checkbox' ? 'false' : ''; } escHtml(s) { return String(s ?? '') .replace(/&/g, '&').replace(//g, '>'); } escAttr(s) { return this.escHtml(s).replace(/"/g, '"').replace(/'/g, '''); } findMatchingCFGKey(fieldKey, appConfig) { // Try exact match first const exactMatch = `CFG_${fieldKey}`; if (appConfig.hasOwnProperty(exactMatch)) { return exactMatch; } // Try partial matches (more precise) const keys = Object.keys(appConfig); for (const cfgKey of keys) { const cfgKeyWithoutPrefix = cfgKey.replace('CFG_', ''); // Only match if the field key is a complete word within the cfg key if (cfgKeyWithoutPrefix === fieldKey || cfgKeyWithoutPrefix.endsWith('_' + fieldKey) || cfgKeyWithoutPrefix.startsWith(fieldKey + '_')) { return cfgKey; } } return null; } // Helper methods to load config data (working methods from app-config-original.js) async getConfigCategories() { try { // Load config categories (for app config tabs) const response = await fetch('/data/apps/apps-config-categories.json'); const data = await response.json(); //// // console.log('✅ Loaded config categories from apps folder'); return data.categories || data; // Return the actual data object } catch (error) { console.error('Error loading config categories:', error); throw new Error('Failed to load config categories. Please check your configuration files.'); } } async getFieldMappings() { try { // Load from apps folder (static file) const response = await fetch('/data/apps/apps-field-mappings.json'); const data = await response.json(); //// // console.log('✅ Loaded field mappings from apps folder'); return data.fields || data; } catch (error) { console.error('Error loading field mappings:', error); throw new Error('Failed to load field mappings. Please check your configuration files.'); } } // Get domain options for DOMAIN field async getDomainOptions() { //// // console.log('🎯 Getting domain options...'); try { //// // console.log('🔍 Starting domain fetch...'); // Try to load system config to get domain information const response = await fetch('/data/config/generated/configs.json'); //// // console.log('📡 Config response status:', response.status); if (!response.ok) { console.warn('Could not load system config for domains, returning empty list'); return [ { value: '1', label: 'No domains configured - Configure domains in Network settings first' } ]; } const configData = await response.json(); //// // console.log('📄 Full config data:', configData); //// // console.log('🔧 Config keys available:', Object.keys(configData)); const config = configData.config || {}; //// // console.log('⚙️ Config object:', config); //// // console.log('🔑 Config keys:', Object.keys(config)); const domains = []; // Check CFG_DOMAIN_1 through CFG_DOMAIN_9 for (let i = 1; i <= 9; i++) { const domainKey = `CFG_DOMAIN_${i}`; const domainConfig = config[domainKey]; //// // console.log(`🌐 Checking ${domainKey}:`, domainConfig, 'type:', typeof domainConfig); // Check if domainConfig has a value property and it's a non-empty string let domainValue = ''; if (domainConfig && typeof domainConfig === 'object' && domainConfig.value) { domainValue = domainConfig.value; } else if (typeof domainConfig === 'string') { domainValue = domainConfig; } //// // console.log(`🔤 Extracted domain value: "${domainValue}" type: ${typeof domainValue}`); // Only add domains that have actual content (non-empty string) if (typeof domainValue === 'string' && domainValue.trim() !== '') { //// // console.log(`✅ Adding domain: ${domainValue.trim()}`); domains.push({ number: i, domain: domainValue.trim(), key: domainKey }); } else { //// // console.log(`⏭️ Skipping empty domain ${domainKey}`); } } //// // console.log('✅ Found configured domains:', domains); if (domains.length === 0) { //// // console.log('⚠️ No domains found, returning fallback option'); return [ { value: '1', label: 'No domains configured - Configure domains in Network settings first' } ]; } // Create options with just domain names const options = domains.map(domain => ({ value: domain.number.toString(), label: domain.domain })); //// // console.log('✅ Generated domain options:', options); return options; } catch (error) { console.error('❌ Error fetching domains:', error); return [ { value: '1', label: 'Error loading domains - Check console for details' } ]; } } // Get current app name getCurrentAppName() { // Try to get from current app data if (this.currentApp) { return this.currentApp; } // Try to get from URL const urlParams = new URLSearchParams(window.location.search); const appName = urlParams.get('app'); if (appName) { return appName; } // Try to get from current path const pathParts = window.location.pathname.split('/'); const appIndex = pathParts.indexOf('app'); if (appIndex !== -1 && pathParts[appIndex + 1]) { return pathParts[appIndex + 1]; } return 'unknown'; } // Initialize port managers after DOM is ready async initializePortManagers() { //// // console.log('🔌 Looking for port manager containers...'); const portContainers = document.querySelectorAll('.port-manager-container'); //// // console.log(`🔌 Found ${portContainers.length} port manager containers`); // Group port containers by app const appPortContainers = {}; for (const container of portContainers) { const appName = container.dataset.appName; if (!appPortContainers[appName]) { appPortContainers[appName] = []; } appPortContainers[appName].push(container); } // Create one consolidated port manager per app for (const [appName, containers] of Object.entries(appPortContainers)) { //// // console.log(`🔌 Creating consolidated port manager for app: ${appName} with ${containers.length} port fields`); try { // Get all port configurations for this app const appConfig = this.getCurrentAppConfig(); const allPortConfigs = this.getAllPortConfigs(appConfig, appName); // Create consolidated port manager const portManager = new PortManager(); const html = portManager.generateHTML(appName, allPortConfigs); // Replace the first container with the consolidated port manager const firstContainer = containers[0]; firstContainer.innerHTML = html; // Hide other port containers (PORT_2, PORT_3, etc.) and their labels for (let i = 1; i < containers.length; i++) { const container = containers[i]; const formField = container.closest('.form-field'); if (formField) { formField.style.display = 'none'; } else { container.style.display = 'none'; } } // Hide labels and help text for the first port container this.hidePortFieldLabels(containers[0]); // Initialize port manager with services await portManager.initialize(appName); //// // console.log(`🔌 Consolidated port manager initialized successfully for ${appName}`); } catch (error) { console.error(`Error initializing consolidated port manager for ${appName}:`, error); containers[0].innerHTML = `
Failed to initialize port manager: ${error.message}
`; } } } // Hide labels and help text for port field containers hidePortFieldLabels(container) { const formField = container.closest('.form-field'); if (formField) { // Hide the label const label = formField.querySelector('label.form-label'); if (label) { label.style.display = 'none'; } // Hide the help text const helpText = formField.querySelector('small.form-help'); if (helpText) { helpText.style.display = 'none'; } // Hide any help icons const helpIcons = formField.querySelectorAll('.help-icon'); helpIcons.forEach(icon => { icon.style.display = 'none'; }); } } // Get all port configurations for an app getAllPortConfigs(appConfig, appName) { const portConfigs = []; const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`; Object.keys(appConfig).forEach(key => { if (key.startsWith(portPrefix)) { const configValue = appConfig[key]; if (configValue && configValue.trim() !== '') { portConfigs.push(configValue); } } }); // Return as array — one CFG__PORT_N value per element. The // port manager iterates this directly so commas inside fields // (multi-button labels / paths) stay meaningful and hand-editable. return portConfigs; } // Get current app configuration getCurrentAppConfig() { //// // console.log(`🔧 getCurrentAppConfig DEBUG: this.currentApp = "${this.currentApp}"`); //// // console.log(`🔧 getCurrentAppConfig DEBUG: window.apps =`, window.apps ? `${window.apps.length} apps` : 'undefined'); if (window.apps && this.currentApp) { const target = String(this.currentApp).toLowerCase(); const app = window.apps.find(a => { const slug = (a.command || '').split(' ').pop(); return slug.toLowerCase() === target; }); return app?.config || {}; } return {}; } // True when the current app can be backed up live (set by the apps.json // generator from its compose backup labels). Drives whether the per-app // "live" backup-strategy option is offered. isCurrentAppLiveCapable() { if (window.apps && this.currentApp) { const target = String(this.currentApp).toLowerCase(); const app = window.apps.find(a => { const slug = (a.command || '').split(' ').pop(); return slug.toLowerCase() === target; }); return app?.backup_live_capable === true; } return false; } /** * Collect configuration from form and format as pipe-separated string */ collectConfigFromForm(appName) { const form = document.getElementById(`app-form-${appName}`); if (!form) { console.warn(`ℹ️ No config form found for ${appName}, proceeding with no config override`); return ''; } const configPairs = []; const inputs = form.querySelectorAll('input, select, textarea'); inputs.forEach(input => { const name = input.name; if (!name || !name.startsWith('CFG_')) return; // Skip the port-manager's aggregate hidden input — it's UI-only state // (combined value of all ports for the live editor) and isn't a real key // in any .config file. The actual per-port keys (CFG__PORT_1 …) are // separate hidden inputs that we DO want to collect. if (name.endsWith('_PORT_MANAGER')) return; let value; if (input.type === 'checkbox') { value = input.checked ? 'true' : 'false'; } else { value = input.value.trim(); if (!value) return; } // Encode `|` characters inside values so the bash splitter (IFS='|') doesn't // fragment values that legitimately contain a pipe — port configs are the // main case, format: `service|name|ext:int|access|...`. The bash side decodes // `%7C` back to `|` after splitting. const encodedValue = value.replace(/\|/g, '%7C'); configPairs.push(`${name}=${encodedValue}`); }); // `|` between pairs — `,` shows up in real config values (domain lists etc). const collectedConfig = configPairs.join('|'); console.log(`📋 Collected config for ${appName}:`, collectedConfig || '(empty - using defaults from apps.json)'); return collectedConfig; } // ----- Unsaved config-change tracking ------------------------------------- // The app config panel is pure DOM until the user hits Apply/Update. These // helpers track when a field differs from its rendered (saved) value, show a // sticky bar offering Apply/Discard, and register an SPA nav guard so leaving // the page with unsaved edits prompts first. // Snapshot of CFG_ field values, keyed by input name. Mirrors the filter in // collectConfigFromForm so the two agree on what counts as a config field. _readConfigFieldState(form) { const state = {}; form.querySelectorAll('input, select, textarea').forEach((input) => { const name = input.name; if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return; state[name] = (input.type === 'checkbox') ? (input.checked ? 'true' : 'false') : input.value; }); return state; } _getDirtyConfigFields() { if (!this._dirtyAppName || !this._configSnapshot) return []; const form = document.getElementById(`app-form-${this._dirtyAppName}`); if (!form) return []; const current = this._readConfigFieldState(form); return Object.keys(current).filter((name) => current[name] !== (this._configSnapshot[name] ?? '')); } _isConfigDirty() { return this._getDirtyConfigFields().length > 0; } // Called once per config-panel render: snapshots the saved state, wires the // change listener + sticky bar, and (re)registers the nav guard. Only tracks // installed apps — for a fresh install the Install button is already the // "apply" action, so a dirty bar would just be noise. wireConfigDirtyTracking(appName) { const form = document.getElementById(`app-form-${appName}`); if (!form) return; const app = (window.apps || []).find((a) => (a.command || '').endsWith(` ${appName}`) || (a.name && a.name.toLowerCase() === appName.toLowerCase()) ); if (!app || !app.installed) { this._clearConfigDirty(); return; } this._dirtyAppName = appName; this._configSnapshot = this._readConfigFieldState(form); if (form.dataset.dirtyWired !== '1') { form.dataset.dirtyWired = '1'; const onEdit = () => this._refreshDirtyBar(); form.addEventListener('input', onEdit); form.addEventListener('change', onEdit); } this._ensureDirtyBar(appName, form); // beforeunload covers tab close / refresh / external nav — the browser // shows its own generic prompt. Registered once for the page lifetime. if (!this._beforeUnloadWired) { this._beforeUnloadWired = true; window.addEventListener('beforeunload', (e) => { if (this._isConfigDirty()) { e.preventDefault(); e.returnValue = ''; } }); } // SPA route changes route through this guard (see spa.js navigate()). window.__appConfigNavGuard = (targetPath) => this._appConfigNavGuard(targetPath); this._refreshDirtyBar(); } // Build (or rebuild) the sticky bar at the bottom of the config form. _ensureDirtyBar(appName, form) { const stale = document.getElementById('config-dirty-bar'); if (stale) stale.remove(); const bar = document.createElement('div'); bar.id = 'config-dirty-bar'; bar.className = 'config-dirty-bar'; bar.style.display = 'none'; bar.innerHTML = ` `; // Sits in normal flow between the config content and the action buttons. const actions = form.querySelector('.config-actions'); if (actions) { form.insertBefore(bar, actions); } else { form.appendChild(bar); } bar.querySelector('.config-dirty-discard').addEventListener('click', () => this._discardConfigChanges()); bar.querySelector('.config-dirty-apply').addEventListener('click', () => this.installApp(appName)); } _refreshDirtyBar() { const bar = document.getElementById('config-dirty-bar'); if (!bar) return; const count = this._getDirtyConfigFields().length; if (count === 0) { bar.style.display = 'none'; return; } const label = bar.querySelector('.config-dirty-count'); if (label) label.textContent = `${count} unsaved change${count === 1 ? '' : 's'}`; bar.style.display = 'flex'; } // Revert every field to its snapshot value, then re-fire change/input so // dependent UI (showWhen visibility, etc.) reconciles. _discardConfigChanges() { if (!this._dirtyAppName || !this._configSnapshot) return; const form = document.getElementById(`app-form-${this._dirtyAppName}`); if (!form) return; form.querySelectorAll('input, select, textarea').forEach((input) => { const name = input.name; if (!name || !name.startsWith('CFG_') || name.endsWith('_PORT_MANAGER')) return; if (!(name in this._configSnapshot)) return; const orig = this._configSnapshot[name]; if (input.type === 'checkbox') { input.checked = (orig === 'true'); } else { input.value = orig; } input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); }); this._refreshDirtyBar(); } // Drop the dirty state without touching the form — used when the changes are // being applied (the form is about to be replaced) or discarded on leave. _clearConfigDirty() { this._configSnapshot = null; this._dirtyAppName = null; window.__appConfigNavGuard = null; const bar = document.getElementById('config-dirty-bar'); if (bar) bar.style.display = 'none'; } // SPA nav guard body — returns 'proceed' | 'stay'. 'apply' kicks off the // normal apply flow and stays put (apply navigates to the tasks view itself). async _appConfigNavGuard() { if (!this._isConfigDirty()) return 'proceed'; const appName = this._dirtyAppName; const decision = await this._confirmLeaveUnsaved(appName); if (decision === 'apply') { this.installApp(appName); return 'stay'; } if (decision === 'discard') { this._clearConfigDirty(); return 'proceed'; } return 'stay'; } // Apply / Discard / Stay prompt. Resolves with the chosen action; closing // via the X or backdrop resolves 'stay' (the safe default). _confirmLeaveUnsaved(appName) { let displayName = appName; const app = (window.apps || []).find((a) => (a.command || '').endsWith(` ${appName}`) || (a.name && a.name.toLowerCase() === appName.toLowerCase()) ); if (app && app.name) displayName = app.name.split(' - ')[0].trim(); return new Promise((resolve) => { let decided = false; const finish = (val, modal) => { if (decided) return; decided = true; if (modal) modal.close(); resolve(val); }; window.openEoModal({ id: 'config-unsaved-modal', size: 'sm', eyebrow: '⚠ Unsaved changes', title: displayName, desc: 'You have configuration changes that haven’t been applied.', body: `

Apply before you go?

Apply runs the update now. Discard throws the edits away. Stay keeps you on this page.

`, actions: [ { label: 'Apply', variant: 'primary', onClick: (m) => finish('apply', m) }, { label: 'Discard', variant: 'secondary', onClick: (m) => finish('discard', m) }, { label: 'Stay', variant: 'secondary', onClick: (m) => finish('stay', m) } ], onClose: () => { if (!decided) { decided = true; resolve('stay'); } } }); }); } // Update service buttons sidebar with service data from apps-services.json async updateServiceButtonsSidebar(app, appName) { if (!window.serviceButtons) return; try { // Update sidebar using the new ServiceButtons API await window.serviceButtons.updateSidebar(appName); } catch (error) { console.error('Error updating service buttons sidebar:', error); } } // Show service popup on hover async showServicePopup(event, appName) { if (!window.serviceButtons) return; const popup = document.getElementById(`service-popup-${appName}`); if (!popup) return; try { // Load services if not already loaded if (window.serviceButtons.services.length === 0) { await window.serviceButtons.loadServices(); } // Generate buttons HTML const buttonsHTML = await window.serviceButtons.generateButtonsHTML(appName); const content = popup.querySelector('.service-popup-content'); if (content) { content.innerHTML = buttonsHTML; } // Show popup popup.style.display = 'block'; } catch (error) { console.error('Error showing service popup:', error); } } // Hide service popup hideServicePopup() { const popups = document.querySelectorAll('.service-popup'); popups.forEach(popup => { popup.style.display = 'none'; }); } async installApp(appName) { const installedApp = (window.apps || []).find(a => (a.command || '').endsWith(` ${appName}`) || a.name === appName ); const isInstalled = !!(installedApp && installedApp.installed); if (isInstalled) { this.showUpdateConfirmModal(appName); return; } if (await this.shouldRecommendGluetun(appName, installedApp)) { this.showGluetunRecommendModal(appName, installedApp); return; } return this.executeInstall(appName, false); } async shouldRecommendGluetun(appName, appData) { if (appName === 'gluetun') return false; if (this.checkServiceInstalled('gluetun')) return false; const overrideOn = this.checkRequirementEnabled?.('GLUETUN_FOR_ALL'); if (overrideOn) return true; if (!this._gluetunEligiblePromise) { this._gluetunEligiblePromise = (async () => { try { const r = await fetch('/data/apps/gluetun-eligible-categories.json', { cache: 'no-store' }); if (!r.ok) return new Set(); const j = await r.json(); return new Set((j.categories || []).map((c) => String(c).toLowerCase())); } catch { return new Set(); } })(); } const allow = await this._gluetunEligiblePromise; const cat = String(appData?.category || '').toLowerCase(); return allow.has(cat); } showGluetunRecommendModal(appName, appData) { const existing = document.getElementById('gluetun-recommend-modal'); if (existing) existing.remove(); const displayName = appData?.name?.split(' - ')[0]?.trim() || appName; let icon = appData?.icon || `/core/icons/apps/${appName}.svg`; if (icon && !icon.startsWith('/')) icon = '/' + icon; const bodyHtml = `

Apps in this category usually benefit from VPN routing

Without Gluetun, this app's outbound traffic uses your real public IP — exposing activity to ISPs, copyright trackers, and the destination services.

`; window.openEoModal({ id: 'gluetun-recommend-modal', size: 'sm', icon, iconAlt: displayName, eyebrow: 'About to install', title: displayName, desc: 'VPN routing is recommended for this app.', body: bodyHtml, actions: [ { label: 'Install Gluetun first', variant: 'primary', onClick: (modal) => { modal.close(); this.showAppDetailWithConfig('gluetun'); }}, { label: 'Continue without VPN', variant: 'secondary', onClick: (modal) => { modal.close(); this.executeInstall(appName, false); }} ] }); } // Per-app required-field map keyed by lowercased app slug. Add entries // here as new apps grow required inputs. Each value is { keys, message } // where `keys` is the CFG_* names that must be non-empty. getRequiredConfigKeys(appName) { const slug = (appName || '').toLowerCase(); const map = { traefik: ['CFG_TRAEFIK_EMAIL'] }; return map[slug] || []; } validateRequiredConfig(appName) { const required = this.getRequiredConfigKeys(appName); if (required.length === 0) return { ok: true, missing: [] }; const missing = []; for (const key of required) { const input = document.getElementById(`config-${key}`); if (!input) continue; const v = (input.value || '').trim(); if (!v || v === 'changeme' || v === 'changeme.com') missing.push({ key, input }); } return { ok: missing.length === 0, missing }; } flashRequiredFields(missing) { if (!missing.length) return; if (window.appTabbedManager && typeof window.appTabbedManager.switchTab === 'function') { window.appTabbedManager.switchTab('config'); } missing.forEach(({ input }, idx) => { input.classList.add('field-required-error'); input.setAttribute('title', 'Required'); const clear = () => { input.classList.remove('field-required-error'); input.removeAttribute('title'); input.removeEventListener('input', clear); input.removeEventListener('change', clear); }; input.addEventListener('input', clear); input.addEventListener('change', clear); if (idx === 0) { setTimeout(() => { input.scrollIntoView({ behavior: 'smooth', block: 'center' }); try { input.focus({ preventScroll: true }); } catch { input.focus(); } }, 200); } }); } async executeInstall(appName, resetNetwork) { const validation = this.validateRequiredConfig(appName); if (!validation.ok) { this.flashRequiredFields(validation.missing); const labels = validation.missing.map(({ key }) => key.replace(/^CFG_[A-Z0-9]+_/, '').replace(/_/g, ' ').toLowerCase() ).join(', '); const sys = (typeof window.ensureNotificationSystem === 'function') ? window.ensureNotificationSystem() : window.notificationSystem; if (sys && typeof sys.show === 'function') { sys.show(`Missing required field${validation.missing.length > 1 ? 's' : ''}: ${labels}`, 'error'); } return; } // Immediately disable buttons using appTabbedManager for consistency if (window.appTabbedManager) { window.appTabbedManager.disableAppButtons(appName, 'install'); } else { this.disableInstallButton(appName, 'install'); } // Initialize task system if not available if (!window.tasksManager || !window.tasksManager.router) { try { await this.loadTaskSystem(); } catch (error) { console.error(`❌ Failed to initialize task system:`, error); if (window.appTabbedManager) { window.appTabbedManager.enableAppButtons(appName); } else { this.enableInstallButton(appName); } } } if (window.tasksManager && window.tasksManager.router) { try { // Collect configuration from form const config = this.collectConfigFromForm(appName); // Create installation task const task = await window.tasksManager.router.routeAction('install', { appName: appName, config: config, resetNetwork: resetNetwork }); // Changes are being applied — clear the unsaved-changes state so the // switch to the tasks view isn't caught by the leave-confirm guard. this._clearConfigDirty(); // Show success message and switch to tasks this.addSuccessLog(`Installation task created for ${appName}. Switching to tasks view...`); // Switch to tasks view to show the installation progress with auto-loaded task setTimeout(() => { if (window.appTabbedManager) { // Switch to tasks tab within current app page window.appTabbedManager.switchTab('tasks'); // Auto-expand the created task setTimeout(() => { if (task && window.appTabbedManager.tasksManager) { window.appTabbedManager.tasksManager.highlightedTaskId = task.id; window.appTabbedManager.tasksManager.renderTasks(); } }, 500); } else if (window.librePortalSPA) { // Fallback: navigate to app with tasks tab const taskUrl = window.appPath(appName, 'tasks', null, task ? task.id : null); window.librePortalSPA.navigateTo(taskUrl); } else if (window.navigateToRoute) { window.navigateToRoute(window.appPath(appName, 'tasks', null, task ? task.id : null).replace(/^\//, '')); } }, 1000); } catch (error) { this.addErrorLog(`Failed to create installation task: ${error.message}`); // Re-enable buttons on error if (window.appTabbedManager) { window.appTabbedManager.enableAppButtons(appName); } else { this.enableInstallButton(appName); } } } else { // Fallback to original simulation if task system not available //// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`); //// // console.log(`🔍 Debug info:`, { //tasksManager: !!window.tasksManager, //router: !!(window.tasksManager && window.tasksManager.router), //windowTasksManager: window.tasksManager //}); this.addInfoLog(`Starting installation of ${appName}...`); // Simulate installation process setTimeout(() => { this.addSuccessLog(`Installation completed successfully!`); // Re-enable buttons after simulation completes if (window.appTabbedManager) { window.appTabbedManager.enableAppButtons(appName); } else { this.enableInstallButton(appName); } }, 2000); } } /** * Initialize task system on demand */ async loadTaskSystem() { try { //// // console.log(`🔧 Loading task system components...`); // Only load scripts if they're not already loaded const scripts = [ { name: 'TaskManager', src: '/core/lib/task-manager.js' }, { name: 'TaskCommands', src: '/core/lib/task-commands.js' }, { name: 'TaskActions', src: '/core/lib/task-actions.js' }, { name: 'TaskRouter', src: '/core/lib/task-router.js' }, { name: 'TasksManager', src: '/components/tasks/tasks-manager.js' } ]; for (const script of scripts) { if (!window[script.name]) { //// // console.log(`📦 Loading ${script.name}...`); await this.loadScript(script.src); } else { //// // console.log(`✅ ${script.name} already loaded`); } } // Initialize tasks manager if not already initialized if (window.TasksManager && !window.tasksManager) { //// // console.log(`🔧 Initializing TasksManager instance...`); try { window.tasksManager = new TasksManager(); //// // console.log(`✅ TasksManager constructor completed`); //// // console.log(`🔧 TasksManager instance:`, window.tasksManager); if (typeof window.tasksManager.init === 'function') { //// // console.log(`🔧 Calling TasksManager.init()...`); await window.tasksManager.init(); //// // console.log(`✅ TasksManager.init() completed`); } else { //// // console.log(`⚠️ TasksManager.init() is not a function`); } } catch (error) { console.error(`❌ Failed to initialize TasksManager:`, error); throw error; } } else if (window.tasksManager) { //// // console.log(`✅ TasksManager instance already exists`); } else { //// // console.log(`❌ TasksManager class not available`); } //// // console.log(`✅ Task system components loaded and initialized`); } catch (error) { console.error(`❌ Failed to load task system:`, error); throw error; } } /** * Load script helper */ loadScript(src) { return new Promise((resolve, reject) => { // Check if script is already loaded if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } const script = document.createElement('script'); script.src = src; script.onload = resolve; script.onerror = reject; document.head.appendChild(script); }); } // Terminal logging functions with old styling addLogMessage(message, type = 'info') { const messageLog = document.getElementById('message-log'); if (!messageLog) return; const timestamp = new Date().toLocaleTimeString(); const messageLine = document.createElement('div'); messageLine.className = `log-entry ${type}`; messageLine.innerHTML = `[${timestamp}] ${message}`; messageLog.appendChild(messageLine); // Only scroll to bottom if user hasn't scrolled up if (messageLog.scrollTop >= messageLog.scrollHeight - messageLog.clientHeight - 50) { messageLog.scrollTop = messageLog.scrollHeight; } } addSuccessLog(message) { this.addLogMessage(message, 'success'); } addErrorLog(message) { this.addLogMessage(message, 'error'); } addWarningLog(message) { this.addLogMessage(message, 'warning'); } addInfoLog(message) { this.addLogMessage(message, 'info'); } clearConsole() { const messageLog = document.getElementById('message-log'); if (!messageLog) return; messageLog.innerHTML = '
[' + new Date().toLocaleTimeString() + '] Console cleared...
'; messageLog.scrollTop = 0; // Force to top } initializeConsole() { const messageLog = document.getElementById('message-log'); if (messageLog) { messageLog.scrollTop = 0; // Force scroll to top messageLog.innerHTML = ''; // Clear any existing content // Add initial message after a tiny delay to ensure it renders at top setTimeout(() => { this.addInfoLog('Application configuration loaded successfully'); }, 100); } } // Public entry point bound to the "Uninstall App" button. Doesn't kick // anything off itself — it only opens the confirmation modal so the user // has to explicitly approve a destructive action (matches the create // backup / update modals' UX). Actual work happens in executeUninstall. uninstallApp(appName) { this.showUninstallConfirmModal(appName); } async executeUninstall(appName, deleteImage = false, deleteTasks = false) { // Track the task id this call spawns so the post-completion handler // can delete it (the bash side skips the in-flight task to avoid a // race with the processor still writing its status). this._pendingTaskCleanup = this._pendingTaskCleanup || new Map(); // Immediately disable buttons using appTabbedManager for consistency if (window.appTabbedManager) { window.appTabbedManager.disableAppButtons(appName, 'uninstall'); } else { // Fallback to our own button disabling if appTabbedManager not available this.disableUninstallButton(appName, 'uninstall'); } // Check if tasks system is available, initialize if needed //// // console.log(`🔍 Checking task system availability for uninstall...`); //// // console.log(`🔍 window.tasksManager:`, !!window.tasksManager); //// // console.log(`🔍 window.tasksManager.router:`, !!(window.tasksManager && window.tasksManager.router)); // Initialize task system if not available if (!window.tasksManager || !window.tasksManager.router) { //// // console.log(`🔧 Initializing task system...`); try { // Load task system components await this.loadTaskSystem(); //// // console.log(`✅ Task system initialized successfully`); } catch (error) { console.error(`❌ Failed to initialize task system:`, error); // Re-enable buttons on error if (window.appTabbedManager) { window.appTabbedManager.enableAppButtons(appName); } else { this.enableUninstallButton(appName); } // Continue with fallback if initialization fails } } if (window.tasksManager && window.tasksManager.router) { //// // console.log(`🚀 Uninstalling ${appName} via task system...`); try { // Create uninstallation task const task = await window.tasksManager.router.routeAction('uninstall', { appName: appName, deleteImage: deleteImage, deleteTasks: deleteTasks }); if (deleteTasks && task && task.id) { this._pendingTaskCleanup.set(task.id, appName); } // Show success message and switch to tasks this.addSuccessLog(`Uninstallation task created for ${appName}. Switching to tasks view...`); // Switch to tasks view to show the uninstallation progress with auto-loaded task setTimeout(() => { if (window.appTabbedManager) { // Switch to tasks tab within current app page window.appTabbedManager.switchTab('tasks'); // Auto-expand the created task setTimeout(() => { if (task && window.appTabbedManager.tasksManager) { window.appTabbedManager.tasksManager.highlightedTaskId = task.id; window.appTabbedManager.tasksManager.renderTasks(); } }, 500); } else if (window.librePortalSPA) { // Fallback: navigate to app with tasks tab const taskUrl = window.appPath(appName, 'tasks', null, task ? task.id : null); // console.log(`🔄 Navigating to app tasks with uninstall task: ${task?.id}`); window.librePortalSPA.navigateTo(taskUrl); } else if (window.navigateToRoute) { window.navigateToRoute(window.appPath(appName, 'tasks', null, task ? task.id : null).replace(/^\//, '')); } }, 1000); } catch (error) { this.addErrorLog(`Failed to create uninstallation task: ${error.message}`); // Re-enable buttons on error if (window.appTabbedManager) { window.appTabbedManager.enableAppButtons(appName); } else { this.enableUninstallButton(appName); } } } else { // Fallback to original simulation if task system not available //// // console.log(`⚠️ Task system not available, using fallback for ${appName}...`); this.addInfoLog(`Starting uninstallation of ${appName}...`); // Simulate uninstallation process setTimeout(() => { this.addSuccessLog(`Uninstallation completed successfully!`); // Re-enable buttons after simulation completes if (window.appTabbedManager) { window.appTabbedManager.enableAppButtons(appName); } else { this.enableUninstallButton(appName); } }, 1500); } } // Enhance scrollbar dynamically for tabs-list enhanceTabsScrollbar() { const tabsList = document.querySelector('.tabs-list'); if (tabsList) { // Check if scrolling is needed const isScrollable = tabsList.scrollWidth > tabsList.clientWidth; if (isScrollable) { // Add data attribute for enhanced styling tabsList.setAttribute('data-scrollable', 'true'); //// // console.log('✅ Enhanced tabs scrollbar for scrollable content'); } else { // Remove attribute if not scrollable tabsList.removeAttribute('data-scrollable'); //// // console.log('📝 Tabs list not scrollable, using default styling'); } // Monitor for content changes const observer = new MutationObserver(() => { setTimeout(() => this.enhanceTabsScrollbar(), 100); }); observer.observe(tabsList, { childList: true, subtree: true, attributes: true, attributeFilter: ['class', 'style'] }); } } // Helper methods for button state management disableInstallButton(appName, action) { const form = document.getElementById(`app-form-${appName}`); if (!form) return; // Find the install/update button const button = form.querySelector('.btn-install, .btn-manage'); if (!button) return; // Disable button and add spinner button.disabled = true; button.classList.add('disabled', 'task-running'); // Add loading spinner if not already present if (!button.querySelector('.spinner')) { const originalContent = button.innerHTML; button.dataset.originalContent = originalContent; // Extract text content from original HTML (remove icons/SVGs) const tempDiv = document.createElement('div'); tempDiv.innerHTML = originalContent; const textContent = tempDiv.textContent || tempDiv.innerText || originalContent; button.innerHTML = ` ${textContent.trim()} `; } // console.log(`🔍 Install button disabled for ${appName} during ${action}`); } enableInstallButton(appName) { const form = document.getElementById(`app-form-${appName}`); if (!form) return; // Find the install/update button const button = form.querySelector('.btn-install, .btn-manage'); if (!button) return; // Re-enable button and restore original content button.disabled = false; button.classList.remove('disabled', 'task-running'); // Restore original content if it was saved if (button.dataset.originalContent) { button.innerHTML = button.dataset.originalContent; delete button.dataset.originalContent; } // Remove any spinners const spinners = button.querySelectorAll('.spinner'); spinners.forEach(spinner => spinner.remove()); // console.log(`🔍 Install button enabled for ${appName}`); } disableUninstallButton(appName, action) { const form = document.getElementById(`app-form-${appName}`); if (!form) return; // Find the uninstall button const button = form.querySelector('.btn-uninstall'); if (!button) return; // Disable button and add spinner button.disabled = true; button.classList.add('disabled', 'task-running'); // Add loading spinner if not already present if (!button.querySelector('.spinner')) { const originalContent = button.innerHTML; button.dataset.originalContent = originalContent; // Extract text content from original HTML (remove icons/SVGs) const tempDiv = document.createElement('div'); tempDiv.innerHTML = originalContent; const textContent = tempDiv.textContent || tempDiv.innerText || originalContent; button.innerHTML = ` ${textContent.trim()} `; } // console.log(`🔍 Uninstall button disabled for ${appName} during ${action}`); } enableUninstallButton(appName) { const form = document.getElementById(`app-form-${appName}`); if (!form) return; // Find the uninstall button const button = form.querySelector('.btn-uninstall'); if (!button) return; // Re-enable button and restore original content button.disabled = false; button.classList.remove('disabled', 'task-running'); // Restore original content if it was saved if (button.dataset.originalContent) { button.innerHTML = button.dataset.originalContent; delete button.dataset.originalContent; } // Remove any spinners const spinners = button.querySelectorAll('.spinner'); spinners.forEach(spinner => spinner.remove()); // console.log(`🔍 Uninstall button enabled for ${appName}`); } // Static methods for global access static showAppsList(category = null) { if (window.appsManager) { window.appsManager.showAppsList(category); } } static showAppDetail(appName) { if (window.appsManager) { window.appsManager.showAppDetail(appName); } } showUpdateConfirmModal(appName) { let displayName = appName; let icon = `/core/icons/apps/${appName}.svg`; if (window.apps) { const app = window.apps.find(a => (a.command || '').endsWith(` ${appName}`) || (a.name && a.name.toLowerCase() === appName.toLowerCase()) ); if (app) { displayName = app.name; if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.icon; } } const bodyHtml = `

Container will restart

The configuration will be reapplied and the container restarted to pick up changes.

`; window.openEoModal({ id: 'update-confirm-modal', size: 'sm', icon, iconAlt: displayName, eyebrow: 'Apply Configuration', title: displayName, desc: 'Reapply config and restart the container.', body: bodyHtml, actions: [ { label: 'Update', variant: 'primary', onClick: (modal) => { const cb = modal.contentEl.querySelector('#update-reset-network'); const resetNetwork = !!(cb && cb.checked); modal.close(); this.executeInstall(appName, resetNetwork); }}, { label: 'Cancel', variant: 'secondary' } ] }); } // Confirmation modal for the destructive "Uninstall App" action. showUninstallConfirmModal(appName) { let displayName = appName; let icon = `/core/icons/apps/${appName}.svg`; let app = null; if (window.apps) { app = window.apps.find(a => (a.command || '').endsWith(` ${appName}`) || (a.name && a.name.toLowerCase() === appName.toLowerCase()) ); if (app) { displayName = app.name; if (app.icon) icon = app.icon.startsWith('/') ? app.icon : '/' + app.icon; } } // Cascade warning: if the app declares related apps (via CFG__RELATED_APPS) // and any of them are currently installed, the uninstall will take them down // too — surface that up-front so the user isn't surprised. const upper = appName.toUpperCase().replace(/-/g, '_'); const relatedRaw = app?.config?.[`CFG_${upper}_RELATED_APPS`]?.value || app?.config?.[`CFG_${upper}_RELATED_APPS`] || ''; const relatedNames = String(relatedRaw) .split(',').map(s => s.trim()).filter(Boolean) .map(slug => { const rel = (window.apps || []).find(a => (a.command || '').endsWith(` ${slug}`) || (a.name && a.name.toLowerCase() === slug.toLowerCase()) ); return rel && rel.installed ? (rel.name || slug) : null; }) .filter(Boolean); const cascadeBlock = relatedNames.length ? `

Cascade removal

Will also uninstall: ${relatedNames.map(n => `${n}`).join(', ')}.

` : ''; const bodyHtml = ` ${cascadeBlock}

This cannot be undone

The container will be stopped and its data removed.

`; window.openEoModal({ id: 'uninstall-confirm-modal', size: 'sm', icon, iconAlt: displayName, eyebrow: 'Uninstall', title: displayName, desc: 'Confirm to remove this application.', body: bodyHtml, actions: [ { label: 'Uninstall', variant: 'danger', onClick: (modal) => { const cb1 = modal.contentEl.querySelector('#uninstall-delete-image'); const cb2 = modal.contentEl.querySelector('#uninstall-delete-tasks'); const deleteImage = !!(cb1 && cb1.checked); const deleteTasks = !!(cb2 && cb2.checked); modal.close(); this.executeUninstall(appName, deleteImage, deleteTasks); }}, { label: 'Cancel', variant: 'secondary' } ] }); } } // Service Buttons Manager - handles service URL buttons from port config + apps-services.json class ServiceButtons { constructor() { this.services = []; } // Load services from apps-services.json async loadServices() { try { const response = await fetch('/data/apps/generated/apps-services.json', { cache: 'no-store' }); const data = await response.json(); this.services = data.services || []; return this.services; } catch (error) { console.error('Error loading services:', error); return []; } } // Parse port configuration from app config parsePortConfig(appName) { const ports = []; // Get app config from window.apps if available const app = window.apps?.find(a => a.name === appName || a.command.includes(appName)); if (!app || !app.config) return ports; const appConfig = app.config; const portPrefix = `CFG_${appName.toUpperCase()}_PORT_`; Object.keys(appConfig).forEach(key => { if (key.startsWith(portPrefix)) { const configValue = appConfig[key]; if (configValue && configValue.trim() !== '') { const parts = configValue.split('|'); if (parts.length >= 8) { const isNineCol = parts.length >= 9; ports.push({ service: parts[0], name: parts[1], external: parts[2].split(':')[0], internal: parts[2].split(':')[1], access: parts[3], protocol: parts[4], loginRequired: isNineCol ? parts[5] === 'true' : false, traefikManaged: isNineCol ? parts[6] === 'true' : parts[5] === 'true', buttonEnabled: isNineCol ? parts[7] === 'true' : parts[6] === 'true', buttonText: isNineCol ? parts[8] : parts[7] }); } } } }); return ports; } // Get services for a specific app from config, then fill in real IPs/ports from apps-services.json async getServicesForApp(appName) { const portConfig = this.parsePortConfig(appName); // console.log(`📦 Port config for ${appName}:`, portConfig); const portServices = portConfig.filter(p => p.buttonEnabled); // console.log(`✅ Enabled services for ${appName}:`, portServices); if (portServices.length === 0) return []; // Load services from apps-services.json if not already loaded if (this.services.length === 0) { await this.loadServices(); } // console.log(`🌐 Loaded ${this.services.length} services from apps-services.json`); // Merge port config with real data from apps-services.json return portServices.map(portService => { // Find matching service in apps-services.json const serviceData = this.services.find(s => s.app === appName && s.name === portService.name ); // console.log(`🔗 Matching service for ${portService.name}:`, serviceData); const merged = { ...portService, serviceIP: serviceData?.serviceIP || '', externalPort: serviceData?.externalPort || portService.external, internalPort: serviceData?.internalPort || portService.internal, serverIP: serviceData?.serverIP || '', externalURL: serviceData?.externalURL || '', internalURL: serviceData?.internalURL || '' }; // console.log(`🎯 Merged service data:`, merged); return merged; }); } getServiceIcon(serviceName) { const icons = { 'webui': '🌐', 'ssh': '🔒', 'dns': '🌍', 'api': '🔌', 'admin': '⚙️', 'dashboard': '📊', 'default': '🔗' }; return icons[serviceName.toLowerCase()] || icons['default']; } // Generate HTML for service buttons async generateButtonsHTML(appName) { const appServices = await this.getServicesForApp(appName); if (appServices.length === 0) { return ''; } return appServices.map(service => { // Use externalURL if available, otherwise construct from serverIP and port let url; const proto = ['http', 'https'].includes((service.protocol || '').toLowerCase()) ? service.protocol.toLowerCase() : 'http'; if (service.externalURL) { url = service.externalURL; } else if (service.serverIP && service.externalPort) { url = `${proto}://${service.serverIP}:${service.externalPort}`; } else if (service.externalPort && service.externalPort !== 'random') { url = `${proto}://localhost:${service.externalPort}`; } else { return ''; } const icon = this.getServiceIcon(service.name); const protectedClass = service.loginRequired ? ' protected' : ''; const lockIcon = service.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : ''; return ` ${icon} ${service.buttonText} ${lockIcon} `; }).join(''); } lockIconSVG(title) { return ` `; } // Update service buttons in app-header async updateSidebar(appName) { const buttonsContainer = document.getElementById('service-buttons-container'); if (!buttonsContainer) return; if (this.services.length === 0) { await this.loadServices(); } // Use apps-services.json as primary source — filter by app and buttonEnabled flag const proto = s => ['http', 'https'].includes((s.protocol || '').toLowerCase()) ? s.protocol.toLowerCase() : 'http'; const appServices = this.services.filter(s => s.app === appName && s.buttonEnabled === true); // Welcome chip — first slot, before the URL buttons. const welcomeBtn = ` `; if (appServices.length === 0) { buttonsContainer.innerHTML = welcomeBtn; buttonsContainer.style.display = 'flex'; return; } // Multi-button render via the shared expandServiceLinks() helper. buttonsContainer.innerHTML = welcomeBtn + appServices.flatMap(s => { const protectedClass = s.loginRequired ? ' protected' : ''; const lockIcon = s.loginRequired ? this.lockIconSVG('Login required for this URL — credentials in Config → General → Logins.') : ''; return window.expandServiceLinks(s).map(({ url, label }) => ` ${label} ${lockIcon} `); }).filter(Boolean).join(''); buttonsContainer.style.display = 'flex'; } } // Global instance window.appsManager = new AppsManager(); window.serviceButtons = new ServiceButtons(); // Toggle service trigger popup — one open at a time, click-outside to close window.toggleServiceTrigger = (appName) => { const clicked = document.getElementById(`service-trigger-${appName}`); if (!clicked) return; const isOpen = clicked.classList.contains('open'); // Close all open triggers document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open')); if (!isOpen) clicked.classList.add('open'); }; document.addEventListener('click', () => { document.querySelectorAll('.service-trigger.open').forEach(el => el.classList.remove('open')); }); document.addEventListener('change', (e) => { if (!e.target?.classList?.contains('advanced-fields-checkbox')) return; const panel = e.target.closest('.panel-fields') || e.target.closest('.config-section') || document; panel.querySelectorAll('.advanced-field').forEach(el => { el.classList.toggle('is-hidden', !e.target.checked); }); });