Files
peardock/libs/templateDeploy.js
T
Raven Scott 254310b495
Release rolling / release (push) Successful in 7m47s
Precheck deploy networking before container create
Validate port mappings for empty host ports, in-form duplicates,
privileged low ports, and peer-side host port conflicts. Runs on
template deploy, deploy view, and add-container before create RPC.
2026-07-16 14:39:19 -04:00

4258 lines
171 KiB
JavaScript

// Import dependencies first (ES6 imports must be at top)
import {
closeAllModals,
showStatusIndicator,
hideStatusIndicator,
updateStatusIndicator,
showAlert,
jobSpinnerLoadingBlock,
} from './uiUtils.js';
import { fetchMergedTemplates, getTemplateListUrls } from '../client/templateLists.js';
import {
isStackTemplate,
resolveTemplateForDeploy,
buildStackDeployPayload,
} from '../client/templateResolve.js';
import {
precheckDeployNetworking,
validateNetworkingSync,
formatNetworkingPrecheckMessage,
} from '../client/deployNetworkPrecheck.js';
// DOM Elements - Lazy loaded (initialized when modal opens)
let templateList = null;
let templateSearchInput = null;
let templateDeployModal = null;
let deployForm = null;
let templates = [];
let searchInputListenerSetup = false; // Track if search input listener is set up
let formSubmitListenerSetup = false; // Track if form submit listener is set up
/**
* Active form root (view or modal). Both UIs reuse the same element ids, so
* document.getElementById always hits the *first* (view) form and leaves the
* modal empty — scope all deploy-* lookups here.
* @type {ParentNode|null}
*/
let formScope = null;
/**
* @param {ParentNode|null|undefined} root
*/
export function setDeployFormScope(root) {
formScope = root || null;
}
/**
* Scoped lookup for deploy form controls (prefer active form root).
* @param {string} id
* @returns {HTMLElement|null}
*/
function deployEl(id) {
const key = String(id || '');
if (!key) return null;
if (formScope && typeof formScope.querySelector === 'function') {
try {
const el = formScope.querySelector(`#${key}`);
if (el) return /** @type {HTMLElement} */ (el);
} catch {
// ignore invalid selector
}
}
return document.getElementById(key);
}
// Array item counters for unique IDs
let portCounter = 0;
let volumeCounter = 0;
let envCounter = 0;
let labelCounter = 0;
let dnsCounter = 0;
let extraHostCounter = 0;
let deviceCounter = 0;
let capabilityCounter = 0;
let securityOptCounter = 0;
let logOptCounter = 0;
let sysctlCounter = 0;
let ulimitCounter = 0;
let tmpfsCounter = 0;
// Utility functions are now imported from uiUtils.js
// Also explicitly close the deploy modal if needed
function closeDeployModal() {
if (templateDeployModal) {
templateDeployModal.hide();
}
}
// Lazy initialization - set up DOM elements and modal only when needed
function initTemplateDeployer() {
// Initialize DOM elements if not already cached
if (!templateList) {
templateList = document.getElementById('template-list');
}
if (!templateSearchInput) {
templateSearchInput = document.getElementById('template-search-input');
}
if (!deployForm) {
deployForm = document.getElementById('deploy-form');
}
// Create Bootstrap Modal if not already created
if (!templateDeployModal && typeof bootstrap !== 'undefined') {
const modalElement = document.getElementById('templateDeployModalUnique');
if (modalElement) {
templateDeployModal = new bootstrap.Modal(modalElement);
}
}
// Set up search input listener if not already set up
setupSearchInputListener();
// Set up form submit listener if not already set up
setupFormSubmitListener();
}
// Fetch templates from all configured list URLs (multi-source, de-duplicated)
async function fetchTemplates() {
// Ensure template deployer is initialized before fetching
initTemplateDeployer();
try {
const urls = getTemplateListUrls();
const result = await fetchMergedTemplates(urls);
templates = result.templates || [];
displayTemplateList(templates);
const st = result.stats || {};
if (st.duplicates > 0) {
console.log(
`[INFO] Templates: ${st.unique} unique from ${st.sources} list(s), removed ${st.duplicates} duplicate(s)`
);
}
if (st.errors?.length && !templates.length) {
showAlert('danger', `Failed to load templates from ${st.errors.length} list(s).`);
} else if (st.errors?.length) {
showAlert(
'warning',
`Loaded ${templates.length} templates; ${st.errors.length} list(s) failed to fetch.`
);
}
return result;
} catch (error) {
console.error('[ERROR] Failed to fetch templates:', error.message);
showAlert('danger', 'Failed to load templates.');
return null;
}
}
/** @type {ReturnType<typeof setTimeout>|null} */
let modalSearchDebounceTimer = null
const SEARCH_DEBOUNCE_MS = 250
function filterTemplatesByQuery(list, query) {
const q = String(query || '').toLowerCase().trim()
if (!q) return list
return list.filter((template) => {
const title = String(template?.title || template?.name || '').toLowerCase()
const description = String(template?.description || '').toLowerCase()
const image = String(template?.image || '').toLowerCase()
const categories = Array.isArray(template?.categories)
? template.categories.join(' ').toLowerCase()
: String(template?.categories || '').toLowerCase()
const platform = String(template?.platform || '').toLowerCase()
return (
title.includes(q) ||
description.includes(q) ||
image.includes(q) ||
categories.includes(q) ||
platform.includes(q)
)
})
}
// Filter templates by search input (debounced)
function setupSearchInputListener() {
if (!templateSearchInput || searchInputListenerSetup) return
templateSearchInput.addEventListener('input', () => {
const value = templateSearchInput.value
if (modalSearchDebounceTimer) clearTimeout(modalSearchDebounceTimer)
modalSearchDebounceTimer = setTimeout(() => {
const filteredTemplates = filterTemplatesByQuery(templates, value)
displayTemplateList(filteredTemplates)
}, SEARCH_DEBOUNCE_MS)
})
searchInputListenerSetup = true
}
// Set up form submit listener (will be set up lazily)
function setupFormSubmitListener() {
if (!deployForm || formSubmitListenerSetup) return;
deployForm.addEventListener('submit', async (e) => {
e.preventDefault();
setDeployFormScope(deployForm);
let formData;
try {
formData = collectFormData();
} catch (collectError) {
console.error('[ERROR] Failed to collect form data:', collectError);
console.error('[ERROR] Collect error stack:', collectError.stack);
showAlert('danger', 'Failed to collect form data. Check console for details.');
return;
}
// Validate
let errors = [];
try {
errors = validateFormData(formData);
} catch (validateError) {
console.error('[ERROR] Failed to validate form data:', validateError);
console.error('[ERROR] Validate error stack:', validateError.stack);
showAlert('danger', 'Failed to validate form data. Check console for details.');
return;
}
if (errors.length > 0) {
showAlert('danger', errors.join(' '));
return;
}
// Ensure formData is initialized before use
if (!formData || typeof formData !== 'object') {
console.error('[ERROR] Invalid form data:', formData);
showAlert('danger', 'Invalid form data. Please check your input and try again.');
return;
}
// Async networking precheck (host-port conflicts on peer) before closing UI
try {
const netCheck = await precheckDeployNetworking(formData);
if (!netCheck.ok) {
showAlert(
'danger',
formatNetworkingPrecheckMessage(netCheck) ||
'Networking configuration is invalid.'
);
return;
}
if (netCheck.warnings?.length) {
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false });
}
// Mark so deployDockerContainer can skip a second remote conflict scan
formData._networkingPrechecked = true;
} catch (netErr) {
console.warn('[deploy] networking precheck failed', netErr);
// Continue — deployDockerContainer will re-run checks
}
// Safely get container name with fallback
const containerName = (formData && formData.containerName) ? String(formData.containerName) : 'container';
// Close modal immediately before async operation
if (typeof closeAllModals === 'function') {
closeAllModals();
}
closeDeployModal();
// Job drawer (live log) owns progress/result feedback — no top toasts
try {
const successResponse = await deployDockerContainer(formData);
if (!successResponse || typeof successResponse !== 'object') {
throw new Error('Invalid response from deployment function');
}
const successMessage = (successResponse && successResponse.message)
? String(successResponse.message)
: 'Container deployed successfully!';
// Only toast when deploy ran without the live job tray
if (!successResponse.viaJob) {
showAlert('success', successMessage, { toast: true, tray: true });
}
if (typeof window.sendCommand === 'function') {
window.sendCommand('listContainers');
setTimeout(() => {
if (typeof window.navigateToNewContainer === 'function') {
window.navigateToNewContainer(containerName);
}
}, 800);
}
} catch (error) {
if (error?.code === 'DEPLOY_CANCELLED') {
return;
}
const errorMessage = (error && error.message)
? String(error.message)
: 'Failed to deploy container. Check console for details.';
console.error('[ERROR] Failed to deploy container:', errorMessage);
if (error && error.stack) {
console.error('[ERROR] Full error stack:', error.stack);
}
// Job drawer already shows full multi-line error + fix hints
if (!error?.viaJob) {
showAlert('danger', errorMessage, { duration: 12000 });
}
}
});
formSubmitListenerSetup = true;
}
// Default icon as SVG data URI (generic container/box icon) - URL encoded
const DEFAULT_TEMPLATE_ICON = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent('<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2L22 7V17L12 22L2 17V7L12 2Z" stroke="#666666" stroke-width="1.5" fill="rgba(100, 116, 255, 0.1)"/></svg>');
// Display templates in the list
function displayTemplateList(templates) {
// Ensure template list is initialized
if (!templateList) {
initTemplateDeployer();
}
if (!templateList) {
console.error('[ERROR] Template list element not found');
return;
}
templateList.innerHTML = '';
templates.forEach(template => {
const listItem = document.createElement('li');
listItem.className = 'list-group-item d-flex justify-content-between align-items-center';
// Create the div container
const div = document.createElement('div');
// Create img element with error handler for default icon fallback
const img = document.createElement('img');
const logoUrl = template.logo && template.logo.trim() ? template.logo : DEFAULT_TEMPLATE_ICON;
img.src = logoUrl;
img.alt = 'Logo';
img.className = 'me-2';
img.style.width = '24px';
img.style.height = '24px';
img.style.objectFit = 'contain';
// Set error handler BEFORE appending to DOM to ensure it's attached
img.addEventListener('error', function handleImageError() {
// Replace with default icon on load error (404, network issues, CORS, etc.)
if (this.src !== DEFAULT_TEMPLATE_ICON) {
this.src = DEFAULT_TEMPLATE_ICON;
// Remove this handler to prevent infinite loop
this.removeEventListener('error', handleImageError);
}
});
// Create title span
const titleSpan = document.createElement('span');
titleSpan.textContent = template.title;
// Create deploy button
const deployBtn = document.createElement('button');
deployBtn.className = 'btn btn-primary btn-sm deploy-btn';
deployBtn.textContent = 'Deploy';
deployBtn.addEventListener('click', () => {
openDeployModal(template);
});
// Assemble the structure
div.appendChild(img);
div.appendChild(titleSpan);
listItem.appendChild(div);
listItem.appendChild(deployBtn);
templateList.appendChild(listItem);
});
}
// Array management functions
function addPortMapping(portData = null) {
const container = deployEl('deploy-ports-container');
if (!container) {
console.error('[ERROR] Ports container not found - element with id "deploy-ports-container" does not exist');
return;
}
const id = `port-${portCounter++}`;
const item = document.createElement('div');
item.className = 'array-item port-mapping-item';
item.id = id;
// Default values
let hostPort = '';
let containerPort = '';
let protocol = 'tcp';
if (portData != null) {
const portStr = String(portData).trim();
if (!portStr) {
// Empty → leave defaults
} else if (portStr.includes(':')) {
// Format: "host:container/protocol" OR "host:container"
const [hostPart, containerPart] = portStr.split(':');
hostPort = hostPart.trim();
if (containerPart.includes('/')) {
const [cPort, proto] = containerPart.split('/');
containerPort = cPort.trim();
protocol = (proto && proto.trim().toLowerCase() === 'udp') ? 'udp' : 'tcp';
} else {
containerPort = containerPart.trim();
protocol = 'tcp'; // default if no protocol
}
} else if (portStr.includes('/')) {
// Format: "container/protocol"
const [cPort, proto] = portStr.split('/');
containerPort = cPort.trim();
protocol = (proto && proto.trim().toLowerCase() === 'udp') ? 'udp' : 'tcp';
} else {
// Just a number → assume container port only
containerPort = portStr.trim();
protocol = 'tcp';
}
// Final validation: container port is mandatory
if (!containerPort || isNaN(parseInt(containerPort, 10))) {
console.warn('[WARN] Invalid container port in template:', portData);
containerPort = '';
}
}
item.innerHTML = `
<div class="port-mapping-fields">
<div class="port-field-group">
<label class="port-field-label">Host Port</label>
<input type="number"
class="form-control bg-dark text-white port-host-input"
placeholder="8080"
min="1"
max="65535"
data-port-host="${id}"
value="${hostPort}"
oninput="validatePortMapping('${id}')">
<small class="port-error-msg" data-port-host-error="${id}" style="display: none;"></small>
</div>
<div class="port-connector">
<i class="fas fa-arrow-right"></i>
</div>
<div class="port-field-group">
<label class="port-field-label">Container Port</label>
<input type="number"
class="form-control bg-dark text-white port-container-input"
placeholder="80"
min="1"
max="65535"
required
data-port-container="${id}"
value="${containerPort}"
oninput="validatePortMapping('${id}')">
<small class="port-error-msg" data-port-container-error="${id}" style="display: none;"></small>
</div>
<div class="port-field-group">
<label class="port-field-label">Protocol</label>
<select class="form-select bg-dark text-white port-protocol-input"
data-port-protocol="${id}"
onchange="validatePortMapping('${id}')">
<option value="tcp" ${protocol === 'tcp' ? 'selected' : ''}>TCP</option>
<option value="udp" ${protocol === 'udp' ? 'selected' : ''}>UDP</option>
</select>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
// Validate port mapping in real-time
function validatePortMapping(portId) {
const hostInput = document.querySelector(`[data-port-host="${portId}"]`);
const containerInput = document.querySelector(`[data-port-container="${portId}"]`);
const hostError = document.querySelector(`[data-port-host-error="${portId}"]`);
const containerError = document.querySelector(`[data-port-container-error="${portId}"]`);
let isValid = true;
// Container port required
if (containerInput) {
const containerPort = parseInt(containerInput.value, 10);
if (!containerInput.value || isNaN(containerPort) || containerPort < 1 || containerPort > 65535) {
if (containerError) {
containerError.textContent = 'Container port is required (1-65535)';
containerError.style.display = 'block';
containerInput.classList.add('is-invalid');
}
isValid = false;
} else {
if (containerError) {
containerError.style.display = 'none';
containerInput.classList.remove('is-invalid');
}
}
}
const hasContainer =
containerInput?.value &&
!isNaN(parseInt(containerInput.value, 10)) &&
parseInt(containerInput.value, 10) >= 1;
const hostVal = hostInput?.value?.trim() || '';
// Host port required when container port is set (prevents empty host-side publish)
if (hostInput) {
if (hasContainer && !hostVal) {
if (hostError) {
hostError.textContent = 'Host port required when container port is set';
hostError.style.display = 'block';
hostInput.classList.add('is-invalid');
}
isValid = false;
} else if (hostVal) {
const hostPort = parseInt(hostVal, 10);
if (isNaN(hostPort) || hostPort < 1 || hostPort > 65535) {
if (hostError) {
hostError.textContent = 'Port must be between 1 and 65535';
hostError.style.display = 'block';
hostInput.classList.add('is-invalid');
}
isValid = false;
} else if (hostPort < 1024) {
if (hostError) {
hostError.textContent = 'Privileged port (<1024) — may need elevated host permissions';
hostError.style.display = 'block';
hostInput.classList.remove('is-invalid');
hostInput.classList.add('is-warning');
}
// warning only — still valid for submit path with warning banner
} else {
if (hostError) {
hostError.style.display = 'none';
hostInput.classList.remove('is-invalid', 'is-warning');
}
}
} else if (hostError) {
hostError.style.display = 'none';
hostInput.classList.remove('is-invalid', 'is-warning');
}
}
// Check for duplicate host ports in this form
if (hostInput && hostVal) {
const hostPort = hostVal;
const allHostInputs = document.querySelectorAll('.port-host-input');
let duplicateCount = 0;
allHostInputs.forEach(input => {
if (input.value === hostPort && input !== hostInput) {
duplicateCount++;
}
});
if (duplicateCount > 0) {
if (hostError) {
hostError.textContent = 'This host port is already used in another mapping';
hostError.style.display = 'block';
hostInput.classList.add('is-invalid');
}
isValid = false;
}
}
updatePreview();
return isValid;
}
function addVolumeMount(volumeData = null) {
const container = deployEl('deploy-volumes-container');
if (!container) return;
const id = `volume-${volumeCounter++}`;
const item = document.createElement('div');
item.className = 'array-item volume-mount-item';
item.id = id;
const parsed = parseVolumeMountSpec(volumeData);
const volumeType = parsed.volumeType;
const hostPath = parsed.hostPath;
const containerPath = parsed.containerPath;
const mountMode = parsed.mountMode;
const hostEsc = escapeAttrValue(hostPath);
const destEsc = escapeAttrValue(containerPath);
item.innerHTML = `
<div class="volume-mount-fields">
<div class="volume-field-group">
<label class="volume-field-label">Type</label>
<select class="form-select bg-dark text-white volume-type-input"
data-volume-type="${id}"
onchange="handleVolumeTypeChange('${id}')">
<option value="bind" ${volumeType === 'bind' ? 'selected' : ''}>Bind Mount</option>
<option value="named" ${volumeType === 'named' ? 'selected' : ''}>Named Volume</option>
</select>
</div>
<div class="volume-field-group volume-host-path-group" style="${volumeType === 'named' ? 'display: none;' : ''}">
<label class="volume-field-label">Host Path</label>
<div class="input-group">
<input type="text"
class="form-control bg-dark text-white volume-host-input"
placeholder="/host/path"
data-volume-host="${id}"
value="${hostEsc}"
oninput="validateVolumeMount('${id}')">
<button type="button"
class="btn btn-outline-secondary"
onclick="openFileBrowser('${id}')"
title="Browse directory">
<i class="fas fa-folder-open"></i>
</button>
</div>
<small class="volume-error-msg" data-volume-host-error="${id}" style="display: none;"></small>
</div>
<div class="volume-field-group volume-named-group" style="${volumeType === 'bind' ? 'display: none;' : ''}">
<label class="volume-field-label">Volume Name</label>
<select class="form-select bg-dark text-white volume-named-input"
data-volume-named="${id}"
onchange="validateVolumeMount('${id}')"
onfocus="if(this.options.length <= 1) loadVolumesForSelect('${id}')"
onclick="if(this.options.length <= 1) loadVolumesForSelect('${id}')">
<option value="">Select or create volume...</option>
</select>
<small class="volume-error-msg" data-volume-named-error="${id}" style="display: none;"></small>
</div>
<div class="volume-connector">
<i class="fas fa-arrow-right"></i>
</div>
<div class="volume-field-group">
<label class="volume-field-label">Container Path</label>
<input type="text"
class="form-control bg-dark text-white volume-container-input"
placeholder="/container/path"
required
data-volume-container="${id}"
value="${destEsc}"
oninput="validateVolumeMount('${id}')">
<small class="volume-error-msg" data-volume-container-error="${id}" style="display: none;"></small>
</div>
<div class="volume-field-group">
<label class="volume-field-label">Mode</label>
<select class="form-select bg-dark text-white volume-mode-input"
data-volume-mode="${id}"
onchange="validateVolumeMount('${id}')">
<option value="rw" ${mountMode === 'rw' ? 'selected' : ''}>Read-Write</option>
<option value="ro" ${mountMode === 'ro' ? 'selected' : ''}>Read-Only</option>
</select>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
if (volumeType === 'named' && hostPath) {
const namedSelect = item.querySelector('.volume-named-input');
if (namedSelect) {
namedSelect.dataset.preferredVolume = hostPath;
namedSelect.add(new Option(hostPath, hostPath, true, true));
namedSelect.value = hostPath;
}
loadVolumesForSelect(id, hostPath);
}
updatePreview();
}
// Handle volume type change
function handleVolumeTypeChange(volumeId) {
const typeSelect = document.querySelector(`[data-volume-type="${volumeId}"]`);
const hostPathGroup = document.querySelector(`[data-volume-host="${volumeId}"]`)?.closest('.volume-host-path-group');
const namedGroup = document.querySelector(`[data-volume-named="${volumeId}"]`)?.closest('.volume-named-group');
if (!typeSelect) return;
const volumeType = typeSelect.value;
if (volumeType === 'bind') {
if (hostPathGroup) hostPathGroup.style.display = '';
if (namedGroup) namedGroup.style.display = 'none';
} else {
if (hostPathGroup) hostPathGroup.style.display = 'none';
if (namedGroup) namedGroup.style.display = '';
// Always fetch fresh volumes list when Named Volume is selected
loadVolumesForSelect(volumeId);
}
validateVolumeMount(volumeId);
updatePreview();
}
// Validate volume mount in real-time
function validateVolumeMount(volumeId) {
const typeSelect = document.querySelector(`[data-volume-type="${volumeId}"]`);
const hostInput = document.querySelector(`[data-volume-host="${volumeId}"]`);
const namedSelect = document.querySelector(`[data-volume-named="${volumeId}"]`);
const containerInput = document.querySelector(`[data-volume-container="${volumeId}"]`);
const hostError = document.querySelector(`[data-volume-host-error="${volumeId}"]`);
const namedError = document.querySelector(`[data-volume-named-error="${volumeId}"]`);
const containerError = document.querySelector(`[data-volume-container-error="${volumeId}"]`);
let isValid = true;
const volumeType = typeSelect?.value || 'bind';
// Validate container path (required)
if (containerInput) {
const containerPath = containerInput.value.trim();
if (!containerPath) {
if (containerError) {
containerError.textContent = 'Container path is required';
containerError.style.display = 'block';
containerInput.classList.add('is-invalid');
}
isValid = false;
} else if (!containerPath.startsWith('/')) {
if (containerError) {
containerError.textContent = 'Container path must start with /';
containerError.style.display = 'block';
containerInput.classList.add('is-invalid');
}
isValid = false;
} else {
if (containerError) {
containerError.style.display = 'none';
containerInput.classList.remove('is-invalid');
}
}
}
// Validate based on type
if (volumeType === 'bind') {
if (hostInput) {
const hostPath = hostInput.value.trim();
if (!hostPath) {
if (hostError) {
hostError.textContent = 'Host path is required for bind mounts';
hostError.style.display = 'block';
hostInput.classList.add('is-invalid');
}
isValid = false;
} else if (hostPath.includes('..')) {
if (hostError) {
hostError.textContent = 'Path traversal not allowed';
hostError.style.display = 'block';
hostInput.classList.add('is-invalid');
}
isValid = false;
} else {
if (hostError) {
hostError.style.display = 'none';
hostInput.classList.remove('is-invalid');
}
}
}
} else {
// Named volume
if (namedSelect) {
const volumeName = namedSelect.value.trim();
if (!volumeName) {
if (namedError) {
namedError.textContent = 'Please select or create a volume';
namedError.style.display = 'block';
namedSelect.classList.add('is-invalid');
}
isValid = false;
} else {
if (namedError) {
namedError.style.display = 'none';
namedSelect.classList.remove('is-invalid');
}
}
}
}
updatePreview();
return isValid;
}
function addTmpfsMount() {
const container = deployEl('deploy-tmpfs-container');
const id = `tmpfs-${tmpfsCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="/tmp:100m" data-tmpfs-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
// Helper function to determine input type based on env var properties
function determineInputType(envVar) {
const name = (envVar.name || '').toUpperCase();
const defaultValue = envVar.default || envVar.set || '';
// Check for select dropdown
if (envVar.select && Array.isArray(envVar.select) && envVar.select.length > 0) {
return 'select';
}
// Check for boolean
if (typeof defaultValue === 'boolean' || defaultValue === 'true' || defaultValue === 'false') {
return 'checkbox';
}
// Check for password fields
if (name.includes('PASSWORD') || name.includes('SECRET') || name.includes('KEY') ||
name.includes('TOKEN') || name.includes('AUTH') || name.includes('CREDENTIAL')) {
return 'password';
}
// Check for numeric values
if (typeof defaultValue === 'number' || (!isNaN(parseFloat(defaultValue)) && isFinite(defaultValue) && defaultValue !== '')) {
return 'number';
}
// Check for long text (URLs, descriptions, etc.)
if (defaultValue.length > 100 || name.includes('URL') || name.includes('DESCRIPTION') ||
name.includes('NOTE') || name.includes('COMMENT')) {
return 'textarea';
}
return 'text';
}
// Create appropriate input element based on env var properties
function createEnvVarInput(envVar, id) {
const inputType = determineInputType(envVar);
const name = envVar.name || '';
const label = envVar.label || name;
const description = envVar.description || '';
const defaultValue =
envVar.default !== undefined && envVar.default !== null
? envVar.default
: envVar.set !== undefined && envVar.set !== null
? envVar.set
: '';
const defaultStr = defaultValue === true || defaultValue === false
? String(defaultValue)
: String(defaultValue ?? '');
const isPreset = envVar.preset === true;
// Only mark required when explicitly required — empty optional env is common in templates
const isRequired = envVar.required === true;
const selectOptions = envVar.select || [];
const nameEsc = escapeAttrValue(name);
const labelEsc = escapeAttrValue(label);
const descEsc = escapeAttrValue(description);
const valEsc = escapeAttrValue(defaultStr);
let valueInput = '';
const presetClass = isPreset ? 'preset-field' : '';
const readonlyAttr = isPreset ? 'readonly' : '';
const requiredAttr = isRequired ? 'required' : '';
const presetIcon = isPreset ? '<i class="fas fa-lock text-muted ms-2" title="Preset value"></i>' : '';
switch (inputType) {
case 'select': {
// Portainer: option may set default:true when env.default is absent
let effectiveDefault = defaultStr;
if (effectiveDefault === '' || effectiveDefault == null) {
const marked = selectOptions.find(
(o) => o && typeof o === 'object' && (o.default === true || o.default === 'true')
);
if (marked) effectiveDefault = String(marked.value ?? '');
}
const options = selectOptions.map(opt => {
const optValue = typeof opt === 'object' ? opt.value : opt;
const optLabel = typeof opt === 'object' ? (opt.text || opt.label || opt.value) : opt;
const isDefaultOpt =
opt && typeof opt === 'object' && (opt.default === true || opt.default === 'true');
const selected =
String(optValue) === String(effectiveDefault) ||
(effectiveDefault === '' && isDefaultOpt)
? 'selected'
: '';
return `<option value="${escapeAttrValue(optValue)}" ${selected}>${escapeAttrValue(optLabel)}</option>`;
}).join('');
valueInput = `
<select class="form-select bg-dark text-white ${presetClass}"
data-env-value="${id}"
${readonlyAttr}
${requiredAttr}
${isPreset ? 'disabled' : ''}>
${options}
</select>
`;
break;
}
case 'checkbox': {
const checked = (defaultValue === true || defaultValue === 'true' || String(defaultValue).toLowerCase() === 'true') ? 'checked' : '';
valueInput = `
<div class="form-check form-switch">
<input class="form-check-input"
type="checkbox"
data-env-value="${id}"
${checked}
${readonlyAttr}
${isPreset ? 'disabled' : ''}>
</div>
`;
break;
}
case 'password':
valueInput = `
<input type="password"
class="form-control bg-dark text-white ${presetClass}"
placeholder="Enter ${labelEsc.toLowerCase()}"
data-env-value="${id}"
value="${valEsc}"
${readonlyAttr}
${requiredAttr}
${isPreset ? 'disabled' : ''}>
`;
break;
case 'number':
const numValue = typeof defaultValue === 'number' ? defaultValue : (defaultStr !== '' && !isNaN(parseFloat(defaultStr)) ? parseFloat(defaultStr) : '');
const min = envVar.min !== undefined ? envVar.min : (numValue !== '' ? Math.max(0, numValue - 100) : 0);
const max = envVar.max !== undefined ? envVar.max : (numValue !== '' ? numValue + 100 : 1000);
const step = envVar.step !== undefined ? envVar.step : 1;
const useSlider = envVar.slider !== false && (max - min) <= 1000;
if (useSlider) {
valueInput = `
<div class="slider-container">
<input type="range"
class="form-range slider-input"
data-env-value="${id}"
min="${min}"
max="${max}"
step="${step}"
value="${numValue !== '' ? numValue : min}"
${readonlyAttr}
${isPreset ? 'disabled' : ''}
oninput="updateSliderValue('${id}', this.value)">
<div class="slider-value-display">
<input type="number"
class="form-control bg-dark text-white slider-number-input"
data-env-value="${id}"
value="${numValue !== '' ? numValue : min}"
min="${min}"
max="${max}"
step="${step}"
${readonlyAttr}
${requiredAttr}
${isPreset ? 'disabled' : ''}
oninput="updateSliderRange('${id}', this.value, ${min}, ${max})">
</div>
</div>
`;
} else {
valueInput = `
<input type="number"
class="form-control bg-dark text-white ${presetClass}"
placeholder="Enter ${labelEsc.toLowerCase()}"
data-env-value="${id}"
value="${numValue}"
min="${min}"
max="${max}"
step="${step}"
${readonlyAttr}
${requiredAttr}
${isPreset ? 'disabled' : ''}>
`;
}
break;
case 'textarea': {
const textBody = String(defaultStr ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
valueInput = `
<textarea class="form-control bg-dark text-white ${presetClass}"
placeholder="Enter ${labelEsc.toLowerCase()}"
data-env-value="${id}"
rows="3"
${readonlyAttr}
${requiredAttr}
${isPreset ? 'disabled' : ''}>${textBody}</textarea>
`;
break;
}
default: // text
valueInput = `
<input type="text"
class="form-control bg-dark text-white ${presetClass}"
placeholder="Enter ${labelEsc.toLowerCase()}"
data-env-value="${id}"
value="${valEsc}"
${readonlyAttr}
${requiredAttr}
${isPreset ? 'disabled' : ''}>
`;
}
const descriptionHtml = description ? `<small class="text-muted d-block mt-1">${descEsc}</small>` : '';
const requiredIndicator = isRequired ? '<span class="text-danger">*</span>' : '';
return `
<div class="mb-3 env-var-item" data-env-name="${nameEsc}">
<label class="form-label">
${labelEsc} ${requiredIndicator} ${presetIcon}
</label>
<input type="hidden" data-env-key="${id}" value="${nameEsc}" data-env-preset="${isPreset}">
${valueInput}
${descriptionHtml}
</div>
`;
}
function addEnvVar(envVar = null) {
const container = deployEl('deploy-env');
const id = `env-${envCounter++}`;
const item = document.createElement('div');
item.className = 'array-item mb-2';
item.id = id;
if (envVar) {
// Use template-based input creation
item.innerHTML = createEnvVarInput(envVar, id);
// Add remove button if not preset
if (!envVar.preset) {
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'btn btn-sm btn-outline-danger mt-2';
removeBtn.innerHTML = '<i class="fas fa-times"></i> Remove';
removeBtn.onclick = () => removeArrayItem(id);
item.appendChild(removeBtn);
}
} else {
// Default simple text inputs for manual addition
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="KEY" data-env-key="${id}" style="flex: 0 0 40%;">
<input type="text" class="form-control bg-dark text-white" placeholder="value" data-env-value="${id}" style="flex: 1;">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
}
container.appendChild(item);
updatePreview();
}
function addLabel() {
const container = deployEl('deploy-labels-container');
const id = `label-${labelCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="key=value" data-label-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
function addDnsServer() {
const container = deployEl('deploy-dns-container');
const id = `dns-${dnsCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="8.8.8.8" data-dns-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
function addExtraHost() {
const container = deployEl('deploy-extra-hosts-container');
const id = `host-${extraHostCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="example.com:127.0.0.1" data-host-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
function addDeviceMapping() {
const container = deployEl('deploy-devices-container');
const id = `device-${deviceCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="/dev/ttyUSB0:/dev/ttyUSB0:rwm" data-device-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
function addCapability() {
const container = deployEl('deploy-capabilities-container');
const id = `cap-${capabilityCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="NET_ADMIN" data-cap-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
function addSecurityOpt() {
const container = deployEl('deploy-security-opts-container');
const id = `secopt-${securityOptCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="apparmor=profile" data-secopt-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
function addLogOpt() {
const container = deployEl('deploy-log-opts-container');
const id = `logopt-${logOptCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="max-size=10m" data-logopt-key="${id}" style="flex: 0 0 40%;">
<input type="text" class="form-control bg-dark text-white" placeholder="value" data-logopt-value="${id}" style="flex: 1;">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
function addSysctl() {
const container = deployEl('deploy-sysctls-container');
const id = `sysctl-${sysctlCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="net.ipv4.ip_forward=1" data-sysctl-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
function addUlimit() {
const container = deployEl('deploy-ulimits-container');
const id = `ulimit-${ulimitCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="nofile=1024:2048" data-ulimit-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
updatePreview();
}
function removeArrayItem(id) {
const item = document.getElementById(id);
if (item) {
item.remove();
updatePreview();
}
}
// Slider helper functions
function updateSliderValue(id, value) {
const numberInput = document.querySelector(`[data-env-value="${id}"].slider-number-input`);
if (numberInput) {
numberInput.value = value;
}
updatePreview();
}
function updateSliderRange(id, value, min, max) {
const numValue = parseFloat(value);
if (isNaN(numValue)) return;
const clampedValue = Math.max(min, Math.min(max, numValue));
const sliderInput = document.querySelector(`[data-env-value="${id}"].slider-input`);
const numberInput = document.querySelector(`[data-env-value="${id}"].slider-number-input`);
if (sliderInput) {
sliderInput.value = clampedValue;
}
if (numberInput) {
numberInput.value = clampedValue;
}
updatePreview();
}
// Make functions globally available
window.addPortMapping = addPortMapping;
window.validatePortMapping = validatePortMapping;
window.addVolumeMount = addVolumeMount;
window.handleVolumeTypeChange = handleVolumeTypeChange;
window.validateVolumeMount = validateVolumeMount;
window.openFileBrowser = openFileBrowser;
// Simplified volumes selector - use centralized cache from app.js
// Track which select elements are waiting for volumes
const pendingVolumeSelects = new Set();
// Store active volume handlers to prevent them from being replaced
const activeVolumeHandlers = new Map();
// Expose to window for app.js to access
window.activeVolumeHandlers = activeVolumeHandlers;
// Simplified load volumes for named volume select
async function loadVolumesForSelect(volumeId, preferredName) {
const namedSelect = document.querySelector(`[data-volume-named="${volumeId}"]`);
if (!namedSelect) {
console.error('[ERROR] Named volume select element not found for ID:', volumeId);
return;
}
if (preferredName) {
namedSelect.dataset.preferredVolume = String(preferredName);
}
// Check if already has volumes loaded (more than just placeholder)
const hasVolumeOptions = Array.from(namedSelect.options).some(opt => opt.value && opt.value !== '');
if (hasVolumeOptions && namedSelect.options.length > 1) {
// Still restore preferred selection if needed
const preferred = namedSelect.dataset.preferredVolume || preferredName || namedSelect.value;
if (preferred) {
if (![...namedSelect.options].some((o) => o.value === preferred)) {
namedSelect.add(new Option(preferred, preferred, true, true));
}
namedSelect.value = preferred;
}
return; // Already loaded
}
// Check if already in pending list
if (pendingVolumeSelects.has(volumeId)) {
return; // Already loading
}
// Check cache first - if we have volumes in cache, use them immediately
if (window.volumesStore && !window.volumesStore.isStale() && window.volumesStore.get().length > 0) {
populateVolumeSelect(volumeId, window.volumesStore.get());
return;
}
try {
// Mark as pending
pendingVolumeSelects.add(volumeId);
// Show loading state
const currentValue = namedSelect.value;
namedSelect.innerHTML = '<option value="">Loading volumes...</option>';
namedSelect.disabled = true;
// Set up handler to populate select when volumes arrive
const handlerState = {
volumesReceived: false,
volumeId: volumeId,
startTime: Date.now()
};
const volumeHandler = (response) => {
// Check if this is a volumes response
let volumesArray = null;
if (response && response.type === 'volumes' && Array.isArray(response.data)) {
volumesArray = response.data;
} else if (response && response.success === true && Array.isArray(response.volumes)) {
volumesArray = response.volumes;
} else if (response && Array.isArray(response.volumes)) {
volumesArray = response.volumes;
}
// If not a volumes response, ignore
if (volumesArray === null) {
return;
}
// Only process once per handler
if (handlerState.volumesReceived) {
return;
}
handlerState.volumesReceived = true;
pendingVolumeSelects.delete(volumeId);
activeVolumeHandlers.delete(volumeId);
// Restore original handler if needed
if (window.handlePeerResponse === volumeHandler && handlerState.originalHandler) {
window.handlePeerResponse = handlerState.originalHandler;
}
// Populate the select
populateVolumeSelect(volumeId, volumesArray);
};
// Store original handler
handlerState.originalHandler = window.handlePeerResponse;
// Set up handler
window.handlePeerResponse = volumeHandler;
activeVolumeHandlers.set(volumeId, {
handler: volumeHandler,
state: handlerState,
originalHandler: handlerState.originalHandler
});
// Subscribe to cache updates as fallback
let unsubscribe = null;
if (window.volumesStore) {
unsubscribe = window.volumesStore.subscribe((volumes) => {
if (volumes.length > 0 && pendingVolumeSelects.has(volumeId)) {
pendingVolumeSelects.delete(volumeId);
activeVolumeHandlers.delete(volumeId);
if (window.handlePeerResponse === volumeHandler && handlerState.originalHandler) {
window.handlePeerResponse = handlerState.originalHandler;
}
if (unsubscribe) {
unsubscribe();
}
populateVolumeSelect(volumeId, volumes);
}
});
}
// Request volumes from server
if (typeof window.sendCommand !== 'function') {
console.error('[ERROR] sendCommand function not available');
namedSelect.innerHTML = '<option value="">Error: Cannot communicate with server</option>';
namedSelect.disabled = false;
pendingVolumeSelects.delete(volumeId);
activeVolumeHandlers.delete(volumeId);
if (window.handlePeerResponse === volumeHandler && handlerState.originalHandler) {
window.handlePeerResponse = handlerState.originalHandler;
}
return;
}
window.sendCommand('listVolumes');
// Timeout after 10 seconds
setTimeout(() => {
if (pendingVolumeSelects.has(volumeId)) {
pendingVolumeSelects.delete(volumeId);
activeVolumeHandlers.delete(volumeId);
if (window.handlePeerResponse === volumeHandler && handlerState.originalHandler) {
window.handlePeerResponse = handlerState.originalHandler;
}
const currentSelect = document.querySelector(`[data-volume-named="${volumeId}"]`);
if (currentSelect) {
currentSelect.innerHTML = '<option value="">Request timed out - click to retry</option>';
currentSelect.disabled = false;
}
}
}, 10000);
} catch (error) {
console.error('[ERROR] Failed to load volumes:', error);
pendingVolumeSelects.delete(volumeId);
activeVolumeHandlers.delete(volumeId);
const currentSelect = document.querySelector(`[data-volume-named="${volumeId}"]`);
if (currentSelect) {
currentSelect.innerHTML = '<option value="">Error loading volumes</option>';
currentSelect.disabled = false;
}
}
}
// Helper function to populate volume select dropdown
function populateVolumeSelect(volumeId, volumesArray) {
const namedSelect = document.querySelector(`[data-volume-named="${volumeId}"]`);
if (!namedSelect) {
return;
}
const preferred =
namedSelect.dataset.preferredVolume ||
namedSelect.value ||
'';
// Clear and rebuild options
namedSelect.innerHTML = '';
// Add placeholder option
const placeholderOption = new Option('Select or create volume...', '', !preferred, false);
namedSelect.add(placeholderOption);
// Add volumes
const seen = new Set();
if (volumesArray && volumesArray.length > 0) {
volumesArray.forEach((volume) => {
const volumeName = volume.Name || volume.name || (typeof volume === 'string' ? volume : null);
if (volumeName && !seen.has(volumeName)) {
seen.add(volumeName);
namedSelect.add(new Option(volumeName, volumeName, false, false));
}
});
} else if (!preferred) {
const option = new Option('No volumes available', '', false, false);
option.disabled = true;
namedSelect.add(option);
}
// Always keep the preferred/cloned volume selectable even if not in list yet
if (preferred && !seen.has(preferred)) {
namedSelect.add(new Option(preferred, preferred, true, true));
seen.add(preferred);
}
if (preferred) {
namedSelect.value = preferred;
}
namedSelect.disabled = false;
}
// Expose loadVolumesForSelect to window for inline handlers
window.loadVolumesForSelect = loadVolumesForSelect;
// Open file browser modal
let currentFileBrowserVolumeId = null;
let currentFileBrowserPath = '/';
function openFileBrowser(volumeId) {
currentFileBrowserVolumeId = volumeId;
currentFileBrowserPath = '/';
const fileBrowserModal = document.getElementById('fileBrowserModal');
if (!fileBrowserModal) {
console.error('[ERROR] File browser modal element not found');
return;
}
if (typeof bootstrap === 'undefined') {
console.error('[ERROR] Bootstrap is not available');
return;
}
try {
const modal = new bootstrap.Modal(fileBrowserModal);
modal.show();
loadDirectoryContents('/');
} catch (error) {
console.error('[ERROR] Failed to open file browser modal:', error);
}
}
// Load directory contents
async function loadDirectoryContents(path) {
const fileBrowserContent = document.getElementById('fileBrowserContent');
const fileBrowserBreadcrumb = document.getElementById('fileBrowserBreadcrumb');
if (!fileBrowserContent) {
console.error('[ERROR] File browser content element not found');
return;
}
// Show tray-style loading
fileBrowserContent.innerHTML = jobSpinnerLoadingBlock('Loading…');
// Update breadcrumb
if (fileBrowserBreadcrumb) {
const pathParts = path.split('/').filter(p => p);
let breadcrumbHtml = '<nav aria-label="breadcrumb"><ol class="breadcrumb mb-0">';
breadcrumbHtml += '<li class="breadcrumb-item"><a href="#" onclick="loadDirectoryContents(\'/\'); return false;"><i class="fas fa-home"></i> Root</a></li>';
let currentPath = '';
pathParts.forEach((part, index) => {
currentPath += '/' + part;
const isLast = index === pathParts.length - 1;
breadcrumbHtml += `<li class="breadcrumb-item ${isLast ? 'active' : ''}">`;
if (!isLast) {
breadcrumbHtml += `<a href="#" onclick="loadDirectoryContents('${currentPath}'); return false;">${part}</a>`;
} else {
breadcrumbHtml += part;
}
breadcrumbHtml += '</li>';
});
breadcrumbHtml += '</ol></nav>';
fileBrowserBreadcrumb.innerHTML = breadcrumbHtml;
}
try {
if (typeof window.sendCommand !== 'function') {
console.error('[ERROR] sendCommand function not available');
fileBrowserContent.innerHTML = '<div class="alert alert-danger">Error: Cannot communicate with server</div>';
return;
}
// Store original handler
const originalHandler = window.handlePeerResponse;
let directoryReceived = false;
const requestId = `browseDir_${Date.now()}_${Math.random()}`;
const directoryHandler = (response) => {
// Check if this is a directory browser response
// Look for: success + contents, or error related to directory browsing
const isDirectoryResponse =
(response.success === true && Array.isArray(response.contents)) ||
(response.error && (response.error.includes('directory') || response.error.includes('browse'))) ||
(response.path && response.contents !== undefined);
if (!isDirectoryResponse) {
// Not a directory response, pass to original handler
if (typeof originalHandler === 'function') {
originalHandler(response);
}
return;
}
if (directoryReceived) {
// Already processed, pass to original handler
if (typeof originalHandler === 'function') {
originalHandler(response);
}
return;
}
// Handle success response
if (response.success === true && Array.isArray(response.contents)) {
directoryReceived = true;
window.handlePeerResponse = originalHandler;
currentFileBrowserPath = response.path || path;
displayDirectoryContents(response.contents, currentFileBrowserPath);
}
// Handle error response
else if (response.error) {
directoryReceived = true;
window.handlePeerResponse = originalHandler;
console.error('[ERROR] Directory browse error:', response.error);
fileBrowserContent.innerHTML = `<div class="alert alert-danger"><i class="fas fa-exclamation-triangle"></i> Error: ${response.error}</div>`;
}
// Handle unexpected format
else {
console.warn('[WARN] Unexpected response format:', response);
// Still try to process if it has contents
if (response.contents !== undefined) {
directoryReceived = true;
window.handlePeerResponse = originalHandler;
currentFileBrowserPath = response.path || path;
displayDirectoryContents(response.contents || [], currentFileBrowserPath);
} else {
// Pass to original handler if we can't process it
if (typeof originalHandler === 'function') {
originalHandler(response);
}
}
}
};
// Set the handler
window.handlePeerResponse = directoryHandler;
// Send the command
window.sendCommand('browseDirectory', { path: path });
// Timeout after 10 seconds
const timeoutId = setTimeout(() => {
if (!directoryReceived) {
console.warn('[WARN] Directory browse request timed out');
window.handlePeerResponse = originalHandler;
directoryReceived = true; // Mark as received to prevent double handling
fileBrowserContent.innerHTML = '<div class="alert alert-warning"><i class="fas fa-clock"></i> Request timed out. Please try again.</div>';
}
}, 10000);
// Store timeout ID for potential cleanup (though we don't need it after timeout)
// This is just for reference
} catch (error) {
console.error('[ERROR] Failed to load directory:', error);
fileBrowserContent.innerHTML = `<div class="alert alert-danger"><i class="fas fa-exclamation-triangle"></i> Error: ${error.message}</div>`;
}
}
// Display directory contents
function displayDirectoryContents(contents, currentPath) {
const fileBrowserContent = document.getElementById('fileBrowserContent');
if (!fileBrowserContent) {
console.error('[ERROR] File browser content element not found');
return;
}
if (!contents || contents.length === 0) {
fileBrowserContent.innerHTML = '<div class="text-center p-4 text-muted"><i class="fas fa-folder-open"></i> Directory is empty</div>';
return;
}
// Sort: directories first, then files
const sorted = contents.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1;
if (a.type !== 'directory' && b.type === 'directory') return 1;
return (a.name || '').localeCompare(b.name || '');
});
let html = '<div class="file-browser-list">';
sorted.forEach(item => {
const icon = item.type === 'directory' ? 'fa-folder' : 'fa-file';
const iconColor = item.type === 'directory' ? 'text-warning' : 'text-secondary';
// Escape path for onclick to prevent XSS
const escapedPath = (currentPath === '/' ? `/${item.name}` : `${currentPath}/${item.name}`)
.replace(/'/g, "\\'")
.replace(/"/g, '&quot;');
const escapedName = (item.name || '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
if (item.type === 'directory') {
html += `
<div class="file-browser-item" onclick="loadDirectoryContents('${escapedPath}')" title="Click to open">
<i class="fas ${icon} ${iconColor}"></i>
<span>${escapedName}</span>
<i class="fas fa-chevron-right text-muted"></i>
</div>
`;
} else {
const size = item.size ? formatFileSize(item.size) : '';
html += `
<div class="file-browser-item" title="File${size ? ': ' + size : ''}">
<i class="fas ${icon} ${iconColor}"></i>
<span>${escapedName}</span>
${size ? `<small class="text-muted ms-2">${size}</small>` : ''}
</div>
`;
}
});
html += '</div>';
fileBrowserContent.innerHTML = html;
}
// Helper function to format file size
function formatFileSize(bytes) {
if (!bytes || bytes === 0) return '';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
// Select directory for volume mount
function selectDirectoryForVolume() {
if (!currentFileBrowserVolumeId) {
console.warn('[WARN] No volume ID set for directory selection');
return;
}
const hostInput = document.querySelector(`[data-volume-host="${currentFileBrowserVolumeId}"]`);
if (hostInput) {
hostInput.value = currentFileBrowserPath;
validateVolumeMount(currentFileBrowserVolumeId);
updatePreview();
} else {
console.error('[ERROR] Host input not found for volume ID:', currentFileBrowserVolumeId);
}
// Close modal
const fileBrowserModal = document.getElementById('fileBrowserModal');
if (fileBrowserModal && typeof bootstrap !== 'undefined') {
try {
const modal = bootstrap.Modal.getInstance(fileBrowserModal);
if (modal) {
modal.hide();
}
} catch (error) {
console.error('[ERROR] Failed to close file browser modal:', error);
}
}
}
window.loadDirectoryContents = loadDirectoryContents;
window.selectDirectoryForVolume = selectDirectoryForVolume;
window.addTmpfsMount = addTmpfsMount;
window.addEnvVar = addEnvVar;
window.addLabel = addLabel;
window.addDnsServer = addDnsServer;
window.addExtraHost = addExtraHost;
window.addDeviceMapping = addDeviceMapping;
window.addCapability = addCapability;
window.addSecurityOpt = addSecurityOpt;
window.addLogOpt = addLogOpt;
window.addSysctl = addSysctl;
window.addUlimit = addUlimit;
window.removeArrayItem = removeArrayItem;
window.updateSliderValue = updateSliderValue;
window.updateSliderRange = updateSliderRange;
// Network mode change handler
document.addEventListener('DOMContentLoaded', () => {
const networkMode = deployEl('deploy-network-mode');
const customNetworkContainer = deployEl('deploy-custom-network-container');
if (networkMode && customNetworkContainer) {
networkMode.addEventListener('change', (e) => {
if (e.target.value === 'container') {
customNetworkContainer.style.display = 'block';
const input = customNetworkContainer.querySelector('input');
if (input) input.placeholder = 'container-name';
} else if (e.target.value !== 'host' && e.target.value !== 'none' && e.target.value !== 'bridge') {
customNetworkContainer.style.display = 'block';
const input = customNetworkContainer.querySelector('input');
if (input) input.placeholder = 'network-name';
} else {
customNetworkContainer.style.display = 'none';
}
updatePreview();
});
}
// Add input listeners for preview updates
const form = document.getElementById('deploy-form');
if (form) {
form.addEventListener('input', () => updatePreview());
form.addEventListener('change', () => updatePreview());
}
});
// Collect all form data
function collectFormData() {
const data = {
containerName: deployEl('deploy-container-name')?.value.trim() || '',
image: deployEl('deploy-image')?.value.trim() || '',
command: deployEl('deploy-command')?.value.trim() || null,
entrypoint: deployEl('deploy-entrypoint')?.value.trim() || null,
workingDir: deployEl('deploy-workdir')?.value.trim() || null,
// Networking
networkMode: deployEl('deploy-network-mode')?.value || 'bridge',
customNetwork: (() => {
const mode = deployEl('deploy-network-mode')?.value || 'bridge';
const customNet = deployEl('deploy-custom-network')?.value.trim();
if (!customNet) return null;
// host/none: no extra attach; container: peer container name; bridge: optional user network
if (mode === 'host' || mode === 'none') return null;
return customNet;
})(),
hostname: deployEl('deploy-hostname')?.value.trim() || null,
domainname: deployEl('deploy-domainname')?.value.trim() || null,
ports: collectPortMappings(),
dns: collectArrayItems('deploy-dns-container', 'data-dns-id'),
extraHosts: collectArrayItems('deploy-extra-hosts-container', 'data-host-id'),
// Volumes
volumes: collectVolumeMounts(),
tmpfs: collectArrayItems('deploy-tmpfs-container', 'data-tmpfs-id'),
// Resources
// Range sliders use 0 for “unset / unlimited”
cpuLimit: (() => {
const v = parseFloat(deployEl('deploy-cpu-limit')?.value)
return Number.isFinite(v) && v > 0 ? v : null
})(),
cpuReservation: (() => {
const v = parseFloat(deployEl('deploy-cpu-reservation')?.value)
return Number.isFinite(v) && v > 0 ? v : null
})(),
cpuShares: parseInt(deployEl('deploy-cpu-shares')?.value) || null,
memoryLimit: (() => {
const v = parseInt(deployEl('deploy-memory-limit')?.value, 10)
return Number.isFinite(v) && v > 0 ? v : null
})(),
memoryReservation: (() => {
const v = parseInt(deployEl('deploy-memory-reservation')?.value, 10)
return Number.isFinite(v) && v > 0 ? v : null
})(),
memorySwap: parseInt(deployEl('deploy-memory-swap')?.value) || null,
devices: collectArrayItems('deploy-devices-container', 'data-device-id'),
// Environment & Labels
env: collectEnvVars(),
labels: collectLabels(),
// Security
user: deployEl('deploy-user')?.value.trim() || null,
group: deployEl('deploy-group')?.value.trim() || null,
privileged: deployEl('deploy-privileged')?.checked || false,
readonlyRootfs: deployEl('deploy-readonly-rootfs')?.checked || false,
capabilities: collectArrayItems('deploy-capabilities-container', 'data-cap-id'),
securityOpts: collectArrayItems('deploy-security-opts-container', 'data-secopt-id'),
// Runtime
restartPolicy: deployEl('deploy-restart-policy')?.value || 'no',
restartMaxRetries: parseInt(deployEl('deploy-restart-max-retries')?.value) || null,
autoRemove: deployEl('deploy-auto-remove')?.checked || false,
tty: deployEl('deploy-tty')?.checked || false,
stdinOpen: deployEl('deploy-stdin-open')?.checked || false,
detach: deployEl('deploy-detach')?.checked !== false, // Default true
init: deployEl('deploy-init')?.checked || false,
// Health & Logging
healthCmd: deployEl('deploy-health-cmd')?.value.trim() || null,
healthInterval: parseInt(deployEl('deploy-health-interval')?.value) || null,
healthTimeout: parseInt(deployEl('deploy-health-timeout')?.value) || null,
healthRetries: parseInt(deployEl('deploy-health-retries')?.value) || null,
healthStartPeriod: parseInt(deployEl('deploy-health-start-period')?.value) || null,
logDriver: deployEl('deploy-log-driver')?.value || null,
logOpts: collectLogOpts(),
// Advanced
sysctls: collectSysctls(),
ulimits: collectUlimits(),
oomKillDisable: deployEl('deploy-oom-kill-disable')?.checked || false,
pidsLimit: parseInt(deployEl('deploy-pids-limit')?.value) || null,
shmSize: parseInt(deployEl('deploy-shm-size')?.value) || null,
};
// Remove null/empty values
Object.keys(data).forEach(key => {
if (data[key] === null || data[key] === '' || (Array.isArray(data[key]) && data[key].length === 0)) {
delete data[key];
}
});
return data;
}
function collectArrayItems(containerId, dataAttr) {
const container = document.getElementById(containerId);
if (!container) return [];
const items = [];
container.querySelectorAll(`[${dataAttr}]`).forEach(input => {
const value = input.value.trim();
if (value) items.push(value);
});
return items;
}
// Collect port mappings from structured inputs
function collectPortMappings() {
const container = deployEl('deploy-ports-container');
if (!container) return [];
const ports = [];
container.querySelectorAll('.port-mapping-item').forEach(item => {
const hostInput = item.querySelector('.port-host-input');
const containerInput = item.querySelector('.port-container-input');
const protocolInput = item.querySelector('.port-protocol-input');
if (!containerInput || !containerInput.value) return;
const hostPort = hostInput?.value.trim() || '';
const containerPort = containerInput.value.trim();
const protocol = protocolInput?.value || 'tcp';
// Build port string: host:container/protocol or container/protocol
const portStr = hostPort ? `${hostPort}:${containerPort}/${protocol}` : `${containerPort}/${protocol}`;
ports.push(portStr);
});
return ports;
}
// Collect volume mounts from structured inputs
function collectVolumeMounts() {
const container = deployEl('deploy-volumes-container');
if (!container) return [];
const volumes = [];
container.querySelectorAll('.volume-mount-item').forEach(item => {
const typeSelect = item.querySelector('.volume-type-input');
const hostInput = item.querySelector('.volume-host-input');
const namedSelect = item.querySelector('.volume-named-input');
const containerInput = item.querySelector('.volume-container-input');
const modeSelect = item.querySelector('.volume-mode-input');
if (!containerInput || !containerInput.value) return;
const volumeType = typeSelect?.value || 'bind';
const containerPath = containerInput.value.trim();
const mountMode = modeSelect?.value || 'rw';
let volumeStr = '';
if (volumeType === 'bind') {
const hostPath = hostInput?.value.trim() || '';
if (!hostPath) return; // Skip if host path is empty
volumeStr = `${hostPath}:${containerPath}:${mountMode}`;
} else {
// Named volume
const volumeName = namedSelect?.value.trim() || '';
if (!volumeName) return; // Skip if volume name is empty
volumeStr = `${volumeName}:${containerPath}:${mountMode}`;
}
volumes.push(volumeStr);
});
return volumes;
}
function collectEnvVars() {
const container = deployEl('deploy-env');
if (!container) return [];
const envVars = [];
container.querySelectorAll('[data-env-key]').forEach(keyInput => {
const key = keyInput.value.trim();
if (!key) return;
const id = keyInput.getAttribute('data-env-key');
const isPreset = keyInput.getAttribute('data-env-preset') === 'true';
// Find the value input/select/checkbox
const valueInput = container.querySelector(`[data-env-value="${id}"]`);
if (!valueInput) return;
let value = '';
// Handle different input types
if (valueInput.type === 'checkbox') {
// For checkboxes, use 'true' or 'false' as string
value = valueInput.checked ? 'true' : 'false';
} else if (valueInput.tagName === 'SELECT') {
value = valueInput.value || '';
} else if (valueInput.tagName === 'TEXTAREA') {
value = valueInput.value.trim();
} else if (valueInput.type === 'range') {
// For sliders, get the number input value
const numberInput = container.querySelector(`[data-env-value="${id}"].slider-number-input`);
value = numberInput ? String(numberInput.value).trim() : String(valueInput.value);
} else if (valueInput.type === 'number') {
// For number inputs, preserve the numeric value as string
value = valueInput.value !== '' ? String(valueInput.value).trim() : '';
} else {
value = valueInput.value.trim();
}
// Include preset values even if disabled
if (isPreset || value) {
envVars.push({
name: key,
value: value || '',
preset: isPreset
});
}
});
return envVars;
}
function collectLabels() {
const items = collectArrayItems('deploy-labels-container', 'data-label-id');
const labels = {};
items.forEach(item => {
if (!item || !item.includes('=')) return;
const eq = item.indexOf('=');
const key = item.slice(0, eq).trim();
const value = item.slice(eq + 1);
if (key) labels[key] = value;
});
return Object.keys(labels).length > 0 ? labels : null;
}
function collectLogOpts() {
const container = deployEl('deploy-log-opts-container');
if (!container) return null;
const opts = {};
container.querySelectorAll('[data-logopt-key]').forEach(keyInput => {
const key = keyInput.value.trim();
const valueInput = container.querySelector(`[data-logopt-value="${keyInput.getAttribute('data-logopt-key')}"]`);
const value = valueInput?.value.trim() || '';
if (key) {
opts[key] = value;
}
});
return Object.keys(opts).length > 0 ? opts : null;
}
function collectSysctls() {
const items = collectArrayItems('deploy-sysctls-container', 'data-sysctl-id');
const sysctls = {};
items.forEach(item => {
const [key, value] = item.split('=');
if (key && value) {
sysctls[key.trim()] = value.trim();
}
});
return Object.keys(sysctls).length > 0 ? sysctls : null;
}
function collectUlimits() {
const items = collectArrayItems('deploy-ulimits-container', 'data-ulimit-id');
const ulimits = [];
items.forEach(item => {
const [name, limits] = item.split('=');
if (name && limits) {
const [soft, hard] = limits.split(':');
ulimits.push({
Name: name.trim(),
Soft: soft ? parseInt(soft) : null,
Hard: hard ? parseInt(hard) : null
});
}
});
return ulimits.length > 0 ? ulimits : null;
}
// Update preview
function updatePreview() {
const previewContainer = deployEl('deploy-preview-container');
const preview = deployEl('deploy-preview');
const showPreview = deployEl('deploy-show-preview')?.checked;
if (!previewContainer || !preview) return;
if (showPreview) {
const data = collectFormData();
preview.textContent = JSON.stringify(data, null, 2);
previewContainer.style.display = 'block';
} else {
previewContainer.style.display = 'none';
}
}
window.togglePreview = updatePreview;
/**
* Convert a Portainer template port entry to form string (host:container/proto or container/proto).
* @param {unknown} port
* @returns {string|null}
*/
function portainerPortToString(port) {
if (port == null) return null;
if (typeof port === 'number' && Number.isFinite(port)) {
return `${port}/tcp`;
}
if (typeof port === 'string') {
const s = port.trim();
return s || null;
}
if (typeof port === 'object') {
const containerPort =
port.container ?? port.target ?? port.containerPort ?? port.ContainerPort;
if (containerPort == null || containerPort === '') return null;
const protocol = String(port.protocol || port.Protocol || 'tcp').toLowerCase() || 'tcp';
const hostPort =
port.host ?? port.published ?? port.hostPort ?? port.HostPort ?? port.public ?? '';
return hostPort !== '' && hostPort != null
? `${hostPort}:${containerPort}/${protocol}`
: `${containerPort}/${protocol}`;
}
return null;
}
/**
* Convert a Portainer volume entry to bind string (source:dest[:mode]).
* @param {unknown} volume
* @param {string} [nameHint]
* @returns {string|null}
*/
function portainerVolumeToString(volume, nameHint = 'data') {
if (volume == null) return null;
if (typeof volume === 'string') {
const s = volume.trim();
return s || null;
}
if (typeof volume !== 'object') return null;
const dest =
volume.container ||
volume.target ||
volume.containerPath ||
volume.Destination ||
volume.Target ||
'';
if (!dest) return null;
const host =
volume.bind ||
volume.host ||
volume.source ||
volume.Source ||
volume.hostPath ||
volume.name ||
volume.Name ||
'';
const readonly =
volume.readonly === true ||
volume.read_only === true ||
volume.ReadOnly === true ||
volume.mode === 'ro' ||
volume.Mode === 'ro';
const mode = readonly ? 'ro' : volume.mode || volume.Mode || 'rw';
const modeStr = mode === 'ro' || mode === 'rw' ? mode : 'rw';
if (host) {
return `${host}:${dest}:${modeStr}`;
}
// No bind path → named volume (Portainer creates a volume). Suggest a stable name.
const slug = String(nameHint || 'data')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'data';
const destSlug = String(dest)
.replace(/^\//, '')
.replace(/[^a-z0-9]+/gi, '-')
.slice(0, 24) || 'vol';
return `${slug}-${destSlug}:${dest}:${modeStr}`;
}
/**
* Apply Portainer/template network field to deploy form controls.
* @param {object} template
*/
function applyTemplateNetwork(template) {
const modeEl = deployEl('deploy-network-mode');
const customEl = deployEl('deploy-custom-network');
const customWrap = deployEl('deploy-custom-network-container');
if (!modeEl) return;
const raw =
template.network ??
template.networkMode ??
template.NetworkMode ??
template.net ??
null;
if (raw == null || raw === '') {
modeEl.value = 'bridge';
if (customWrap) customWrap.style.display = 'none';
return;
}
const n = String(raw).trim();
if (n === 'host' || n === 'none' || n === 'bridge') {
modeEl.value = n;
if (customEl) customEl.value = '';
if (customWrap) customWrap.style.display = 'none';
} else if (n.startsWith('container:')) {
modeEl.value = 'container';
if (customEl) customEl.value = n.slice('container:'.length);
if (customWrap) customWrap.style.display = 'block';
} else {
// User-defined Docker network name (attach after create)
modeEl.value = 'bridge';
if (customEl) customEl.value = n;
if (customWrap) customWrap.style.display = 'block';
}
try {
modeEl.dispatchEvent(new Event('change', { bubbles: true }));
} catch {
// ignore
}
}
/**
* Populate the Deploy form (view + modal share element ids) from a Portainer template.
* Maps every Portainer app-template field we support into the form.
*
* @param {object} template
* @param {{ skipName?: boolean }} [opts]
* @returns {{ ok: boolean, isStack: boolean, warnings: string[] }}
*/
function populateDeployFormFromTemplate(template, opts = {}) {
const warnings = [];
if (!template || typeof template !== 'object') {
return { ok: false, isStack: false, warnings: ['Invalid template'] };
}
// Reset counters so new rows get unique ids
portCounter = volumeCounter = envCounter = labelCounter = dnsCounter = 0;
extraHostCounter = deviceCounter = capabilityCounter = securityOptCounter = 0;
logOptCounter = sysctlCounter = ulimitCounter = tmpfsCounter = 0;
const typeNum = Number(template.type);
const catalogIsStack = isStackTemplate(template) || typeNum === 2 || typeNum === 3;
// After compose resolve, treat as deployable container when image is present
const hasImage = Boolean(String(template.image || template.Image || '').trim());
const isStack = catalogIsStack && !hasImage && !template._composeResolved;
// Clear dynamic lists first (scoped to active form root)
[
'deploy-ports-container',
'deploy-volumes-container',
'deploy-env',
'deploy-labels-container',
'deploy-dns-container',
'deploy-extra-hosts-container',
'deploy-devices-container',
'deploy-capabilities-container',
'deploy-security-opts-container',
'deploy-log-opts-container',
'deploy-sysctls-container',
'deploy-ulimits-container',
'deploy-tmpfs-container',
].forEach((id) => {
const el = deployEl(id);
if (el) el.innerHTML = '';
});
const nameHint = String(template.name || template.title || 'app')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 40) || 'app';
// Container name
if (!opts.skipName) {
const nameEl = deployEl('deploy-container-name');
if (nameEl) {
const preferred = String(template.name || template.title || nameHint)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 63);
nameEl.value = preferred || nameHint;
}
}
// Image (+ optional registry prefix when image is bare and registry set)
const imageEl = deployEl('deploy-image');
if (imageEl) {
let image = String(template.image || template.Image || '').trim();
const registry = String(template.registry || template.Registry || '').trim();
if (image && registry && !image.includes('/') && !registry.includes('://')) {
// registry host only, e.g. "ghcr.io" + "app:tag"
image = `${registry.replace(/\/$/, '')}/${image}`;
}
imageEl.value = image;
if (!image && catalogIsStack) {
warnings.push(
template._composeWarning ||
'Compose/stack template has no single image — could not resolve compose from the repository. Deploy the full stack via Stacks, or pick a container (type 1) template.'
);
} else if (template._composeResolved && template._composeService) {
warnings.push(
`Resolved image from compose service “${template._composeService}” (multi-service stacks may need Stacks for full deploy).`
);
} else if (template._composeWarning) {
warnings.push(template._composeWarning);
}
}
// Command
const cmdEl = deployEl('deploy-command');
if (cmdEl) {
const cmd = template.command ?? template.Command ?? template.cmd;
if (Array.isArray(cmd)) cmdEl.value = cmd.join(' ');
else if (cmd != null && String(cmd).trim()) cmdEl.value = String(cmd);
else cmdEl.value = '';
}
// Entrypoint (rare in Portainer templates)
const epEl = deployEl('deploy-entrypoint');
if (epEl) {
const ep = template.entrypoint ?? template.Entrypoint;
if (Array.isArray(ep)) epEl.value = ep.join(' ');
else if (ep != null && String(ep).trim()) epEl.value = String(ep);
else epEl.value = '';
}
// Working dir
const wdEl = deployEl('deploy-workdir');
if (wdEl) {
wdEl.value = template.workingDir || template.working_dir || template.WorkingDir || '';
}
// Hostname / domain
const hostnameEl = deployEl('deploy-hostname');
if (hostnameEl) {
hostnameEl.value = template.hostname || template.Hostname || '';
}
const domainEl = deployEl('deploy-domainname');
if (domainEl) {
domainEl.value = template.domainname || template.domainName || template.Domainname || '';
}
// Network
applyTemplateNetwork(template);
// Interactive / TTY
const interactive = template.interactive === true || template.Interactive === true;
const ttyEl = deployEl('deploy-tty');
const stdinEl = deployEl('deploy-stdin-open');
if (ttyEl) ttyEl.checked = interactive || template.tty === true || template.Tty === true;
if (stdinEl) {
stdinEl.checked =
interactive || template.stdin_open === true || template.OpenStdin === true;
}
// Privileged
const privEl = deployEl('deploy-privileged');
if (privEl) {
privEl.checked =
template.privileged === true ||
template.Privileged === true ||
String(template.privileged).toLowerCase() === 'true';
}
// Read-only rootfs
const roEl = deployEl('deploy-readonly-rootfs');
if (roEl) {
roEl.checked =
template.readonly === true ||
template.read_only === true ||
template.ReadonlyRootfs === true;
}
// Restart policy
const rpEl = deployEl('deploy-restart-policy');
if (rpEl) {
const rp =
template.restart_policy ||
template.restartPolicy ||
template.RestartPolicy ||
'unless-stopped';
const allowed = ['no', 'always', 'unless-stopped', 'on-failure'];
rpEl.value = allowed.includes(String(rp)) ? String(rp) : 'unless-stopped';
}
// Ports
const portsRaw = template.ports ?? template.Ports;
if (portsRaw != null) {
const portsArray = Array.isArray(portsRaw) ? portsRaw : [portsRaw];
for (const p of portsArray) {
const portStr = portainerPortToString(p);
if (portStr) addPortMapping(portStr);
}
}
// Volumes
const volsRaw = template.volumes ?? template.Volumes;
if (volsRaw != null) {
const volumesArray = Array.isArray(volsRaw) ? volsRaw : [volsRaw];
for (const v of volumesArray) {
const volStr = portainerVolumeToString(v, nameHint);
if (volStr) addVolumeMount(volStr);
}
}
// Env
const envRaw = template.env ?? template.Env ?? template.environment;
if (envRaw != null) {
const envArray = Array.isArray(envRaw) ? envRaw : [envRaw];
for (const env of envArray) {
if (env == null) continue;
if (typeof env === 'object' && (env.name || env.Name)) {
// Normalize Portainer env shape
addEnvVar({
name: env.name || env.Name,
label: env.label || env.Label || env.name || env.Name,
default:
env.default !== undefined
? env.default
: env.set !== undefined
? env.set
: env.value !== undefined
? env.value
: '',
set: env.set,
description: env.description || env.Description || '',
preset: env.preset === true,
select: env.select || env.Select,
required: env.required === true,
});
} else if (typeof env === 'string') {
const i = env.indexOf('=');
addEnvVar({
name: i >= 0 ? env.slice(0, i) : env,
default: i >= 0 ? env.slice(i + 1) : '',
label: i >= 0 ? env.slice(0, i) : env,
});
}
}
}
// Labels — Portainer: [{ name, value }] or map
const labelsRaw = template.labels ?? template.Labels;
if (labelsRaw != null) {
let pairs = [];
if (Array.isArray(labelsRaw)) {
pairs = labelsRaw
.map((l) => {
if (typeof l === 'string') return l;
if (l && typeof l === 'object') {
const k = l.name || l.key || l.Name || l.Key;
const v = l.value ?? l.Value ?? '';
return k ? `${k}=${v}` : null;
}
return null;
})
.filter(Boolean);
} else if (typeof labelsRaw === 'object') {
pairs = Object.entries(labelsRaw).map(([k, v]) => `${k}=${v}`);
}
for (const pair of pairs) {
addLabel();
const container = deployEl('deploy-labels-container');
const last = container?.lastElementChild?.querySelector('input');
if (last) last.value = pair;
}
}
// Extra hosts — Portainer: hosts: ["host:ip", ...]
const hostsRaw = template.hosts ?? template.extra_hosts ?? template.ExtraHosts;
if (hostsRaw != null) {
const hosts = Array.isArray(hostsRaw) ? hostsRaw : [hostsRaw];
for (const h of hosts) {
if (h == null || h === '') continue;
addExtraHost();
const container = deployEl('deploy-extra-hosts-container');
const last = container?.lastElementChild?.querySelector('input');
if (last) last.value = String(h);
}
}
// Devices
const devicesRaw = template.devices ?? template.Devices;
if (devicesRaw != null && Array.isArray(devicesRaw)) {
for (const d of devicesRaw) {
if (!d) continue;
addDeviceMapping();
const container = deployEl('deploy-devices-container');
const last = container?.lastElementChild?.querySelector('input');
if (last) {
last.value =
typeof d === 'string'
? d
: `${d.pathOnHost || d.PathOnHost || ''}:${d.pathInContainer || d.PathInContainer || d.pathOnHost || ''}:${d.cgroupPermissions || d.CgroupPermissions || 'rwm'}`;
}
}
}
// Capabilities
const caps = template.cap_add || template.CapAdd || template.capabilities;
if (Array.isArray(caps)) {
for (const c of caps) {
if (!c) continue;
addCapability();
const container = deployEl('deploy-capabilities-container');
const last = container?.lastElementChild?.querySelector('input');
if (last) last.value = String(c);
}
}
// Sysctls
const sysctls = template.sysctls || template.Sysctls;
if (sysctls && typeof sysctls === 'object' && !Array.isArray(sysctls)) {
for (const [k, v] of Object.entries(sysctls)) {
addSysctl();
const container = deployEl('deploy-sysctls-container');
const last = container?.lastElementChild?.querySelector('input');
if (last) last.value = `${k}=${v}`;
}
}
// Template note (setup instructions from the catalog — HTML from maintainers)
renderTemplateNote(template.note || template.Note || '');
try {
updatePreview();
} catch {
// ignore
}
return { ok: true, isStack, warnings, catalogIsStack, hasImage };
}
/**
* Show or hide the Portainer template note panel above the form.
* @param {string} noteHtml
*/
function renderTemplateNote(noteHtml) {
const text = String(noteHtml || '').trim();
for (const form of [formScope, document.getElementById('deploy-view-form'), document.getElementById('deploy-form')].filter(Boolean)) {
if (!form || !form.querySelector) continue;
let panel = form.querySelector('.deploy-template-note');
if (!text) {
if (panel) panel.remove();
continue;
}
if (!panel) {
panel = document.createElement('div');
panel.className = 'deploy-template-note alert alert-info py-2 px-3 mb-3 small';
panel.setAttribute('role', 'note');
form.insertBefore(panel, form.firstChild);
}
// Catalog notes are trusted HTML from the template list (same as Portainer)
panel.innerHTML = `<div class="fw-semibold mb-1"><i class="fas fa-info-circle me-1"></i>Template notes</div><div class="deploy-template-note-body">${text}</div>`;
}
}
// Open deploy modal and populate the form dynamically
async function openDeployModal(template) {
if (!template || typeof template !== 'object') {
console.error('[ERROR] Invalid template provided to openDeployModal:', template);
showAlert('danger', 'Invalid template data. Please try again.');
return;
}
// Initialize template deployer lazily (DOM elements, modal, event listeners)
initTemplateDeployer();
// Scope all deploy-* lookups to the modal form (not the hidden deploy-view form)
const form = document.getElementById('deploy-form');
setDeployFormScope(form);
// Store current template for validation
currentTemplate = template;
// Set the modal title
const deployTitle = document.getElementById('deploy-title');
if (deployTitle) {
deployTitle.textContent = `Deploy ${template.title || template.name || 'Template'}`;
}
if (form) form.reset();
let resolved = template;
const typeNum = Number(template.type);
const isCatalogStack =
typeNum === 2 || typeNum === 3 || isStackTemplate(template);
if (isCatalogStack || !String(template.image || template.Image || '').trim()) {
showStatusIndicator(`Loading compose for ${template.title || template.name || 'template'}…`);
try {
resolved = await resolveTemplateForDeploy(template);
} finally {
hideStatusIndicator();
}
}
// Stack templates with compose → Stacks modal (full multi-service support)
if (isCatalogStack && resolved._composeText && typeof window.openStackDeployFromTemplate === 'function') {
if (window.openStackDeployFromTemplate(resolved)) {
return;
}
}
currentTemplate = resolved;
const result = populateDeployFormFromTemplate(resolved);
if (result.isStack) {
showAlert(
'warning',
result.warnings[0] ||
'This is a Compose/stack template. Use Stacks to deploy the compose file from its repository.'
);
} else if (result.warnings.length) {
showAlert(
resolved._composeResolved ? 'info' : 'warning',
result.warnings.join(' ')
);
}
if (templateDeployModal) {
templateDeployModal.show();
// Re-apply after shown in case modal DOM was hidden during first paint
const modalElement = document.getElementById('templateDeployModalUnique');
if (modalElement) {
modalElement.addEventListener(
'shown.bs.modal',
() => {
setDeployFormScope(document.getElementById('deploy-form'));
const portsContainer = deployEl('deploy-ports-container');
const imageEl = deployEl('deploy-image');
const emptyPorts =
!portsContainer ||
portsContainer.querySelectorAll('.port-mapping-item').length === 0;
const emptyImage = !imageEl?.value?.trim();
const hasPorts =
Array.isArray(resolved.ports) && resolved.ports.length > 0;
if ((emptyPorts && hasPorts) || (emptyImage && resolved.image)) {
populateDeployFormFromTemplate(resolved, { skipName: true });
}
},
{ once: true }
);
}
}
updatePreview();
}
// Store current template for validation
let currentTemplate = null;
// Validate form data
function validateFormData(data) {
const errors = [];
// Container name validation
if (!data.containerName || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*[a-zA-Z0-9]$|^[a-zA-Z0-9]$/.test(data.containerName)) {
errors.push('Container name must be alphanumeric and may include dashes, underscores, or dots. Must start and end with alphanumeric.');
}
if (data.containerName && data.containerName.length > 63) {
errors.push('Container name must be 63 characters or less.');
}
// Image validation — allow registry hosts, ports, tags, and digests
if (!data.image || !data.image.trim()) {
errors.push('Image name is required.');
} else {
const img = data.image.trim();
// e.g. nginx, nginx:latest, ghcr.io/org/app:1.2, host:5000/ns/img@sha256:…
const imagePattern =
/^(?:(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])(?:\.(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9]))*(?::[0-9]+)?\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})?(?:@sha256:[a-f0-9]{64})?$/i;
if (!imagePattern.test(img) || img.length > 256) {
errors.push('Invalid Docker image name format.');
}
}
// Networking / ports (empty host side, ranges, in-form duplicates)
const net = validateNetworkingSync(data);
errors.push(...net.errors);
// Surface warnings as soft errors only when there are no hard errors yet —
// warnings are re-checked async with peer port conflicts before deploy.
// Validate volumes
if (data.volumes && Array.isArray(data.volumes)) {
data.volumes.forEach((volume, idx) => {
const volumeStr = String(volume).trim();
if (!volumeStr.includes(':')) {
errors.push(`Volume ${idx + 1} must contain a colon (host:container or host:container:mode).`);
} else {
const parts = volumeStr.split(':');
if (parts.length < 2 || parts.length > 3) {
errors.push(`Volume ${idx + 1} has invalid format. Use "host:container" or "host:container:mode".`);
}
// Check for path traversal attempts
if (parts.some(part => part.includes('..'))) {
errors.push(`Volume ${idx + 1} contains invalid path (path traversal not allowed).`);
}
}
});
}
// Validate environment variables
if (data.env && Array.isArray(data.env)) {
const envContainer = deployEl('deploy-env');
if (envContainer && currentTemplate && currentTemplate.env) {
// Create a map of template env vars for validation
const templateEnvMap = {};
currentTemplate.env.forEach(env => {
templateEnvMap[env.name] = env;
});
data.env.forEach(envVar => {
const templateEnv = templateEnvMap[envVar.name];
if (templateEnv) {
// Validate select options
if (templateEnv.select && Array.isArray(templateEnv.select)) {
const validOptions = templateEnv.select.map(opt =>
typeof opt === 'object' ? opt.value : opt
);
if (!validOptions.includes(envVar.value)) {
errors.push(`Environment variable "${templateEnv.label || envVar.name}": Value must be one of: ${validOptions.join(', ')}`);
}
}
// Validate numeric ranges
if (templateEnv.min !== undefined || templateEnv.max !== undefined) {
const numValue = parseFloat(envVar.value);
if (isNaN(numValue)) {
errors.push(`Environment variable "${templateEnv.label || envVar.name}": Must be a number.`);
} else {
if (templateEnv.min !== undefined && numValue < templateEnv.min) {
errors.push(`Environment variable "${templateEnv.label || envVar.name}": Must be at least ${templateEnv.min}.`);
}
if (templateEnv.max !== undefined && numValue > templateEnv.max) {
errors.push(`Environment variable "${templateEnv.label || envVar.name}": Must be at most ${templateEnv.max}.`);
}
}
}
// Check required fields
if ((templateEnv.required === true || (templateEnv.default === undefined && templateEnv.set === undefined)) && !envVar.value) {
errors.push(`Environment variable "${templateEnv.label || envVar.name}" is required.`);
}
}
});
}
// Validate env var names
data.env.forEach(envVar => {
if (envVar.name && !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(envVar.name)) {
errors.push(`Environment variable name "${envVar.name}" is invalid. Must start with letter or underscore and contain only alphanumeric characters and underscores.`);
}
});
}
// Validate resource limits
if (data.cpuLimit !== undefined && data.cpuLimit !== null) {
if (isNaN(data.cpuLimit) || data.cpuLimit <= 0) {
errors.push('CPU limit must be a positive number.');
}
}
if (data.memoryLimit !== undefined && data.memoryLimit !== null) {
if (isNaN(data.memoryLimit) || data.memoryLimit <= 0) {
errors.push('Memory limit must be a positive number (in MB).');
}
}
if (data.memoryReservation !== undefined && data.memoryReservation !== null) {
if (isNaN(data.memoryReservation) || data.memoryReservation <= 0) {
errors.push('Memory reservation must be a positive number (in MB).');
}
if (data.memoryLimit && data.memoryReservation > data.memoryLimit) {
errors.push('Memory reservation cannot exceed memory limit.');
}
}
if (data.cpuReservation !== undefined && data.cpuReservation !== null) {
if (isNaN(data.cpuReservation) || data.cpuReservation <= 0) {
errors.push('CPU reservation must be a positive number.');
}
if (data.cpuLimit && data.cpuReservation > data.cpuLimit) {
errors.push('CPU reservation cannot exceed CPU limit.');
}
}
// Validate hostname
if (data.hostname) {
const hostnamePattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
if (data.hostname.length > 253 || !hostnamePattern.test(data.hostname)) {
errors.push('Invalid hostname format.');
}
}
// Validate domainname
if (data.domainname) {
const domainnamePattern = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
if (data.domainname.length > 253 || !domainnamePattern.test(data.domainname)) {
errors.push('Invalid domain name format.');
}
}
// Validate DNS servers
if (data.dns && Array.isArray(data.dns)) {
data.dns.forEach((dns, idx) => {
const dnsStr = String(dns).trim();
// IPv4 pattern
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/;
// IPv6 pattern (simplified)
const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
if (!ipv4Pattern.test(dnsStr) && !ipv6Pattern.test(dnsStr)) {
errors.push(`DNS server ${idx + 1} has invalid IP address format.`);
} else if (ipv4Pattern.test(dnsStr)) {
const parts = dnsStr.split('.');
if (parts.some(part => {
const num = parseInt(part, 10);
return num < 0 || num > 255;
})) {
errors.push(`DNS server ${idx + 1} has invalid IPv4 address.`);
}
}
});
}
// Validate restart policy
const validRestartPolicies = ['no', 'always', 'on-failure', 'unless-stopped'];
if (data.restartPolicy && !validRestartPolicies.includes(data.restartPolicy)) {
errors.push(`Invalid restart policy. Must be one of: ${validRestartPolicies.join(', ')}`);
}
return errors;
}
/**
* style replace prompt when a container name is already taken.
* @param {object} payload
* @returns {Promise<object>} payload (possibly with replace: true)
*/
async function confirmReplaceExistingContainer(payload) {
if (!payload?.containerName || payload.replace === true) return payload
const name = String(payload.containerName)
let existing = null
try {
const { manager, Methods } = await import('../client/manager.js')
if (manager.active?.connected) {
const res = await manager.request(Methods.listContainers, { all: true })
const list = res?.data || res?.containers || []
existing = list.find((c) =>
(c.Names || []).some((n) => String(n || '').replace(/^\//, '') === name)
)
}
} catch {
// fall through — server will still enforce on create
}
// Local cache fallback (avoids extra RPC failure blocking deploy)
if (!existing && typeof window !== 'undefined') {
try {
const cached =
window.containerFilterState?.allContainers ||
(typeof containerFilterState !== 'undefined' ? containerFilterState.allContainers : null)
if (Array.isArray(cached)) {
existing = cached.find((c) =>
(c.Names || []).some((n) => String(n || '').replace(/^\//, '') === name)
)
}
} catch {
// ignore
}
}
if (!existing) return payload
const state = existing.State || 'unknown'
const image = existing.Image || 'unknown image'
const shortId = String(existing.Id || '').slice(0, 12)
const body =
`A container named "${name}" already exists` +
(shortId ? ` (${shortId})` : '') +
`.\n\nState: ${state}\nImage: ${image}\n\n` +
`Replacing will stop and remove it, then create a new container with your settings. ` +
`Anonymous volumes may be lost. Named volumes and bind mounts are kept.`
let ok = false
if (typeof window.peardockOps?.askUserConfirm === 'function') {
ok = await window.peardockOps.askUserConfirm('Replace existing container?', body, {
confirmLabel: 'Replace',
cancelLabel: 'Cancel',
icon: 'fa-recycle',
danger: true,
})
} else if (typeof window.peardockOps?.confirmDestructive === 'function') {
ok = await window.peardockOps.confirmDestructive(
'Replace existing container?',
`Container "${name}" already exists (${state}). Stop and remove it, then deploy?`
)
} else {
ok = window.confirm(
`Container "${name}" already exists (${state}). Replace it? This stops and removes the old container.`
)
}
if (!ok) {
const err = new Error('Deploy cancelled')
err.code = 'DEPLOY_CANCELLED'
throw err
}
return { ...payload, replace: true }
}
// Deploy Docker container via typed RPC (reliable request/response)
async function deployDockerContainer(payload) {
console.log('[INFO] Sending deployment command to the server...');
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid deployment payload');
}
if (!payload.containerName || !payload.image) {
throw new Error(
'Container name and image are required — How to fix: fill both fields before deploying.'
);
}
// Pre-deploy networking: empty host ports, conflicts, privileged ports, network mode
// (may already be done by form submit; re-run unless marked prechecked this turn)
if (!payload._networkingPrechecked) {
const netCheck = await precheckDeployNetworking(payload);
if (!netCheck.ok) {
const msg =
formatNetworkingPrecheckMessage(netCheck) ||
'Networking configuration is invalid.';
throw new Error(msg);
}
if (netCheck.warnings?.length) {
console.warn('[deploy] networking warnings:', netCheck.warnings.join(' | '));
try {
showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false });
} catch {
// ignore
}
}
}
// Never send internal flag to the server
if (payload && typeof payload === 'object') {
delete payload._networkingPrechecked;
}
// Offer replace when name collides (style)
payload = await confirmReplaceExistingContainer(payload)
// Prefer job-stepper (live tray log). Do not fall back after a real deploy failure
// or we risk double-create / confusing errors.
if (typeof window.peardockOps?.deployContainerWithSteps === 'function') {
try {
const job = await window.peardockOps.deployContainerWithSteps(payload)
return {
success: true,
viaJob: true,
message:
job?.result?.message ||
`Container "${payload.containerName}" deployed successfully from image "${payload.image}"`,
id: job?.result?.id,
replaced: Boolean(payload.replace),
}
} catch (err) {
// Server-side conflict after race: offer replace once more
if (
err?.code === 'CONTAINER_NAME_CONFLICT' ||
/already exists|name.*already|already in use/i.test(String(err?.message || ''))
) {
const retried = await confirmReplaceExistingContainer({
...payload,
replace: false,
})
if (retried.replace) {
const job = await window.peardockOps.deployContainerWithSteps(retried)
return {
success: true,
viaJob: true,
message:
job?.result?.message ||
`Container "${payload.containerName}" replaced successfully`,
id: job?.result?.id,
replaced: true,
}
}
}
// Preserve viaJob so callers skip redundant top toasts
if (err && typeof err === 'object') err.viaJob = true
throw err
}
}
const { manager, Methods } = await import('../client/manager.js')
if (!manager.active?.connected) {
throw new Error(
'Not connected to a peardock server — How to fix: connect a peer from the sidebar, then retry deploy.'
)
}
const timeoutMs = 120000
const runDeploy = (body) =>
Promise.race([
manager.request(Methods.deployContainer, body),
new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error(
'Deployment timed out after 120s — How to fix: check network to the peer, Docker pull speed, and server logs.'
)
),
timeoutMs
)
),
])
let res
try {
res = await runDeploy(payload)
} catch (err) {
if (
err?.code === 'CONTAINER_NAME_CONFLICT' ||
/already exists|name.*already|already in use/i.test(String(err?.message || ''))
) {
const retried = await confirmReplaceExistingContainer({
...payload,
replace: false,
})
if (retried.replace) res = await runDeploy(retried)
else throw err
} else {
throw err
}
}
if (!res) throw new Error('Empty response from server')
if (res.success === false) throw new Error(res.message || res.error || 'Deployment failed')
return {
success: true,
viaJob: false,
message:
res.message ||
`Container "${payload.containerName}" deployed successfully from image "${payload.image}"`,
id: res.id,
data: res,
replaced: Boolean(payload.replace || res.replaced),
}
}
// Form submission is now handled by setupFormSubmitListener() which is called during lazy initialization
// Save template functionality removed to match working version
// Templates are now loaded lazily when the modal is opened (via fetchTemplates() in openTemplateDeployModal)
// This prevents icons and template data from loading on app startup
// Duplicate modal array management functions
let duplicatePortCounter = 0;
let duplicateVolumeCounter = 0;
let duplicateEnvCounter = 0;
let duplicateLabelCounter = 0;
let duplicateDnsCounter = 0;
let duplicateExtraHostCounter = 0;
let duplicateDeviceCounter = 0;
let duplicateCapabilityCounter = 0;
let duplicateSecurityOptCounter = 0;
let duplicateLogOptCounter = 0;
let duplicateSysctlCounter = 0;
let duplicateUlimitCounter = 0;
let duplicateTmpfsCounter = 0;
function addDuplicatePortMapping(portData = null) {
const container = document.getElementById('duplicate-ports-container');
if (!container) return;
const id = `duplicate-port-${duplicatePortCounter++}`;
const item = document.createElement('div');
item.className = 'array-item port-mapping-item';
item.id = id;
// Parse existing port data if provided
let hostPort = '';
let containerPort = '';
let protocol = 'tcp';
if (portData) {
if (portData.includes(':')) {
const [host, rest] = portData.split(':');
hostPort = host;
const [container, proto] = rest.split('/');
containerPort = container;
protocol = proto || 'tcp';
} else {
const [container, proto] = portData.split('/');
containerPort = container;
protocol = proto || 'tcp';
}
}
item.innerHTML = `
<div class="port-mapping-fields">
<div class="port-field-group">
<label class="port-field-label">Host Port</label>
<input type="number"
class="form-control bg-dark text-white port-host-input"
placeholder="8080"
min="1"
max="65535"
data-port-host="${id}"
value="${hostPort}"
oninput="validatePortMapping('${id}')">
<small class="port-error-msg" data-port-host-error="${id}" style="display: none;"></small>
</div>
<div class="port-connector">
<i class="fas fa-arrow-right"></i>
</div>
<div class="port-field-group">
<label class="port-field-label">Container Port</label>
<input type="number"
class="form-control bg-dark text-white port-container-input"
placeholder="80"
min="1"
max="65535"
required
data-port-container="${id}"
value="${containerPort}"
oninput="validatePortMapping('${id}')">
<small class="port-error-msg" data-port-container-error="${id}" style="display: none;"></small>
</div>
<div class="port-field-group">
<label class="port-field-label">Protocol</label>
<select class="form-select bg-dark text-white port-protocol-input"
data-port-protocol="${id}"
onchange="validatePortMapping('${id}')">
<option value="tcp" ${protocol === 'tcp' ? 'selected' : ''}>TCP</option>
<option value="udp" ${protocol === 'udp' ? 'selected' : ''}>UDP</option>
</select>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
/**
* Parse a Docker bind/volume string into form fields.
* Handles modes with commas (ro,Z) and named volumes.
* @param {string|null|undefined} volumeData
* @returns {{ volumeType: 'bind'|'named', hostPath: string, containerPath: string, mountMode: 'rw'|'ro' }}
*/
function parseVolumeMountSpec(volumeData) {
let volumeType = 'bind';
let hostPath = '';
let containerPath = '';
let mountMode = 'rw';
if (!volumeData || typeof volumeData !== 'string') {
return { volumeType, hostPath, containerPath, mountMode };
}
const raw = volumeData.trim();
const modeRe =
/^(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate)(?:,(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate))*$/i;
const parts = raw.split(':');
if (parts.length < 2) {
return { volumeType, hostPath, containerPath, mountMode };
}
let source;
let dest;
let modePart = '';
if (parts.length >= 3 && modeRe.test(parts[parts.length - 1])) {
modePart = parts[parts.length - 1];
dest = parts[parts.length - 2];
source = parts.slice(0, -2).join(':');
} else {
dest = parts[parts.length - 1];
source = parts.slice(0, -1).join(':');
}
hostPath = source || '';
containerPath = dest || '';
if (modePart) {
mountMode = /\bro\b/i.test(modePart) && !/\brw\b/i.test(modePart) ? 'ro' : 'rw';
}
const namedVol = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const looksNamed =
namedVol.test(hostPath) &&
!hostPath.startsWith('/') &&
!hostPath.startsWith('~') &&
!hostPath.startsWith('.') &&
!/^[A-Za-z]:[\\/]/.test(hostPath);
volumeType = looksNamed ? 'named' : 'bind';
return { volumeType, hostPath, containerPath, mountMode };
}
/**
* Escape a value for use inside double-quoted HTML attributes.
* @param {string} s
*/
function escapeAttrValue(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Build volume bind strings from a container inspect result.
* Merges Mounts + HostConfig.Binds + HostConfig.Mounts (deduped by destination).
* @param {object} config
* @returns {string[]}
*/
function extractVolumeSpecsFromInspect(config) {
/** @type {Map<string, string>} dest → bind string */
const byDest = new Map();
const pushSpec = (source, dest, mode = 'rw', typeHint = '') => {
if (!source || !dest) return;
const modeStr = mode === 'ro' ? 'ro' : 'rw';
const hint = String(typeHint || '').toLowerCase();
const namedVol = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const isNamed =
hint === 'volume' ||
(namedVol.test(source) &&
!source.startsWith('/') &&
!source.startsWith('~') &&
!source.startsWith('.'));
// Prefer first non-empty; later Binds can fill gaps only
if (byDest.has(dest)) return;
byDest.set(dest, `${source}:${dest}:${modeStr}`);
void isNamed;
};
const modeFromMount = (m) => {
if (m?.RW === false || m?.ReadOnly === true) return 'ro';
if (/\bro\b/i.test(String(m?.Mode || ''))) return 'ro';
return 'rw';
};
const considerMount = (m) => {
if (!m || typeof m !== 'object') return;
const type = String(m.Type || m.type || '').toLowerCase();
if (type === 'tmpfs') return; // handled in tmpfs section
const dest = m.Destination || m.Target || m.destination || m.target;
if (!dest) return;
const mode = modeFromMount(m);
if (type === 'volume' || m.Name || m.Driver) {
const name = m.Name || m.name;
if (name && !String(name).startsWith('/')) {
pushSpec(String(name), dest, mode, 'volume');
return;
}
}
if (type === 'bind' || m.Source || m.source) {
const src = m.Source || m.source;
if (src) pushSpec(String(src), dest, mode, type || 'bind');
}
};
if (Array.isArray(config?.Mounts)) {
for (const m of config.Mounts) considerMount(m);
}
if (Array.isArray(config?.HostConfig?.Mounts)) {
for (const m of config.HostConfig.Mounts) considerMount(m);
}
// Binds fill anything Mounts missed (or when Mounts empty)
if (Array.isArray(config?.HostConfig?.Binds)) {
for (const bind of config.HostConfig.Binds) {
if (!bind) continue;
const parsed = parseVolumeMountSpec(String(bind));
if (!parsed.containerPath || !parsed.hostPath) continue;
if (byDest.has(parsed.containerPath)) {
// Prefer Binds mode if we only had a weak entry — keep existing
continue;
}
byDest.set(
parsed.containerPath,
`${parsed.hostPath}:${parsed.containerPath}:${parsed.mountMode}`
);
}
}
// Config.Volumes keys are container paths declared in the image (anonymous).
// Only include if we already have a real mount for that path — never invent host paths.
return [...byDest.values()];
}
function addDuplicateVolumeMount(volumeData = null) {
const container = document.getElementById('duplicate-volumes-container');
if (!container) return;
const id = `duplicate-volume-${duplicateVolumeCounter++}`;
const item = document.createElement('div');
item.className = 'array-item volume-mount-item';
item.id = id;
const parsed = parseVolumeMountSpec(volumeData);
const volumeType = parsed.volumeType;
const hostPath = parsed.hostPath;
const containerPath = parsed.containerPath;
const mountMode = parsed.mountMode;
const hostEsc = escapeAttrValue(hostPath);
const destEsc = escapeAttrValue(containerPath);
item.innerHTML = `
<div class="volume-mount-fields">
<div class="volume-field-group">
<label class="volume-field-label">Type</label>
<select class="form-select bg-dark text-white volume-type-input"
data-volume-type="${id}"
onchange="handleVolumeTypeChange('${id}')">
<option value="bind" ${volumeType === 'bind' ? 'selected' : ''}>Bind Mount</option>
<option value="named" ${volumeType === 'named' ? 'selected' : ''}>Named Volume</option>
</select>
</div>
<div class="volume-field-group volume-host-path-group" style="${volumeType === 'named' ? 'display: none;' : ''}">
<label class="volume-field-label">Host Path</label>
<div class="input-group">
<input type="text"
class="form-control bg-dark text-white volume-host-input"
placeholder="/host/path"
data-volume-host="${id}"
value="${hostEsc}"
oninput="validateVolumeMount('${id}')">
<button type="button"
class="btn btn-outline-secondary"
onclick="openFileBrowser('${id}')"
title="Browse directory">
<i class="fas fa-folder-open"></i>
</button>
</div>
<small class="volume-error-msg" data-volume-host-error="${id}" style="display: none;"></small>
</div>
<div class="volume-field-group volume-named-group" style="${volumeType === 'bind' ? 'display: none;' : ''}">
<label class="volume-field-label">Volume Name</label>
<select class="form-select bg-dark text-white volume-named-input"
data-volume-named="${id}"
onchange="validateVolumeMount('${id}')"
onfocus="if(this.options.length <= 1) loadVolumesForSelect('${id}')"
onclick="if(this.options.length <= 1) loadVolumesForSelect('${id}')">
<option value="">Select or create volume...</option>
</select>
<small class="volume-error-msg" data-volume-named-error="${id}" style="display: none;"></small>
</div>
<div class="volume-connector">
<i class="fas fa-arrow-right"></i>
</div>
<div class="volume-field-group">
<label class="volume-field-label">Container Path</label>
<input type="text"
class="form-control bg-dark text-white volume-container-input"
placeholder="/container/path"
required
data-volume-container="${id}"
value="${destEsc}"
oninput="validateVolumeMount('${id}')">
<small class="volume-error-msg" data-volume-container-error="${id}" style="display: none;"></small>
</div>
<div class="volume-field-group">
<label class="volume-field-label">Mode</label>
<select class="form-select bg-dark text-white volume-mode-input"
data-volume-mode="${id}"
onchange="validateVolumeMount('${id}')">
<option value="rw" ${mountMode === 'rw' ? 'selected' : ''}>Read-Write</option>
<option value="ro" ${mountMode === 'ro' ? 'selected' : ''}>Read-Only</option>
</select>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
// Named volumes: seed the select immediately so collect works before list loads
if (volumeType === 'named' && hostPath) {
const namedSelect = item.querySelector('.volume-named-input');
if (namedSelect) {
namedSelect.dataset.preferredVolume = hostPath;
const seed = new Option(hostPath, hostPath, true, true);
namedSelect.add(seed);
namedSelect.value = hostPath;
}
loadVolumesForSelect(id, hostPath);
}
}
function addDuplicateTmpfsMount() {
const container = document.getElementById('duplicate-tmpfs-container');
if (!container) return;
const id = `duplicate-tmpfs-${duplicateTmpfsCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="/tmp:100m" data-tmpfs-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateEnvVar() {
const container = document.getElementById('duplicate-env');
if (!container) return;
const id = `duplicate-env-${duplicateEnvCounter++}`;
const item = document.createElement('div');
item.className = 'array-item mb-2';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="KEY" data-env-key="${id}" style="flex: 0 0 40%;">
<input type="text" class="form-control bg-dark text-white" placeholder="value" data-env-value="${id}" style="flex: 1;">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateLabel() {
const container = document.getElementById('duplicate-labels-container');
if (!container) return;
const id = `duplicate-label-${duplicateLabelCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="key=value" data-label-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateDnsServer() {
const container = document.getElementById('duplicate-dns-container');
if (!container) return;
const id = `duplicate-dns-${duplicateDnsCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="8.8.8.8" data-dns-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateExtraHost() {
const container = document.getElementById('duplicate-extra-hosts-container');
if (!container) return;
const id = `duplicate-host-${duplicateExtraHostCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="example.com:127.0.0.1" data-host-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateDeviceMapping() {
const container = document.getElementById('duplicate-devices-container');
if (!container) return;
const id = `duplicate-device-${duplicateDeviceCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="/dev/ttyUSB0:/dev/ttyUSB0:rwm" data-device-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateCapability() {
const container = document.getElementById('duplicate-capabilities-container');
if (!container) return;
const id = `duplicate-cap-${duplicateCapabilityCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="NET_ADMIN" data-cap-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateSecurityOpt() {
const container = document.getElementById('duplicate-security-opts-container');
if (!container) return;
const id = `duplicate-secopt-${duplicateSecurityOptCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="apparmor=profile" data-secopt-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateLogOpt() {
const container = document.getElementById('duplicate-log-opts-container');
if (!container) return;
const id = `duplicate-logopt-${duplicateLogOptCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="max-size=10m" data-logopt-key="${id}" style="flex: 0 0 40%;">
<input type="text" class="form-control bg-dark text-white" placeholder="value" data-logopt-value="${id}" style="flex: 1;">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateSysctl() {
const container = document.getElementById('duplicate-sysctls-container');
if (!container) return;
const id = `duplicate-sysctl-${duplicateSysctlCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="net.ipv4.ip_forward=1" data-sysctl-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
function addDuplicateUlimit() {
const container = document.getElementById('duplicate-ulimits-container');
if (!container) return;
const id = `duplicate-ulimit-${duplicateUlimitCounter++}`;
const item = document.createElement('div');
item.className = 'array-item';
item.id = id;
item.innerHTML = `
<input type="text" class="form-control bg-dark text-white" placeholder="nofile=1024:2048" data-ulimit-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeArrayItem('${id}')">
<i class="fas fa-times"></i>
</button>
`;
container.appendChild(item);
}
// Make duplicate functions globally available
window.addDuplicatePortMapping = addDuplicatePortMapping;
window.addDuplicateVolumeMount = addDuplicateVolumeMount;
window.addDuplicateTmpfsMount = addDuplicateTmpfsMount;
window.addDuplicateEnvVar = addDuplicateEnvVar;
window.addDuplicateLabel = addDuplicateLabel;
window.addDuplicateDnsServer = addDuplicateDnsServer;
window.addDuplicateExtraHost = addDuplicateExtraHost;
window.addDuplicateDeviceMapping = addDuplicateDeviceMapping;
window.addDuplicateCapability = addDuplicateCapability;
window.addDuplicateSecurityOpt = addDuplicateSecurityOpt;
window.addDuplicateLogOpt = addDuplicateLogOpt;
window.addDuplicateSysctl = addDuplicateSysctl;
window.addDuplicateUlimit = addDuplicateUlimit;
// Collect duplicate form data (similar to collectFormData but for duplicate modal)
function collectDuplicateFormData() {
const data = {
containerName: document.getElementById('duplicate-container-name')?.value.trim() || '',
image: document.getElementById('duplicate-image')?.value.trim() || '',
alwaysPull: document.getElementById('duplicate-always-pull')?.checked !== false,
command: document.getElementById('duplicate-command')?.value.trim() || null,
entrypoint: document.getElementById('duplicate-entrypoint')?.value.trim() || null,
workingDir: document.getElementById('duplicate-workdir')?.value.trim() || null,
// Networking
networkMode: document.getElementById('duplicate-network-mode')?.value || 'bridge',
customNetwork: (() => {
const mode = document.getElementById('duplicate-network-mode')?.value || 'bridge';
const customNet = document.getElementById('duplicate-custom-network')?.value.trim();
if (!customNet) return null;
// host/none: no extra network attach; container: uses custom field as peer container
if (mode === 'host' || mode === 'none') return null;
return customNet;
})(),
hostname: document.getElementById('duplicate-hostname')?.value.trim() || null,
domainname: document.getElementById('duplicate-domainname')?.value.trim() || null,
ports: collectDuplicatePortMappings(),
dns: collectArrayItems('duplicate-dns-container', 'data-dns-id'),
extraHosts: collectArrayItems('duplicate-extra-hosts-container', 'data-host-id'),
// Volumes
volumes: collectDuplicateVolumeMounts(),
tmpfs: collectArrayItems('duplicate-tmpfs-container', 'data-tmpfs-id'),
// Resources (sliders: 0 = unset)
cpuLimit: (() => {
const v = parseFloat(document.getElementById('duplicate-cpu-limit')?.value)
return Number.isFinite(v) && v > 0 ? v : null
})(),
cpuReservation: parseFloat(document.getElementById('duplicate-cpu-reservation')?.value) || null,
cpuShares: parseInt(document.getElementById('duplicate-cpu-shares')?.value) || null,
memoryLimit: (() => {
const v = parseInt(document.getElementById('duplicate-memory-limit')?.value, 10)
return Number.isFinite(v) && v > 0 ? v : null
})(),
memoryReservation: parseInt(document.getElementById('duplicate-memory-reservation')?.value) || null,
memorySwap: parseInt(document.getElementById('duplicate-memory-swap')?.value) || null,
devices: collectArrayItems('duplicate-devices-container', 'data-device-id'),
// Environment & Labels
env: collectDuplicateEnvVars(),
labels: collectDuplicateLabels(),
// Security
user: document.getElementById('duplicate-user')?.value.trim() || null,
group: document.getElementById('duplicate-group')?.value.trim() || null,
privileged: document.getElementById('duplicate-privileged')?.checked || false,
readonlyRootfs: document.getElementById('duplicate-readonly-rootfs')?.checked || false,
capabilities: collectArrayItems('duplicate-capabilities-container', 'data-cap-id'),
securityOpts: collectArrayItems('duplicate-security-opts-container', 'data-secopt-id'),
// Runtime
restartPolicy: document.getElementById('duplicate-restart-policy')?.value || 'no',
restartMaxRetries: parseInt(document.getElementById('duplicate-restart-max-retries')?.value) || null,
autoRemove: document.getElementById('duplicate-auto-remove')?.checked || false,
tty: document.getElementById('duplicate-tty')?.checked || false,
stdinOpen: document.getElementById('duplicate-stdin-open')?.checked || false,
detach: document.getElementById('duplicate-detach')?.checked !== false,
init: document.getElementById('duplicate-init')?.checked || false,
// Health & Logging
healthCmd: document.getElementById('duplicate-health-cmd')?.value.trim() || null,
healthInterval: parseInt(document.getElementById('duplicate-health-interval')?.value) || null,
healthTimeout: parseInt(document.getElementById('duplicate-health-timeout')?.value) || null,
healthRetries: parseInt(document.getElementById('duplicate-health-retries')?.value) || null,
healthStartPeriod: parseInt(document.getElementById('duplicate-health-start-period')?.value) || null,
logDriver: document.getElementById('duplicate-log-driver')?.value || null,
logOpts: collectDuplicateLogOpts(),
// Advanced
sysctls: collectDuplicateSysctls(),
ulimits: collectDuplicateUlimits(),
oomKillDisable: document.getElementById('duplicate-oom-kill-disable')?.checked || false,
pidsLimit: parseInt(document.getElementById('duplicate-pids-limit')?.value) || null,
shmSize: parseInt(document.getElementById('duplicate-shm-size')?.value) || null,
};
// Remove null/empty values
Object.keys(data).forEach(key => {
if (data[key] === null || data[key] === '' || (Array.isArray(data[key]) && data[key].length === 0)) {
delete data[key];
}
});
// Keep boolean alwaysPull even when false (deploy path treats undefined as force-pull)
data.alwaysPull = document.getElementById('duplicate-always-pull')?.checked !== false;
return data;
}
function collectDuplicateEnvVars() {
const container = document.getElementById('duplicate-env');
if (!container) return [];
const envVars = [];
container.querySelectorAll('[data-env-key]').forEach(keyInput => {
const key = keyInput.value.trim();
const valueInput = container.querySelector(`[data-env-value="${keyInput.getAttribute('data-env-key')}"]`);
const value = valueInput?.value.trim() || '';
if (key) {
envVars.push({ name: key, value });
}
});
return envVars;
}
function collectDuplicateLabels() {
const items = collectArrayItems('duplicate-labels-container', 'data-label-id');
const labels = {};
items.forEach(item => {
if (!item || !item.includes('=')) return;
const eq = item.indexOf('=');
const key = item.slice(0, eq).trim();
const value = item.slice(eq + 1); // preserve = inside values
if (key) labels[key] = value;
});
return Object.keys(labels).length > 0 ? labels : null;
}
function collectDuplicateLogOpts() {
const container = document.getElementById('duplicate-log-opts-container');
if (!container) return null;
const opts = {};
container.querySelectorAll('[data-logopt-key]').forEach(keyInput => {
const key = keyInput.value.trim();
const valueInput = container.querySelector(`[data-logopt-value="${keyInput.getAttribute('data-logopt-key')}"]`);
const value = valueInput?.value.trim() || '';
if (key) {
opts[key] = value;
}
});
return Object.keys(opts).length > 0 ? opts : null;
}
function collectDuplicateSysctls() {
const items = collectArrayItems('duplicate-sysctls-container', 'data-sysctl-id');
const sysctls = {};
items.forEach(item => {
if (!item || !item.includes('=')) return;
const eq = item.indexOf('=');
const key = item.slice(0, eq).trim();
const value = item.slice(eq + 1).trim();
if (key) sysctls[key] = value;
});
return Object.keys(sysctls).length > 0 ? sysctls : null;
}
function collectDuplicateUlimits() {
const items = collectArrayItems('duplicate-ulimits-container', 'data-ulimit-id');
const ulimits = [];
items.forEach(item => {
const [name, limits] = item.split('=');
if (name && limits) {
const [soft, hard] = limits.split(':');
ulimits.push({
Name: name.trim(),
Soft: soft ? parseInt(soft) : null,
Hard: hard ? parseInt(hard) : null
});
}
});
return ulimits.length > 0 ? ulimits : null;
}
// Collect duplicate port mappings from structured inputs
function collectDuplicatePortMappings() {
const container = document.getElementById('duplicate-ports-container');
if (!container) return [];
const ports = [];
container.querySelectorAll('.port-mapping-item').forEach(item => {
const hostInput = item.querySelector('.port-host-input');
const containerInput = item.querySelector('.port-container-input');
const protocolInput = item.querySelector('.port-protocol-input');
if (!containerInput || !containerInput.value) return;
const hostPort = hostInput?.value.trim() || '';
const containerPort = containerInput.value.trim();
const protocol = protocolInput?.value || 'tcp';
// Build port string: host:container/protocol or container/protocol
const portStr = hostPort ? `${hostPort}:${containerPort}/${protocol}` : `${containerPort}/${protocol}`;
ports.push(portStr);
});
return ports;
}
// Collect duplicate volume mounts from structured inputs
function collectDuplicateVolumeMounts() {
const container = document.getElementById('duplicate-volumes-container');
if (!container) return [];
const volumes = [];
container.querySelectorAll('.volume-mount-item').forEach(item => {
const typeSelect = item.querySelector('.volume-type-input');
const hostInput = item.querySelector('.volume-host-input');
const namedSelect = item.querySelector('.volume-named-input');
const containerInput = item.querySelector('.volume-container-input');
const modeSelect = item.querySelector('.volume-mode-input');
if (!containerInput || !containerInput.value) return;
const volumeType = typeSelect?.value || 'bind';
const containerPath = containerInput.value.trim();
const mountMode = modeSelect?.value || 'rw';
let volumeStr = '';
if (volumeType === 'bind') {
const hostPath = hostInput?.value.trim() || '';
if (!hostPath) return; // Skip if host path is empty
volumeStr = `${hostPath}:${containerPath}:${mountMode}`;
} else {
// Named volume
const volumeName = namedSelect?.value.trim() || '';
if (!volumeName) return; // Skip if volume name is empty
volumeStr = `${volumeName}:${containerPath}:${mountMode}`;
}
volumes.push(volumeStr);
});
return volumes;
}
/**
* Suggest a free container name for duplication (name-copy, name-copy-2, …).
* @param {string} base
* @param {Set<string>|string[]} existingNames
*/
function suggestDuplicateName(base, existingNames) {
const taken = existingNames instanceof Set
? existingNames
: new Set(Array.isArray(existingNames) ? existingNames : []);
const root = String(base || 'container').replace(/^\//, '') || 'container';
// If user already has a free name, still prefer *-copy so we don't replace by default
let candidate = `${root}-copy`;
if (!taken.has(candidate)) return candidate.slice(0, 63);
for (let i = 2; i < 1000; i++) {
candidate = `${root}-copy-${i}`
if (!taken.has(candidate)) return candidate.slice(0, 63);
}
return `${root}-copy-${Date.now().toString(36)}`.slice(0, 63);
}
// Populate duplicate form from container config
function populateDuplicateForm(config, opts = {}) {
if (!config) return;
// Reset counters
duplicatePortCounter = duplicateVolumeCounter = duplicateEnvCounter = duplicateLabelCounter = duplicateDnsCounter = 0;
duplicateExtraHostCounter = duplicateDeviceCounter = duplicateCapabilityCounter = duplicateSecurityOptCounter = 0;
duplicateLogOptCounter = duplicateSysctlCounter = duplicateUlimitCounter = duplicateTmpfsCounter = 0;
// Clear all array containers
['duplicate-ports-container', 'duplicate-volumes-container', 'duplicate-env', 'duplicate-labels-container',
'duplicate-dns-container', 'duplicate-extra-hosts-container', 'duplicate-devices-container',
'duplicate-capabilities-container', 'duplicate-security-opts-container', 'duplicate-log-opts-container',
'duplicate-sysctls-container', 'duplicate-ulimits-container', 'duplicate-tmpfs-container'].forEach(id => {
const container = document.getElementById(id);
if (container) container.innerHTML = '';
});
// Basic settings — suggest a free name so "Duplicate" doesn't replace by default
const originalName = config.Name ? String(config.Name).replace(/^\//, '') : '';
const nameEl = document.getElementById('duplicate-container-name');
if (nameEl) {
nameEl.value = suggestDuplicateName(originalName, opts.existingNames || []);
nameEl.dataset.originalName = originalName;
}
const imageEl = document.getElementById('duplicate-image');
if (imageEl) {
// Prefer Config.Image; fall back to top-level Image / Config.ImageID-less tag
imageEl.value =
config.Config?.Image ||
(typeof config.Image === 'string' && !config.Image.startsWith('sha256:')
? config.Image
: '') ||
'';
}
// Default on (match Add container); user can turn off to reuse a local image
const alwaysPullEl = document.getElementById('duplicate-always-pull');
if (alwaysPullEl) alwaysPullEl.checked = true;
const commandEl = document.getElementById('duplicate-command');
if (commandEl) {
const cmd = config.Config?.Cmd;
if (Array.isArray(cmd) && cmd.length) {
commandEl.value = cmd.join(' ');
} else if (typeof cmd === 'string' && cmd) {
commandEl.value = cmd;
} else {
commandEl.value = '';
}
}
const entrypointEl = document.getElementById('duplicate-entrypoint');
if (entrypointEl) {
const ep = config.Config?.Entrypoint;
if (Array.isArray(ep) && ep.length) {
entrypointEl.value = ep.join(' ');
} else if (typeof ep === 'string' && ep) {
entrypointEl.value = ep;
} else {
entrypointEl.value = '';
}
}
const workdirEl = document.getElementById('duplicate-workdir');
if (workdirEl) workdirEl.value = config.Config?.WorkingDir || '';
// Networking — select only supports bridge/host/none/container
const networkModeEl = document.getElementById('duplicate-network-mode');
const customNetEl = document.getElementById('duplicate-custom-network');
const customNetWrap = document.getElementById('duplicate-custom-network-container');
const knownModes = new Set(['bridge', 'host', 'none', 'container']);
let netMode = config.HostConfig?.NetworkMode || 'bridge';
// Prefer a non-default attached network from NetworkSettings when NetworkMode is default bridge
const netSettings = config.NetworkSettings?.Networks || {};
const attachedNets = Object.keys(netSettings).filter(
(n) => n && n !== 'bridge' && n !== 'host' && n !== 'none'
);
if (networkModeEl) {
if (String(netMode).startsWith('container:')) {
networkModeEl.value = 'container';
if (customNetEl) {
customNetEl.value = String(netMode).replace(/^container:/, '');
}
if (customNetWrap) customNetWrap.style.display = 'block';
} else if (knownModes.has(netMode)) {
networkModeEl.value = netMode;
if (netMode === 'bridge' && attachedNets.length && customNetEl) {
// User-defined network attached on top of bridge mode
customNetEl.value = attachedNets[0];
if (customNetWrap) customNetWrap.style.display = 'block';
} else if (customNetWrap) {
customNetWrap.style.display =
netMode === 'host' || netMode === 'none' ? 'none' : customNetWrap.style.display;
}
} else {
// Docker often sets NetworkMode to the user network name
networkModeEl.value = 'bridge';
if (customNetEl) customNetEl.value = netMode;
if (customNetWrap) customNetWrap.style.display = 'block';
}
}
const hostnameEl = document.getElementById('duplicate-hostname');
if (hostnameEl) hostnameEl.value = config.Config?.Hostname || '';
const domainnameEl = document.getElementById('duplicate-domainname');
if (domainnameEl) domainnameEl.value = config.Config?.Domainname || '';
// Port bindings (HostConfig + live NetworkSettings.Ports fallback) — all host mappings
const portBindings = config.HostConfig?.PortBindings || {};
const livePorts = config.NetworkSettings?.Ports || {};
const portKeys = new Set([
...Object.keys(portBindings),
...Object.keys(livePorts),
...Object.keys(config.Config?.ExposedPorts || {}),
]);
portKeys.forEach((containerPort) => {
const bindings =
(portBindings[containerPort] && portBindings[containerPort].length
? portBindings[containerPort]
: null) ||
(livePorts[containerPort] && livePorts[containerPort].length
? livePorts[containerPort]
: null) ||
[null];
const protocol = containerPort.split('/')[1] || 'tcp';
const portNum = containerPort.split('/')[0];
// One form row per host binding (duplicate may publish the same container port multiple times)
for (const binding of bindings) {
const hostPort = binding?.HostPort || '';
const portStr = hostPort
? `${hostPort}:${portNum}/${protocol}`
: `${portNum}/${protocol}`;
addDuplicatePortMapping(portStr);
}
});
// DNS
if (config.HostConfig?.Dns && Array.isArray(config.HostConfig.Dns)) {
config.HostConfig.Dns.forEach(dns => {
addDuplicateDnsServer();
const container = document.getElementById('duplicate-dns-container');
const lastInput = container?.lastElementChild?.querySelector('input');
if (lastInput) lastInput.value = dns;
});
}
// Extra hosts
if (config.HostConfig?.ExtraHosts && Array.isArray(config.HostConfig.ExtraHosts)) {
config.HostConfig.ExtraHosts.forEach(host => {
addDuplicateExtraHost();
const container = document.getElementById('duplicate-extra-hosts-container');
const lastInput = container?.lastElementChild?.querySelector('input');
if (lastInput) lastInput.value = host;
});
}
// Volumes — Mounts + Binds + HostConfig.Mounts (named + bind), deduped by destination
const volumeSpecs = extractVolumeSpecsFromInspect(config);
volumeSpecs.forEach((bind) => addDuplicateVolumeMount(bind));
// Tmpfs (HostConfig.Tmpfs + Mounts type=tmpfs)
const tmpfsMap = { ...(config.HostConfig?.Tmpfs || {}) };
if (Array.isArray(config.Mounts)) {
for (const m of config.Mounts) {
if (String(m?.Type || '').toLowerCase() !== 'tmpfs') continue;
const dest = m.Destination || m.Target;
if (!dest || tmpfsMap[dest] !== undefined) continue;
tmpfsMap[dest] = m.Mode || m.TmpfsOptions
? Object.entries(m.TmpfsOptions || {})
.map(([k, v]) => (v === true ? k : `${k}=${v}`))
.join(',')
: '';
}
}
Object.entries(tmpfsMap).forEach(([path, opts]) => {
addDuplicateTmpfsMount();
const container = document.getElementById('duplicate-tmpfs-container');
const lastInput = container?.lastElementChild?.querySelector('input');
if (lastInput) lastInput.value = opts ? `${path}:${opts}` : path;
});
// Resources
if (config.HostConfig?.NanoCpus) {
const cpuLimitEl = document.getElementById('duplicate-cpu-limit');
if (cpuLimitEl) cpuLimitEl.value = config.HostConfig.NanoCpus / 1e9;
}
// CpuQuota is microseconds per period (default period 100000), not nanoseconds
if (config.HostConfig?.CpuQuota > 0) {
const cpuReservationEl = document.getElementById('duplicate-cpu-reservation');
if (cpuReservationEl) {
const period =
config.HostConfig.CpuPeriod > 0 ? config.HostConfig.CpuPeriod : 100000;
cpuReservationEl.value = config.HostConfig.CpuQuota / period;
}
}
if (config.HostConfig?.CpuShares) {
const cpuSharesEl = document.getElementById('duplicate-cpu-shares');
if (cpuSharesEl) cpuSharesEl.value = config.HostConfig.CpuShares;
}
if (config.HostConfig?.Memory) {
const memoryLimitEl = document.getElementById('duplicate-memory-limit');
if (memoryLimitEl) memoryLimitEl.value = Math.round(config.HostConfig.Memory / (1024 * 1024));
}
if (config.HostConfig?.MemoryReservation) {
const memoryReservationEl = document.getElementById('duplicate-memory-reservation');
if (memoryReservationEl) memoryReservationEl.value = Math.round(config.HostConfig.MemoryReservation / (1024 * 1024));
}
if (config.HostConfig?.MemorySwap !== undefined && config.HostConfig.MemorySwap !== null) {
const memorySwapEl = document.getElementById('duplicate-memory-swap');
if (memorySwapEl) {
memorySwapEl.value =
config.HostConfig.MemorySwap === -1
? -1
: Math.round(config.HostConfig.MemorySwap / (1024 * 1024));
}
}
// Devices
if (config.HostConfig?.Devices && Array.isArray(config.HostConfig.Devices)) {
config.HostConfig.Devices.forEach(device => {
const deviceStr = `${device.PathOnHost}:${device.PathInContainer}:${device.CgroupPermissions || 'rwm'}`;
addDuplicateDeviceMapping();
const container = document.getElementById('duplicate-devices-container');
const lastInput = container?.lastElementChild?.querySelector('input');
if (lastInput) lastInput.value = deviceStr;
});
}
// Environment variables
if (config.Config?.Env && Array.isArray(config.Config.Env)) {
config.Config.Env.forEach(envStr => {
const [name, ...valueParts] = envStr.split('=');
const value = valueParts.join('=');
addDuplicateEnvVar();
const container = document.getElementById('duplicate-env');
const items = container.querySelectorAll('.array-item');
const lastItem = items[items.length - 1];
if (lastItem) {
const keyInput = lastItem.querySelector('[data-env-key]');
const valueInput = lastItem.querySelector('[data-env-value]');
if (keyInput) keyInput.value = name || '';
if (valueInput) valueInput.value = value || '';
}
});
}
// Labels
if (config.Config?.Labels && typeof config.Config.Labels === 'object') {
Object.entries(config.Config.Labels).forEach(([key, value]) => {
addDuplicateLabel();
const container = document.getElementById('duplicate-labels-container');
const lastInput = container?.lastElementChild?.querySelector('input');
if (lastInput) lastInput.value = `${key}=${value}`;
});
}
// Security
const userEl = document.getElementById('duplicate-user');
if (userEl) userEl.value = config.Config?.User || '';
const groupEl = document.getElementById('duplicate-group');
if (groupEl) {
// Form has a single group field — join extra groups so they aren't dropped
const groups = config.HostConfig?.GroupAdd;
if (Array.isArray(groups) && groups.length) {
groupEl.value = groups.join(',');
} else {
groupEl.value = '';
}
}
const privilegedEl = document.getElementById('duplicate-privileged');
if (privilegedEl) privilegedEl.checked = Boolean(config.HostConfig?.Privileged);
const readonlyRootfsEl = document.getElementById('duplicate-readonly-rootfs');
if (readonlyRootfsEl) {
readonlyRootfsEl.checked = Boolean(
config.HostConfig?.ReadonlyRootfs ?? config.Config?.ReadonlyRootfs
);
}
// Capabilities (CapAdd). CapDrop is not a form field — surface as security notes via CapAdd only.
if (config.HostConfig?.CapAdd && Array.isArray(config.HostConfig.CapAdd)) {
config.HostConfig.CapAdd.forEach(cap => {
if (!cap || cap === 'null') return;
addDuplicateCapability();
const container = document.getElementById('duplicate-capabilities-container');
const lastInput = container?.lastElementChild?.querySelector('input');
if (lastInput) lastInput.value = cap;
});
}
// Security options
if (config.HostConfig?.SecurityOpt && Array.isArray(config.HostConfig.SecurityOpt)) {
config.HostConfig.SecurityOpt.forEach(opt => {
if (!opt) return;
addDuplicateSecurityOpt();
const container = document.getElementById('duplicate-security-opts-container');
const lastInput = container?.lastElementChild?.querySelector('input');
if (lastInput) lastInput.value = opt;
});
}
// Runtime
const restartPolicyEl = document.getElementById('duplicate-restart-policy');
if (restartPolicyEl && config.HostConfig?.RestartPolicy) {
const policyName = config.HostConfig.RestartPolicy.Name || 'no';
// Docker may return "" for no restart
restartPolicyEl.value = policyName === '' ? 'no' : policyName;
const restartMaxRetriesEl = document.getElementById('duplicate-restart-max-retries');
if (restartMaxRetriesEl) {
const max = config.HostConfig.RestartPolicy.MaximumRetryCount;
restartMaxRetriesEl.value =
max !== undefined && max !== null && max !== 0 ? max : '';
}
}
const autoRemoveEl = document.getElementById('duplicate-auto-remove');
if (autoRemoveEl) autoRemoveEl.checked = Boolean(config.HostConfig?.AutoRemove);
const ttyEl = document.getElementById('duplicate-tty');
if (ttyEl) ttyEl.checked = Boolean(config.Config?.Tty);
const stdinOpenEl = document.getElementById('duplicate-stdin-open');
if (stdinOpenEl) stdinOpenEl.checked = Boolean(config.Config?.OpenStdin);
const detachEl = document.getElementById('duplicate-detach');
// Detach = not attaching stdin interactively in our UI; default true for cloned services
if (detachEl) {
detachEl.checked =
config.Config?.OpenStdin === true ? false : true;
}
const initEl = document.getElementById('duplicate-init');
if (initEl) initEl.checked = Boolean(config.HostConfig?.Init);
// Health check — strip CMD / CMD-SHELL prefix so re-deploy wraps correctly
if (config.Config?.Healthcheck) {
const hc = config.Config.Healthcheck;
const healthCmdEl = document.getElementById('duplicate-health-cmd');
if (healthCmdEl && hc.Test) {
const test = hc.Test;
if (Array.isArray(test)) {
if (test[0] === 'NONE' || test[0] === 'none') {
healthCmdEl.value = '';
} else if (test[0] === 'CMD-SHELL' || test[0] === 'CMD') {
healthCmdEl.value = test.slice(1).join(' ');
} else {
healthCmdEl.value = test.join(' ');
}
} else {
healthCmdEl.value = String(test);
}
}
const nsToSec = (ns) =>
ns && Number(ns) > 0 ? Math.round(Number(ns) / 1e9) : '';
const healthIntervalEl = document.getElementById('duplicate-health-interval');
if (healthIntervalEl) healthIntervalEl.value = nsToSec(hc.Interval);
const healthTimeoutEl = document.getElementById('duplicate-health-timeout');
if (healthTimeoutEl) healthTimeoutEl.value = nsToSec(hc.Timeout);
const healthRetriesEl = document.getElementById('duplicate-health-retries');
if (healthRetriesEl && hc.Retries != null) healthRetriesEl.value = hc.Retries;
const healthStartPeriodEl = document.getElementById('duplicate-health-start-period');
if (healthStartPeriodEl) healthStartPeriodEl.value = nsToSec(hc.StartPeriod);
}
// Logging
if (config.HostConfig?.LogConfig) {
const logDriverEl = document.getElementById('duplicate-log-driver');
if (logDriverEl) logDriverEl.value = config.HostConfig.LogConfig.Type || '';
if (config.HostConfig.LogConfig.Config && typeof config.HostConfig.LogConfig.Config === 'object') {
Object.entries(config.HostConfig.LogConfig.Config).forEach(([key, value]) => {
addDuplicateLogOpt();
const container = document.getElementById('duplicate-log-opts-container');
const items = container.querySelectorAll('.array-item');
const lastItem = items[items.length - 1];
if (lastItem) {
const keyInput = lastItem.querySelector('[data-logopt-key]');
const valueInput = lastItem.querySelector('[data-logopt-value]');
if (keyInput) keyInput.value = key;
if (valueInput) valueInput.value = value || '';
}
});
}
}
// Advanced
if (config.HostConfig?.Sysctls && typeof config.HostConfig.Sysctls === 'object') {
Object.entries(config.HostConfig.Sysctls).forEach(([key, value]) => {
addDuplicateSysctl();
const container = document.getElementById('duplicate-sysctls-container');
const lastInput = container?.lastElementChild?.querySelector('input');
if (lastInput) lastInput.value = `${key}=${value}`;
});
}
if (config.HostConfig?.Ulimits && Array.isArray(config.HostConfig.Ulimits)) {
config.HostConfig.Ulimits.forEach(ulimit => {
const ulimitStr = `${ulimit.Name}=${ulimit.Soft || ''}:${ulimit.Hard || ''}`;
addDuplicateUlimit();
const container = document.getElementById('duplicate-ulimits-container');
const lastInput = container?.lastElementChild?.querySelector('input');
if (lastInput) lastInput.value = ulimitStr;
});
}
const oomKillDisableEl = document.getElementById('duplicate-oom-kill-disable');
if (oomKillDisableEl) oomKillDisableEl.checked = config.HostConfig?.OomKillDisable || false;
const pidsLimitEl = document.getElementById('duplicate-pids-limit');
if (pidsLimitEl && config.HostConfig?.PidsLimit !== undefined) {
pidsLimitEl.value = config.HostConfig.PidsLimit === 0 ? -1 : config.HostConfig.PidsLimit;
}
const shmSizeEl = document.getElementById('duplicate-shm-size');
if (shmSizeEl && config.HostConfig?.ShmSize) {
shmSizeEl.value = Math.round(config.HostConfig.ShmSize / (1024 * 1024));
}
}
// Expose form functions to window for use in deploy view
window.collectFormData = collectFormData;
window.validateFormData = validateFormData;
window.deployDockerContainer = deployDockerContainer;
// Export required functions
export {
fetchTemplates,
displayTemplateList,
openDeployModal,
populateDeployFormFromTemplate,
collectDuplicateFormData,
populateDuplicateForm,
suggestDuplicateName,
initTemplateDeployer,
filterTemplatesByQuery,
};
window.collectDuplicateFormData = collectDuplicateFormData;
window.populateDuplicateForm = populateDuplicateForm;
window.populateDeployFormFromTemplate = populateDeployFormFromTemplate;
window.setDeployFormScope = setDeployFormScope;
window.resolveTemplateForDeploy = resolveTemplateForDeploy;
window.buildStackDeployPayload = buildStackDeployPayload;