Release rolling / release (push) Successful in 7m47s
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.
4258 lines
171 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
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, '"');
|
|
const escapedName = (item.name || '').replace(/</g, '<').replace(/>/g, '>');
|
|
|
|
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, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
}
|
|
|
|
/**
|
|
* 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;
|