Fix Duplicate/Edit cloning (volumes/settings) and initial image update check.
Release rolling / release (push) Successful in 9m49s

This commit is contained in:
Raven Scott
2026-07-16 00:01:07 -04:00
parent b53f4a58b1
commit 645f025042
5 changed files with 455 additions and 143 deletions
+31 -3
View File
@@ -1028,6 +1028,8 @@ function navigateToView(viewName, opts = {}) {
);
}
sendCommand('listContainers');
// First open of Containers for this peer: compare digests to registries
ensureInitialImageUpdateCheck();
}
} else if (viewName === 'images') {
loadImages();
@@ -2962,6 +2964,8 @@ const imageUpdateByImage = new Map();
const imageUpdateByContainer = new Map();
let imageUpdateCheckInFlight = false;
let imageUpdateCheckTimer = null;
/** Peer id for which we already kicked off the first Containers-tab update check */
let imageUpdateInitialPeerId = '';
let currentImageFilter = 'all'; // Current filter: 'all', 'used', 'unused'
function loadImages() {
@@ -9318,6 +9322,10 @@ function clearContainerStore() {
containerStore.gen = 0;
containerStore.topicId = '';
containerFilterState.allContainers = [];
// New peer / reset — allow Containers tab to run an initial update check again
imageUpdateInitialPeerId = '';
imageUpdateByImage.clear();
imageUpdateByContainer.clear();
}
/** Merge IP from inspect payload without reordering / wiping the table */
@@ -9693,6 +9701,20 @@ function scheduleImageUpdateCheck(opts = {}) {
}, delay);
}
/**
* Run image update check once when Containers tab first loads for the active peer.
* List reconcile often skips this (structFp unchanged / check deferred while off-tab).
*/
function ensureInitialImageUpdateCheck() {
if (!manager?.active?.connected) return;
if (currentView && currentView !== 'containers') return;
const peerId = manager.active.id || '';
if (!peerId) return;
if (imageUpdateInitialPeerId === peerId) return;
imageUpdateInitialPeerId = peerId;
scheduleImageUpdateCheck({ force: false, immediate: true, initial: true });
}
async function runImageUpdateCheck(opts = {}) {
if (!manager.active?.connected) return;
if (currentView && currentView !== 'containers' && !opts.force) return;
@@ -9706,14 +9728,15 @@ async function runImageUpdateCheck(opts = {}) {
}
if (line) line.textContent = 'Checking image digests against registries…';
// Mark visible rows as checking when no cached status
// Mark visible rows as checking on force or first tab load (no client cache yet)
const listElement = domCache.containerList || containerList;
if (listElement && opts.force) {
const showChecking = Boolean(opts.force || opts.initial || imageUpdateByImage.size === 0);
if (listElement && showChecking) {
listElement.querySelectorAll('tr[data-container-id]').forEach((row) => {
const c = row._pdContainer;
if (!c) return;
const existing = imageUpdateByContainer.get(c.Id) || imageUpdateByImage.get(c.Image);
if (!existing || opts.force) {
if (!existing || opts.force || opts.initial) {
imageUpdateByContainer.set(c.Id, { status: 'checking', image: c.Image });
applyImageUpdateToRow(row, c);
}
@@ -10384,6 +10407,11 @@ function renderContainers(containers, topicId) {
applyRoleUI();
// Digest check (cached server-side; cheap when warm)
scheduleImageUpdateCheck({ force: false });
// Ensure first Containers-tab open for this peer always kicks a check
// (schedule above is skipped when currentView was not containers when the timer fired)
if (currentView === 'containers') {
ensureInitialImageUpdateCheck();
}
}
+330 -114
View File
@@ -1127,16 +1127,28 @@ const activeVolumeHandlers = new Map();
window.activeVolumeHandlers = activeVolumeHandlers;
// Simplified load volumes for named volume select
async function loadVolumesForSelect(volumeId) {
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
}
@@ -1281,33 +1293,42 @@ function populateVolumeSelect(volumeId, volumesArray) {
return;
}
const currentValue = namedSelect.value;
const preferred =
namedSelect.dataset.preferredVolume ||
namedSelect.value ||
'';
// Clear and rebuild options
namedSelect.innerHTML = '';
// Add placeholder option
const placeholderOption = new Option('Select or create volume...', '', true, false);
const placeholderOption = new Option('Select or create volume...', '', !preferred, false);
namedSelect.add(placeholderOption);
// Add volumes
if (!volumesArray || volumesArray.length === 0) {
const option = new Option('No volumes available', '', false, true);
option.disabled = true;
namedSelect.add(option);
} else {
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) {
const option = new Option(volumeName, volumeName, false, false);
namedSelect.add(option);
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);
}
// Restore previous selection if it still exists
if (currentValue && Array.from(namedSelect.options).some(opt => opt.value === currentValue)) {
namedSelect.value = currentValue;
// 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;
@@ -2738,6 +2759,157 @@ function addDuplicatePortMapping(portData = null) {
container.appendChild(item);
}
/**
* Parse a Docker bind/volume string into form fields.
* Handles modes with commas (ro,Z) and named volumes.
* @param {string|null|undefined} volumeData
* @returns {{ volumeType: 'bind'|'named', hostPath: string, containerPath: string, mountMode: 'rw'|'ro' }}
*/
function parseVolumeMountSpec(volumeData) {
let volumeType = 'bind';
let hostPath = '';
let containerPath = '';
let mountMode = 'rw';
if (!volumeData || typeof volumeData !== 'string') {
return { volumeType, hostPath, containerPath, mountMode };
}
const raw = volumeData.trim();
const modeRe =
/^(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate)(?:,(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate))*$/i;
const parts = raw.split(':');
if (parts.length < 2) {
return { volumeType, hostPath, containerPath, mountMode };
}
let source;
let dest;
let modePart = '';
if (parts.length >= 3 && modeRe.test(parts[parts.length - 1])) {
modePart = parts[parts.length - 1];
dest = parts[parts.length - 2];
source = parts.slice(0, -2).join(':');
} else {
dest = parts[parts.length - 1];
source = parts.slice(0, -1).join(':');
}
hostPath = source || '';
containerPath = dest || '';
if (modePart) {
mountMode = /\bro\b/i.test(modePart) && !/\brw\b/i.test(modePart) ? 'ro' : 'rw';
}
const namedVol = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const looksNamed =
namedVol.test(hostPath) &&
!hostPath.startsWith('/') &&
!hostPath.startsWith('~') &&
!hostPath.startsWith('.') &&
!/^[A-Za-z]:[\\/]/.test(hostPath);
volumeType = looksNamed ? 'named' : 'bind';
return { volumeType, hostPath, containerPath, mountMode };
}
/**
* Escape a value for use inside double-quoted HTML attributes.
* @param {string} s
*/
function escapeAttrValue(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
/**
* Build volume bind strings from a container inspect result.
* Merges Mounts + HostConfig.Binds + HostConfig.Mounts (deduped by destination).
* @param {object} config
* @returns {string[]}
*/
function extractVolumeSpecsFromInspect(config) {
/** @type {Map<string, string>} dest → bind string */
const byDest = new Map();
const pushSpec = (source, dest, mode = 'rw', typeHint = '') => {
if (!source || !dest) return;
const modeStr = mode === 'ro' ? 'ro' : 'rw';
const hint = String(typeHint || '').toLowerCase();
const namedVol = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const isNamed =
hint === 'volume' ||
(namedVol.test(source) &&
!source.startsWith('/') &&
!source.startsWith('~') &&
!source.startsWith('.'));
// Prefer first non-empty; later Binds can fill gaps only
if (byDest.has(dest)) return;
byDest.set(dest, `${source}:${dest}:${modeStr}`);
void isNamed;
};
const modeFromMount = (m) => {
if (m?.RW === false || m?.ReadOnly === true) return 'ro';
if (/\bro\b/i.test(String(m?.Mode || ''))) return 'ro';
return 'rw';
};
const considerMount = (m) => {
if (!m || typeof m !== 'object') return;
const type = String(m.Type || m.type || '').toLowerCase();
if (type === 'tmpfs') return; // handled in tmpfs section
const dest = m.Destination || m.Target || m.destination || m.target;
if (!dest) return;
const mode = modeFromMount(m);
if (type === 'volume' || m.Name || m.Driver) {
const name = m.Name || m.name;
if (name && !String(name).startsWith('/')) {
pushSpec(String(name), dest, mode, 'volume');
return;
}
}
if (type === 'bind' || m.Source || m.source) {
const src = m.Source || m.source;
if (src) pushSpec(String(src), dest, mode, type || 'bind');
}
};
if (Array.isArray(config?.Mounts)) {
for (const m of config.Mounts) considerMount(m);
}
if (Array.isArray(config?.HostConfig?.Mounts)) {
for (const m of config.HostConfig.Mounts) considerMount(m);
}
// Binds fill anything Mounts missed (or when Mounts empty)
if (Array.isArray(config?.HostConfig?.Binds)) {
for (const bind of config.HostConfig.Binds) {
if (!bind) continue;
const parsed = parseVolumeMountSpec(String(bind));
if (!parsed.containerPath || !parsed.hostPath) continue;
if (byDest.has(parsed.containerPath)) {
// Prefer Binds mode if we only had a weak entry — keep existing
continue;
}
byDest.set(
parsed.containerPath,
`${parsed.hostPath}:${parsed.containerPath}:${parsed.mountMode}`
);
}
}
// Config.Volumes keys are container paths declared in the image (anonymous).
// Only include if we already have a real mount for that path — never invent host paths.
return [...byDest.values()];
}
function addDuplicateVolumeMount(volumeData = null) {
const container = document.getElementById('duplicate-volumes-container');
if (!container) return;
@@ -2746,29 +2918,13 @@ function addDuplicateVolumeMount(volumeData = null) {
item.className = 'array-item volume-mount-item';
item.id = id;
// Parse existing volume data if provided
let volumeType = 'bind';
let hostPath = '';
let containerPath = '';
let mountMode = 'rw';
if (volumeData) {
const parts = volumeData.split(':');
if (parts.length >= 2) {
// Check if it's a named volume (starts with volume name, no leading slash)
if (!parts[0].startsWith('/') && !parts[0].startsWith('~')) {
volumeType = 'named';
hostPath = parts[0];
} else {
volumeType = 'bind';
hostPath = parts[0];
}
containerPath = parts[1];
if (parts.length === 3) {
mountMode = parts[2];
}
}
}
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">
@@ -2788,7 +2944,7 @@ function addDuplicateVolumeMount(volumeData = null) {
class="form-control bg-dark text-white volume-host-input"
placeholder="/host/path"
data-volume-host="${id}"
value="${hostPath}"
value="${hostEsc}"
oninput="validateVolumeMount('${id}')">
<button type="button"
class="btn btn-outline-secondary"
@@ -2820,7 +2976,7 @@ function addDuplicateVolumeMount(volumeData = null) {
placeholder="/container/path"
required
data-volume-container="${id}"
value="${containerPath}"
value="${destEsc}"
oninput="validateVolumeMount('${id}')">
<small class="volume-error-msg" data-volume-container-error="${id}" style="display: none;"></small>
</div>
@@ -2840,9 +2996,16 @@ function addDuplicateVolumeMount(volumeData = null) {
`;
container.appendChild(item);
// Load volumes if named volume is selected
if (volumeType === 'named') {
loadVolumesForSelect(id);
// 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);
}
}
@@ -3153,10 +3316,11 @@ function collectDuplicateLabels() {
const items = collectArrayItems('duplicate-labels-container', 'data-label-id');
const labels = {};
items.forEach(item => {
const [key, value] = item.split('=');
if (key && value) {
labels[key.trim()] = value.trim();
}
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;
}
@@ -3180,10 +3344,11 @@ function collectDuplicateSysctls() {
const items = collectArrayItems('duplicate-sysctls-container', 'data-sysctl-id');
const sysctls = {};
items.forEach(item => {
const [key, value] = item.split('=');
if (key && value) {
sysctls[key.trim()] = value.trim();
}
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;
}
@@ -3315,20 +3480,42 @@ function populateDuplicateForm(config, opts = {}) {
}
const imageEl = document.getElementById('duplicate-image');
if (imageEl) imageEl.value = config.Config?.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 && config.Config?.Cmd) {
commandEl.value = Array.isArray(config.Config.Cmd) ? config.Config.Cmd.join(' ') : config.Config.Cmd;
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 && config.Config?.Entrypoint) {
entrypointEl.value = Array.isArray(config.Config.Entrypoint) ? config.Config.Entrypoint.join(' ') : config.Config.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');
@@ -3378,12 +3565,13 @@ function populateDuplicateForm(config, opts = {}) {
const domainnameEl = document.getElementById('duplicate-domainname');
if (domainnameEl) domainnameEl.value = config.Config?.Domainname || '';
// Port bindings (HostConfig + live NetworkSettings.Ports fallback)
// 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 =
@@ -3394,14 +3582,16 @@ function populateDuplicateForm(config, opts = {}) {
? livePorts[containerPort]
: null) ||
[null];
const binding = bindings[0];
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
@@ -3424,44 +3614,44 @@ function populateDuplicateForm(config, opts = {}) {
});
}
// Volumes — Binds preferred; fall back to Mounts (named volumes / binds)
const volumeSpecs = [];
if (config.HostConfig?.Binds && Array.isArray(config.HostConfig.Binds)) {
for (const bind of config.HostConfig.Binds) {
if (bind) volumeSpecs.push(bind);
}
}
if (!volumeSpecs.length && Array.isArray(config.Mounts)) {
for (const m of config.Mounts) {
if (!m?.Destination) continue;
const mode = m.RW === false || m.Mode === 'ro' ? 'ro' : 'rw';
if (m.Type === 'volume' && m.Name) {
volumeSpecs.push(`${m.Name}:${m.Destination}:${mode}`);
} else if (m.Source && m.Destination) {
volumeSpecs.push(`${m.Source}:${m.Destination}:${mode}`);
}
}
}
// Volumes — Mounts + Binds + HostConfig.Mounts (named + bind), deduped by destination
const volumeSpecs = extractVolumeSpecsFromInspect(config);
volumeSpecs.forEach((bind) => addDuplicateVolumeMount(bind));
// Tmpfs
if (config.HostConfig?.Tmpfs && typeof config.HostConfig.Tmpfs === 'object') {
Object.entries(config.HostConfig.Tmpfs).forEach(([path, opts]) => {
// 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 / 1000000000;
if (cpuLimitEl) cpuLimitEl.value = config.HostConfig.NanoCpus / 1e9;
}
if (config.HostConfig?.CpuQuota) {
// CpuQuota is microseconds per period (default period 100000), not nanoseconds
if (config.HostConfig?.CpuQuota > 0) {
const cpuReservationEl = document.getElementById('duplicate-cpu-reservation');
if (cpuReservationEl) cpuReservationEl.value = config.HostConfig.CpuQuota / 1000000000;
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');
@@ -3475,9 +3665,14 @@ function populateDuplicateForm(config, opts = {}) {
const memoryReservationEl = document.getElementById('duplicate-memory-reservation');
if (memoryReservationEl) memoryReservationEl.value = Math.round(config.HostConfig.MemoryReservation / (1024 * 1024));
}
if (config.HostConfig?.MemorySwap !== undefined) {
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));
if (memorySwapEl) {
memorySwapEl.value =
config.HostConfig.MemorySwap === -1
? -1
: Math.round(config.HostConfig.MemorySwap / (1024 * 1024));
}
}
// Devices
@@ -3524,19 +3719,30 @@ function populateDuplicateForm(config, opts = {}) {
if (userEl) userEl.value = config.Config?.User || '';
const groupEl = document.getElementById('duplicate-group');
if (groupEl && config.HostConfig?.GroupAdd && config.HostConfig.GroupAdd.length > 0) {
groupEl.value = config.HostConfig.GroupAdd[0];
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 = config.HostConfig?.Privileged || false;
if (privilegedEl) privilegedEl.checked = Boolean(config.HostConfig?.Privileged);
const readonlyRootfsEl = document.getElementById('duplicate-readonly-rootfs');
if (readonlyRootfsEl) readonlyRootfsEl.checked = config.HostConfig?.ReadonlyRootfs || false;
if (readonlyRootfsEl) {
readonlyRootfsEl.checked = Boolean(
config.HostConfig?.ReadonlyRootfs ?? config.Config?.ReadonlyRootfs
);
}
// Capabilities
// 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');
@@ -3547,6 +3753,7 @@ function populateDuplicateForm(config, opts = {}) {
// 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');
@@ -3557,55 +3764,64 @@ function populateDuplicateForm(config, opts = {}) {
// Runtime
const restartPolicyEl = document.getElementById('duplicate-restart-policy');
if (restartPolicyEl && config.HostConfig?.RestartPolicy) {
restartPolicyEl.value = config.HostConfig.RestartPolicy.Name || 'no';
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 && config.HostConfig.RestartPolicy.MaximumRetryCount) {
restartMaxRetriesEl.value = config.HostConfig.RestartPolicy.MaximumRetryCount;
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 = config.HostConfig?.AutoRemove || false;
if (autoRemoveEl) autoRemoveEl.checked = Boolean(config.HostConfig?.AutoRemove);
const ttyEl = document.getElementById('duplicate-tty');
if (ttyEl) ttyEl.checked = config.Config?.Tty || false;
if (ttyEl) ttyEl.checked = Boolean(config.Config?.Tty);
const stdinOpenEl = document.getElementById('duplicate-stdin-open');
if (stdinOpenEl) stdinOpenEl.checked = config.Config?.OpenStdin || false;
if (stdinOpenEl) stdinOpenEl.checked = Boolean(config.Config?.OpenStdin);
const detachEl = document.getElementById('duplicate-detach');
if (detachEl) detachEl.checked = config.Config?.AttachStdin === false;
// 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 = config.HostConfig?.Init || false;
if (initEl) initEl.checked = Boolean(config.HostConfig?.Init);
// Health check
// 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 && config.Config.Healthcheck.Test) {
const test = config.Config.Healthcheck.Test;
if (healthCmdEl && hc.Test) {
const test = hc.Test;
if (Array.isArray(test)) {
healthCmdEl.value = test.join(' ');
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;
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 && config.Config.Healthcheck.Interval) {
healthIntervalEl.value = Math.round(config.Config.Healthcheck.Interval / 1000000000);
}
if (healthIntervalEl) healthIntervalEl.value = nsToSec(hc.Interval);
const healthTimeoutEl = document.getElementById('duplicate-health-timeout');
if (healthTimeoutEl && config.Config.Healthcheck.Timeout) {
healthTimeoutEl.value = Math.round(config.Config.Healthcheck.Timeout / 1000000000);
}
if (healthTimeoutEl) healthTimeoutEl.value = nsToSec(hc.Timeout);
const healthRetriesEl = document.getElementById('duplicate-health-retries');
if (healthRetriesEl && config.Config.Healthcheck.Retries) {
healthRetriesEl.value = config.Config.Healthcheck.Retries;
}
if (healthRetriesEl && hc.Retries != null) healthRetriesEl.value = hc.Retries;
const healthStartPeriodEl = document.getElementById('duplicate-health-start-period');
if (healthStartPeriodEl && config.Config.Healthcheck.StartPeriod) {
healthStartPeriodEl.value = Math.round(config.Config.Healthcheck.StartPeriod / 1000000000);
}
if (healthStartPeriodEl) healthStartPeriodEl.value = nsToSec(hc.StartPeriod);
}
// Logging
+14
View File
@@ -159,6 +159,19 @@ export function registerDeployHandlers(session) {
}
if (args.user) containerConfig.User = args.user
// Extra groups (comma/space separated from Duplicate form, or array)
let groupAdd = null
if (args.group || args.groupAdd) {
const raw = args.groupAdd || args.group
const groups = Array.isArray(raw)
? raw.map((g) => String(g).trim()).filter(Boolean)
: String(raw)
.split(/[,\s]+/)
.map((g) => g.trim())
.filter(Boolean)
if (groups.length) groupAdd = groups
}
if (args.healthCmd) {
containerConfig.Healthcheck = {
Test: args.healthCmd.startsWith('CMD-SHELL')
@@ -258,6 +271,7 @@ export function registerDeployHandlers(session) {
}
if (args.autoRemove === true) hostConfig.AutoRemove = true
if (args.privileged === true) hostConfig.Privileged = true
if (groupAdd?.length) hostConfig.GroupAdd = groupAdd
if (args.capabilities && Array.isArray(args.capabilities)) {
hostConfig.CapAdd = args.capabilities
}
+56 -9
View File
@@ -94,23 +94,70 @@ function isValidPortMapping(portMapping) {
}
/**
* Validates volume mount format
* @param {string} volume - Volume mount string (e.g., "/host:/container:ro")
* Mount option suffix (Docker bind mode flags).
* @param {string} part
*/
function isMountModePart(part) {
if (!part || typeof part !== 'string') return false;
// e.g. ro, rw, ro,Z, rw,z, shared, rprivate, …
return /^(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate)(?:,(?:ro|rw|z|Z|shared|rshared|slave|rslave|private|rprivate))*$/i.test(
part.trim()
);
}
/**
* Validates volume mount format.
* Accepts bind mounts and named volumes:
* /host/path:/container/path
* /host/path:/container/path:ro
* volume-name:/container/path
* volume-name:/container/path:rw
* /host:/container:ro,Z
* @param {string} volume - Volume mount string
* @returns {boolean} - True if valid
*/
function isValidVolumeMount(volume) {
if (!volume || typeof volume !== 'string') return false;
if (!volume.includes(':')) return false;
// Block path traversal in any segment
if (volume.includes('..')) return false;
const parts = volume.split(':');
if (parts.length < 2 || parts.length > 3) return false;
const raw = volume.trim();
const parts = raw.split(':');
if (parts.length < 2) return false;
// Check for path traversal attempts
if (parts.some(part => part.includes('..'))) return false;
let source;
let dest;
if (parts.length >= 3 && isMountModePart(parts[parts.length - 1])) {
dest = parts[parts.length - 2];
source = parts.slice(0, -2).join(':');
} else if (parts.length === 2) {
source = parts[0];
dest = parts[1];
} else if (parts.length > 2) {
// e.g. Windows drive or unusual source with colons: join all but last as source
dest = parts[parts.length - 1];
source = parts.slice(0, -1).join(':');
} else {
return false;
}
// Basic path validation
const pathPattern = /^(\/[^\/]+)*\/?$/;
return parts.slice(0, 2).every(part => pathPattern.test(part) || part.startsWith('/'));
if (!source || !dest) return false;
// Container path must be absolute
if (!dest.startsWith('/')) return false;
// Named volume (Docker volume name) OR host path (absolute/relative/~ / Windows drive)
const namedVol = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
const isNamed = namedVol.test(source);
const isHostPath =
source.startsWith('/') ||
source.startsWith('./') ||
source.startsWith('~/') ||
source === '~' ||
/^[A-Za-z]:[\\/]/.test(source);
return isNamed || isHostPath;
}
/**
+7
View File
@@ -42,12 +42,19 @@ test('isValidPortMapping - invalid ports', (t) => {
test('isValidVolumeMount - valid volumes', (t) => {
t.ok(validation.isValidVolumeMount('/host:/container'));
t.ok(validation.isValidVolumeMount('/host:/container:ro'));
t.ok(validation.isValidVolumeMount('/host/path:/container/path:rw'));
t.ok(validation.isValidVolumeMount('/host:/container:ro,Z'));
// Named volumes must be accepted (deploy filters with this helper)
t.ok(validation.isValidVolumeMount('mydata:/data'));
t.ok(validation.isValidVolumeMount('my-volume:/var/lib/app:rw'));
t.ok(validation.isValidVolumeMount('app_data:/app/data:ro'));
});
test('isValidVolumeMount - invalid volumes', (t) => {
t.not(validation.isValidVolumeMount(''));
t.not(validation.isValidVolumeMount('no-colon'));
t.not(validation.isValidVolumeMount('/host/../container')); // Path traversal
t.not(validation.isValidVolumeMount('myvol:relative-dest')); // dest must be absolute
});
test('sanitizeEnvVarName - valid names', (t) => {