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:<rank>", 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 <div>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 <td>, 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
Menu="Podman"
|
Menu="Tasks:66"
|
||||||
|
Type="xmenu"
|
||||||
|
Tabs="false"
|
||||||
Title="Podman"
|
Title="Podman"
|
||||||
Icon="podman"
|
Icon="podman"
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
|
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
|
||||||
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
|
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
|
||||||
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
|
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
|
||||||
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no",
|
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
|
||||||
* "privileged": false, "startAfterCreate": true}
|
* "privileged": false, "startAfterCreate": true}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -238,6 +238,14 @@ function build_container_spec(string $image, array $body): array
|
|||||||
$spec['privileged'] = true;
|
$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;
|
return $spec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +273,17 @@ function containers_list(PodmanClient $client): array
|
|||||||
$out = [];
|
$out = [];
|
||||||
|
|
||||||
foreach ($raw as $c) {
|
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'] ?? [];
|
$names = $c['Names'] ?? [];
|
||||||
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
|
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,10 @@
|
|||||||
*
|
*
|
||||||
* Actions (?action=...):
|
* Actions (?action=...):
|
||||||
* list GET -> pods with nested container summaries
|
* list GET -> pods with nested container summaries
|
||||||
|
* create POST {"name": "...", "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}]}
|
||||||
* start POST {"name": "..."}
|
* start POST {"name": "..."}
|
||||||
* stop POST {"name": "...", "timeout": 10}
|
* stop POST {"name": "...", "timeout": 10}
|
||||||
|
* restart POST {"name": "...", "timeout": 10}
|
||||||
* remove POST {"name": "...", "force": false}
|
* remove POST {"name": "...", "force": false}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,6 +27,12 @@ switch ($action) {
|
|||||||
podman_json_response(pods_list($client));
|
podman_json_response(pods_list($client));
|
||||||
break;
|
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':
|
case 'start':
|
||||||
$body = podman_read_json_body();
|
$body = podman_read_json_body();
|
||||||
$client->startPod(require_name($body));
|
$client->startPod(require_name($body));
|
||||||
@@ -37,6 +45,12 @@ switch ($action) {
|
|||||||
podman_json_response(['status' => 'stopped']);
|
podman_json_response(['status' => 'stopped']);
|
||||||
break;
|
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':
|
case 'remove':
|
||||||
$body = podman_read_json_body();
|
$body = podman_read_json_body();
|
||||||
$client->removePod(require_name($body), (bool) ($body['force'] ?? false));
|
$client->removePod(require_name($body), (bool) ($body['force'] ?? false));
|
||||||
@@ -47,6 +61,55 @@ switch ($action) {
|
|||||||
podman_json_error("Unknown action '{$action}'", 400);
|
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<string,mixed> $body
|
||||||
|
* @return array<string,mixed>
|
||||||
|
*/
|
||||||
|
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<string,mixed> $body */
|
/** @param array<string,mixed> $body */
|
||||||
function require_name(array $body): string
|
function require_name(array $body): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -234,6 +234,22 @@ final class PodmanClient
|
|||||||
return $this->request('GET', '/pods/' . rawurlencode($name) . '/json');
|
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<string,mixed> $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
|
public function startPod(string $name): void
|
||||||
{
|
{
|
||||||
$this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true);
|
$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);
|
$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
|
public function removePod(string $name, bool $force = false): void
|
||||||
{
|
{
|
||||||
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
|
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
|
||||||
|
|||||||
@@ -252,15 +252,25 @@ window.Podman = (function () {
|
|||||||
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
|
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Viewport-relative (see the "position: fixed" comment on
|
||||||
|
// .podman-context-menu in podman.css) — no scrollY/scrollX added.
|
||||||
const rect = anchorEl.getBoundingClientRect();
|
const rect = anchorEl.getBoundingClientRect();
|
||||||
menu.style.top = (rect.bottom + window.scrollY + 4) + 'px';
|
menu.style.top = (rect.bottom + 4) + 'px';
|
||||||
menu.style.left = (rect.right + window.scrollX - 180) + 'px';
|
menu.style.left = (rect.right - 180) + 'px';
|
||||||
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
|
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
|
||||||
|
|
||||||
|
// menu.children includes the separator <div>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 <div> instead of its own <button>, so
|
||||||
|
// clicking it did nothing. querySelectorAll('button') only ever
|
||||||
|
// returns the actual buttons, in the same order as the non-separator
|
||||||
|
// items, so indexing into that stays aligned regardless of separators.
|
||||||
|
const buttons = menu.querySelectorAll('button');
|
||||||
let buttonIndex = 0;
|
let buttonIndex = 0;
|
||||||
items.forEach(function (item) {
|
items.forEach(function (item) {
|
||||||
if (item === 'separator') return;
|
if (item === 'separator') return;
|
||||||
const btn = menu.children[buttonIndex];
|
const btn = buttons[buttonIndex];
|
||||||
buttonIndex++;
|
buttonIndex++;
|
||||||
if (item.disabled) return;
|
if (item.disabled) return;
|
||||||
btn.addEventListener('click', function (e) {
|
btn.addEventListener('click', function (e) {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
'<td>' + cpuMem + '</td>' +
|
'<td>' + cpuMem + '</td>' +
|
||||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '—') + '</td>' +
|
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '—') + '</td>' +
|
||||||
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</td>' +
|
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</td>' +
|
||||||
'<td class="podman-actions">' + actionButtons(c) + '</td>' +
|
'<td class="podman-actions"><div class="podman-actions-row">' + actionButtons(c) + '</div></td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,6 +325,9 @@
|
|||||||
'<div class="podman-modal-field"><label>Network</label>' +
|
'<div class="podman-modal-field"><label>Network</label>' +
|
||||||
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
|
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
|
||||||
'<option value="host">Host</option><option value="none">None</option></select></div>' +
|
'<option value="host">Host</option><option value="none">None</option></select></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"><label>Port mappings</label>' +
|
||||||
'<div class="podman-row-group" id="cc-ports"></div>' +
|
'<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>' +
|
||||||
@@ -388,6 +391,16 @@
|
|||||||
});
|
});
|
||||||
}).catch(function () { /* built-in modes still usable */ });
|
}).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-image').focus();
|
||||||
|
|
||||||
backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) {
|
backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) {
|
||||||
@@ -440,6 +453,7 @@
|
|||||||
image: image,
|
image: image,
|
||||||
name: backdrop.querySelector('#cc-name').value.trim(),
|
name: backdrop.querySelector('#cc-name').value.trim(),
|
||||||
networkMode: networkMode,
|
networkMode: networkMode,
|
||||||
|
pod: backdrop.querySelector('#cc-pod').value,
|
||||||
ports: ports,
|
ports: ports,
|
||||||
volumes: volumes,
|
volumes: volumes,
|
||||||
env: env,
|
env: env,
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
|
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
|
||||||
'<td class="tnum">' + created + '</td>' +
|
'<td class="tnum">' + created + '</td>' +
|
||||||
'<td class="tnum">' + img.usedBy + '</td>' +
|
'<td class="tnum">' + img.usedBy + '</td>' +
|
||||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||||
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></td>' +
|
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></div></td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
'<td class="mono">' + P.escapeHtml(n.subnet || '—') + '</td>' +
|
'<td class="mono">' + P.escapeHtml(n.subnet || '—') + '</td>' +
|
||||||
'<td class="mono">' + P.escapeHtml(n.gateway || '—') + '</td>' +
|
'<td class="mono">' + P.escapeHtml(n.gateway || '—') + '</td>' +
|
||||||
'<td class="tnum">' + n.containers + '</td>' +
|
'<td class="tnum">' + n.containers + '</td>' +
|
||||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||||
(removeDisabled ? ' disabled' : '') + ' title="Remove">🗑</button></td>' +
|
(removeDisabled ? ' disabled' : '') + ' title="Remove">🗑</button></div></td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,107 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
const P = window.Podman;
|
const P = window.Podman;
|
||||||
|
let allPods = [];
|
||||||
|
|
||||||
|
function portRowHtml() {
|
||||||
|
return '' +
|
||||||
|
'<div class="podman-row-group-item">' +
|
||||||
|
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
|
||||||
|
'<span>→</span>' +
|
||||||
|
'<input type="text" class="mono podman-input-narrow" data-field="containerPort" placeholder="Container port">' +
|
||||||
|
'<select data-field="protocol"><option value="tcp">TCP</option><option value="udp">UDP</option></select>' +
|
||||||
|
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '' +
|
||||||
|
'<div class="podman-modal" role="dialog" aria-modal="true">' +
|
||||||
|
'<div class="podman-modal-head"><h3>New Pod</h3></div>' +
|
||||||
|
'<form class="podman-modal-body">' +
|
||||||
|
'<div class="podman-modal-field"><label>Name</label>' +
|
||||||
|
'<input type="text" id="cp-name" placeholder="my-pod">' +
|
||||||
|
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
|
||||||
|
'<div class="podman-modal-field"><label>Port mappings</label>' +
|
||||||
|
'<div class="podman-row-group" id="cp-ports"></div>' +
|
||||||
|
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
|
||||||
|
'<div class="hint">Shared by every container later added to this pod.</div></div>' +
|
||||||
|
'</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>' +
|
||||||
|
'</div></div>';
|
||||||
|
|
||||||
|
(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) {
|
function memberRow(m) {
|
||||||
return '' +
|
return '' +
|
||||||
@@ -24,27 +125,77 @@
|
|||||||
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
|
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
|
||||||
|
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="podman-pod-card">' +
|
'<div class="podman-pod-card" data-name="' + P.escapeHtml(pod.name) + '">' +
|
||||||
'<div class="podman-pod-head">' +
|
'<div class="podman-pod-head">' +
|
||||||
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
|
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
|
||||||
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
|
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
|
||||||
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
|
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
|
||||||
|
'<button type="button" class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
|
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
|
||||||
'<tbody>' + members + '</tbody></table></div>' +
|
'<tbody>' + members + '</tbody></table></div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
const grid = P.el('pods-grid');
|
||||||
|
grid.innerHTML = allPods.length
|
||||||
|
? allPods.map(podCard).join('')
|
||||||
|
: '<div class="podman-empty-note">No pods yet — create one, or run a container with a "pod" set from the Create Container form.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
const container = P.el('podman-panel-pods');
|
const container = P.el('podman-panel-pods');
|
||||||
return P.get('pods', 'list').then(function (pods) {
|
if (!P.el('pods-grid')) {
|
||||||
container.innerHTML = pods.length
|
container.innerHTML = '' +
|
||||||
? pods.map(podCard).join('')
|
'<div class="podman-card">' +
|
||||||
: '<div class="podman-card"><div class="podman-empty-note">No pods yet.</div></div>';
|
'<div class="podman-toolbar">' +
|
||||||
|
'<strong style="flex:1;">Group containers sharing network/storage namespaces</strong>' +
|
||||||
|
'<button class="podman-btn podman-btn-primary" id="pods-create-btn">+ New Pod</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="pods-grid"></div>' +
|
||||||
|
'</div>';
|
||||||
|
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) {
|
}).catch(function (err) {
|
||||||
container.innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
|
P.el('pods-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 });
|
P.registerPanel('pods', { init: load, refresh: load });
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -17,11 +17,11 @@
|
|||||||
return '<tr data-index="' + i + '">' +
|
return '<tr data-index="' + i + '">' +
|
||||||
'<td class="tnum">' + (i + 1) + '</td>' +
|
'<td class="tnum">' + (i + 1) + '</td>' +
|
||||||
'<td>' + P.escapeHtml(name) + '</td>' +
|
'<td>' + P.escapeHtml(name) + '</td>' +
|
||||||
'<td class="podman-actions">' +
|
'<td class="podman-actions"><div class="podman-actions-row">' +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="up"' + (i === 0 ? ' disabled' : '') + ' title="Move up">↑</button>' +
|
'<button class="podman-btn podman-btn-icon" data-action="up"' + (i === 0 ? ' disabled' : '') + ' title="Move up">↑</button>' +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">↓</button>' +
|
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">↓</button>' +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">🗑</button>' +
|
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">🗑</button>' +
|
||||||
'</td></tr>';
|
'</div></td></tr>';
|
||||||
}).join('')
|
}).join('')
|
||||||
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
|
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
|
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
|
||||||
'<td class="mono podman-row-sub">' + pathCell + '</td>' +
|
'<td class="mono podman-row-sub">' + pathCell + '</td>' +
|
||||||
'<td class="tnum">' + v.usedBy + '</td>' +
|
'<td class="tnum">' + v.usedBy + '</td>' +
|
||||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||||
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></td>' +
|
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></div></td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
}
|
}
|
||||||
.podman-btn-danger { color: var(--bad); }
|
.podman-btn-danger { color: var(--bad); }
|
||||||
.podman-btn-danger:hover { border-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; }
|
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
|
||||||
/*
|
/*
|
||||||
* Secondary action (Cancel, "+ Add row") — every button previously shared
|
* 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);
|
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-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 <td> 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 <td> 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 { 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; }
|
.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-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 { 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 .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; }
|
.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(). */
|
/* Anchored dropdown context menu — see app.js openContextMenu(). */
|
||||||
.podman-context-menu {
|
.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;
|
border-radius: 9px; box-shadow: var(--shadow); z-index: 1001; padding: 4px; display: grid; gap: 1px;
|
||||||
}
|
}
|
||||||
.podman-context-menu button {
|
.podman-context-menu button {
|
||||||
|
|||||||
Reference in New Issue
Block a user