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>
822 lines
35 KiB
JavaScript
Executable File
822 lines
35 KiB
JavaScript
Executable File
// 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">×</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;
|
||
}
|