Add Portainer-style container name replace on deploy.
Release rolling / release (push) Has been cancelled

When deploying a container whose name already exists, prompt to Replace
(stop and remove the old container) or Cancel. Server accepts replace:true
to perform the swap; clients pre-check names and retry on conflict races.
This commit is contained in:
Raven Scott
2026-07-13 18:16:54 -04:00
parent b2e04caabc
commit 245d532efd
5 changed files with 244 additions and 29 deletions
+145 -14
View File
@@ -209,6 +209,9 @@ function setupFormSubmitListener() {
}, 800);
}
} catch (error) {
if (error?.code === 'DEPLOY_CANCELLED') {
return;
}
const errorMessage = (error && error.message)
? String(error.message)
: 'Failed to deploy container. Check console for details.';
@@ -2444,6 +2447,87 @@ function validateFormData(data) {
return errors;
}
/**
* Portainer-style replace prompt when a container name is already taken.
* @param {object} payload
* @returns {Promise<object>} payload (possibly with replace: true)
*/
async function confirmReplaceExistingContainer(payload) {
if (!payload?.containerName || payload.replace === true) return payload
const name = String(payload.containerName)
let existing = null
try {
const { manager, Methods } = await import('../client/manager.js')
if (manager.active?.connected) {
const res = await manager.request(Methods.listContainers, { all: true })
const list = res?.data || res?.containers || []
existing = list.find((c) =>
(c.Names || []).some((n) => String(n || '').replace(/^\//, '') === name)
)
}
} catch {
// fall through — server will still enforce on create
}
// Local cache fallback (avoids extra RPC failure blocking deploy)
if (!existing && typeof window !== 'undefined') {
try {
const cached =
window.containerFilterState?.allContainers ||
(typeof containerFilterState !== 'undefined' ? containerFilterState.allContainers : null)
if (Array.isArray(cached)) {
existing = cached.find((c) =>
(c.Names || []).some((n) => String(n || '').replace(/^\//, '') === name)
)
}
} catch {
// ignore
}
}
if (!existing) return payload
const state = existing.State || 'unknown'
const image = existing.Image || 'unknown image'
const shortId = String(existing.Id || '').slice(0, 12)
const body =
`A container named "${name}" already exists` +
(shortId ? ` (${shortId})` : '') +
`.\n\nState: ${state}\nImage: ${image}\n\n` +
`Replacing will stop and remove it, then create a new container with your settings. ` +
`Anonymous volumes may be lost. Named volumes and bind mounts are kept.`
let ok = false
if (typeof window.peardockOps?.askUserConfirm === 'function') {
ok = await window.peardockOps.askUserConfirm('Replace existing container?', body, {
confirmLabel: 'Replace',
cancelLabel: 'Cancel',
icon: 'fa-recycle',
danger: true,
})
} else if (typeof window.peardockOps?.confirmDestructive === 'function') {
ok = await window.peardockOps.confirmDestructive(
'Replace existing container?',
`Container "${name}" already exists (${state}). Stop and remove it, then deploy?`
)
} else {
ok = window.confirm(
`Container "${name}" already exists (${state}). Replace it? This stops and removes the old container.`
)
}
if (!ok) {
const err = new Error('Deploy cancelled')
err.code = 'DEPLOY_CANCELLED'
throw err
}
return { ...payload, replace: true }
}
// Deploy Docker container via typed RPC (reliable request/response)
async function deployDockerContainer(payload) {
console.log('[INFO] Sending deployment command to the server...');
@@ -2456,6 +2540,9 @@ async function deployDockerContainer(payload) {
);
}
// Offer replace when name collides (Portainer-style)
payload = await confirmReplaceExistingContainer(payload)
// Prefer job-stepper (live tray log). Do not fall back after a real deploy failure
// or we risk double-create / confusing errors.
if (typeof window.peardockOps?.deployContainerWithSteps === 'function') {
@@ -2468,8 +2555,31 @@ async function deployDockerContainer(payload) {
job?.result?.message ||
`Container "${payload.containerName}" deployed successfully from image "${payload.image}"`,
id: job?.result?.id,
replaced: Boolean(payload.replace),
}
} catch (err) {
// Server-side conflict after race: offer replace once more
if (
err?.code === 'CONTAINER_NAME_CONFLICT' ||
/already exists|name.*already|already in use/i.test(String(err?.message || ''))
) {
const retried = await confirmReplaceExistingContainer({
...payload,
replace: false,
})
if (retried.replace) {
const job = await window.peardockOps.deployContainerWithSteps(retried)
return {
success: true,
viaJob: true,
message:
job?.result?.message ||
`Container "${payload.containerName}" replaced successfully`,
id: job?.result?.id,
replaced: true,
}
}
}
// Preserve viaJob so callers skip redundant top toasts
if (err && typeof err === 'object') err.viaJob = true
throw err
@@ -2484,20 +2594,40 @@ async function deployDockerContainer(payload) {
}
const timeoutMs = 120000
const res = await Promise.race([
manager.request(Methods.deployContainer, payload),
new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error(
'Deployment timed out after 120s — How to fix: check network to the peer, Docker pull speed, and server logs.'
)
),
timeoutMs
)
),
])
const runDeploy = (body) =>
Promise.race([
manager.request(Methods.deployContainer, body),
new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error(
'Deployment timed out after 120s — How to fix: check network to the peer, Docker pull speed, and server logs.'
)
),
timeoutMs
)
),
])
let res
try {
res = await runDeploy(payload)
} catch (err) {
if (
err?.code === 'CONTAINER_NAME_CONFLICT' ||
/already exists|name.*already|already in use/i.test(String(err?.message || ''))
) {
const retried = await confirmReplaceExistingContainer({
...payload,
replace: false,
})
if (retried.replace) res = await runDeploy(retried)
else throw err
} else {
throw err
}
}
if (!res) throw new Error('Empty response from server')
if (res.success === false) throw new Error(res.message || res.error || 'Deployment failed')
return {
@@ -2508,6 +2638,7 @@ async function deployDockerContainer(payload) {
`Container "${payload.containerName}" deployed successfully from image "${payload.image}"`,
id: res.id,
data: res,
replaced: Boolean(payload.replace || res.replaced),
}
}