diff --git a/webui/plugins/podman/ajax/containers.php b/webui/plugins/podman/ajax/containers.php index 234f252..11de4f6 100644 --- a/webui/plugins/podman/ajax/containers.php +++ b/webui/plugins/podman/ajax/containers.php @@ -13,6 +13,10 @@ * stop POST {"id": "...", "timeout": 10} * restart POST {"id": "...", "timeout": 10} * remove POST {"id": "...", "force": false} + * pause POST {"id": "..."} + * unpause POST {"id": "..."} + * kill POST {"id": "...", "signal": "SIGKILL"} + * rename POST {"id": "...", "name": "..."} * logs GET (&id=...&tail=200) -> plain text * create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"", * "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}], @@ -73,6 +77,34 @@ switch ($action) { podman_json_response(['status' => 'removed']); break; + case 'pause': + $body = podman_read_json_body(); + $client->pauseContainer(require_id($body)); + podman_json_response(['status' => 'paused']); + break; + + case 'unpause': + $body = podman_read_json_body(); + $client->unpauseContainer(require_id($body)); + podman_json_response(['status' => 'unpaused']); + break; + + case 'kill': + $body = podman_read_json_body(); + $client->killContainer(require_id($body), (string) ($body['signal'] ?? 'SIGKILL')); + podman_json_response(['status' => 'killed']); + break; + + case 'rename': + $body = podman_read_json_body(); + $newName = trim((string) ($body['name'] ?? '')); + if ($newName === '') { + podman_json_error('Missing name in request body', 400); + } + $client->renameContainer(require_id($body), $newName); + podman_json_response(['status' => 'renamed']); + break; + case 'create': $body = podman_read_json_body(); $image = trim((string) ($body['image'] ?? '')); diff --git a/webui/plugins/podman/include/PodmanClient.php b/webui/plugins/podman/include/PodmanClient.php index c7e15b8..1e8f5b0 100644 --- a/webui/plugins/podman/include/PodmanClient.php +++ b/webui/plugins/podman/include/PodmanClient.php @@ -138,6 +138,27 @@ final class PodmanClient $this->request('DELETE', '/containers/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true); } + public function pauseContainer(string $id): void + { + $this->request('POST', '/containers/' . rawurlencode($id) . '/pause', [], true); + } + + public function unpauseContainer(string $id): void + { + $this->request('POST', '/containers/' . rawurlencode($id) . '/unpause', [], true); + } + + /** $signal accepts both a name ("SIGKILL") and a bare number, matching libpod's own `kill?signal=` parsing. */ + public function killContainer(string $id, string $signal = 'SIGKILL'): void + { + $this->request('POST', '/containers/' . rawurlencode($id) . '/kill', ['signal' => $signal], true); + } + + public function renameContainer(string $id, string $newName): void + { + $this->request('POST', '/containers/' . rawurlencode($id) . '/rename', ['name' => $newName], true); + } + /** * GET /containers/{id}/logs — returns the raw (already de-multiplexed * where possible) log text. Podman's non-TTY log stream uses the same diff --git a/webui/plugins/podman/javascript/app.js b/webui/plugins/podman/javascript/app.js index 7d5988c..e07257c 100644 --- a/webui/plugins/podman/javascript/app.js +++ b/webui/plugins/podman/javascript/app.js @@ -229,6 +229,69 @@ window.Podman = (function () { }); } + /** + * Small anchored dropdown menu — used for secondary per-row actions + * (pause/kill/rename/...) that would otherwise clutter a table row with + * one icon button each. Only one menu is ever open at a time. + * + * @param {HTMLElement} anchorEl button the menu opens from/closes on + * @param {Array<{label:string, danger?:boolean, disabled?:boolean, onClick?:Function}|'separator'>} items + */ + let openMenuCloser = null; + function openContextMenu(anchorEl, items) { + if (openMenuCloser) { + openMenuCloser(); + return; + } + + const menu = document.createElement('div'); + menu.className = 'podman-context-menu'; + menu.innerHTML = items.map(function (item) { + if (item === 'separator') return '
'; + return ''; + }).join(''); + + const rect = anchorEl.getBoundingClientRect(); + menu.style.top = (rect.bottom + window.scrollY + 4) + 'px'; + menu.style.left = (rect.right + window.scrollX - 180) + 'px'; + (document.querySelector('.podman-plugin') || document.body).appendChild(menu); + + let buttonIndex = 0; + items.forEach(function (item) { + if (item === 'separator') return; + const btn = menu.children[buttonIndex]; + buttonIndex++; + if (item.disabled) return; + btn.addEventListener('click', function (e) { + e.stopPropagation(); + close(); + if (item.onClick) item.onClick(); + }); + }); + + function close() { + menu.remove(); + document.removeEventListener('click', onOutsideClick); + document.removeEventListener('keydown', onKey); + openMenuCloser = null; + } + function onOutsideClick(e) { + if (!menu.contains(e.target)) close(); + } + function onKey(e) { + if (e.key === 'Escape') close(); + } + + openMenuCloser = close; + // Deferred so the click that opened the menu doesn't immediately + // trigger onOutsideClick via event bubbling. + setTimeout(function () { + document.addEventListener('click', onOutsideClick); + document.addEventListener('keydown', onKey); + }, 0); + } + // --- Panel router ---------------------------------------------------------- const panelModules = {}; @@ -295,6 +358,7 @@ window.Podman = (function () { loadingRow: loadingRow, errorRow: errorRow, openFormModal: openFormModal, + openContextMenu: openContextMenu, registerPanel: registerPanel, activatePanel: activatePanel, }; diff --git a/webui/plugins/podman/javascript/containers.js b/webui/plugins/podman/javascript/containers.js index 9e4f1d1..c37463d 100644 --- a/webui/plugins/podman/javascript/containers.js +++ b/webui/plugins/podman/javascript/containers.js @@ -37,11 +37,44 @@ return '' + '' + '' + - ''; + ''; + } + if (c.state === 'paused') { + return '' + + '' + + ''; } return '' + '' + - ''; + ''; + } + + function openRowMenu(c, anchorBtn) { + const items = []; + if (c.state === 'running') { + items.push({ label: 'Pause', onClick: function () { handleAction(c.id, 'pause'); } }); + items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } }); + } + items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } }); + items.push('separator'); + items.push({ + label: 'Remove', + danger: true, + disabled: c.state === 'running', + onClick: function () { handleAction(c.id, 'remove'); }, + }); + P.openContextMenu(anchorBtn, items); + } + + function openRenameModal(c) { + P.openFormModal({ + title: 'Rename Container', + submitLabel: 'Rename', + fields: [{ name: 'name', label: 'New name', required: true, placeholder: c.name }], + onSubmit: function (values) { + return P.post('containers', 'rename', { id: c.id, name: values.name }).then(load); + }, + }); } function applyFilters() { @@ -256,15 +289,18 @@ function handleAction(id, action, btn) { const doIt = function (extra) { - btn.disabled = true; + if (btn) btn.disabled = true; return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) { alert('Action failed: ' + err.message); - btn.disabled = false; + if (btn) btn.disabled = false; }); }; if (action === 'remove') { if (!confirm('Remove this container? This does not remove its volumes.')) return; doIt({ force: true }); + } else if (action === 'kill') { + if (!confirm('Send SIGKILL to this container? Unlike Stop, this does not let it shut down gracefully.')) return; + doIt({}); } else { doIt({}); } @@ -291,7 +327,13 @@ const btn = e.target.closest('button[data-action]'); if (!btn || btn.disabled) return; const row = btn.closest('tr'); - handleAction(row.dataset.id, btn.dataset.action, btn); + const id = row.dataset.id; + if (btn.dataset.action === 'menu') { + const c = allContainers.find(function (x) { return x.id === id; }); + if (c) openRowMenu(c, btn); + return; + } + handleAction(id, btn.dataset.action, btn); }); return load(); diff --git a/webui/plugins/podman/styles/podman.css b/webui/plugins/podman/styles/podman.css index c5c1c5a..641999c 100644 --- a/webui/plugins/podman/styles/podman.css +++ b/webui/plugins/podman/styles/podman.css @@ -271,3 +271,18 @@ font-size: 12.5px; color: var(--text); font-family: var(--font-ui); flex: none; } .podman-row-group-item span { color: var(--text-faint); font-size: 12px; flex: none; } + +/* Anchored dropdown context menu — see app.js openContextMenu(). */ +.podman-context-menu { + position: absolute; 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 { + appearance: none; border: none; background: none; text-align: left; padding: 8px 10px; + font-size: 12.5px; font-weight: 600; color: var(--text); border-radius: 6px; cursor: pointer; + font-family: var(--font-ui); width: 100%; +} +.podman-context-menu button:hover { background: var(--surface-2); } +.podman-context-menu button.danger { color: var(--bad); } +.podman-context-menu button[disabled] { opacity: .4; cursor: not-allowed; } +.podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }