' +
'| ' + P.escapeHtml(c.health || c.state) + ' | ' +
' | ' +
+ '' + iconLabel(c.name) + '' + P.escapeHtml(c.name) + '' + updateBadge + '' +
'' + P.escapeHtml(c.image) + ' | ' +
'' + cpuMem + ' | ' +
'' + P.escapeHtml(c.ports.join(', ') || '—') + ' | ' +
@@ -34,18 +49,21 @@
}
function actionButtons(c) {
+ const updateBtn = hasUpdate(c)
+ ? ''
+ : '';
if (c.state === 'running') {
- return '' +
+ return updateBtn +
'' +
'' +
'';
}
if (c.state === 'paused') {
- return '' +
+ return updateBtn +
'' +
'';
}
- return '' +
+ return updateBtn +
'' +
'';
}
@@ -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 = '' +
'' +
- '
New Container
' +
+ '
' + (editing ? 'Edit Container' : 'New Container') + '
' +
'
';
}
@@ -55,16 +57,66 @@
});
});
+ P.el('images-prune-btn').addEventListener('click', function () {
+ // Computed client-side from the list already on screen — no extra
+ // round trip needed, and it lets the confirm() be specific instead
+ // of a generic warning. "Unused" here matches libpod's own
+ // definition (zero containers, running or stopped, referencing the
+ // image) — the same "Used By" count already shown in the table, not
+ // just dangling/untagged images. Found live that this can be far
+ // more aggressive than expected: with no containers at all, it
+ // removes every image on the host.
+ const unused = images.filter(function (img) { return img.usedBy === 0; });
+ if (!unused.length) {
+ alert('No unused images to remove — every image is referenced by at least one container.');
+ return;
+ }
+ const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
+ if (!confirm(
+ 'Remove ' + unused.length + ' image(s) not used by any container (' + P.formatBytes(totalBytes) + ')?\n\n' +
+ 'This removes any tagged image with zero containers using it, not just dangling ones.'
+ )) return;
+
+ const btn = this;
+ btn.disabled = true;
+ P.post('images', 'prune').then(function (result) {
+ btn.disabled = false;
+ alert('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.');
+ return load();
+ }).catch(function (err) {
+ btn.disabled = false;
+ alert('Prune failed: ' + err.message);
+ });
+ });
+
P.el('images-tbody').addEventListener('click', function (e) {
- const btn = e.target.closest('button[data-action="remove"]');
+ const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const id = btn.closest('tr').dataset.id;
- if (!confirm('Remove this image?')) return;
- btn.disabled = true;
- P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
- alert('Remove failed: ' + err.message);
- btn.disabled = false;
- });
+
+ if (btn.dataset.action === 'tag') {
+ P.openFormModal({
+ title: 'Add Tag',
+ submitLabel: 'Add tag',
+ fields: [
+ { name: 'repo', label: 'Repository', required: true, placeholder: 'my-registry.local/my-image' },
+ { name: 'tag', label: 'Tag', placeholder: 'latest' },
+ ],
+ onSubmit: function (values) {
+ return P.post('images', 'tag', { id: id, repo: values.repo, tag: values.tag || 'latest' }).then(load);
+ },
+ });
+ return;
+ }
+
+ if (btn.dataset.action === 'remove') {
+ if (!confirm('Remove this image?')) return;
+ btn.disabled = true;
+ P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
+ alert('Remove failed: ' + err.message);
+ btn.disabled = false;
+ });
+ }
});
return load();
diff --git a/webui/plugins/podman/javascript/networks.js b/webui/plugins/podman/javascript/networks.js
index 39b0975..1ca8335 100644
--- a/webui/plugins/podman/javascript/networks.js
+++ b/webui/plugins/podman/javascript/networks.js
@@ -43,21 +43,120 @@
});
}
- function init() {
- P.el('networks-create-btn').addEventListener('click', function () {
- P.openFormModal({
- title: 'New Network',
- submitLabel: 'Create',
- fields: [
- { name: 'name', label: 'Network name', required: true, placeholder: 'my-network' },
- { name: 'subnet', label: 'Subnet (optional)', placeholder: '10.89.2.0/24' },
- ],
- onSubmit: function (values) {
- return P.post('networks', 'create', { name: values.name, driver: 'bridge', subnet: values.subnet || undefined }).then(load);
- },
- });
+ // Purpose-built modal (not app.js's generic openFormModal, which only
+ // supports flat always-visible text fields) — the parent-interface
+ // dropdown and gateway field only make sense for "macvlan" and need to
+ // show/hide based on the driver choice.
+ function openCreateNetworkModal() {
+ const backdrop = document.createElement('div');
+ backdrop.className = 'podman-modal-backdrop';
+ backdrop.innerHTML = '' +
+ '' +
'
' + P.escapeHtml(t.name) + '
' +
'
' + P.escapeHtml(t.image) + '
' +
- (t.category ? '
' + P.escapeHtml(t.category) + '' : '') +
(overview ? '
' + P.escapeHtml(overview) + '
' : '') +
'
' +
'' +
diff --git a/webui/plugins/podman/styles/podman.css b/webui/plugins/podman/styles/podman.css
index 2d2538f..7e71ae5 100644
--- a/webui/plugins/podman/styles/podman.css
+++ b/webui/plugins/podman/styles/podman.css
@@ -185,7 +185,12 @@
.podman-table-wrap { overflow-x: auto; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
.podman-row-name-btn {
- appearance: none; border: none; background: none; padding: 0; cursor: pointer;
+ /* !important for the same reason as .podman-btn-ghost/-primary — Unraid's
+ own site-wide button theme otherwise still shows its default border
+ at rest (only losing to plain rules on hover), so a name link one
+ click away from every table row still looked like a bordered button
+ forever, not a plain label. */
+ appearance: none; border: none !important; background: none !important; padding: 0; cursor: pointer;
color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left;
}
.podman-row-name-btn:hover { color: var(--accent-strong); }
@@ -207,13 +212,47 @@
.podman-actions { text-align: right; white-space: nowrap; }
.podman-actions-row { display: inline-flex; gap: 4px; justify-content: flex-end; }
+/*
+ * Segmented toggle (Containers' All/Running/Stopped filter, Logs' Follow/
+ * Paused) — previously just an inline-styled wrapper
around plain
+ *