Add container pause/resume/kill/rename + reusable context-menu component
First increment of the big WebUI feature-parity spec (see task list) — row-level actions were about to run out of icon-button space, so this adds a small anchored dropdown menu (app.js openContextMenu) for secondary per-container actions instead of cramming more buttons into every row. All four new actions verified live against the real podman API before wiring up the UI (same discipline as the CSRF/pull/compose bugs found earlier — field names and response shapes checked against the running socket, not assumed from docs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"|"<custom-network-name>",
|
||||
* "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'] ?? ''));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '<div class="podman-context-menu-sep"></div>';
|
||||
return '<button type="button" class="' + (item.danger ? 'danger' : '') + '"' +
|
||||
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
|
||||
}).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,
|
||||
};
|
||||
|
||||
@@ -37,11 +37,44 @@
|
||||
return '' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">↻</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">■</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove" disabled>🗑</button>';
|
||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||
}
|
||||
if (c.state === 'paused') {
|
||||
return '' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">▶</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||
}
|
||||
return '' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove">🗑</button>';
|
||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user