Fix container duplication end-to-end (inspect, form, deploy).
Release rolling / release (push) Has been cancelled
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:
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user