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
-
-
-
- `;
-
- 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.
-
`;
- } 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 = `
- `;
- }
-
- // 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 = `
-
- 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.
-
- 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.
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
+
+
+
+ `;
+
+ 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.
+
`;
+ } 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 = `
+ `;
+ },
+ // 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 = `
+
+ 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.
+
+ 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.
+