From df750593308b9bd6284c620b5b6f8e89c3a30755 Mon Sep 17 00:00:00 2001 From: librelad Date: Sat, 30 May 2026 15:06:42 +0100 Subject: [PATCH] refactor(apps): decompose apps-manager god-file into 7 responsibility files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brace-aware split of apps-manager.js (4154->2015 base) into apps-grid, config-form, port-manager-integration, gluetun-vpn, config-dirty-guard, install-console, service-buttons-sidebar — augmenting AppsManager.prototype. Removed the dead duplicate initializeSimpleTabs (kept the live later def). Used a self-checking extractor (node --check per cluster, auto-revert on failure): install-dispatch contains a regex literal (/^\d{16}$/) that trips brace-bounding, so its methods were safely LEFT in the base rather than risk a bad split. ServiceButtons class + expandServiceLinks + bootstrap also remain inline. Verified: all 117 methods preserved (none lost), all files node --check clean. Co-Authored-By: Claude Opus 4.8 Signed-off-by: librelad --- .../components/apps/core/js/apps-grid.js | 280 +++ .../components/apps/core/js/apps-manager.js | 2139 ----------------- .../apps/core/js/config-dirty-guard.js | 198 ++ .../components/apps/core/js/config-form.js | 1133 +++++++++ .../components/apps/core/js/gluetun-vpn.js | 350 +++ .../apps/core/js/install-console.js | 49 + .../apps/core/js/port-manager-integration.js | 99 + .../apps/core/js/service-buttons-sidebar.js | 47 + .../frontend/core/boot/system-loader.js | 10 +- 9 files changed, 2165 insertions(+), 2140 deletions(-) create mode 100644 containers/libreportal/frontend/components/apps/core/js/apps-grid.js create mode 100644 containers/libreportal/frontend/components/apps/core/js/config-dirty-guard.js create mode 100644 containers/libreportal/frontend/components/apps/core/js/config-form.js create mode 100644 containers/libreportal/frontend/components/apps/core/js/gluetun-vpn.js create mode 100644 containers/libreportal/frontend/components/apps/core/js/install-console.js create mode 100644 containers/libreportal/frontend/components/apps/core/js/port-manager-integration.js create mode 100644 containers/libreportal/frontend/components/apps/core/js/service-buttons-sidebar.js diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-grid.js b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js new file mode 100644 index 0000000..a3683dd --- /dev/null +++ b/containers/libreportal/frontend/components/apps/core/js/apps-grid.js @@ -0,0 +1,280 @@ +// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base. +Object.assign(AppsManager.prototype, { + 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(); + }, + 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 = ''; + } + } + }, +}); diff --git a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js index a70aff2..4b33e7c 100755 --- a/containers/libreportal/frontend/components/apps/core/js/apps-manager.js +++ b/containers/libreportal/frontend/components/apps/core/js/apps-manager.js @@ -471,162 +471,13 @@ class AppsManager { } } - 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}`); @@ -853,1028 +704,23 @@ class AppsManager { 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; @@ -1926,44 +772,7 @@ class AppsManager { 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) { @@ -1985,87 +794,9 @@ class AppsManager { 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 = {}) { @@ -2181,295 +912,9 @@ class AppsManager { 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) || '/components/apps/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}"]`); @@ -2498,10 +943,6 @@ class AppsManager { } } - // Off-value for a checkbox whose dependency isn't installed. - unmetDependencyValue(fieldConfig) { - return fieldConfig.type === 'checkbox' ? 'false' : ''; - } escHtml(s) { return String(s ?? '') @@ -2533,116 +974,8 @@ class AppsManager { 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() { @@ -2668,104 +1001,8 @@ class AppsManager { 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() { @@ -2848,257 +1085,18 @@ class AppsManager { // 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 => @@ -3119,68 +1117,7 @@ class AppsManager { 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 } @@ -3417,58 +1354,12 @@ class AppsManager { }); } - // 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 @@ -3582,36 +1473,6 @@ class AppsManager { } } - // 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) { diff --git a/containers/libreportal/frontend/components/apps/core/js/config-dirty-guard.js b/containers/libreportal/frontend/components/apps/core/js/config-dirty-guard.js new file mode 100644 index 0000000..877a21d --- /dev/null +++ b/containers/libreportal/frontend/components/apps/core/js/config-dirty-guard.js @@ -0,0 +1,198 @@ +// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base. +Object.assign(AppsManager.prototype, { + // 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'); } } + }); + }); + }, +}); diff --git a/containers/libreportal/frontend/components/apps/core/js/config-form.js b/containers/libreportal/frontend/components/apps/core/js/config-form.js new file mode 100644 index 0000000..895108a --- /dev/null +++ b/containers/libreportal/frontend/components/apps/core/js/config-form.js @@ -0,0 +1,1133 @@ +// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base. +Object.assign(AppsManager.prototype, { + 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 simple tabs (working method from app-config-original.js) + 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; + }, + // 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); + } + } + }, + // 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 ? `` : ''} +
+ `; + }, + // Off-value for a checkbox whose dependency isn't installed. + unmetDependencyValue(fieldConfig) { + return fieldConfig.type === 'checkbox' ? 'false' : ''; + }, + // 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' } + ]; + } + }, + // 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'] + }); + } + }, +}); diff --git a/containers/libreportal/frontend/components/apps/core/js/gluetun-vpn.js b/containers/libreportal/frontend/components/apps/core/js/gluetun-vpn.js new file mode 100644 index 0000000..6235b1a --- /dev/null +++ b/containers/libreportal/frontend/components/apps/core/js/gluetun-vpn.js @@ -0,0 +1,350 @@ +// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base. +Object.assign(AppsManager.prototype, { + 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) || '/components/apps/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(); + }, + 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); + }} + ] + }); + }, +}); diff --git a/containers/libreportal/frontend/components/apps/core/js/install-console.js b/containers/libreportal/frontend/components/apps/core/js/install-console.js new file mode 100644 index 0000000..21eafc4 --- /dev/null +++ b/containers/libreportal/frontend/components/apps/core/js/install-console.js @@ -0,0 +1,49 @@ +// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base. +Object.assign(AppsManager.prototype, { + // 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); + } + }, +}); diff --git a/containers/libreportal/frontend/components/apps/core/js/port-manager-integration.js b/containers/libreportal/frontend/components/apps/core/js/port-manager-integration.js new file mode 100644 index 0000000..477d3aa --- /dev/null +++ b/containers/libreportal/frontend/components/apps/core/js/port-manager-integration.js @@ -0,0 +1,99 @@ +// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base. +Object.assign(AppsManager.prototype, { + // 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; + }, +}); diff --git a/containers/libreportal/frontend/components/apps/core/js/service-buttons-sidebar.js b/containers/libreportal/frontend/components/apps/core/js/service-buttons-sidebar.js new file mode 100644 index 0000000..34d625a --- /dev/null +++ b/containers/libreportal/frontend/components/apps/core/js/service-buttons-sidebar.js @@ -0,0 +1,47 @@ +// Auto-extracted from apps-manager.js (verbatim) — augments AppsManager.prototype. Loaded after the base. +Object.assign(AppsManager.prototype, { + // 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'; + }); + }, +}); diff --git a/containers/libreportal/frontend/core/boot/system-loader.js b/containers/libreportal/frontend/core/boot/system-loader.js index 34ab91a..32fbefc 100755 --- a/containers/libreportal/frontend/core/boot/system-loader.js +++ b/containers/libreportal/frontend/core/boot/system-loader.js @@ -219,7 +219,15 @@ class SystemLoader { '/components/apps/services/js/services-manager.js', '/components/apps/tools/js/tools-manager.js', '/components/apps/routing/js/routing-manager.js', - '/components/apps/core/js/apps-manager.js' + '/components/apps/core/js/apps-manager.js', // base: class + constructor + orchestration + ServiceButtons + bootstrap (+ install-dispatch, kept inline) + // prototype-augment clusters (load after base; ordered via async=false): + '/components/apps/core/js/apps-grid.js', + '/components/apps/core/js/config-form.js', + '/components/apps/core/js/port-manager-integration.js', + '/components/apps/core/js/gluetun-vpn.js', + '/components/apps/core/js/config-dirty-guard.js', + '/components/apps/core/js/install-console.js', + '/components/apps/core/js/service-buttons-sidebar.js' ] });