librelad d39852aa3d refactor(webui): reorganize into components/ + core/ taxonomy
Final modularization layout (user-chosen): every page is a self-contained
folder under components/<id>/ (controllers + CSS + its html fragment), and all
shared/framework code folds into core/:
  core/kernel  (feature-registry, lifecycle, services, spa)
  core/boot    (auth, system-loader/orchestrator, setup, loaders)
  core/lib     (data-loader, router, helpers, the task kernel, shared modules)
  core/ui      (topbar, modal, notifications, … + topbar.html)
  core/css     (all shared stylesheets)
  core/icons
Top level is now just: components/, core/, themes/, index.html (+ runtime data/).

Every path reference rewritten (index.html, scripts arrays, fetch()/
loadFragment()/loadScript() literals, system-loader + config-manager controller
paths, kernel manifest URL, feature.json, backend FEATURES_DIR). The
/api/features/list endpoint NAME is unchanged (it now scans components/).
Deleted 3 dead files (app-content.html, apps-content.html, html-cache.js).
Verified: 0 stale prefixes, 0 double-rewrites, all JS/JSON valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-30 07:13:52 +01:00

822 lines
35 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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_<APP>_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 = `
<div class="port-manager-modal">
<div class="port-manager-modal-header">
<h3>${title}</h3>
<button class="port-manager-modal-close">&times;</button>
</div>
<div class="port-manager-modal-body">
<p>${message}</p>
</div>
<div class="port-manager-modal-footer">
<button class="btn btn-secondary port-manager-cancel">Cancel</button>
<button class="btn btn-danger port-manager-confirm">${confirmText}</button>
</div>
</div>
</div>
`;
// 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 = `
<div class="port-manager port-manager-full-width" data-app="${appName}">
<div class="port-manager-header">
<h4>Port Configuration</h4>
<div class="port-manager-header-actions">
<label class="port-manager-advanced-toggle">
<input type="checkbox" class="port-manager-show-advanced">
<span>Show advanced fields</span>
</label>
<button type="button" class="btn btn-primary btn-sm add-port-btn">
<span class="add-icon">+</span> Add Port
</button>
</div>
</div>
<div class="port-manager-list">
`;
// Generate port cards (simplified without individual headers)
this.ports.forEach((port, index) => {
html += this.generateSimplePortCard(port, index);
});
html += `
</div>
<input type="hidden" name="CFG_${appName.toUpperCase()}_PORT_MANAGER" value="${this.generatePortConfig(this.ports)}" class="port-manager-hidden">
</div>
`;
return html;
}
// Generate simplified HTML for individual port card (with header)
generateSimplePortCard(port, index) {
return `
<div class="port-card" data-index="${index}" data-access="${port.access}">
<div class="port-card-header">
<div class="port-card-title">Port ${index + 1}</div>
<button type="button" class="btn btn-danger btn-xs remove-port-btn" data-index="${index}" title="Remove Port">
<span class="remove-icon">×</span>
</button>
</div>
<div class="port-card-body">
<div class="port-row">
<div class="port-field">
<label>Service <span class="help-icon" title="The Docker service this port belongs to">?</span><span class="auto-match-indicator" title="✓ Auto-assigned: Only one service available" style="display:none;">✓</span></label>
<select class="port-service" data-index="${index}">
<option value="">Select a service...</option>
</select>
</div>
<div class="port-field">
<label>Name <span class="help-icon" title="Internal name for this port (e.g., webui, ssh, dns)">?</span></label>
<input type="text" class="port-name" placeholder="webui, ssh, etc." value="${port.name}" data-index="${index}">
</div>
<div class="port-field">
<label>External Port <span class="help-icon" title="External port mapping (use 'random' for auto-assignment)">?</span></label>
<input type="text" class="port-external" placeholder="random or 8080" value="${port.external}" data-index="${index}">
</div>
<div class="port-field">
<label>Internal Port <span class="help-icon" title="The port number inside the container">?</span></label>
<input type="text" class="port-internal" placeholder="1111" value="${port.internal}" data-index="${index}">
</div>
<div class="port-field port-field-advanced">
<label>Protocol <span class="help-icon" title="Network protocol for this port connection">?</span></label>
<select class="port-protocol" data-index="${index}">
<option value="tcp" ${port.protocol === 'tcp' ? 'selected' : ''}>TCP</option>
<option value="udp" ${port.protocol === 'udp' ? 'selected' : ''}>UDP</option>
</select>
</div>
</div>
<div class="port-row">
<div class="port-field">
<label>Access <span class="help-icon" title="Who can access this port from the network">?</span></label>
<select class="port-access" data-index="${index}">
<option value="disabled" ${port.access === 'disabled' ? 'selected' : ''}>Disabled</option>
<option value="private" ${port.access === 'private' ? 'selected' : ''}>Private</option>
<option value="public" ${port.access === 'public' ? 'selected' : ''}>Public</option>
</select>
</div>
<div class="port-field port-field-advanced">
<label>Traefik Managed <span class="help-icon" title="Let Traefik handle reverse proxy for this port">?</span></label>
<select class="port-traefik" data-index="${index}">
<option value="false" ${!port.traefik_managed ? 'selected' : ''}>False</option>
<option value="true" ${port.traefik_managed ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field port-field-advanced">
<label>Button Enabled <span class="help-icon" title="Show this port as a clickable button in the main interface">?</span></label>
<select class="port-button-enabled" data-index="${index}">
<option value="false" ${!port.button_enabled ? 'selected' : ''}>False</option>
<option value="true" ${port.button_enabled ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field port-field-advanced">
<label>Login Required <span class="help-icon" title="Put this route behind Traefik basic auth (uses CFG_TRAEFIK_USER / CFG_TRAEFIK_PASS). Ignored when Authelia is enabled — Authelia takes precedence.">?</span></label>
<select class="port-login-required" data-index="${index}" ${!port.traefik_managed ? 'disabled' : ''}>
<option value="false" ${!port.login_required ? 'selected' : ''}>False</option>
<option value="true" ${port.login_required ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field port-field-advanced">
<label>Recommended <span class="help-icon" title="If true, this port shows up by default in the Traefik routing panel's primary list. Independent of Button Enabled — multiple ports per app can be recommended.">?</span></label>
<select class="port-recommended" data-index="${index}">
<option value="false" ${!port.recommended ? 'selected' : ''}>False</option>
<option value="true" ${port.recommended ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field">
<label>Button Text <span class="help-icon" title="Text displayed on the interface button for this service. Comma-separate to render multiple buttons sharing this port (line up with URL paths below).">?</span></label>
<input type="text" class="port-button-text" placeholder="e.g. Speedtest,Results" value="${port.button_text}" data-index="${index}">
</div>
<div class="port-field port-field-advanced">
<label>URL Path <span class="help-icon" title="Path appended to the URL when opening (e.g. /admin/). Leave empty for root. Comma-separate when paired with multiple Button Text labels for multi-button entries.">?</span></label>
<input type="text" class="port-url-path" placeholder="e.g. /,/results/stats.php" value="${port.url_path || ''}" data-index="${index}">
</div>
<div class="port-field port-field-advanced">
<label>Subdomain <span class="help-icon" title="The subdomain this port is served on — e.g. 'vault' → vault.example.com, or 'admin.app' for a multi-level host. Use @ for the root of your domain. Leave empty to default to the app's name.">?</span></label>
<input type="text" class="port-subdomain" placeholder="vault · @ = root · blank = app name" value="${port.subdomain || ''}" data-index="${index}">
</div>
</div>
</div>
</div>
`;
}
// Generate HTML for individual port card
generatePortCard(port, index) {
return `
<div class="port-card" data-index="${index}">
<div class="port-card-header">
<button type="button" class="btn btn-danger btn-xs remove-port-btn" data-index="${index}" title="Remove Port">
<span class="remove-icon">×</span>
</button>
</div>
<div class="port-card-body">
<div class="port-row">
<div class="port-field">
<label>Service</label>
<select class="port-service" data-index="${index}">
<option value="">Select a service...</option>
</select>
</div>
<div class="port-field">
<label>Name</label>
<input type="text" class="port-name" placeholder="webui, ssh, etc." value="${port.name}" data-index="${index}">
</div>
<div class="port-field">
<label>External Port</label>
<input type="text" class="port-external" placeholder="random or 8080" value="${port.external}" data-index="${index}">
</div>
<div class="port-field">
<label>Internal Port</label>
<input type="text" class="port-internal" placeholder="1111" value="${port.internal}" data-index="${index}">
</div>
</div>
<div class="port-row">
<div class="port-field">
<label>Protocol</label>
<select class="port-protocol" data-index="${index}">
<option value="tcp" ${port.protocol === 'tcp' ? 'selected' : ''}>TCP</option>
<option value="udp" ${port.protocol === 'udp' ? 'selected' : ''}>UDP</option>
</select>
</div>
<div class="port-field">
<label>Access</label>
<select class="port-access" data-index="${index}">
<option value="disabled" ${port.access === 'disabled' ? 'selected' : ''}>Disabled</option>
<option value="private" ${port.access === 'private' ? 'selected' : ''}>Private</option>
<option value="public" ${port.access === 'public' ? 'selected' : ''}>Public</option>
</select>
</div>
<div class="port-field">
<label>Traefik Managed</label>
<select class="port-traefik" data-index="${index}">
<option value="false" ${!port.traefik_managed ? 'selected' : ''}>False</option>
<option value="true" ${port.traefik_managed ? 'selected' : ''}>True</option>
</select>
</div>
</div>
<div class="port-row">
<div class="port-field">
<label>Button Enabled</label>
<select class="port-button-enabled" data-index="${index}">
<option value="false" ${!port.button_enabled ? 'selected' : ''}>False</option>
<option value="true" ${port.button_enabled ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field">
<label>Login Required</label>
<select class="port-login-required" data-index="${index}" ${!port.traefik_managed ? 'disabled' : ''}>
<option value="false" ${!port.login_required ? 'selected' : ''}>False</option>
<option value="true" ${port.login_required ? 'selected' : ''}>True</option>
</select>
</div>
<div class="port-field">
<label>Button Text</label>
<input type="text" class="port-button-text" placeholder="e.g. Speedtest,Results" value="${port.button_text}" data-index="${index}">
</div>
<div class="port-field">
<label>URL Path</label>
<input type="text" class="port-url-path" placeholder="e.g. /,/results/stats.php" value="${port.url_path || ''}" data-index="${index}">
</div>
</div>
</div>
</div>
`;
}
// 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 = '<option value="">Select a service...</option>';
// 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')) {
// <select> with "true"/"false" — treat as string-to-bool, not checkbox.
port.traefik_managed = field.value === 'true';
// Login Required only applies to Traefik-routed ports — disable +
// force false when traefik is turned off so the saved config can't
// claim "protected" on a port that bypasses Traefik.
const loginSel = field.closest('.port-card')?.querySelector('.port-login-required');
if (loginSel) {
loginSel.disabled = !port.traefik_managed;
if (!port.traefik_managed) {
loginSel.value = 'false';
port.login_required = false;
}
}
} else if (field.classList.contains('port-button-enabled')) {
port.button_enabled = field.value === 'true';
} else if (field.classList.contains('port-login-required')) {
port.login_required = field.value === 'true';
} else if (field.classList.contains('port-button-text')) {
port.button_text = field.value;
} else if (field.classList.contains('port-url-path')) {
port.url_path = field.value;
} else if (field.classList.contains('port-recommended')) {
port.recommended = field.value === 'true';
} else if (field.classList.contains('port-subdomain')) {
port.subdomain = field.value;
}
// Update hidden field and individual PORT_X fields
this.updateAllPortFields();
}
// Update both the consolidated hidden field and individual PORT_X fields
updateAllPortFields() {
const consolidatedConfig = this.generatePortConfig(this.ports);
// Update the main hidden field
const hiddenField = document.querySelector('.port-manager-hidden');
if (hiddenField) {
hiddenField.value = consolidatedConfig;
}
// Update individual PORT_X fields
this.updateIndividualPortFields();
}
// Update individual PORT_X fields in the form
updateIndividualPortFields() {
const appName = this.appName || document.querySelector('.port-manager')?.dataset.app;
this.ports.forEach((port, index) => {
const login = port.login_required ? 'true' : 'false';
const portConfig = `${port.service}|${port.name}|${port.external}:${port.internal}|${port.access}|${port.protocol}|${login}|${port.traefik_managed}|${port.button_enabled}|${port.button_text}|${port.url_path || ''}`;
const fieldName = `CFG_${appName.toUpperCase()}_PORT_${index + 1}`;
// Find and update the individual PORT_X field
const portField = document.querySelector(`[name="${fieldName}"]`);
if (portField) {
portField.value = portConfig;
} else {
// Create hidden field if it doesn't exist
const hiddenField = document.createElement('input');
hiddenField.type = 'hidden';
hiddenField.name = fieldName;
hiddenField.value = portConfig;
const portManager = document.querySelector('.port-manager');
if (portManager) {
portManager.appendChild(hiddenField);
}
}
});
}
// Refresh port list HTML
refreshPortList() {
const portList = document.querySelector('.port-manager-list');
if (!portList) return;
let html = '';
this.ports.forEach((port, index) => {
html += this.generateSimplePortCard(port, index);
});
portList.innerHTML = html;
this.reinitializeAfterRefresh();
}
// Reinitialize after refresh (without duplicating event listeners)
reinitializeAfterRefresh() {
const appName = this.appName || document.querySelector('.port-manager')?.dataset.app;
// Repopulate 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 = '<option value="">Select a service...</option>';
// 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';
}
}
});
// Re-attach remove port button listeners (these are new elements)
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();
}
});
});
// Re-attach port field change listeners (these are new elements)
document.querySelectorAll('.port-service, .port-external, .port-internal, .port-access, .port-protocol, .port-traefik, .port-url, .port-label').forEach(field => {
field.addEventListener('change', () => this.updatePortData(field));
});
}
}
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = PortManager;
} else {
window.PortManager = PortManager;
}