Fix container duplication end-to-end (inspect, form, deploy).
Release rolling / release (push) Has been cancelled

Replace the broken fire-and-forget submit path with deployDockerContainer
(including replace prompts), suggest a free name-copy by default, correctly
map custom networks/mounts/ports from inspect, and load config via typed RPC.
This commit is contained in:
Raven Scott
2026-07-13 18:18:48 -04:00
parent 245d532efd
commit 4879b7ca18
3 changed files with 213 additions and 180 deletions
+100 -145
View File
@@ -7714,114 +7714,52 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
// Validate required fields
if (!formData.containerName || !formData.image) {
showAlert('danger', 'Container name and image are required.');
return;
}
// Get container name for notifications
const containerName = formData.containerName || 'container';
// Close modal immediately before async operation
if (duplicateModal) {
duplicateModal.hide();
const containerName = formData.containerName;
const deploy =
typeof window.deployDockerContainer === 'function'
? window.deployDockerContainer
: null;
if (!deploy) {
showAlert('danger', 'Deploy function unavailable. Reload the app and try again.');
return;
}
// Close modal first so the replace confirm (if any) is visible
if (duplicateModal) duplicateModal.hide();
closeAllModals();
// Add notification for container creation
notificationManager.add('info', `Creating container "${containerName}"...`, { autoDismiss: false });
showStatusIndicator('Preparing container configuration...');
try {
// Use deployContainer command with the collected form data
// This reuses the same deployment logic
const originalHandler = window.handlePeerResponse;
let timeoutId = null;
let isResolved = false;
const result = await deploy(formData);
if (result?.code === 'DEPLOY_CANCELLED' || !result) return;
const duplicateHandler = (response) => {
if (isResolved) {
if (typeof originalHandler === 'function') {
originalHandler(response);
}
return;
}
const isDuplicateResponse =
(response.success && response.message && typeof response.message === 'string' && response.message.includes('deployed successfully')) ||
(response.error && (
(response.message && typeof response.message === 'string' && response.message.includes('deploy')) ||
(typeof response.error === 'string' && (response.error.includes('deploy') || response.error.includes('Container')))
));
if (isDuplicateResponse) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
window.handlePeerResponse = originalHandler;
isResolved = true;
if (response.success && response.message && response.message.includes('deployed successfully')) {
// Update message to indicate we're transferring
updateStatusIndicator('Transferring you to the container');
// Update notification to success
notificationManager.add('success', `Container "${formData.containerName}" created successfully!`);
showAlert('success', `Container "${formData.containerName}" duplicated successfully!`);
sendCommand('listContainers');
// Hide spinner after a delay to allow navigation
setTimeout(() => {
hideStatusIndicator();
}, 1500);
} else if (response.error) {
hideStatusIndicator();
const errorMessage = typeof response.error === 'string'
? response.error
: (response.error?.message || response.error?.toString() || 'Unknown error');
// Update notification to error
notificationManager.add('danger', `Failed to create container "${formData.containerName}"`);
showAlert('danger', errorMessage);
}
} else {
if (typeof originalHandler === 'function') {
originalHandler(response);
}
}
};
window.handlePeerResponse = duplicateHandler;
// Update message when starting deployment
updateStatusIndicator('Creating container...');
if (typeof window.sendCommand === 'function') {
window.sendCommand('deployContainer', formData);
} else {
window.handlePeerResponse = originalHandler;
hideStatusIndicator();
showAlert('danger', 'sendCommand is not available. Please ensure app.js is loaded.');
return;
if (!result.viaJob) {
showAlert(
'success',
result.message ||
(result.replaced
? `Container "${containerName}" replaced successfully`
: `Container "${containerName}" duplicated successfully`)
);
}
if (typeof sendCommand === 'function') {
sendCommand('listContainers');
setTimeout(() => {
if (typeof navigateToNewContainer === 'function') {
navigateToNewContainer(containerName);
}
}, 800);
}
timeoutId = setTimeout(() => {
if (!isResolved) {
window.handlePeerResponse = originalHandler;
isResolved = true;
hideStatusIndicator();
// Update notification to timeout error
notificationManager.add('danger', `Failed to create container "${containerName}" (timeout)`);
showAlert('danger', 'Duplication timed out. No response from server.');
}
}, 60000);
} catch (error) {
hideStatusIndicator();
if (error?.code === 'DEPLOY_CANCELLED') return;
console.error('[ERROR] Failed to duplicate container:', error);
// Update notification to error
notificationManager.add('danger', `Failed to create container "${containerName}"`);
showAlert('danger', error.message || 'Failed to duplicate container. Check console for details.');
if (!error?.viaJob) {
presentError(error, 'deployContainer', { showAlert });
}
}
});
}
@@ -9795,69 +9733,86 @@ function updateStatsUI(row, stats) {
// Function to open the Duplicate Modal with container configurations
function openDuplicateModal(container) {
async function openDuplicateModal(container) {
if (!container?.Id) return;
console.log(`[INFO] Opening Duplicate Modal for container: ${container.Id}`);
showStatusIndicator('Fetching container configuration...');
// Send a command to inspect the container
sendCommand('inspectContainer', { id: container.Id });
// Listen for the inspectContainer response
window.inspectContainerCallback = (config) => {
try {
let config = null;
if (manager.active?.connected) {
const res = await manager.request(Methods.inspectContainer, { id: container.Id });
config = res?.data || res?.config || res;
} else {
// Offline fallback via push callback path
config = await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('Inspect timed out')), 20000);
window.inspectContainerCallback = (cfg) => {
clearTimeout(t);
window.inspectContainerCallback = null;
resolve(cfg);
};
sendCommand('inspectContainer', { id: container.Id });
});
}
hideStatusIndicator();
if (!config) {
console.error('[ERROR] Failed to retrieve container configuration.');
if (!config || (!config.Config && !config.Id && !config.Name)) {
showAlert('danger', 'Failed to retrieve container configuration.');
return;
}
console.log(`[DEBUG] Retrieved container configuration: ${JSON.stringify(config)}`);
const form = document.getElementById('duplicate-container-form');
if (form) form.reset();
// Parse configuration and populate the accordion form
// Existing names so we can suggest a free duplicate name
const existingNames = new Set();
try {
// Clear the form first
const form = document.getElementById('duplicate-container-form');
if (form) form.reset();
// Populate all fields using the helper function
populateDuplicateForm(config);
setupDeployResourceSliders();
// Set up network mode change handler for duplicate modal
const networkMode = document.getElementById('duplicate-network-mode');
const customNetworkContainer = document.getElementById('duplicate-custom-network-container');
if (networkMode && customNetworkContainer && networkMode.parentNode) {
// Remove existing listeners by cloning and replacing
const newNetworkMode = networkMode.cloneNode(true);
networkMode.parentNode.replaceChild(newNetworkMode, networkMode);
newNetworkMode.addEventListener('change', (e) => {
if (e.target.value === 'container') {
customNetworkContainer.style.display = 'block';
const input = customNetworkContainer.querySelector('input');
if (input) input.placeholder = 'container-name';
} else if (e.target.value !== 'host' && e.target.value !== 'none' && e.target.value !== 'bridge') {
customNetworkContainer.style.display = 'block';
const input = customNetworkContainer.querySelector('input');
if (input) input.placeholder = 'network-name';
} else {
customNetworkContainer.style.display = 'none';
}
});
const list = containerFilterState?.allContainers || [];
for (const c of list) {
for (const n of c.Names || []) {
existingNames.add(String(n).replace(/^\//, ''));
}
}
// Show the duplicate modal
if (duplicateModal) {
duplicateModal.show();
}
} catch (error) {
console.error(`[ERROR] Failed to populate modal fields: ${error.message}`);
showAlert('danger', 'Failed to populate container configuration fields.');
} catch {
// ignore
}
};
populateDuplicateForm(config, { existingNames });
setupDeployResourceSliders();
const networkMode = document.getElementById('duplicate-network-mode');
const customNetworkContainer = document.getElementById('duplicate-custom-network-container');
if (networkMode && customNetworkContainer && networkMode.parentNode) {
const newNetworkMode = networkMode.cloneNode(true);
networkMode.parentNode.replaceChild(newNetworkMode, networkMode);
const syncCustomNet = (value) => {
const input = customNetworkContainer.querySelector('input');
if (value === 'host' || value === 'none') {
customNetworkContainer.style.display = 'none';
return;
}
customNetworkContainer.style.display = 'block';
if (input) {
input.placeholder =
value === 'container'
? 'container-name'
: 'user network name (optional)';
}
};
newNetworkMode.addEventListener('change', (e) => syncCustomNet(e.target.value));
syncCustomNet(newNetworkMode.value);
}
if (duplicateModal) duplicateModal.show();
} catch (error) {
hideStatusIndicator();
console.error('[ERROR] Failed to open duplicate modal:', error);
presentError(error, 'inspectContainer', { showAlert });
}
}
window.openDuplicateModal = openDuplicateModal;
// Function to open the Inspect Modal with container information
function openInspectModal(container) {
+5 -3
View File
@@ -2389,7 +2389,9 @@ services:
<div class="modal-dialog modal-xl">
<div class="modal-content bg-dark text-white">
<div class="modal-header">
<h5 class="modal-title" id="duplicateModalLabel">Duplicate Container</h5>
<h5 class="modal-title" id="duplicateModalLabel">
<i class="fas fa-clone me-2"></i>Duplicate Container
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" style="max-height: 80vh; overflow-y: auto;">
@@ -2400,8 +2402,8 @@ services:
<div class="row">
<div class="col-md-6 mb-3">
<label for="duplicate-container-name" class="form-label">Container Name <span class="text-danger">*</span></label>
<input type="text" id="duplicate-container-name" class="form-control bg-dark text-white" placeholder="my-container" required>
<small class="text-muted">Alphanumeric, dashes, and underscores only</small>
<input type="text" id="duplicate-container-name" class="form-control bg-dark text-white" placeholder="my-container-copy" required>
<small class="text-muted">Defaults to a free name (original-copy). Reusing an existing name will offer Replace.</small>
</div>
<div class="col-md-6 mb-3">
<label for="duplicate-image" class="form-label">Image <span class="text-danger">*</span></label>
+108 -32
View File
@@ -3051,12 +3051,12 @@ function collectDuplicateFormData() {
// Networking
networkMode: document.getElementById('duplicate-network-mode')?.value || 'bridge',
customNetwork: (() => {
const mode = document.getElementById('duplicate-network-mode')?.value;
const mode = document.getElementById('duplicate-network-mode')?.value || 'bridge';
const customNet = document.getElementById('duplicate-custom-network')?.value.trim();
if ((mode === 'container' || (mode && mode !== 'bridge' && mode !== 'host' && mode !== 'none')) && customNet) {
return customNet;
}
return null;
if (!customNet) return null;
// host/none: no extra network attach; container: uses custom field as peer container
if (mode === 'host' || mode === 'none') return null;
return customNet;
})(),
hostname: document.getElementById('duplicate-hostname')?.value.trim() || null,
domainname: document.getElementById('duplicate-domainname')?.value.trim() || null,
@@ -3265,8 +3265,28 @@ function collectDuplicateVolumeMounts() {
return volumes;
}
/**
* Suggest a free container name for duplication (name-copy, name-copy-2, …).
* @param {string} base
* @param {Set<string>|string[]} existingNames
*/
function suggestDuplicateName(base, existingNames) {
const taken = existingNames instanceof Set
? existingNames
: new Set(Array.isArray(existingNames) ? existingNames : []);
const root = String(base || 'container').replace(/^\//, '') || 'container';
// If user already has a free name, still prefer *-copy so we don't replace by default
let candidate = `${root}-copy`;
if (!taken.has(candidate)) return candidate.slice(0, 63);
for (let i = 2; i < 1000; i++) {
candidate = `${root}-copy-${i}`
if (!taken.has(candidate)) return candidate.slice(0, 63);
}
return `${root}-copy-${Date.now().toString(36)}`.slice(0, 63);
}
// Populate duplicate form from container config
function populateDuplicateForm(config) {
function populateDuplicateForm(config, opts = {}) {
if (!config) return;
// Reset counters
@@ -3283,9 +3303,13 @@ function populateDuplicateForm(config) {
if (container) container.innerHTML = '';
});
// Basic settings
// Basic settings — suggest a free name so "Duplicate" doesn't replace by default
const originalName = config.Name ? String(config.Name).replace(/^\//, '') : '';
const nameEl = document.getElementById('duplicate-container-name');
if (nameEl) nameEl.value = config.Name ? config.Name.replace(/^\//, '') : '';
if (nameEl) {
nameEl.value = suggestDuplicateName(originalName, opts.existingNames || []);
nameEl.dataset.originalName = originalName;
}
const imageEl = document.getElementById('duplicate-image');
if (imageEl) imageEl.value = config.Config?.Image || '';
@@ -3303,19 +3327,41 @@ function populateDuplicateForm(config) {
const workdirEl = document.getElementById('duplicate-workdir');
if (workdirEl) workdirEl.value = config.Config?.WorkingDir || '';
// Networking
// Networking — select only supports bridge/host/none/container
const networkModeEl = document.getElementById('duplicate-network-mode');
if (networkModeEl && config.HostConfig?.NetworkMode) {
const netMode = config.HostConfig.NetworkMode;
if (netMode.startsWith('container:')) {
const customNetEl = document.getElementById('duplicate-custom-network');
const customNetWrap = document.getElementById('duplicate-custom-network-container');
const knownModes = new Set(['bridge', 'host', 'none', 'container']);
let netMode = config.HostConfig?.NetworkMode || 'bridge';
// Prefer a non-default attached network from NetworkSettings when NetworkMode is default bridge
const netSettings = config.NetworkSettings?.Networks || {};
const attachedNets = Object.keys(netSettings).filter(
(n) => n && n !== 'bridge' && n !== 'host' && n !== 'none'
);
if (networkModeEl) {
if (String(netMode).startsWith('container:')) {
networkModeEl.value = 'container';
const customNetEl = document.getElementById('duplicate-custom-network');
if (customNetEl) {
customNetEl.value = netMode.replace('container:', '');
document.getElementById('duplicate-custom-network-container').style.display = 'block';
customNetEl.value = String(netMode).replace(/^container:/, '');
}
if (customNetWrap) customNetWrap.style.display = 'block';
} else if (knownModes.has(netMode)) {
networkModeEl.value = netMode;
if (netMode === 'bridge' && attachedNets.length && customNetEl) {
// User-defined network attached on top of bridge mode
customNetEl.value = attachedNets[0];
if (customNetWrap) customNetWrap.style.display = 'block';
} else if (customNetWrap) {
customNetWrap.style.display =
netMode === 'host' || netMode === 'none' ? 'none' : customNetWrap.style.display;
}
} else {
networkModeEl.value = netMode;
// Docker often sets NetworkMode to the user network name
networkModeEl.value = 'bridge';
if (customNetEl) customNetEl.value = netMode;
if (customNetWrap) customNetWrap.style.display = 'block';
}
}
@@ -3325,18 +3371,31 @@ function populateDuplicateForm(config) {
const domainnameEl = document.getElementById('duplicate-domainname');
if (domainnameEl) domainnameEl.value = config.Config?.Domainname || '';
// Port bindings
if (config.HostConfig?.PortBindings) {
Object.entries(config.HostConfig.PortBindings).forEach(([containerPort, bindings]) => {
if (bindings && bindings.length > 0) {
const binding = bindings[0];
const protocol = containerPort.split('/')[1] || 'tcp';
const portNum = containerPort.split('/')[0];
const portStr = binding.HostPort ? `${binding.HostPort}:${portNum}/${protocol}` : `${portNum}/${protocol}`;
addDuplicatePortMapping(portStr);
}
});
}
// Port bindings (HostConfig + live NetworkSettings.Ports fallback)
const portBindings = config.HostConfig?.PortBindings || {};
const livePorts = config.NetworkSettings?.Ports || {};
const portKeys = new Set([
...Object.keys(portBindings),
...Object.keys(livePorts),
]);
portKeys.forEach((containerPort) => {
const bindings =
(portBindings[containerPort] && portBindings[containerPort].length
? portBindings[containerPort]
: null) ||
(livePorts[containerPort] && livePorts[containerPort].length
? 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);
});
// DNS
if (config.HostConfig?.Dns && Array.isArray(config.HostConfig.Dns)) {
@@ -3358,12 +3417,25 @@ function populateDuplicateForm(config) {
});
}
// Volumes
// Volumes — Binds preferred; fall back to Mounts (named volumes / binds)
const volumeSpecs = [];
if (config.HostConfig?.Binds && Array.isArray(config.HostConfig.Binds)) {
config.HostConfig.Binds.forEach(bind => {
addDuplicateVolumeMount(bind);
});
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}`);
}
}
}
volumeSpecs.forEach((bind) => addDuplicateVolumeMount(bind));
// Tmpfs
if (config.HostConfig?.Tmpfs && typeof config.HostConfig.Tmpfs === 'object') {
@@ -3596,6 +3668,10 @@ export {
openDeployModal,
collectDuplicateFormData,
populateDuplicateForm,
suggestDuplicateName,
initTemplateDeployer,
filterTemplatesByQuery,
};
window.collectDuplicateFormData = collectDuplicateFormData;
window.populateDuplicateForm = populateDuplicateForm;