Add GPU/macvlan passthrough, container edit/update, image prune/tag

Create Container form:
- GPU passthrough dropdown (AMD/Intel via /dev/dri detection, NVIDIA
  excluded since it needs a different runtime) - device paths strictly
  validated server-side against the host's own detected list.
- Macvlan network support: selecting a macvlan network reveals a static
  IP field and hides port mappings (meaningless once the container has
  its own LAN address), matching Unraid Docker Manager's "Custom: br0"
  behavior. Networks panel gained a matching macvlan network-creation
  flow, with the parent-interface dropdown read from Unraid's own
  network.cfg so it lists exactly what Docker Manager itself offers.

Containers panel:
- Edit: reopens the create form pre-filled from the container's current
  config (image/ports/volumes/env/network/restart policy/GPU/static IP);
  saving stops+removes the old container and recreates it under the same
  settings, since podman/Docker have no in-place "modify" API for most of
  this.
- Update: same stop/remove/recreate flow, but pulls the current image
  first. "Check for Updates" compares each in-use image's local digest
  against its origin registry (Docker Hub/GHCR/self-hosted registries all
  verified live) with no podman-side feature backing it - implemented via
  the registry's own HTTP API. A small log-modal shows progress for both
  actions instead of a silent wait.
- Fixed a real bug hit live: PodmanClient's flat 15s HTTP timeout aborted
  real image pulls/container creates mid-request; bumped to 600s (nginx
  already allows up to 640s for this plugin's requests).

Images panel:
- "Prune unused" (removes every image with zero containers referencing
  it, not just dangling ones - confirmation copy says so explicitly since
  this is more aggressive than it sounds) and per-image "Tag".

Also several real UI bugs found via live screenshots: unused-image prune
having no visible effect until reloaded, table action-button columns
drifting row to row (a bare "display:flex" on a <td> was fighting the
table layout algorithm), Templates category badges dumping raw multi-tag
strings from real Unraid templates, and low-contrast search/filter
controls that were nearly invisible against the card background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 18:34:57 +00:00
co-authored by Claude Sonnet 5
parent 8ac9cde621
commit ca62577a8b
13 changed files with 1158 additions and 70 deletions
+383 -29
View File
@@ -10,21 +10,36 @@
let allContainers = [];
let filter = 'all';
let searchTerm = '';
// Keyed by image reference (not container id) — several containers
// commonly share the same image, and ajax/containers.php's
// check_updates action itself already dedupes registry requests the
// same way. Persists across load()/renderTable() refreshes so the
// badge doesn't disappear on the next auto-refresh; only re-running
// "Check for Updates" replaces it.
let imageUpdateStatus = {};
function iconLabel(name) {
return P.escapeHtml(name.slice(0, 2).toUpperCase());
}
function hasUpdate(c) {
const status = imageUpdateStatus[c.image];
return !!(status && status.updateAvailable);
}
function rowHtml(c) {
const cpuMem = c.state === 'running'
? '<span class="podman-row-sub">running</span>'
const cpuMem = c.state === 'running' && c.cpuPercent != null
? '<span class="tnum">' + c.cpuPercent.toFixed(1) + '%</span> <span class="podman-row-sub">/ ' + P.formatBytes(c.memUsageBytes) + '</span>'
: '<span class="podman-row-sub">&mdash;</span>';
const updateBadge = hasUpdate(c)
? ' <span class="podman-badge-update" title="A newer image is available">&#8593; Update</span>'
: '';
return '' +
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
'<td><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button></td>' +
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button>' + updateBadge + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
'<td>' + cpuMem + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' +
@@ -34,18 +49,21 @@
}
function actionButtons(c) {
const updateBtn = hasUpdate(c)
? '<button class="podman-btn podman-btn-icon" data-action="update" title="Update to the newer image">&#8593;</button>'
: '';
if (c.state === 'running') {
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">&#8635;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">&#9632;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
if (c.state === 'paused') {
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
@@ -57,6 +75,7 @@
items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
}
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } });
items.push('separator');
items.push({
label: 'Remove',
@@ -78,6 +97,218 @@
});
}
// --- Edit (recreate) --------------------------------------------------------
//
// Podman/Docker have no "modify a running container" API for most of
// this (image, ports, volumes, env, ...) — the only real way to "edit"
// is to stop the old one, remove it (this does NOT touch named volumes,
// only the container itself), and create a new one under the same name
// with the changed settings. Same pattern Unraid's own Docker Manager
// and every other Docker/Podman WebUI uses. Reuses the existing
// "inspect" action (already fetched for the detail modal) rather than
// adding a new endpoint — envToPrefill()/etc. below just reshape that
// same raw libpod inspect JSON into openCreateContainerModal's prefill
// shape.
// Auto-injected by the container runtime itself, not something a user
// set through this form — dropped so the edit form isn't full of noise
// that didn't come from the original Create Container submission.
const AUTO_ENV_KEYS = ['PATH', 'HOSTNAME', 'HOME', 'container', 'TERM'];
function inspectToPrefill(c, d) {
const cfg = d.Config || {};
const hostCfg = d.HostConfig || {};
const ports = [];
Object.keys((hostCfg.PortBindings) || {}).forEach(function (key) {
const [containerPort, protocol] = key.split('/');
((hostCfg.PortBindings[key]) || []).forEach(function (binding) {
ports.push({ hostPort: binding.HostPort, containerPort: containerPort, protocol: protocol || 'tcp' });
});
});
const volumes = (d.Mounts || []).reduce(function (list, m) {
if (m.Type === 'bind') {
list.push({ kind: 'path', source: m.Source, containerPath: m.Destination });
} else if (m.Type === 'volume') {
list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination });
}
return list;
}, []);
const env = (cfg.Env || []).reduce(function (list, line) {
const idx = line.indexOf('=');
const key = idx === -1 ? line : line.slice(0, idx);
if (AUTO_ENV_KEYS.indexOf(key) === -1) {
list.push({ key: key, value: idx === -1 ? '' : line.slice(idx + 1) });
}
return list;
}, []);
// Only the /dev/dri paths our own GPU passthrough checkbox could have
// added — same host-path pattern ajax/containers.php's build_container_
// spec() validates against, so a container with some unrelated device
// mapping (added outside this UI) doesn't get misread as a GPU pick.
const gpuDevices = (hostCfg.Devices || [])
.map(function (dev) { return dev.PathOnHost; })
.filter(function (path) { return /^\/dev\/dri\/(card|renderD)\d+$/.test(path); });
// Only meaningful on a macvlan network (see updateNetworkFieldsVisibility()
// in openCreateContainerModal) — the container's actual address on
// that network, so editing one doesn't blank out an IP it was
// deliberately given.
const netName = hostCfg.NetworkMode;
const netInfo = d.NetworkSettings && d.NetworkSettings.Networks && d.NetworkSettings.Networks[netName];
const staticIp = netInfo && netInfo.IPAddress ? netInfo.IPAddress : '';
return {
name: (d.Name || c.name || '').replace(/^\//, ''),
image: cfg.Image || c.image,
networkMode: hostCfg.NetworkMode || 'bridge',
staticIp: staticIp,
pod: c.podName || '',
privileged: !!hostCfg.Privileged,
restartPolicy: (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || 'no',
ports: ports,
volumes: volumes,
env: env,
gpuDevices: gpuDevices,
};
}
function openEditContainerModal(c) {
P.get('containers', 'inspect', { id: c.id }).then(function (d) {
openCreateContainerModal(inspectToPrefill(c, d), { id: c.id });
}).catch(function (err) {
alert('Could not load container config: ' + err.message);
});
}
// --- Update (pull + recreate, unchanged settings) ---------------------------
//
// "Update" is the same stop/remove/recreate as Edit — see that comment
// above — except nothing in the config changes and an image pull happens
// first. Reuses inspectToPrefill() so both features read a container's
// current settings the exact same way.
//
// Both this and checkForUpdates()/updateAll() below take a `log`
// callback and write one line per step to it — a plain confirm()/alert()
// at the very end left no visible sign anything was happening while a
// check or a several-container update ran (found live: clicking "Check
// for Updates" against two already-current images looked completely
// inert). See app.js's openLogModal() for the small scrolling log window
// these lines end up in.
function updateContainer(c, log) {
return P.get('containers', 'inspect', { id: c.id }).then(function (d) {
const prefill = inspectToPrefill(c, d);
log('Pulling ' + prefill.image + '…');
return P.post('images', 'pull', { reference: prefill.image })
.then(function () {
log('Stopping ' + c.name + '…');
return P.post('containers', 'stop', { id: c.id }).catch(function () { /* already stopped is fine */ });
})
.then(function () {
log('Removing old container…');
return P.post('containers', 'remove', { id: c.id, force: true });
})
.then(function () {
log('Creating new container…');
return P.post('containers', 'create', {
image: prefill.image,
name: prefill.name,
networkMode: prefill.networkMode,
staticIp: prefill.staticIp,
pod: prefill.pod,
ports: prefill.ports,
volumes: prefill.volumes,
env: prefill.env,
restartPolicy: prefill.restartPolicy,
gpuDevices: prefill.gpuDevices,
privileged: prefill.privileged,
startAfterCreate: true,
});
}).then(function () {
// The image just pulled is now current — clear the stale flag
// for it specifically rather than wiping every row's status,
// since other images may still be genuinely outdated.
delete imageUpdateStatus[prefill.image];
log('Done: ' + c.name + ' is up to date.');
});
});
}
function checkForUpdates() {
const modal = P.openLogModal('Check for Updates');
modal.log('Checking every image currently in use…');
return P.get('containers', 'check_updates').then(function (results) {
imageUpdateStatus = results;
let updatable = 0;
Object.keys(results).forEach(function (ref) {
const r = results[ref];
if (r.error) {
modal.log('! ' + ref + ' — ' + r.error);
} else if (r.updateAvailable) {
updatable++;
modal.log('↑ ' + ref + ' — update available');
} else {
modal.log('✓ ' + ref + ' — up to date');
}
});
modal.log('');
modal.log(updatable ? updatable + ' image(s) have an update available.' : 'Everything is up to date.');
modal.done();
renderTable();
}).catch(function (err) {
modal.log('Check failed: ' + err.message);
modal.done();
});
}
function updateAll() {
const btn = P.el('containers-update-all-btn');
btn.disabled = true;
const modal = P.openLogModal('Update All');
modal.log('Checking every image currently in use…');
P.get('containers', 'check_updates').then(function (results) {
imageUpdateStatus = results;
renderTable();
const targets = allContainers.filter(hasUpdate);
if (!targets.length) {
modal.log('Everything is already up to date.');
modal.done();
btn.disabled = false;
return;
}
modal.log(targets.length + ' container(s) to update: ' + targets.map(function (c) { return c.name; }).join(', '));
modal.log('');
// Sequential, not parallel — several containers stopping/recreating
// at once is harder to reason about if one of them fails partway,
// and avoids hammering the same registry with simultaneous pulls.
const failures = [];
targets.reduce(function (chain, c) {
return chain.then(function () {
return updateContainer(c, modal.log).catch(function (err) {
modal.log('Failed: ' + c.name + ' — ' + err.message);
failures.push(c.name);
});
});
}, Promise.resolve()).then(function () {
modal.log('');
modal.log(failures.length
? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.'
: 'All ' + targets.length + ' updated.');
modal.done();
btn.disabled = false;
return load();
});
}).catch(function (err) {
modal.log('Check failed: ' + err.message);
modal.done();
btn.disabled = false;
});
}
// --- Detail view -----------------------------------------------------------
//
// Fed entirely by the existing inspect action (raw libpod inspect JSON) —
@@ -305,17 +536,24 @@
/**
* @param {object|null} prefill Optional template data (same shape
* ajax/templates.php's "get" action returns) to seed the form with —
* used by templates.js's "Use template" action. null/omitted opens a
* blank form, same as the toolbar's "+ New Container" button.
* ajax/templates.php's "get" action returns, plus "name"/"pod" which
* only inspectToPrefill() sets) to seed the form with — used by
* templates.js's "Use template" action and openEditContainerModal()
* below. null/omitted opens a blank form, same as the toolbar's
* "+ New Container" button.
* @param {{id:string}|null} editing When set, this is an edit of an
* existing container rather than a fresh create: submitting stops and
* removes container `editing.id` first, then creates a new one under
* whatever name/settings are in the form (see the Podman/Docker have
* no in-place "modify" API comment on openEditContainerModal above).
*/
function openCreateContainerModal(prefill) {
function openCreateContainerModal(prefill, editing) {
prefill = prefill || {};
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Container</h3></div>' +
'<div class="podman-modal-head"><h3>' + (editing ? 'Edit Container' : 'New Container') + '</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Image</label>' +
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' +
@@ -325,12 +563,16 @@
'<div class="podman-modal-field"><label>Network</label>' +
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
'<option value="host">Host</option><option value="none">None</option></select></div>' +
'<div class="podman-modal-field" id="cc-static-ip-field" style="display:none;"><label>Static IP (optional)</label>' +
'<input type="text" class="mono" id="cc-static-ip" placeholder="10.1.1.222">' +
'<div class="hint">Leave blank to let the network assign one automatically.</div></div>' +
'<div class="podman-modal-field"><label>Pod (optional)</label>' +
'<select id="cc-pod"><option value="">None</option></select>' +
'<div class="hint">Joins the pod\'s shared network namespace instead of the setting above.</div></div>' +
'<div class="podman-modal-field"><label>Port mappings</label>' +
'<div class="podman-modal-field" id="cc-ports-field"><label>Port mappings</label>' +
'<div class="podman-row-group" id="cc-ports"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
'<div class="hint" id="cc-ports-macvlan-hint" style="display:none;">Not needed on a macvlan network — the container gets its own address on the LAN.</div></div>' +
'<div class="podman-modal-field"><label>Volumes</label>' +
'<div class="podman-row-group" id="cc-volumes"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="volume">+ Add volume</button></div>' +
@@ -340,6 +582,8 @@
'<div class="podman-modal-field"><label>Restart policy</label>' +
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
'<div class="podman-modal-field" id="cc-gpu-field" style="display:none;"><label>GPU passthrough</label>' +
'<select id="cc-gpu-select"><option value="">None</option></select></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
@@ -355,14 +599,35 @@
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' + (editing ? 'Save &amp; Recreate' : 'Create') + '</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name;
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
if (prefill.staticIp) backdrop.querySelector('#cc-static-ip').value = prefill.staticIp;
// Macvlan containers get their own address directly on the LAN (see
// the ajax/networks.php macvlan work) — port mappings are meaningless
// for them (there's no host-side NAT to map through) and a static IP
// becomes a relevant option instead of a Bridge/Host/None-only
// concept. Toggled on network-select change and once up front below,
// driven by each <option>'s data-driver (set when the real network
// list loads — the three built-ins are never macvlan).
function updateNetworkFieldsVisibility() {
const select = backdrop.querySelector('#cc-network');
const selectedOption = select.options[select.selectedIndex];
const isMacvlan = !!(selectedOption && selectedOption.dataset.driver === 'macvlan');
backdrop.querySelector('#cc-static-ip-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cc-ports').style.display = isMacvlan ? 'none' : '';
backdrop.querySelector('[data-add="port"]').style.display = isMacvlan ? 'none' : '';
backdrop.querySelector('#cc-ports-macvlan-hint').style.display = isMacvlan ? '' : 'none';
}
backdrop.querySelector('#cc-network').addEventListener('change', updateNetworkFieldsVisibility);
const portsGroup = backdrop.querySelector('#cc-ports');
const volumesGroup = backdrop.querySelector('#cc-volumes');
@@ -386,9 +651,18 @@
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
const opt = document.createElement('option');
opt.value = n.name;
opt.textContent = n.name;
opt.textContent = n.name + (n.driver === 'macvlan' ? ' (macvlan)' : '');
opt.dataset.driver = n.driver;
select.appendChild(opt);
});
// Re-applied here (not just at load time above) because a custom
// network's <option> doesn't exist yet until this list comes back —
// setting .value to it any earlier would silently no-op and leave
// the select on its default "bridge" option instead. Matters for
// openEditContainerModal(): a container already on a custom network
// needs that option to exist before it can be selected.
if (prefill.networkMode) select.value = prefill.networkMode;
updateNetworkFieldsVisibility();
}).catch(function () { /* built-in modes still usable */ });
P.get('pods', 'list').then(function (pods) {
@@ -399,8 +673,37 @@
opt.textContent = p.name;
select.appendChild(opt);
});
if (prefill.pod) select.value = prefill.pod;
}).catch(function () { /* pod selection stays optional */ });
// Only shown when the host actually has a passthrough-capable GPU
// (AMD/Intel via /dev/dri — see ajax/containers.php's gpu_list(); NVIDIA
// is deliberately excluded there since it needs a different runtime) —
// best-effort, same as networks/pods above.
P.get('containers', 'list_gpus').then(function (gpus) {
if (!gpus.length) return;
const field = backdrop.querySelector('#cc-gpu-field');
const select = backdrop.querySelector('#cc-gpu-select');
field.style.display = '';
gpus.forEach(function (gpu, i) {
const devices = [gpu.render, gpu.card].filter(Boolean).join(', ');
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = gpu.vendor + ' GPU (' + devices + ')';
select.appendChild(opt);
});
select.dataset.gpus = JSON.stringify(gpus);
// Pre-select whichever detected GPU the container being edited is
// already using (matched by device path, not index — gpu_list()'s
// order isn't guaranteed stable across requests).
if (prefill.gpuDevices && prefill.gpuDevices.length) {
const matchIndex = gpus.findIndex(function (gpu) {
return prefill.gpuDevices.indexOf(gpu.render) !== -1 || prefill.gpuDevices.indexOf(gpu.card) !== -1;
});
if (matchIndex !== -1) select.value = String(matchIndex);
}
}).catch(function () { /* GPU passthrough stays unavailable */ });
backdrop.querySelector('#cc-image').focus();
backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) {
@@ -420,6 +723,13 @@
}
function submit() {
if (editing && !confirm(
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?'
)) {
return;
}
const image = backdrop.querySelector('#cc-image').value.trim();
if (!image) {
showError('"Image" is required.');
@@ -433,7 +743,17 @@
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
return;
}
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const networkSelect = backdrop.querySelector('#cc-network');
const selectedNetworkOption = networkSelect.options[networkSelect.selectedIndex];
const isMacvlan = !!(selectedNetworkOption && selectedNetworkOption.dataset.driver === 'macvlan');
// Port mappings map a host port to a container port through NAT —
// meaningless on a macvlan network, where the container already has
// its own real address on the LAN (see updateNetworkFieldsVisibility()
// above, which also hides the UI for this) — so none are sent even
// if some were left over from switching the network dropdown after
// adding a few.
const ports = isMacvlan ? [] : readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const staticIp = isMacvlan ? backdrop.querySelector('#cc-static-ip').value.trim() : '';
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
const env = readRows(envGroup).filter(function (r) { return r.key; });
@@ -446,20 +766,39 @@
const networkMode = backdrop.querySelector('#cc-network').value;
const privileged = backdrop.querySelector('#cc-privileged').checked;
const gpuSelect = backdrop.querySelector('#cc-gpu-select');
const gpus = gpuSelect.dataset.gpus ? JSON.parse(gpuSelect.dataset.gpus) : [];
const selectedGpu = gpuSelect.value !== '' ? gpus[Number(gpuSelect.value)] : null;
const gpuDevices = selectedGpu ? [selectedGpu.render, selectedGpu.card].filter(Boolean) : [];
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('containers', 'create', {
image: image,
name: backdrop.querySelector('#cc-name').value.trim(),
networkMode: networkMode,
pod: backdrop.querySelector('#cc-pod').value,
ports: ports,
volumes: volumes,
env: env,
restartPolicy: backdrop.querySelector('#cc-restart').value,
privileged: privileged,
startAfterCreate: backdrop.querySelector('#cc-start').checked,
// Editing an existing container: no in-place "modify" API exists
// (see the comment on openEditContainerModal above), so this stops
// and removes the old one first — best-effort stop (it may already
// be stopped) followed by a forced remove — before creating the
// replacement under whatever name is in the form now.
const removeOld = editing
? P.post('containers', 'stop', { id: editing.id }).catch(function () { /* already stopped is fine */ })
.then(function () { return P.post('containers', 'remove', { id: editing.id, force: true }); })
: Promise.resolve();
removeOld.then(function () {
return P.post('containers', 'create', {
image: image,
name: backdrop.querySelector('#cc-name').value.trim(),
networkMode: networkMode,
staticIp: staticIp,
pod: backdrop.querySelector('#cc-pod').value,
ports: ports,
volumes: volumes,
env: env,
restartPolicy: backdrop.querySelector('#cc-restart').value,
gpuDevices: gpuDevices,
privileged: privileged,
startAfterCreate: backdrop.querySelector('#cc-start').checked,
});
}).then(function () {
// Best-effort: a template-save failure shouldn't undo or block
// the container that was just successfully created.
@@ -483,7 +822,7 @@
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
showError((editing ? 'The old container may already be removed. ' : '') + err.message);
});
}
@@ -537,11 +876,23 @@
if (!btn || btn.disabled) return;
const row = btn.closest('tr');
const id = row.dataset.id;
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details') {
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') {
const c = allContainers.find(function (x) { return x.id === id; });
if (!c) return;
if (btn.dataset.action === 'menu') {
openRowMenu(c, btn);
} else if (btn.dataset.action === 'update') {
if (!confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.')) return;
btn.disabled = true;
const modal = P.openLogModal('Updating ' + c.name);
updateContainer(c, modal.log).then(function () {
modal.done();
return load();
}).catch(function (err) {
modal.log('Failed: ' + err.message);
modal.done();
btn.disabled = false;
});
} else {
openDetailModal(c);
}
@@ -550,6 +901,9 @@
handleAction(id, btn.dataset.action, btn);
});
P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates);
P.el('containers-update-all-btn').addEventListener('click', updateAll);
return load();
}