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

597 lines
21 KiB
JavaScript

/**
* 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 = `
<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" value="${escapeAttr(hostPort)}">
</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" value="${escapeAttr(containerPort)}" required>
</div>
<div class="port-field-group" style="max-width:6rem">
<label class="port-field-label">Protocol</label>
<select class="form-select bg-dark text-white port-protocol-input">
<option value="tcp" ${protocol === 'tcp' ? 'selected' : ''}>TCP</option>
<option value="udp" ${protocol === 'udp' ? 'selected' : ''}>UDP</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-outline-danger align-self-end" data-addc-remove title="Remove">
<i class="fas fa-times"></i>
</button>
</div>`
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 = `
<div class="volume-mount-fields">
<div class="volume-field-group" style="max-width:8rem">
<label class="volume-field-label">Type</label>
<select class="form-select bg-dark text-white volume-type-input">
<option value="bind">Bind</option>
<option value="named">Named volume</option>
</select>
</div>
<div class="volume-field-group volume-host-wrap flex-grow-1">
<label class="volume-field-label volume-src-label">Host path</label>
<input type="text" class="form-control bg-dark text-white volume-host-input" placeholder="/path/on/host or volume-name">
</div>
<div class="volume-field-group flex-grow-1">
<label class="volume-field-label">Container path</label>
<input type="text" class="form-control bg-dark text-white volume-container-input" placeholder="/data" required>
</div>
<div class="volume-field-group" style="max-width:6rem">
<label class="volume-field-label">Mode</label>
<select class="form-select bg-dark text-white volume-mode-input">
<option value="rw">RW</option>
<option value="ro">RO</option>
</select>
</div>
<button type="button" class="btn btn-sm btn-outline-danger align-self-end" data-addc-remove title="Remove">
<i class="fas fa-times"></i>
</button>
</div>`
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 = `
<input type="text" class="form-control bg-dark text-white" placeholder="KEY" data-env-key="${id}" style="flex:0 0 36%">
<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" data-addc-remove title="Remove">
<i class="fas fa-times"></i>
</button>`
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 = `
<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" data-addc-remove title="Remove">
<i class="fas fa-times"></i>
</button>`
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 = `
<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" data-addc-remove title="Remove">
<i class="fas fa-times"></i>
</button>`
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 = `
<input type="text" class="form-control bg-dark text-white" placeholder="hostname:ip" data-host-id="${id}">
<button type="button" class="btn btn-sm btn-outline-danger" data-addc-remove title="Remove">
<i class="fas fa-times"></i>
</button>`
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 = `
<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" data-addc-remove title="Remove">
<i class="fas fa-times"></i>
</button>`
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<string, string>} */
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<object>}
*/
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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
}
// Window exports for inline handlers if needed
if (typeof window !== 'undefined') {
window.addcAddPort = addcAddPort
window.addcAddVolume = addcAddVolume
window.addcAddEnv = addcAddEnv
window.addcAddLabel = addcAddLabel
window.addcAddDns = addcAddDns
window.addcAddExtraHost = addcAddExtraHost
window.addcAddCapability = addcAddCapability
window.resetAddContainerForm = resetAddContainerForm
window.collectAddContainerForm = collectAddContainerForm
window.submitAddContainer = submitAddContainer
window.initAddContainerPage = initAddContainerPage
window.toggleAddcCustomNetwork = toggleAddcCustomNetwork
}
export default {
initAddContainerPage,
resetAddContainerForm,
collectAddContainerForm,
submitAddContainer,
}