Add Portainer-style container name replace on deploy.
Release rolling / release (push) Has been cancelled
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:
+145
-14
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user