From 8ac9cde62115cafadf7987da0e380d7870f51df7 Mon Sep 17 00:00:00 2001 From: magges Date: Sun, 12 Jul 2026 16:26:49 +0000 Subject: [PATCH] Add Pod lifecycle management, fix nav registration and context-menu bugs Pods panel could previously only list pods - there was no way to create one, start/stop/restart it, or attach a container to it from the UI. Adds a "New Pod" modal (name + port mappings), a per-pod lifecycle menu (start/stop/restart/remove), and an optional "Pod" field on the Create Container modal to join an existing pod's network namespace. Backend verified live against the real podman socket (/pods/create, /pods/{name}/ restart, container "pod" field). Also fixes three real bugs found via live testing: - Podman.page used Menu="Podman" instead of Menu="Tasks:", so the plugin never actually appeared in Unraid's top navigation (traced through PageBuilder.php/DefaultPageLayout.php/Navigation/Main.php - only pages registered under "Tasks" become top-level tabs). - app.js's shared context-menu component mis-mapped every item positioned after a 'separator' entry to the wrong DOM element (an off-by-one against menu.children, which includes the separator
s) - so "Remove", which always sits after a separator, silently did nothing when clicked. Fixed by indexing into querySelectorAll('button') instead. - That same menu was positioned via "position: absolute" math that assumed a viewport-relative containing block, but Unraid's own page wrapper (webGui/styles/default-base.css's ".content") sets position:relative, so the menu rendered far from its anchor button. Switched to "position: fixed" with viewport-relative coordinates. Incidentally, pods add a hidden "infra" container that was leaking into the plain Containers list with no working lifecycle of its own (always "running", so its own Remove was permanently disabled) - now filtered out via libpod's IsInfra flag. And every action-buttons table cell used "display: flex" directly on the , which browsers can size inconsistently row to row - moved onto an inner wrapper div instead, and bumped .podman-btn-icon's touch target size. Co-Authored-By: Claude Sonnet 5 --- webui/plugins/podman/Podman.page | 4 +- webui/plugins/podman/ajax/containers.php | 21 ++- webui/plugins/podman/ajax/pods.php | 63 +++++++ webui/plugins/podman/include/PodmanClient.php | 21 +++ webui/plugins/podman/javascript/app.js | 16 +- webui/plugins/podman/javascript/containers.js | 16 +- webui/plugins/podman/javascript/images.js | 4 +- webui/plugins/podman/javascript/networks.js | 4 +- webui/plugins/podman/javascript/pods.js | 163 +++++++++++++++++- webui/plugins/podman/javascript/settings.js | 4 +- webui/plugins/podman/javascript/volumes.js | 4 +- webui/plugins/podman/styles/podman.css | 29 +++- 12 files changed, 325 insertions(+), 24 deletions(-) diff --git a/webui/plugins/podman/Podman.page b/webui/plugins/podman/Podman.page index 247d5ae..88dad75 100644 --- a/webui/plugins/podman/Podman.page +++ b/webui/plugins/podman/Podman.page @@ -1,4 +1,6 @@ -Menu="Podman" +Menu="Tasks:66" +Type="xmenu" +Tabs="false" Title="Podman" Icon="podman" --- diff --git a/webui/plugins/podman/ajax/containers.php b/webui/plugins/podman/ajax/containers.php index 6312acb..90f0e21 100644 --- a/webui/plugins/podman/ajax/containers.php +++ b/webui/plugins/podman/ajax/containers.php @@ -21,7 +21,7 @@ * create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"", * "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}], * "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}], - * "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", + * "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "", * "privileged": false, "startAfterCreate": true} */ @@ -238,6 +238,14 @@ function build_container_spec(string $image, array $body): array $spec['privileged'] = true; } + $pod = trim((string) ($body['pod'] ?? '')); + if ($pod !== '') { + // "pod" joins an existing pod's shared network namespace — verified + // live that it can be sent alongside "netns" above without + // conflict (podman just defers to the pod's namespace). + $spec['pod'] = $pod; + } + return $spec; } @@ -265,6 +273,17 @@ function containers_list(PodmanClient $client): array $out = []; foreach ($raw as $c) { + // Every pod has a hidden "infra" container managing its shared + // network namespace — not something a user creates or can + // meaningfully stop/remove on its own (found live: it always + // shows "running" with no independent lifecycle, so Containers + // panel gets a permanently un-removable row once any pod exists; + // it already appears as its own row in the Pods panel). See + // ajax/pods.php for actual pod lifecycle management. + if ($c['IsInfra'] ?? false) { + continue; + } + $names = $c['Names'] ?? []; $name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? '')); diff --git a/webui/plugins/podman/ajax/pods.php b/webui/plugins/podman/ajax/pods.php index 1345235..8668cc3 100644 --- a/webui/plugins/podman/ajax/pods.php +++ b/webui/plugins/podman/ajax/pods.php @@ -9,8 +9,10 @@ * * Actions (?action=...): * list GET -> pods with nested container summaries + * create POST {"name": "...", "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}]} * start POST {"name": "..."} * stop POST {"name": "...", "timeout": 10} + * restart POST {"name": "...", "timeout": 10} * remove POST {"name": "...", "force": false} */ @@ -25,6 +27,12 @@ switch ($action) { podman_json_response(pods_list($client)); break; + case 'create': + $body = podman_read_json_body(); + $id = $client->createPod(build_pod_spec($body)); + podman_json_response(['id' => $id, 'status' => 'created']); + break; + case 'start': $body = podman_read_json_body(); $client->startPod(require_name($body)); @@ -37,6 +45,12 @@ switch ($action) { podman_json_response(['status' => 'stopped']); break; + case 'restart': + $body = podman_read_json_body(); + $client->restartPod(require_name($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds)); + podman_json_response(['status' => 'restarted']); + break; + case 'remove': $body = podman_read_json_body(); $client->removePod(require_name($body), (bool) ($body['force'] ?? false)); @@ -47,6 +61,55 @@ switch ($action) { podman_json_error("Unknown action '{$action}'", 400); } +/** + * Builds a libpod pod-create body from the "New Pod" form fields. Verified + * live against a real podman system service — {"name": "...", + * "portmappings": [...]} creates a pod with a shared infra container whose + * port bindings apply to every member container. + * + * @param array $body + * @return array + */ +function build_pod_spec(array $body): array +{ + $name = trim((string) ($body['name'] ?? '')); + if ($name === '') { + podman_json_error('Missing name in request body', 400); + } + // Same character set podman enforces for container names (define.NameRegex + // in libpod applies to pods too) — validated here for the same reason + // ajax/containers.php validates it: a clear message instead of podman's + // raw "names must match ...: invalid argument". + if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) { + podman_json_error( + "Pod name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" . + preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.', + 400 + ); + } + + $spec = ['name' => $name]; + + $ports = []; + foreach (($body['ports'] ?? []) as $row) { + $hostPort = (int) ($row['hostPort'] ?? 0); + $containerPort = (int) ($row['containerPort'] ?? 0); + if ($hostPort > 0 && $containerPort > 0) { + $ports[] = [ + 'host_ip' => '', + 'host_port' => $hostPort, + 'container_port' => $containerPort, + 'protocol' => (string) ($row['protocol'] ?? 'tcp'), + ]; + } + } + if ($ports !== []) { + $spec['portmappings'] = $ports; + } + + return $spec; +} + /** @param array $body */ function require_name(array $body): string { diff --git a/webui/plugins/podman/include/PodmanClient.php b/webui/plugins/podman/include/PodmanClient.php index 1e8f5b0..c9fefb2 100644 --- a/webui/plugins/podman/include/PodmanClient.php +++ b/webui/plugins/podman/include/PodmanClient.php @@ -234,6 +234,22 @@ final class PodmanClient return $this->request('GET', '/pods/' . rawurlencode($name) . '/json'); } + /** + * POST /pods/create — takes a body of {name, portmappings, ...}. + * Verified live against a real podman system service: {"name":"...", + * "portmappings":[{"host_port":...,"container_port":...,"protocol":...}]} + * creates a pod with a shared infra container whose port bindings apply + * to every member container — see ajax/pods.php's build_pod_spec(). + * + * @param array $spec + * @return string the new pod's ID + */ + public function createPod(array $spec): string + { + $result = $this->request('POST', '/pods/create', [], false, $spec); + return (string) ($result['Id'] ?? ''); + } + public function startPod(string $name): void { $this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true); @@ -244,6 +260,11 @@ final class PodmanClient $this->request('POST', '/pods/' . rawurlencode($name) . '/stop', ['t' => (string) $timeoutSeconds], true); } + public function restartPod(string $name, int $timeoutSeconds = 10): void + { + $this->request('POST', '/pods/' . rawurlencode($name) . '/restart', ['t' => (string) $timeoutSeconds], true); + } + public function removePod(string $name, bool $force = false): void { $this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true); diff --git a/webui/plugins/podman/javascript/app.js b/webui/plugins/podman/javascript/app.js index 52cdb94..9d6e050 100644 --- a/webui/plugins/podman/javascript/app.js +++ b/webui/plugins/podman/javascript/app.js @@ -252,15 +252,25 @@ window.Podman = (function () { (item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + ''; }).join(''); + // Viewport-relative (see the "position: fixed" comment on + // .podman-context-menu in podman.css) — no scrollY/scrollX added. const rect = anchorEl.getBoundingClientRect(); - menu.style.top = (rect.bottom + window.scrollY + 4) + 'px'; - menu.style.left = (rect.right + window.scrollX - 180) + 'px'; + menu.style.top = (rect.bottom + 4) + 'px'; + menu.style.left = (rect.right - 180) + 'px'; (document.querySelector('.podman-plugin') || document.body).appendChild(menu); + // menu.children includes the separator
s too, so indexing into it + // directly (by a counter that only advances for real items) drifts by + // one after every separator — e.g. "Remove" (after a separator) ended + // up wired to the separator
instead of its own
' + @@ -388,6 +391,16 @@ }); }).catch(function () { /* built-in modes still usable */ }); + P.get('pods', 'list').then(function (pods) { + const select = backdrop.querySelector('#cc-pod'); + pods.forEach(function (p) { + const opt = document.createElement('option'); + opt.value = p.name; + opt.textContent = p.name; + select.appendChild(opt); + }); + }).catch(function () { /* pod selection stays optional */ }); + backdrop.querySelector('#cc-image').focus(); backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) { @@ -440,6 +453,7 @@ image: image, name: backdrop.querySelector('#cc-name').value.trim(), networkMode: networkMode, + pod: backdrop.querySelector('#cc-pod').value, ports: ports, volumes: volumes, env: env, diff --git a/webui/plugins/podman/javascript/images.js b/webui/plugins/podman/javascript/images.js index 8288c7f..79b5bc5 100644 --- a/webui/plugins/podman/javascript/images.js +++ b/webui/plugins/podman/javascript/images.js @@ -18,8 +18,8 @@ '' + P.escapeHtml(img.sizeFormatted) + '' + '' + created + '' + '' + img.usedBy + '' + - '' + + '
' + ''; } diff --git a/webui/plugins/podman/javascript/networks.js b/webui/plugins/podman/javascript/networks.js index bbd1063..39b0975 100644 --- a/webui/plugins/podman/javascript/networks.js +++ b/webui/plugins/podman/javascript/networks.js @@ -20,8 +20,8 @@ '' + P.escapeHtml(n.subnet || '—') + '' + '' + P.escapeHtml(n.gateway || '—') + '' + '' + n.containers + '' + - '' + + '
' + ''; } diff --git a/webui/plugins/podman/javascript/pods.js b/webui/plugins/podman/javascript/pods.js index 4e07fbe..a633cee 100644 --- a/webui/plugins/podman/javascript/pods.js +++ b/webui/plugins/podman/javascript/pods.js @@ -8,6 +8,107 @@ (function () { 'use strict'; const P = window.Podman; + let allPods = []; + + function portRowHtml() { + return '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
'; + } + + function addRow(groupEl) { + const div = document.createElement('div'); + div.innerHTML = portRowHtml(); + const row = div.firstElementChild; + row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); }); + groupEl.appendChild(row); + } + + function readRows(groupEl) { + return Array.from(groupEl.children).map(function (row) { + const values = {}; + row.querySelectorAll('[data-field]').forEach(function (input) { + values[input.dataset.field] = input.value.trim(); + }); + return values; + }); + } + + function openCreatePodModal() { + const backdrop = document.createElement('div'); + backdrop.className = 'podman-modal-backdrop'; + backdrop.innerHTML = '' + + ''; + + (document.querySelector('.podman-plugin') || document.body).appendChild(backdrop); + + const portsGroup = backdrop.querySelector('#cp-ports'); + addRow(portsGroup); + backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup); }); + backdrop.querySelector('#cp-name').focus(); + + function close() { backdrop.remove(); } + + function showError(message) { + let box = backdrop.querySelector('.podman-modal-error'); + if (!box) { + box = document.createElement('div'); + box.className = 'podman-modal-error'; + backdrop.querySelector('.podman-modal-body').appendChild(box); + } + box.textContent = message; + } + + function submit() { + const name = backdrop.querySelector('#cp-name').value.trim(); + if (!name) { + showError('"Name" is required.'); + return; + } + if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) { + 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 submitBtn = backdrop.querySelector('[data-role="submit"]'); + submitBtn.disabled = true; + P.post('pods', 'create', { name: name, ports: ports }).then(function () { + close(); + return load(); + }).catch(function (err) { + submitBtn.disabled = false; + showError(err.message); + }); + } + + backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close); + backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit); + backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); }); + backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); }); + document.addEventListener('keydown', function onKey(e) { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); } + }); + } function memberRow(m) { return '' + @@ -24,27 +125,77 @@ : 'No member containers'; return '' + - '
' + + '
' + '
' + '' + P.escapeHtml(pod.status) + '' + '' + P.escapeHtml(pod.name) + '' + '' + pod.containersTotal + ' container(s)' + + '' + '
' + '
' + '' + members + '
ContainerImageStatus
' + '
'; } + function render() { + const grid = P.el('pods-grid'); + grid.innerHTML = allPods.length + ? allPods.map(podCard).join('') + : '
No pods yet — create one, or run a container with a "pod" set from the Create Container form.
'; + } + function load() { const container = P.el('podman-panel-pods'); - return P.get('pods', 'list').then(function (pods) { - container.innerHTML = pods.length - ? pods.map(podCard).join('') - : '
No pods yet.
'; + if (!P.el('pods-grid')) { + container.innerHTML = '' + + '
' + + '
' + + 'Group containers sharing network/storage namespaces' + + '' + + '
' + + '
' + + '
'; + P.el('pods-create-btn').addEventListener('click', openCreatePodModal); + P.el('pods-grid').addEventListener('click', handleCardClick); + } + return P.get('pods', 'list').then(function (data) { + allPods = data; + render(); }).catch(function (err) { - container.innerHTML = '
' + P.escapeHtml(err.message) + '
'; + P.el('pods-grid').innerHTML = '
' + P.escapeHtml(err.message) + '
'; }); } + function handleAction(name, action, extra) { + return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) { + alert('Action failed: ' + err.message); + }); + } + + function handleCardClick(e) { + const btn = e.target.closest('button[data-action="menu"]'); + if (!btn) return; + const pod = allPods.find(function (p) { return p.name === btn.closest('.podman-pod-card').dataset.name; }); + if (!pod) return; + + const items = []; + if (pod.status === 'running') { + items.push({ label: 'Stop', onClick: function () { handleAction(pod.name, 'stop', { timeout: 10 }); } }); + items.push({ label: 'Restart', onClick: function () { handleAction(pod.name, 'restart', { timeout: 10 }); } }); + } else { + items.push({ label: 'Start', onClick: function () { handleAction(pod.name, 'start'); } }); + } + items.push('separator'); + items.push({ + label: 'Remove', + danger: true, + onClick: function () { + if (!confirm('Remove pod "' + pod.name + '" and all its member containers?')) return; + handleAction(pod.name, 'remove', { force: true }); + }, + }); + P.openContextMenu(btn, items); + } + P.registerPanel('pods', { init: load, refresh: load }); })(); diff --git a/webui/plugins/podman/javascript/settings.js b/webui/plugins/podman/javascript/settings.js index 83f9dec..20b42fd 100644 --- a/webui/plugins/podman/javascript/settings.js +++ b/webui/plugins/podman/javascript/settings.js @@ -17,11 +17,11 @@ return '' + '' + (i + 1) + '' + '' + P.escapeHtml(name) + '' + - '' + + '
' + '' + '' + '' + - ''; + '
'; }).join('') : 'No containers in the autostart chain.'; } diff --git a/webui/plugins/podman/javascript/volumes.js b/webui/plugins/podman/javascript/volumes.js index 1486124..1064b8e 100644 --- a/webui/plugins/podman/javascript/volumes.js +++ b/webui/plugins/podman/javascript/volumes.js @@ -24,8 +24,8 @@ '' + P.escapeHtml(v.driver) + '' + '' + pathCell + '' + '' + v.usedBy + '' + - '' + + '
' + ''; } diff --git a/webui/plugins/podman/styles/podman.css b/webui/plugins/podman/styles/podman.css index ea38835..2d2538f 100644 --- a/webui/plugins/podman/styles/podman.css +++ b/webui/plugins/podman/styles/podman.css @@ -100,7 +100,7 @@ } .podman-btn-danger { color: var(--bad); } .podman-btn-danger:hover { border-color: var(--bad); } -.podman-btn-icon { padding: 6px 8px; } +.podman-btn-icon { padding: 6px 8px; min-width: 32px; min-height: 32px; justify-content: center; font-size: 15px; line-height: 1; } .podman-btn[disabled] { opacity: .4; cursor: not-allowed; } /* * Secondary action (Cancel, "+ Add row") — every button previously shared @@ -195,7 +195,17 @@ display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim); } .podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; } -.podman-actions { display: flex; gap: 4px; justify-content: flex-end; } +/* + * The actions itself stays a plain table-cell (default display) so + * every row's column width is computed the same way by the table's layout + * algorithm — putting "display: flex" directly on the used to take it + * out of that algorithm, so browsers could size/position it slightly + * differently row to row (found live: the trash-can button in Images drifted + * a few pixels between rows instead of lining up in one column). The actual + * flex/gap/alignment lives on this inner wrapper instead. + */ +.podman-actions { text-align: right; white-space: nowrap; } +.podman-actions-row { display: inline-flex; gap: 4px; justify-content: flex-end; } .podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; } .podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; } @@ -220,7 +230,7 @@ .podman-pod-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 14px; background: var(--surface); box-shadow: var(--shadow); } .podman-pod-head { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); } .podman-pod-head .name { font-weight: 700; font-size: 13.5px; } -.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); } +.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); margin-right: auto; } .podman-badge { display: inline-block; font-size: 10.5px; font-weight: 700; color: var(--text-dim); background: var(--surface-3); padding: 2px 8px; border-radius: 100px; margin-top: 6px; } @@ -368,7 +378,18 @@ /* Anchored dropdown context menu — see app.js openContextMenu(). */ .podman-context-menu { - position: absolute; width: 180px; background: var(--surface); border: 1px solid var(--border); + /* + * "fixed", not "absolute": this menu is appended to .podman-plugin, not + * document.body, and Unraid's own page wrapper around .podman-plugin + * turned out to have its own positioned ancestor — with "absolute" the + * menu was positioning itself relative to THAT ancestor's box while the + * JS math (getBoundingClientRect + scrollY/X) assumed the viewport, + * so it rendered far from the button that opened it (found live: it + * appeared well below and to the side of the anchor). "fixed" is always + * viewport-relative regardless of any ancestor, which is what the JS + * math actually assumes. + */ + position: fixed; width: 180px; background: var(--surface); border: 1px solid var(--border); border-radius: 9px; box-shadow: var(--shadow); z-index: 1001; padding: 4px; display: grid; gap: 1px; } .podman-context-menu button {