// Port Manager Component - Handles port configuration for apps class PortManager { constructor() { this.ports = []; this.availableServices = []; this.appName = null; } // Parse port configuration string into array of port objects parsePortConfig(configInput) { // Accepts either a single CFG__PORT_N string OR an array of // them (preferred). The array form avoids the comma-join/split // round-trip that would shred multi-button button_text/url_path // values like "Speedtest,Results" and "/,/results/stats.php". let portEntries; if (Array.isArray(configInput)) { portEntries = configInput.filter(s => s && String(s).trim() !== ''); } else { if (!configInput || String(configInput).trim() === '') return []; portEntries = String(configInput).split(','); } const ports = []; portEntries.forEach(entry => { const parts = entry.trim().split('|'); // 12-col: parent|name|ext:int|access|proto|login|traefik|webui|label|url_path|subdomain|recommended // 10-col: parent|name|ext:int|access|proto|login|traefik|webui|label|url_path // 9-col: parent|name|ext:int|access|proto|login|traefik|webui|label // 8-col: parent|name|ext:int|access|proto|traefik|webui|label (legacy, login defaults to false) if (parts.length >= 8) { const portMapping = parts[2].split(':'); const external = portMapping[0] || ''; const internal = portMapping[1] || ''; const isTenCol = parts.length >= 10; const isNineCol = parts.length >= 9; const isTwelveCol = parts.length >= 12; // Recommended defaults to the webui flag when not stored on the row โ€” // matches the panel's "primary list" expectation for apps that haven't // been migrated yet. const buttonEnabled = isNineCol ? (parts[7] === 'true') : (parts[6] === 'true'); ports.push({ service: parts[0] || '', name: parts[1] || '', external: external, internal: internal, access: parts[3] || 'private', protocol: parts[4] || 'tcp', login_required: isNineCol ? (parts[5] === 'true') : false, traefik_managed: isNineCol ? (parts[6] === 'true') : (parts[5] === 'true'), button_enabled: buttonEnabled, button_text: isNineCol ? (parts[8] || '') : (parts[7] || ''), url_path: isTenCol ? (parts[9] || '') : '', subdomain: isTwelveCol ? (parts[10] || '') : '', recommended: isTwelveCol ? (parts[11] === 'true') : buttonEnabled }); } }); return ports; } // Generate port configuration string from array of port objects. // Always emits the 12-col format (adds subdomain + recommended on top of the // existing 10-col login_required + url_path schema) so saves forward-migrate // any 8/9/10-col legacy entries automatically. generatePortConfig(ports) { return ports.map(port => { const portMapping = `${port.external}:${port.internal}`; const login = port.login_required ? 'true' : 'false'; const urlPath = port.url_path || ''; const subdomain = port.subdomain || ''; const recommended = port.recommended ? 'true' : 'false'; return `${port.service}|${port.name}|${portMapping}|${port.access}|${port.protocol}|${login}|${port.traefik_managed}|${port.button_enabled}|${port.button_text}|${urlPath}|${subdomain}|${recommended}`; }).join(','); } // Get available services for an app async getAvailableServices(appName) { //console.log(`๐Ÿ”Œ PortManager: Getting services for app: ${appName}`); try { // Load apps data to get services for this app (with cache busting) const timestamp = Date.now(); const response = await fetch(`/data/apps/generated/apps.json?t=${timestamp}`); if (!response.ok) { //console.log(`๐Ÿ”Œ Failed to fetch apps.json: ${response.status}`); return []; } const appsData = await response.json(); //console.log(`๐Ÿ”Œ Apps data loaded:`, appsData); //console.log(`๐Ÿ”Œ Available app names:`, appsData.apps.map(app => app.name)); let app = appsData.apps.find(a => a.name === appName); //console.log(`๐Ÿ”Œ Found app for ${appName}:`, app); // Try fuzzy matching if exact match fails if (!app) { const fuzzyApp = appsData.apps.find(a => a.name.toLowerCase().includes(appName.toLowerCase()) || appName.toLowerCase().includes(a.name.toLowerCase()) ); //console.log(`๐Ÿ”Œ Fuzzy match for ${appName}:`, fuzzyApp); if (fuzzyApp) { app = fuzzyApp; } } if (app && app.services) { //console.log(`๐Ÿ”Œ Services found for ${appName}:`, app.services); return app.services; } //console.log(`๐Ÿ”Œ No services found for ${appName}`); return []; } catch (error) { console.error('Error loading services:', error); return []; } } // Validate port configuration validatePort(portData, allPorts) { const errors = []; // Check required fields if (!portData.service) errors.push('Service is required'); if (!portData.internal) errors.push('Internal port is required'); // Check port format if (portData.external && !portData.external.match(/^(random|\d+(:\d+)?)$/)) { errors.push('External port must be "random" or in format "8080" or "8080:8081"'); } if (portData.internal && !portData.internal.match(/^\d+$/)) { errors.push('Internal port must be a valid port number'); } // Check for conflicts (excluding current port) const conflicts = allPorts.filter((port, index) => { return port.internal === portData.internal && index !== allPorts.indexOf(portData); }); if (conflicts.length > 0) { errors.push(`Internal port ${portData.internal} conflicts with another service`); } return errors; } // Add new port addPort() { const newPort = { service: '', external: 'random', internal: '', access: 'private', protocol: 'tcp', url_accessible: false, traefik_managed: true, login_required: false, label: '', autoMatched: false // Track if service was auto-matched }; // Auto-select service if only one is available if (this.availableServices.length === 1) { newPort.service = this.availableServices[0]; newPort.autoMatched = true; } this.ports.push(newPort); return newPort; } // Remove port with confirmation async removePort(index) { const port = this.ports[index]; if (!port) return false; // Get app name from the port manager const appName = this.appName || document.querySelector('.port-manager')?.dataset.app; // Use internal port if available, otherwise show port number (index + 1) const portDisplay = port.internal ? `#${port.internal}` : `#${index + 1}`; // Get app title from the apps data for better display let appTitle = appName; // fallback to app name try { const response = await fetch(`/data/apps/generated/apps.json?t=${Date.now()}`); if (response.ok) { const appsData = await response.json(); const app = appsData.apps.find(a => a.name === appName) || appsData.apps.find(a => a.name.toLowerCase().includes(appName.toLowerCase())); if (app) { appTitle = app.title || appName; } } } catch (error) { //console.log('Could not fetch app title, using app name'); } // Show confirmation dialog const confirmed = await this.showConfirmation( 'Remove Port', `Are you sure you want to remove port ${portDisplay} from ${appTitle}?`, 'Remove' ); if (confirmed) { this.ports.splice(index, 1); return true; } return false; } // Show confirmation dialog showConfirmation(title, message, confirmText) { return new Promise((resolve) => { // Create modal overlay const modal = document.createElement('div'); modal.className = 'port-manager-modal-overlay'; modal.innerHTML = `

${title}

${message}

`; // Add to page document.body.appendChild(modal); // Handle events const close = () => { document.body.removeChild(modal); resolve(false); }; const confirm = () => { document.body.removeChild(modal); resolve(true); }; modal.querySelector('.port-manager-modal-close').addEventListener('click', close); modal.querySelector('.port-manager-cancel').addEventListener('click', close); modal.querySelector('.port-manager-confirm').addEventListener('click', confirm); modal.addEventListener('click', (e) => { if (e.target === modal) close(); }); }); } // Generate HTML for port manager generateHTML(appName, configValue) { // Parse existing configuration this.ports = this.parsePortConfig(configValue); let html = `

Port Configuration

`; // Generate port cards (simplified without individual headers) this.ports.forEach((port, index) => { html += this.generateSimplePortCard(port, index); }); html += `
`; return html; } // Generate simplified HTML for individual port card (with header) generateSimplePortCard(port, index) { return `
Port ${index + 1}
`; } // Generate HTML for individual port card generatePortCard(port, index) { return `
`; } // Initialize port manager after HTML is generated async initialize(appName) { this.appName = appName; this.availableServices = await this.getAvailableServices(appName); //console.log(`๐Ÿ”Œ PortManager: Available services for ${appName}:`, this.availableServices); //console.log(`๐Ÿ”Œ PortManager: Number of services: ${this.availableServices.length}`); // Force full-width layout for port manager containers this.forceFullWidthLayout(); // Populate service dropdowns with auto-matching const serviceSelects = document.querySelectorAll('.port-service'); serviceSelects.forEach(select => { const index = parseInt(select.dataset.index); const currentService = this.ports[index]?.service || ''; //console.log(`๐Ÿ”Œ PortManager: Port ${index} current service: "${currentService}"`); // Clear existing options select.innerHTML = ''; // Add service options this.availableServices.forEach(service => { const option = document.createElement('option'); option.value = service; option.textContent = service; // Auto-matching logic let shouldAutoSelect = false; let isAutoMatched = false; if (this.availableServices.length === 1 && !currentService) { // Case 1: Only one service and no current service - auto-select shouldAutoSelect = true; isAutoMatched = true; //console.log(`๐Ÿ”Œ PortManager: Auto-selecting single service "${service}" for port ${index}`); } else if (this.availableServices.length === 1 && currentService && currentService !== service) { // Case 2: Only one service but current service doesn't match - auto-match shouldAutoSelect = true; isAutoMatched = true; //console.log(`๐Ÿ”Œ PortManager: Auto-matching service "${service}" (was "${currentService}") for port ${index}`); } else if (this.availableServices.length === 1 && currentService === service) { // Case 3: Single service and current service matches - still show auto-match indicator shouldAutoSelect = true; isAutoMatched = true; //console.log(`๐Ÿ”Œ PortManager: Single service matches "${service}" for port ${index} - showing auto-match indicator`); } else if (this.availableServices.length > 1 && currentService === service) { // Case 4: Multiple services and current service matches - normal selection shouldAutoSelect = true; isAutoMatched = false; //console.log(`๐Ÿ”Œ PortManager: Normal selection of service "${service}" for port ${index}`); } if (shouldAutoSelect) { option.selected = true; // Update the port data to reflect the selected service if (this.ports[index]) { this.ports[index].service = service; // Mark if this was auto-matched this.ports[index].autoMatched = isAutoMatched; } } select.appendChild(option); }); // Add visual indicator for auto-matched services if (this.ports[index]?.autoMatched) { select.style.borderColor = '#28a745'; // Green border for auto-matched select.style.boxShadow = '0 0 0 2px rgba(40, 167, 69, 0.3)'; // Show the auto-match indicator next to the help icon const autoMatchIndicator = select.parentElement.querySelector('.auto-match-indicator'); if (autoMatchIndicator) { autoMatchIndicator.style.display = 'inline-flex'; } } }); // Add event listeners this.attachEventListeners(); // Update all port fields after auto-selection this.updateAllPortFields(); } // Force full-width layout for port manager containers forceFullWidthLayout() { const portContainers = document.querySelectorAll('.form-field[id^="PORT_"]'); portContainers.forEach(container => { container.style.gridColumn = '1 / -1'; container.style.width = '100%'; }); } // Attach event listeners attachEventListeners() { // Add port button const addBtn = document.querySelector('.add-port-btn'); if (addBtn) { addBtn.addEventListener('click', () => { const newPort = this.addPort(); this.refreshPortList(); }); } // Remove port buttons document.querySelectorAll('.remove-port-btn').forEach(btn => { btn.addEventListener('click', async (e) => { const index = parseInt(e.currentTarget.dataset.index); const removed = await this.removePort(index); if (removed) { this.refreshPortList(); } }); }); // Show-advanced toggle โ€” flips `show-advanced` class on the port-manager root; // CSS hides .port-field-advanced when the class isn't present. document.querySelectorAll('.port-manager-show-advanced').forEach(cb => { const mgr = cb.closest('.port-manager'); const apply = () => { if (mgr) mgr.classList.toggle('show-advanced', cb.checked); }; cb.addEventListener('change', apply); apply(); }); // Port field changes. Selector must include EVERY rendered class โ€” fields not // listed here silently no-op when the user edits them, so port name and button // text edits were never persisted into the hidden CFG_*_PORT_N fields. document.querySelectorAll( '.port-service, .port-name, .port-external, .port-internal, ' + '.port-access, .port-protocol, .port-traefik, ' + '.port-button-enabled, .port-button-text, .port-url-path, ' + '.port-recommended, .port-subdomain' ).forEach(field => { field.addEventListener('change', () => this.updatePortData(field)); field.addEventListener('input', () => this.updatePortData(field)); }); } // Update port data when field changes updatePortData(field) { const index = parseInt(field.dataset.index); const port = this.ports[index]; if (!port) return; // Match by classList rather than === on className so additional classes added // elsewhere (e.g. validation styles) don't break the switch. if (field.classList.contains('port-service')) { port.service = field.value; } else if (field.classList.contains('port-name')) { port.name = field.value; } else if (field.classList.contains('port-external')) { port.external = field.value; } else if (field.classList.contains('port-internal')) { port.internal = field.value; } else if (field.classList.contains('port-access')) { port.access = field.value; const portCard = field.closest('.port-card'); if (portCard) portCard.setAttribute('data-access', field.value); } else if (field.classList.contains('port-protocol')) { port.protocol = field.value; } else if (field.classList.contains('port-traefik')) { //