Fix Duplicate/Edit cloning (volumes/settings) and initial image update check.
Release rolling / release (push) Successful in 9m49s
Release rolling / release (push) Successful in 9m49s
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+339
-123
@@ -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, '&')
|
||||
.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;
|
||||
@@ -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];
|
||||
const hostPort = binding?.HostPort || '';
|
||||
const portStr = hostPort
|
||||
? `${hostPort}:${portNum}/${protocol}`
|
||||
: `${portNum}/${protocol}`;
|
||||
addDuplicatePortMapping(portStr);
|
||||
// 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]) => {
|
||||
addDuplicateTmpfsMount();
|
||||
const container = document.getElementById('duplicate-tmpfs-container');
|
||||
const lastInput = container?.lastElementChild?.querySelector('input');
|
||||
if (lastInput) lastInput.value = opts ? `${path}:${opts}` : path;
|
||||
});
|
||||
// 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.join(' ');
|
||||
}
|
||||
} else {
|
||||
healthCmdEl.value = test;
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user