/** * Add container page — blank create form (separate from Deploy templates). * Field IDs use the addc- prefix. Submit goes through deployContainerWithSteps. */ import { showStatusIndicator, hideStatusIndicator, showAlert, } from './uiUtils.js' import { presentError } from '../client/errors.js' import { precheckDeployNetworking, formatNetworkingPrecheckMessage, } from '../client/deployNetworkPrecheck.js' let portSeq = 0 let volSeq = 0 let envSeq = 0 let labelSeq = 0 let dnsSeq = 0 let hostSeq = 0 let capSeq = 0 function el(id) { return document.getElementById(id) } function val(id) { return el(id)?.value?.trim?.() || '' } function checked(id) { return Boolean(el(id)?.checked) } function numOrNull(id, parse = parseFloat) { const raw = el(id)?.value if (raw === '' || raw == null) return null const n = parse(raw) return Number.isFinite(n) && n > 0 ? n : null } /** * Clear dynamic rows and reset primary fields. */ export function resetAddContainerForm() { for (const id of [ 'addc-ports', 'addc-volumes', 'addc-env', 'addc-labels', 'addc-dns', 'addc-extra-hosts', 'addc-capabilities', ]) { const c = el(id) if (c) c.innerHTML = '' } const form = el('add-container-form') if (form) form.reset() // Defaults after reset const always = el('addc-always-pull') if (always) always.checked = true const detach = el('addc-detach') if (detach) detach.checked = true const net = el('addc-network-mode') if (net) net.value = 'bridge' const restart = el('addc-restart-policy') if (restart) restart.value = 'no' toggleAddcCustomNetwork() } export function addcAddPort(portData = null) { const container = el('addc-ports') if (!container) return const id = `addc-port-${++portSeq}` let hostPort = '' let containerPort = '' let protocol = 'tcp' if (portData != null) { const portStr = String(portData).trim() if (portStr.includes(':')) { const [h, rest] = portStr.split(':') hostPort = h.trim() if (rest.includes('/')) { const [c, p] = rest.split('/') containerPort = c.trim() protocol = p?.toLowerCase() === 'udp' ? 'udp' : 'tcp' } else containerPort = rest.trim() } else if (portStr.includes('/')) { const [c, p] = portStr.split('/') containerPort = c.trim() protocol = p?.toLowerCase() === 'udp' ? 'udp' : 'tcp' } else if (portStr) containerPort = portStr } const item = document.createElement('div') item.className = 'array-item port-mapping-item' item.dataset.addcRow = id item.innerHTML = `
` item.querySelector('[data-addc-remove]')?.addEventListener('click', () => item.remove()) container.appendChild(item) } export function addcAddVolume() { const container = el('addc-volumes') if (!container) return const id = `addc-vol-${++volSeq}` const item = document.createElement('div') item.className = 'array-item volume-mount-item' item.dataset.addcRow = id item.innerHTML = `
` const typeSel = item.querySelector('.volume-type-input') const srcLabel = item.querySelector('.volume-src-label') typeSel?.addEventListener('change', () => { if (srcLabel) srcLabel.textContent = typeSel.value === 'named' ? 'Volume name' : 'Host path' }) item.querySelector('[data-addc-remove]')?.addEventListener('click', () => item.remove()) container.appendChild(item) } export function addcAddEnv() { const container = el('addc-env') if (!container) return const id = `addc-env-${++envSeq}` const item = document.createElement('div') item.className = 'array-item mb-2 d-flex gap-2 align-items-center' item.dataset.addcRow = id item.innerHTML = ` ` item.querySelector('[data-addc-remove]')?.addEventListener('click', () => item.remove()) container.appendChild(item) } export function addcAddLabel() { const container = el('addc-labels') if (!container) return const id = `addc-label-${++labelSeq}` const item = document.createElement('div') item.className = 'array-item d-flex gap-2 align-items-center mb-2' item.dataset.addcRow = id item.innerHTML = ` ` item.querySelector('[data-addc-remove]')?.addEventListener('click', () => item.remove()) container.appendChild(item) } export function addcAddDns() { const container = el('addc-dns') if (!container) return const id = `addc-dns-${++dnsSeq}` const item = document.createElement('div') item.className = 'array-item d-flex gap-2 align-items-center mb-2' item.dataset.addcRow = id item.innerHTML = ` ` item.querySelector('[data-addc-remove]')?.addEventListener('click', () => item.remove()) container.appendChild(item) } export function addcAddExtraHost() { const container = el('addc-extra-hosts') if (!container) return const id = `addc-host-${++hostSeq}` const item = document.createElement('div') item.className = 'array-item d-flex gap-2 align-items-center mb-2' item.dataset.addcRow = id item.innerHTML = ` ` item.querySelector('[data-addc-remove]')?.addEventListener('click', () => item.remove()) container.appendChild(item) } export function addcAddCapability() { const container = el('addc-capabilities') if (!container) return const id = `addc-cap-${++capSeq}` const item = document.createElement('div') item.className = 'array-item d-flex gap-2 align-items-center mb-2' item.dataset.addcRow = id item.innerHTML = ` ` item.querySelector('[data-addc-remove]')?.addEventListener('click', () => item.remove()) container.appendChild(item) } export function toggleAddcCustomNetwork() { const mode = val('addc-network-mode') || 'bridge' const wrap = el('addc-custom-network-wrap') const label = el('addc-custom-network-label') const hint = el('addc-custom-network-hint') const input = el('addc-custom-network') if (!wrap) return if (mode === 'host' || mode === 'none') { wrap.style.display = 'none' return } wrap.style.display = '' if (mode === 'container') { if (label) label.textContent = 'Container ID / name' if (hint) hint.textContent = 'Share the network stack of this container' if (input) input.placeholder = 'container-name-or-id' } else { if (label) label.textContent = 'Attach to network' if (hint) hint.textContent = 'Named network to join after create (optional)' if (input) input.placeholder = 'optional network name' } } function collectPorts() { const container = el('addc-ports') if (!container) return [] const ports = [] container.querySelectorAll('.port-mapping-item').forEach((item) => { const host = item.querySelector('.port-host-input')?.value.trim() || '' const cPort = item.querySelector('.port-container-input')?.value.trim() || '' const protocol = item.querySelector('.port-protocol-input')?.value || 'tcp' if (!cPort) return ports.push(host ? `${host}:${cPort}/${protocol}` : `${cPort}/${protocol}`) }) return ports } function collectVolumes() { const container = el('addc-volumes') if (!container) return [] const volumes = [] container.querySelectorAll('.volume-mount-item').forEach((item) => { const src = item.querySelector('.volume-host-input')?.value.trim() || '' const dest = item.querySelector('.volume-container-input')?.value.trim() || '' const mode = item.querySelector('.volume-mode-input')?.value || 'rw' if (!src || !dest) return volumes.push(`${src}:${dest}:${mode}`) }) return volumes } function collectEnv() { const container = el('addc-env') if (!container) return [] const env = [] container.querySelectorAll('[data-env-key]').forEach((keyInput) => { const name = keyInput.value.trim() if (!name) return const id = keyInput.getAttribute('data-env-key') const valueInput = container.querySelector(`[data-env-value="${id}"]`) env.push({ name, value: valueInput?.value ?? '' }) }) return env } function collectLabels() { const container = el('addc-labels') if (!container) return {} /** @type {Record} */ const labels = {} container.querySelectorAll('[data-label-id]').forEach((input) => { const raw = input.value.trim() if (!raw || !raw.includes('=')) return const eq = raw.indexOf('=') const k = raw.slice(0, eq).trim() const v = raw.slice(eq + 1) if (k) labels[k] = v }) return labels } function collectSimpleList(containerId, attr) { const container = el(containerId) if (!container) return [] const out = [] container.querySelectorAll(`[${attr}]`).forEach((input) => { const v = input.value.trim() if (v) out.push(v) }) return out } /** * Build deployContainer payload from the add-container form. * @returns {object} */ export function collectAddContainerForm() { const networkMode = val('addc-network-mode') || 'bridge' const customNet = val('addc-custom-network') const labels = collectLabels() let customNetwork = null if (customNet && networkMode !== 'host' && networkMode !== 'none') { customNetwork = customNet } const data = { source: 'add-container', containerName: val('addc-name'), image: val('addc-image'), alwaysPull: checked('addc-always-pull'), publishAllPorts: checked('addc-publish-all'), command: val('addc-command') || null, entrypoint: val('addc-entrypoint') || null, workingDir: val('addc-workdir') || null, networkMode, customNetwork, hostname: val('addc-hostname') || null, domainname: val('addc-domainname') || null, ports: collectPorts(), dns: collectSimpleList('addc-dns', 'data-dns-id'), extraHosts: collectSimpleList('addc-extra-hosts', 'data-host-id'), volumes: collectVolumes(), env: collectEnv(), labels: Object.keys(labels).length ? labels : null, user: val('addc-user') || null, privileged: checked('addc-privileged'), readonlyRootfs: checked('addc-readonly-rootfs'), capabilities: collectSimpleList('addc-capabilities', 'data-cap-id'), restartPolicy: val('addc-restart-policy') || 'no', restartMaxRetries: parseInt(el('addc-restart-max-retries')?.value, 10) || null, autoRemove: checked('addc-auto-remove'), tty: checked('addc-tty'), stdinOpen: checked('addc-stdin-open'), init: checked('addc-init'), cpuLimit: numOrNull('addc-cpu-limit'), memoryLimit: numOrNull('addc-memory-limit', (x) => parseInt(x, 10)), memoryReservation: numOrNull('addc-memory-reservation', (x) => parseInt(x, 10)), shmSize: numOrNull('addc-shm-size', (x) => parseInt(x, 10)), healthCmd: val('addc-health-cmd') || null, logDriver: val('addc-log-driver') || null, } 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 data.alwaysPull = checked('addc-always-pull') data.publishAllPorts = checked('addc-publish-all') data.source = 'add-container' return data } /** * @param {object} payload */ async function confirmReplaceIfNeeded(payload) { if (payload.replace) return payload if (typeof window.peardockOps?.askUserConfirm !== 'function') return payload // Server returns conflict; caller may also pre-check. Keep replace off until conflict. return payload } /** * Submit create form via job stepper. * @returns {Promise} */ export async function submitAddContainer() { let payload = collectAddContainerForm() if (!payload.containerName || !payload.image) { throw new Error( 'Container name and image are required — How to fix: fill both fields before deploying.' ) } // Networking precheck before any RPC (empty host ports, conflicts, etc.) const netCheck = await precheckDeployNetworking(payload) if (!netCheck.ok) { throw new Error( formatNetworkingPrecheckMessage(netCheck) || 'Networking configuration is invalid.' ) } if (netCheck.warnings?.length) { try { showAlert('warning', netCheck.warnings.join(' '), { toast: true, tray: false }) } catch { // ignore } } payload._networkingPrechecked = true // Reuse template deploy replace helper when available if (typeof window.deployDockerContainer === 'function') { // Prefer shared path for name-conflict handling + job tray const result = await window.deployDockerContainer(payload) return result } if (typeof window.peardockOps?.deployContainerWithSteps !== 'function') { throw new Error('Deploy helpers are not ready') } payload = await confirmReplaceIfNeeded(payload) try { const job = await window.peardockOps.deployContainerWithSteps(payload) return { success: true, viaJob: true, message: job?.result?.message || `Container "${payload.containerName}" created from image "${payload.image}"`, id: job?.result?.id, } } catch (err) { if ( err?.code === 'CONTAINER_NAME_CONFLICT' || /already exists|name.*already|already in use/i.test(String(err?.message || '')) ) { let replace = false if (window.peardockOps?.askUserConfirm) { replace = await window.peardockOps.askUserConfirm( 'Container name already in use', `A container named "${payload.containerName}" already exists. Replace it? This stops and removes the old container, then creates a new one with your settings.`, { confirmLabel: 'Replace', danger: true, icon: 'fa-redo' } ) } if (replace) { const job = await window.peardockOps.deployContainerWithSteps({ ...payload, replace: true, }) return { success: true, viaJob: true, message: job?.result?.message || `Container "${payload.containerName}" replaced successfully`, id: job?.result?.id, replaced: true, } } } if (err && typeof err === 'object') err.viaJob = true throw err } } /** * Wire buttons and network mode toggle once. */ export function initAddContainerPage() { const form = el('add-container-form') if (!form || form.dataset.addcBound === '1') return form.dataset.addcBound = '1' el('addc-network-mode')?.addEventListener('change', toggleAddcCustomNetwork) el('addc-add-port')?.addEventListener('click', () => addcAddPort()) el('addc-add-volume')?.addEventListener('click', () => addcAddVolume()) el('addc-add-env')?.addEventListener('click', () => addcAddEnv()) el('addc-add-label')?.addEventListener('click', () => addcAddLabel()) el('addc-add-dns')?.addEventListener('click', () => addcAddDns()) el('addc-add-extra-host')?.addEventListener('click', () => addcAddExtraHost()) el('addc-add-capability')?.addEventListener('click', () => addcAddCapability()) el('addc-cancel-btn')?.addEventListener('click', () => { if (typeof window.navigateToView === 'function') window.navigateToView('containers') }) el('addc-reset-btn')?.addEventListener('click', () => resetAddContainerForm()) form.addEventListener('submit', async (e) => { e.preventDefault() await handleAddContainerSubmit() }) el('addc-deploy-btn')?.addEventListener('click', async (e) => { e.preventDefault() await handleAddContainerSubmit() }) toggleAddcCustomNetwork() } async function handleAddContainerSubmit() { const btn = el('addc-deploy-btn') if (btn) btn.disabled = true try { showStatusIndicator('Creating container…') const result = await submitAddContainer() // Job drawer owns success feedback when viaJob if (!result?.viaJob) { showAlert('success', result?.message || 'Container created') } resetAddContainerForm() if (typeof window.navigateToView === 'function') { window.navigateToView('containers') } if (typeof window.sendCommand === 'function') { window.sendCommand('listContainers') } } catch (err) { if (err?.code === 'DEPLOY_CANCELLED') return // Job tray owns multi-line error when viaJob if (!err?.viaJob) { presentError(err, 'deployContainer', { showAlert }) } } finally { if (btn) btn.disabled = false hideStatusIndicator() } } function escapeAttr(s) { return String(s ?? '') .replace(/&/g, '&') .replace(/"/g, '"') .replace(/