Add GPU/macvlan passthrough, container edit/update, image prune/tag
Create Container form: - GPU passthrough dropdown (AMD/Intel via /dev/dri detection, NVIDIA excluded since it needs a different runtime) - device paths strictly validated server-side against the host's own detected list. - Macvlan network support: selecting a macvlan network reveals a static IP field and hides port mappings (meaningless once the container has its own LAN address), matching Unraid Docker Manager's "Custom: br0" behavior. Networks panel gained a matching macvlan network-creation flow, with the parent-interface dropdown read from Unraid's own network.cfg so it lists exactly what Docker Manager itself offers. Containers panel: - Edit: reopens the create form pre-filled from the container's current config (image/ports/volumes/env/network/restart policy/GPU/static IP); saving stops+removes the old container and recreates it under the same settings, since podman/Docker have no in-place "modify" API for most of this. - Update: same stop/remove/recreate flow, but pulls the current image first. "Check for Updates" compares each in-use image's local digest against its origin registry (Docker Hub/GHCR/self-hosted registries all verified live) with no podman-side feature backing it - implemented via the registry's own HTTP API. A small log-modal shows progress for both actions instead of a silent wait. - Fixed a real bug hit live: PodmanClient's flat 15s HTTP timeout aborted real image pulls/container creates mid-request; bumped to 600s (nginx already allows up to 640s for this plugin's requests). Images panel: - "Prune unused" (removes every image with zero containers referencing it, not just dangling ones - confirmation copy says so explicitly since this is more aggressive than it sounds) and per-image "Tag". Also several real UI bugs found via live screenshots: unused-image prune having no visible effect until reloaded, table action-button columns drifting row to row (a bare "display:flex" on a <td> was fighting the table layout algorithm), Templates category badges dumping raw multi-tag strings from real Unraid templates, and low-contrast search/filter controls that were nearly invisible against the card background. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -86,11 +86,13 @@ function podman_asset_version(string $relPath): string
|
|||||||
<div class="podman-card">
|
<div class="podman-card">
|
||||||
<div class="podman-toolbar">
|
<div class="podman-toolbar">
|
||||||
<input class="podman-search" id="containers-search" type="text" placeholder="Search containers by name or image…">
|
<input class="podman-search" id="containers-search" type="text" placeholder="Search containers by name or image…">
|
||||||
<div class="filterset" id="containers-filterset" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
|
<div class="podman-segmented" id="containers-filterset">
|
||||||
<button class="active" data-filter="all" id="containers-count-all">All</button>
|
<button class="active" data-filter="all" id="containers-count-all">All</button>
|
||||||
<button data-filter="running" id="containers-count-running">Running</button>
|
<button data-filter="running" id="containers-count-running">Running</button>
|
||||||
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
|
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="podman-btn podman-btn-primary" id="containers-check-updates-btn" style="margin-left:auto;">Check for Updates</button>
|
||||||
|
<button class="podman-btn podman-btn-primary" id="containers-update-all-btn">Update All</button>
|
||||||
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
|
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="podman-table-wrap">
|
<div class="podman-table-wrap">
|
||||||
@@ -113,6 +115,7 @@ function podman_asset_version(string $relPath): string
|
|||||||
<div class="podman-card">
|
<div class="podman-card">
|
||||||
<div class="podman-toolbar">
|
<div class="podman-toolbar">
|
||||||
<input class="podman-search" type="text" placeholder="Search images…" disabled title="Client-side filtering not yet wired up for Images">
|
<input class="podman-search" type="text" placeholder="Search images…" disabled title="Client-side filtering not yet wired up for Images">
|
||||||
|
<button class="podman-btn" id="images-prune-btn" style="margin-left:auto;">Prune unused</button>
|
||||||
<button class="podman-btn" id="images-pull-btn">⬇ Pull Image</button>
|
<button class="podman-btn" id="images-pull-btn">⬇ Pull Image</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="podman-table-wrap">
|
<div class="podman-table-wrap">
|
||||||
@@ -167,7 +170,7 @@ function podman_asset_version(string $relPath): string
|
|||||||
<div>
|
<div>
|
||||||
<div class="podman-toolbar">
|
<div class="podman-toolbar">
|
||||||
<input class="podman-search" id="logs-filter" type="text" placeholder="Filter log output…" style="max-width:280px;">
|
<input class="podman-search" id="logs-filter" type="text" placeholder="Filter log output…" style="max-width:280px;">
|
||||||
<span id="logs-follow-toggle" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
|
<span class="podman-segmented" id="logs-follow-toggle">
|
||||||
<button class="active" data-follow="true">Follow</button>
|
<button class="active" data-follow="true">Follow</button>
|
||||||
<button data-follow="false">Paused</button>
|
<button data-follow="false">Paused</button>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -18,10 +18,14 @@
|
|||||||
* kill POST {"id": "...", "signal": "SIGKILL"}
|
* kill POST {"id": "...", "signal": "SIGKILL"}
|
||||||
* rename POST {"id": "...", "name": "..."}
|
* rename POST {"id": "...", "name": "..."}
|
||||||
* logs GET (&id=...&tail=200) -> plain text
|
* logs GET (&id=...&tail=200) -> plain text
|
||||||
|
* list_gpus GET -> detected AMD/Intel GPUs (/dev/dri), for the Create Container form's optional passthrough toggle
|
||||||
|
* check_updates GET -> {"<image ref>": {"updateAvailable": bool, "error": "..."?}} for every image currently in use
|
||||||
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
|
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
|
||||||
|
* "staticIp": "10.1.1.222" (only meaningful with a custom/macvlan networkMode),
|
||||||
* "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", "pod": "<existing-pod-name>",
|
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
|
||||||
|
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"],
|
||||||
* "privileged": false, "startAfterCreate": true}
|
* "privileged": false, "startAfterCreate": true}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -105,6 +109,14 @@ switch ($action) {
|
|||||||
podman_json_response(['status' => 'renamed']);
|
podman_json_response(['status' => 'renamed']);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'list_gpus':
|
||||||
|
podman_json_response(gpu_list());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'check_updates':
|
||||||
|
podman_json_response(check_image_updates($client));
|
||||||
|
break;
|
||||||
|
|
||||||
case 'create':
|
case 'create':
|
||||||
$body = podman_read_json_body();
|
$body = podman_read_json_body();
|
||||||
$image = trim((string) ($body['image'] ?? ''));
|
$image = trim((string) ($body['image'] ?? ''));
|
||||||
@@ -138,6 +150,102 @@ switch ($action) {
|
|||||||
podman_json_error("Unknown action '{$action}'", 400);
|
podman_json_error("Unknown action '{$action}'", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks every image currently backing a (non-infra) container against its
|
||||||
|
* origin registry — see RegistryClient for how, and why this isn't a
|
||||||
|
* podman/libpod feature at all. Deduplicated per unique image reference
|
||||||
|
* first (several containers commonly share the same image), so a host
|
||||||
|
* with e.g. five containers all on the same base image only makes one
|
||||||
|
* real registry request for it, not five.
|
||||||
|
*
|
||||||
|
* @return array<string,array<string,mixed>> keyed by image reference
|
||||||
|
*/
|
||||||
|
function check_image_updates(PodmanClient $client): array
|
||||||
|
{
|
||||||
|
$digestByImageId = [];
|
||||||
|
foreach ($client->listImages() as $img) {
|
||||||
|
$digestByImageId[(string) ($img['Id'] ?? '')] = (string) ($img['Digest'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
$localDigestByRef = [];
|
||||||
|
foreach ($client->listContainers(true) as $c) {
|
||||||
|
if ($c['IsInfra'] ?? false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ref = (string) ($c['Image'] ?? '');
|
||||||
|
$imageId = (string) ($c['ImageID'] ?? '');
|
||||||
|
if ($ref === '' || !isset($digestByImageId[$imageId])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$localDigestByRef[$ref] = $digestByImageId[$imageId];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach ($localDigestByRef as $ref => $localDigest) {
|
||||||
|
$out[$ref] = $localDigest === ''
|
||||||
|
? ['error' => 'No local digest recorded for this image.']
|
||||||
|
: RegistryClient::checkForUpdate($ref, $localDigest);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects AMD/Intel GPUs via /dev/dri + sysfs — NOT via any podman/libpod
|
||||||
|
* API (libpod has no GPU inventory endpoint; this is plain host hardware
|
||||||
|
* detection). NVIDIA is deliberately excluded: it needs the separate
|
||||||
|
* nvidia-container-toolkit runtime, not a plain /dev/dri device passthrough,
|
||||||
|
* so listing it here would offer a checkbox that doesn't actually work.
|
||||||
|
* Verified live: card/render pairs from the same GPU share a "device"
|
||||||
|
* symlink target under /sys/class/drm, which is how they're grouped below;
|
||||||
|
* vendor 0x1002 = AMD, 0x8086 = Intel (PCI SIG IDs).
|
||||||
|
*
|
||||||
|
* @return array<int,array<string,mixed>>
|
||||||
|
*/
|
||||||
|
function gpu_list(): array
|
||||||
|
{
|
||||||
|
if (!is_dir('/sys/class/drm')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$byDevice = [];
|
||||||
|
foreach (scandir('/sys/class/drm') ?: [] as $entry) {
|
||||||
|
if (!preg_match('/^(card\d+|renderD\d+)$/', $entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$devicePath = "/sys/class/drm/{$entry}/device";
|
||||||
|
$target = @readlink($devicePath);
|
||||||
|
if ($target === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$vendorFile = "{$devicePath}/vendor";
|
||||||
|
if (!is_file($vendorFile)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$vendorId = trim((string) @file_get_contents($vendorFile));
|
||||||
|
$byDevice[$target]['vendorId'] ??= $vendorId;
|
||||||
|
$byDevice[$target][str_starts_with($entry, 'card') ? 'card' : 'render'] = "/dev/dri/{$entry}";
|
||||||
|
}
|
||||||
|
|
||||||
|
$vendorNames = ['0x1002' => 'AMD', '0x8086' => 'Intel', '0x10de' => 'NVIDIA'];
|
||||||
|
$out = [];
|
||||||
|
foreach ($byDevice as $group) {
|
||||||
|
$vendorId = $group['vendorId'] ?? '';
|
||||||
|
$vendorName = $vendorNames[$vendorId] ?? $vendorId;
|
||||||
|
// NVIDIA needs the nvidia-container-toolkit runtime, not a plain
|
||||||
|
// /dev/dri passthrough — excluded so the checkbox we offer always
|
||||||
|
// actually works (see function comment).
|
||||||
|
if ($vendorName === 'NVIDIA' || !isset($group['render'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = [
|
||||||
|
'vendor' => $vendorName,
|
||||||
|
'card' => $group['card'] ?? null,
|
||||||
|
'render' => $group['render'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a libpod SpecGenerator body (POST /containers/create) from the
|
* Builds a libpod SpecGenerator body (POST /containers/create) from the
|
||||||
* WebUI's Create Container form fields. Field names/shapes here
|
* WebUI's Create Container form fields. Field names/shapes here
|
||||||
@@ -228,7 +336,21 @@ function build_container_spec(string $image, array $body): array
|
|||||||
if (in_array($networkMode, ['bridge', 'host', 'none'], true)) {
|
if (in_array($networkMode, ['bridge', 'host', 'none'], true)) {
|
||||||
$spec['netns'] = ['nsmode' => $networkMode];
|
$spec['netns'] = ['nsmode' => $networkMode];
|
||||||
} elseif ($networkMode !== '') {
|
} elseif ($networkMode !== '') {
|
||||||
$spec['networks'] = [$networkMode => new \stdClass()];
|
// A static IP only makes sense on a custom (typically macvlan)
|
||||||
|
// network — verified live that "networks":{"<name>":{"static_ips":
|
||||||
|
// [...]}} assigns it, same as podman itself does for --ip. Basic
|
||||||
|
// IPv4-shape validation only (not full RFC-correctness) — this
|
||||||
|
// goes straight into a create request against the local podman
|
||||||
|
// socket, not anywhere it could reach untrusted input otherwise.
|
||||||
|
$staticIp = trim((string) ($body['staticIp'] ?? ''));
|
||||||
|
if ($staticIp !== '') {
|
||||||
|
if (preg_match('/^(\d{1,3}\.){3}\d{1,3}$/', $staticIp) !== 1) {
|
||||||
|
podman_json_error("Static IP (\"{$staticIp}\") doesn't look like a valid IPv4 address.", 400);
|
||||||
|
}
|
||||||
|
$spec['networks'] = [$networkMode => ['static_ips' => [$staticIp]]];
|
||||||
|
} else {
|
||||||
|
$spec['networks'] = [$networkMode => new \stdClass()];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
|
if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
|
||||||
@@ -238,6 +360,21 @@ function build_container_spec(string $image, array $body): array
|
|||||||
$spec['privileged'] = true;
|
$spec['privileged'] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$devices = [];
|
||||||
|
foreach (($body['gpuDevices'] ?? []) as $path) {
|
||||||
|
// Only ever pass through paths matching the exact shape gpu_list()
|
||||||
|
// itself reports — the client only ever gets those as checkbox
|
||||||
|
// values, but this is the boundary where a tampered/malicious
|
||||||
|
// request body gets rejected rather than handing arbitrary host
|
||||||
|
// device paths (e.g. "/dev/sda") straight into the container spec.
|
||||||
|
if (is_string($path) && preg_match('#^/dev/dri/(card|renderD)\d+$#', $path) === 1) {
|
||||||
|
$devices[] = ['path' => $path];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($devices !== []) {
|
||||||
|
$spec['devices'] = $devices;
|
||||||
|
}
|
||||||
|
|
||||||
$pod = trim((string) ($body['pod'] ?? ''));
|
$pod = trim((string) ($body['pod'] ?? ''));
|
||||||
if ($pod !== '') {
|
if ($pod !== '') {
|
||||||
// "pod" joins an existing pod's shared network namespace — verified
|
// "pod" joins an existing pod's shared network namespace — verified
|
||||||
@@ -299,6 +436,24 @@ function containers_list(PodmanClient $client): array
|
|||||||
$startedAt = podman_parse_time($c['StartedAt'] ?? null);
|
$startedAt = podman_parse_time($c['StartedAt'] ?? null);
|
||||||
$state = strtolower((string) ($c['State'] ?? 'unknown'));
|
$state = strtolower((string) ($c['State'] ?? 'unknown'));
|
||||||
|
|
||||||
|
// One extra local-socket round trip per running container (~15ms
|
||||||
|
// each, verified live — negligible for a home host's container
|
||||||
|
// count). Best-effort: a container that stops between the list
|
||||||
|
// call above and this one shouldn't blank out the whole table.
|
||||||
|
$cpuPercent = null;
|
||||||
|
$memUsageBytes = null;
|
||||||
|
$memLimitBytes = null;
|
||||||
|
if ($state === 'running') {
|
||||||
|
try {
|
||||||
|
$stats = $client->containerStats((string) ($c['Id'] ?? ''));
|
||||||
|
$cpuPercent = isset($stats['cpu_stats']['cpu']) ? round((float) $stats['cpu_stats']['cpu'], 1) : null;
|
||||||
|
$memUsageBytes = isset($stats['memory_stats']['usage']) ? (int) $stats['memory_stats']['usage'] : null;
|
||||||
|
$memLimitBytes = isset($stats['memory_stats']['limit']) ? (int) $stats['memory_stats']['limit'] : null;
|
||||||
|
} catch (PodmanApiException $e) {
|
||||||
|
// leave stats null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$out[] = [
|
$out[] = [
|
||||||
'id' => (string) ($c['Id'] ?? ''),
|
'id' => (string) ($c['Id'] ?? ''),
|
||||||
'shortId' => podman_short_id((string) ($c['Id'] ?? '')),
|
'shortId' => podman_short_id((string) ($c['Id'] ?? '')),
|
||||||
@@ -312,6 +467,9 @@ function containers_list(PodmanClient $client): array
|
|||||||
'podName' => $c['PodName'] ?? null,
|
'podName' => $c['PodName'] ?? null,
|
||||||
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
|
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
|
||||||
'createdAt' => podman_parse_time($c['Created'] ?? null),
|
'createdAt' => podman_parse_time($c['Created'] ?? null),
|
||||||
|
'cpuPercent' => $cpuPercent,
|
||||||
|
'memUsageBytes' => $memUsageBytes,
|
||||||
|
'memLimitBytes' => $memLimitBytes,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@
|
|||||||
* list GET -> normalized image list
|
* list GET -> normalized image list
|
||||||
* pull POST {"reference": "docker.io/library/postgres:16"}
|
* pull POST {"reference": "docker.io/library/postgres:16"}
|
||||||
* remove POST {"id": "...", "force": false}
|
* remove POST {"id": "...", "force": false}
|
||||||
|
* prune POST {} -> removes every image not used by any container
|
||||||
|
* tag POST {"id": "...", "repo": "...", "tag": "latest"}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -40,6 +42,27 @@ switch ($action) {
|
|||||||
podman_json_response(['status' => 'removed']);
|
podman_json_response(['status' => 'removed']);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'prune':
|
||||||
|
$removed = $client->pruneImages();
|
||||||
|
$reclaimed = 0;
|
||||||
|
foreach ($removed as $r) {
|
||||||
|
$reclaimed += (int) ($r['Size'] ?? 0);
|
||||||
|
}
|
||||||
|
podman_json_response(['removedCount' => count($removed), 'reclaimedBytes' => $reclaimed]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tag':
|
||||||
|
$body = podman_read_json_body();
|
||||||
|
$id = (string) ($body['id'] ?? '');
|
||||||
|
$repo = trim((string) ($body['repo'] ?? ''));
|
||||||
|
$tag = trim((string) ($body['tag'] ?? '')) ?: 'latest';
|
||||||
|
if ($id === '' || $repo === '') {
|
||||||
|
podman_json_error('Missing id or repo in request body', 400);
|
||||||
|
}
|
||||||
|
$client->tagImage($id, $repo, $tag);
|
||||||
|
podman_json_response(['status' => 'tagged']);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
podman_json_error("Unknown action '{$action}'", 400);
|
podman_json_error("Unknown action '{$action}'", 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
* from Docker's own docker0/custom-network space.
|
* from Docker's own docker0/custom-network space.
|
||||||
*
|
*
|
||||||
* Actions (?action=...):
|
* Actions (?action=...):
|
||||||
* list GET -> normalized network list with subnet/gateway/usage
|
* list GET -> normalized network list with subnet/gateway/usage
|
||||||
* create POST {"name": "...", "driver": "bridge", "subnet": "...", "gateway": "..."}
|
* list_parent_interfaces GET -> host bridge/VLAN interfaces available as a macvlan parent
|
||||||
* remove POST {"name": "...", "force": false}
|
* create POST {"name": "...", "driver": "bridge"|"macvlan", "subnet": "...",
|
||||||
|
* "gateway": "...", "parentInterface": "br0"}
|
||||||
|
* remove POST {"name": "...", "force": false}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
@@ -23,17 +25,40 @@ switch ($action) {
|
|||||||
podman_json_response(networks_list($client));
|
podman_json_response(networks_list($client));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'list_parent_interfaces':
|
||||||
|
podman_json_response(macvlan_parent_interfaces());
|
||||||
|
break;
|
||||||
|
|
||||||
case 'create':
|
case 'create':
|
||||||
$body = podman_read_json_body();
|
$body = podman_read_json_body();
|
||||||
$name = (string) ($body['name'] ?? '');
|
$name = (string) ($body['name'] ?? '');
|
||||||
if ($name === '') {
|
if ($name === '') {
|
||||||
podman_json_error('Missing name in request body', 400);
|
podman_json_error('Missing name in request body', 400);
|
||||||
}
|
}
|
||||||
|
$driver = (string) ($body['driver'] ?? 'bridge');
|
||||||
|
|
||||||
|
$parentInterface = null;
|
||||||
|
if ($driver === 'macvlan') {
|
||||||
|
$parentInterface = (string) ($body['parentInterface'] ?? '');
|
||||||
|
// Only ever accept an interface this same host reported via
|
||||||
|
// macvlan_parent_interfaces() — the boundary preventing a
|
||||||
|
// tampered request from asking podman to attach to an
|
||||||
|
// arbitrary/unexpected interface name.
|
||||||
|
$known = array_column(macvlan_parent_interfaces(), 'interface');
|
||||||
|
if (!in_array($parentInterface, $known, true)) {
|
||||||
|
podman_json_error('Unknown parent interface — refresh the page and try again.', 400);
|
||||||
|
}
|
||||||
|
if (!isset($body['subnet']) || (string) $body['subnet'] === '') {
|
||||||
|
podman_json_error('Subnet is required for a macvlan network.', 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
podman_json_response($client->createNetwork(
|
podman_json_response($client->createNetwork(
|
||||||
$name,
|
$name,
|
||||||
(string) ($body['driver'] ?? 'bridge'),
|
$driver,
|
||||||
isset($body['subnet']) ? (string) $body['subnet'] : null,
|
isset($body['subnet']) ? (string) $body['subnet'] : null,
|
||||||
isset($body['gateway']) ? (string) $body['gateway'] : null
|
isset($body['gateway']) ? (string) $body['gateway'] : null,
|
||||||
|
$parentInterface
|
||||||
));
|
));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -54,6 +79,61 @@ switch ($action) {
|
|||||||
podman_json_error("Unknown action '{$action}'", 400);
|
podman_json_error("Unknown action '{$action}'", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads Unraid's own /boot/config/network.cfg (BRNAME[i]/VLANID[i,j]/
|
||||||
|
* DESCRIPTION[i,j]) to list the same host bridge + VLAN interfaces
|
||||||
|
* Unraid's own Docker Manager offers as "Custom: br0" / "Custom: br0.3
|
||||||
|
* (VPN)" network types — reusing Unraid's own config instead of guessing
|
||||||
|
* from raw `ip link` output, so the list always matches what Docker
|
||||||
|
* Manager shows for the same host. Verified live: this host's
|
||||||
|
* network.cfg has BRNAME[0]="br0" and VLANID[0,1]="3"/DESCRIPTION[0,1]=
|
||||||
|
* "VPN", producing "br0" and "br0.3 (VPN)" — matching the interface
|
||||||
|
* names shown in that other plugin's own network-type dropdown exactly.
|
||||||
|
* Each candidate is confirmed to actually exist in /sys/class/net before
|
||||||
|
* being offered, in case network.cfg mentions an interface that isn't
|
||||||
|
* currently up.
|
||||||
|
*
|
||||||
|
* @return array<int,array{interface:string,label:string}>
|
||||||
|
*/
|
||||||
|
function macvlan_parent_interfaces(): array
|
||||||
|
{
|
||||||
|
$cfgFile = '/boot/config/network.cfg';
|
||||||
|
if (!is_file($cfgFile)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$cfg = [];
|
||||||
|
foreach (file($cfgFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
|
||||||
|
if (preg_match('/^([A-Z0-9_]+)\[(\d+)(?:,(\d+))?\]="([^"]*)"$/', $line, $m) !== 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[, $key, $i, $j, $value] = $m + [3 => ''];
|
||||||
|
$i = (int) $i;
|
||||||
|
if ($j === '') {
|
||||||
|
$cfg[$key][$i] = $value;
|
||||||
|
} else {
|
||||||
|
$cfg[$key][$i][(int) $j] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
foreach (($cfg['BRNAME'] ?? []) as $i => $brname) {
|
||||||
|
if (!is_string($brname) || $brname === '' || !is_dir("/sys/class/net/{$brname}")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = ['interface' => $brname, 'label' => $brname];
|
||||||
|
foreach (($cfg['VLANID'][$i] ?? []) as $j => $vlanId) {
|
||||||
|
$iface = "{$brname}.{$vlanId}";
|
||||||
|
if (!is_dir("/sys/class/net/{$iface}")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$desc = $cfg['DESCRIPTION'][$i][$j] ?? '';
|
||||||
|
$out[] = ['interface' => $iface, 'label' => $iface . ($desc !== '' ? " ({$desc})" : '')];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<int,array<string,mixed>> */
|
/** @return array<int,array<string,mixed>> */
|
||||||
function networks_list(PodmanClient $client): array
|
function networks_list(PodmanClient $client): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -114,7 +114,15 @@ final class PodmanClient
|
|||||||
*/
|
*/
|
||||||
public function createContainer(array $spec): string
|
public function createContainer(array $spec): string
|
||||||
{
|
{
|
||||||
$result = $this->request('POST', '/containers/create', [], false, $spec);
|
// Longer than this client's normal 15s operation timeout as cheap
|
||||||
|
// insurance: creating a container involves setting up its mounts
|
||||||
|
// (often onto Unraid array/spinning-disk shares, not the cache
|
||||||
|
// pool) and network namespace, which can occasionally run past 15s
|
||||||
|
// even with the image already pulled — found live via a real
|
||||||
|
// "Operation timed out after 15001 milliseconds" error creating a
|
||||||
|
// container. Same 600s ceiling as pullImage(), safely under
|
||||||
|
// nginx's 640s fastcgi_read_timeout.
|
||||||
|
$result = $this->request('POST', '/containers/create', [], false, $spec, 600);
|
||||||
return (string) ($result['Id'] ?? '');
|
return (string) ($result['Id'] ?? '');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,7 +308,14 @@ final class PodmanClient
|
|||||||
*/
|
*/
|
||||||
public function pullImage(string $reference): array
|
public function pullImage(string $reference): array
|
||||||
{
|
{
|
||||||
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference]);
|
// A real image (e.g. a Plex/media-server image, easily several
|
||||||
|
// hundred MB) routinely takes far longer than this client's normal
|
||||||
|
// 15s operation timeout to download — found live: a pull aborted
|
||||||
|
// mid-stream with "Operation timed out after 15001 milliseconds"
|
||||||
|
// after only ~1.4KB of progress data. nginx's own fastcgi_read_timeout
|
||||||
|
// (640s, see /etc/nginx/nginx.conf) already anticipates long-running
|
||||||
|
// plugin requests, so 600s here stays safely under that.
|
||||||
|
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference], null, 600);
|
||||||
|
|
||||||
$last = null;
|
$last = null;
|
||||||
foreach (explode("\n", trim($raw)) as $line) {
|
foreach (explode("\n", trim($raw)) as $line) {
|
||||||
@@ -334,6 +349,28 @@ final class PodmanClient
|
|||||||
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
|
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /images/prune?all=true — removes every image with zero containers
|
||||||
|
* (running or stopped) referencing it, matching this app's own "Used By"
|
||||||
|
* column — not just dangling/untagged images. Verified live: a tagged
|
||||||
|
* but unused image IS removed with all=true (found the hard way: it
|
||||||
|
* also removed every image on a host with no containers at all, which
|
||||||
|
* is correct behavior, just aggressive — see ajax/images.php's prune
|
||||||
|
* action for the confirmation-copy this justifies).
|
||||||
|
*
|
||||||
|
* @return array<int,array{Id:string,Size:int}> one entry per removed image
|
||||||
|
*/
|
||||||
|
public function pruneImages(): array
|
||||||
|
{
|
||||||
|
return $this->request('POST', '/images/prune', ['all' => 'true']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /images/{id}/tag?repo=...&tag=... — adds a new repo:tag pointing at an existing image. */
|
||||||
|
public function tagImage(string $id, string $repo, string $tag): void
|
||||||
|
{
|
||||||
|
$this->request('POST', '/images/' . rawurlencode($id) . '/tag', ['repo' => $repo, 'tag' => $tag], true);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
// Volumes
|
// Volumes
|
||||||
// -------------------------------------------------------------------
|
// -------------------------------------------------------------------
|
||||||
@@ -374,12 +411,25 @@ final class PodmanClient
|
|||||||
return $this->request('GET', '/networks/json');
|
return $this->request('GET', '/networks/json');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null): array
|
/**
|
||||||
|
* $parentInterface (only meaningful for driver="macvlan") attaches the
|
||||||
|
* network directly to an existing host bridge/VLAN interface (e.g.
|
||||||
|
* Unraid's own "br0" or a VLAN sub-interface like "br0.3") via
|
||||||
|
* libpod's "network_interface" field — verified live: containers on
|
||||||
|
* such a network get a real address on that LAN/VLAN's own subnet,
|
||||||
|
* not a NATed one, matching Unraid Docker Manager's "Custom: br0"
|
||||||
|
* network type. See ajax/networks.php's macvlan_parent_interfaces()
|
||||||
|
* for where the interface list itself comes from.
|
||||||
|
*/
|
||||||
|
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null, ?string $parentInterface = null): array
|
||||||
{
|
{
|
||||||
$body = ['name' => $name, 'driver' => $driver];
|
$body = ['name' => $name, 'driver' => $driver];
|
||||||
if ($subnet !== null) {
|
if ($subnet !== null) {
|
||||||
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
|
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
|
||||||
}
|
}
|
||||||
|
if ($parentInterface !== null && $parentInterface !== '') {
|
||||||
|
$body['network_interface'] = $parentInterface;
|
||||||
|
}
|
||||||
return $this->request('POST', '/networks/create', [], false, $body);
|
return $this->request('POST', '/networks/create', [], false, $body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,9 +450,9 @@ final class PodmanClient
|
|||||||
* @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
|
* @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
|
||||||
* @return array<mixed>
|
* @return array<mixed>
|
||||||
*/
|
*/
|
||||||
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null): array
|
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null, ?int $timeoutSeconds = null): array
|
||||||
{
|
{
|
||||||
$raw = $this->requestRaw($method, $path, $query, $jsonBody);
|
$raw = $this->requestRaw($method, $path, $query, $jsonBody, $timeoutSeconds);
|
||||||
if ($expectEmptyBody || trim($raw) === '') {
|
if ($expectEmptyBody || trim($raw) === '') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -421,7 +471,7 @@ final class PodmanClient
|
|||||||
* @param array<string,string> $query
|
* @param array<string,string> $query
|
||||||
* @param array<mixed>|null $jsonBody
|
* @param array<mixed>|null $jsonBody
|
||||||
*/
|
*/
|
||||||
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null): string
|
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null, ?int $timeoutSeconds = null): string
|
||||||
{
|
{
|
||||||
$url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
|
$url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
|
||||||
if (!empty($query)) {
|
if (!empty($query)) {
|
||||||
@@ -434,7 +484,7 @@ final class PodmanClient
|
|||||||
CURLOPT_URL => $url,
|
CURLOPT_URL => $url,
|
||||||
CURLOPT_CUSTOMREQUEST => $method,
|
CURLOPT_CUSTOMREQUEST => $method,
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_TIMEOUT => $this->timeoutSeconds,
|
CURLOPT_TIMEOUT => $timeoutSeconds ?? $this->timeoutSeconds,
|
||||||
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
CURLOPT_HTTPHEADER => ['Accept: application/json'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* RegistryClient.php
|
||||||
|
*
|
||||||
|
* "Is a newer image available?" — deliberately NOT a podman/libpod feature
|
||||||
|
* (verified live: no libpod endpoint exists for this; every tool that
|
||||||
|
* offers it, Watchtower/Diun/Unraid's own Docker Manager included,
|
||||||
|
* re-implements the same registry-side check). This talks directly to the
|
||||||
|
* target image's own registry using the standard Docker Registry HTTP API
|
||||||
|
* V2: a GET on the manifest returns a "Docker-Content-Digest" header
|
||||||
|
* without downloading any image layers, which is compared against the
|
||||||
|
* digest of the image already pulled locally (PodmanClient::listImages()'s
|
||||||
|
* own "Digest" field) — no local image ever needs pulling just to check.
|
||||||
|
*
|
||||||
|
* The auth flow is the generic Bearer-challenge dance every compliant
|
||||||
|
* registry follows (RFC-ish, not just a Docker Hub thing): an
|
||||||
|
* unauthenticated request gets a 401 with a WWW-Authenticate header naming
|
||||||
|
* a token realm/service/scope, a token is fetched from that realm, and the
|
||||||
|
* manifest request is retried with it. Verified live against three
|
||||||
|
* different registries with three different auth setups — Docker Hub,
|
||||||
|
* ghcr.io, and a self-hosted Gitea registry — using this exact same code
|
||||||
|
* path for all three, not registry-specific special-casing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
final class RegistryClient
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{updateAvailable?:bool,remoteDigest?:string,error?:string}
|
||||||
|
*/
|
||||||
|
public static function checkForUpdate(string $reference, string $localDigest): array
|
||||||
|
{
|
||||||
|
[$registry, $repo, $tag] = self::parseReference($reference);
|
||||||
|
$manifestUrl = "https://{$registry}/v2/{$repo}/manifests/{$tag}";
|
||||||
|
$accept = 'application/vnd.docker.distribution.manifest.v2+json, ' .
|
||||||
|
'application/vnd.docker.distribution.manifest.list.v2+json, ' .
|
||||||
|
'application/vnd.oci.image.manifest.v1+json, ' .
|
||||||
|
'application/vnd.oci.image.index.v1+json';
|
||||||
|
|
||||||
|
[$status, $headers] = self::httpRequest($manifestUrl, $accept, null);
|
||||||
|
|
||||||
|
if ($status === 401) {
|
||||||
|
$challenge = self::parseAuthChallenge($headers['www-authenticate'] ?? '');
|
||||||
|
if ($challenge === null) {
|
||||||
|
return ['error' => 'Registry requires authentication this app cannot satisfy.'];
|
||||||
|
}
|
||||||
|
$token = self::fetchToken($challenge);
|
||||||
|
if ($token === null) {
|
||||||
|
return ['error' => 'Could not authenticate with the registry.'];
|
||||||
|
}
|
||||||
|
[$status, $headers] = self::httpRequest($manifestUrl, $accept, $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status !== 200) {
|
||||||
|
return ['error' => "Registry returned HTTP {$status}."];
|
||||||
|
}
|
||||||
|
|
||||||
|
$remoteDigest = $headers['docker-content-digest'] ?? null;
|
||||||
|
if ($remoteDigest === null) {
|
||||||
|
return ['error' => 'Registry response did not include a digest.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['remoteDigest' => $remoteDigest, 'updateAvailable' => $remoteDigest !== $localDigest];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits "docker.io/library/nginx:alpine" (or shorthand forms like
|
||||||
|
* "nginx:alpine" or "someuser/repo:tag") into [registryHost, repoPath,
|
||||||
|
* tag] — same reference-parsing convention every registry client
|
||||||
|
* (including podman/Docker themselves) uses: the first path segment is
|
||||||
|
* a registry host only if it contains a "." or ":" or is "localhost";
|
||||||
|
* otherwise the whole reference is a Docker Hub repo, implicitly under
|
||||||
|
* "library/" if it has no namespace of its own. docker.io's actual API
|
||||||
|
* host is registry-1.docker.io, not docker.io itself — a Docker-Hub-
|
||||||
|
* specific quirk, not something inferred from the general rule above.
|
||||||
|
*
|
||||||
|
* @return array{0:string,1:string,2:string}
|
||||||
|
*/
|
||||||
|
private static function parseReference(string $reference): array
|
||||||
|
{
|
||||||
|
$reference = explode('@', $reference, 2)[0]; // strip any @sha256:... suffix
|
||||||
|
$tag = 'latest';
|
||||||
|
$lastSlash = strrpos($reference, '/');
|
||||||
|
$lastColon = strrpos($reference, ':');
|
||||||
|
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
|
||||||
|
$tag = substr($reference, $lastColon + 1);
|
||||||
|
$reference = substr($reference, 0, $lastColon);
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('/', $reference);
|
||||||
|
$first = $parts[0];
|
||||||
|
$looksLikeHost = str_contains($first, '.') || str_contains($first, ':') || $first === 'localhost';
|
||||||
|
|
||||||
|
if ($looksLikeHost) {
|
||||||
|
$registry = $first;
|
||||||
|
$repo = implode('/', array_slice($parts, 1));
|
||||||
|
} else {
|
||||||
|
$registry = 'docker.io';
|
||||||
|
$repo = str_contains($reference, '/') ? $reference : "library/{$reference}";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($registry === 'docker.io') {
|
||||||
|
$registry = 'registry-1.docker.io';
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$registry, $repo, $tag];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{realm:string,service:string,scope:string}|null */
|
||||||
|
private static function parseAuthChallenge(string $header): ?array
|
||||||
|
{
|
||||||
|
if (preg_match('/realm="([^"]+)"/', $header, $m) !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$service = preg_match('/service="([^"]+)"/', $header, $sm) === 1 ? $sm[1] : '';
|
||||||
|
$scope = preg_match('/scope="([^"]+)"/', $header, $om) === 1 ? $om[1] : '';
|
||||||
|
return ['realm' => $m[1], 'service' => $service, 'scope' => $scope];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{realm:string,service:string,scope:string} $challenge */
|
||||||
|
private static function fetchToken(array $challenge): ?string
|
||||||
|
{
|
||||||
|
$params = array_filter(['service' => $challenge['service'], 'scope' => $challenge['scope']]);
|
||||||
|
$url = $challenge['realm'] . '?' . http_build_query($params);
|
||||||
|
[$status, , $body] = self::httpRequest($url, 'application/json', null, true);
|
||||||
|
if ($status !== 200 || $body === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$decoded = json_decode($body, true);
|
||||||
|
// The spec allows either key; registries are inconsistent about
|
||||||
|
// which one they actually send.
|
||||||
|
return is_array($decoded) ? (string) ($decoded['token'] ?? $decoded['access_token'] ?? '') ?: null : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{0:int,1:array<string,string>,2:?string} [status, lowercased response headers, body (only when $withBody)]
|
||||||
|
*/
|
||||||
|
private static function httpRequest(string $url, string $accept, ?string $token, bool $withBody = false): array
|
||||||
|
{
|
||||||
|
$ch = curl_init($url);
|
||||||
|
$headers = ['Accept: ' . $accept];
|
||||||
|
if ($token !== null) {
|
||||||
|
$headers[] = "Authorization: Bearer {$token}";
|
||||||
|
}
|
||||||
|
curl_setopt_array($ch, [
|
||||||
|
CURLOPT_HTTPHEADER => $headers,
|
||||||
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
|
CURLOPT_HEADER => true,
|
||||||
|
CURLOPT_TIMEOUT => 15,
|
||||||
|
CURLOPT_FOLLOWLOCATION => true,
|
||||||
|
]);
|
||||||
|
$raw = curl_exec($ch);
|
||||||
|
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
||||||
|
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return [0, [], null];
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsedHeaders = [];
|
||||||
|
foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
|
||||||
|
if (str_contains($line, ':')) {
|
||||||
|
[$k, $v] = explode(':', $line, 2);
|
||||||
|
$parsedHeaders[strtolower(trim($k))] = trim($v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$body = $withBody ? substr($raw, $headerSize) : null;
|
||||||
|
return [$status, $parsedHeaders, $body];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ declare(strict_types=1);
|
|||||||
require_once __DIR__ . '/PodmanClient.php';
|
require_once __DIR__ . '/PodmanClient.php';
|
||||||
require_once __DIR__ . '/Config.php';
|
require_once __DIR__ . '/Config.php';
|
||||||
require_once __DIR__ . '/helpers.php';
|
require_once __DIR__ . '/helpers.php';
|
||||||
|
require_once __DIR__ . '/RegistryClient.php';
|
||||||
|
|
||||||
set_exception_handler(static function (\Throwable $e): void {
|
set_exception_handler(static function (\Throwable $e): void {
|
||||||
if ($e instanceof PodmanApiException) {
|
if ($e instanceof PodmanApiException) {
|
||||||
|
|||||||
@@ -229,6 +229,53 @@ window.Podman = (function () {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small modal with a scrolling monospace log pane — for actions that run
|
||||||
|
* several steps in sequence (checking/updating containers) where a plain
|
||||||
|
* confirm()/alert() at the very end leaves the user with no feedback
|
||||||
|
* that anything is happening while it runs. Returns {log, done} rather
|
||||||
|
* than closing itself, since the caller knows when the whole sequence
|
||||||
|
* (not just one call) has actually finished.
|
||||||
|
*
|
||||||
|
* @param {string} title
|
||||||
|
* @returns {{log: (line: string) => void, done: (closeLabel?: string) => void}}
|
||||||
|
*/
|
||||||
|
function openLogModal(title) {
|
||||||
|
const backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'podman-modal-backdrop';
|
||||||
|
backdrop.innerHTML = '' +
|
||||||
|
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
|
||||||
|
'<div class="podman-modal-head"><h3>' + escapeHtml(title) + '</h3></div>' +
|
||||||
|
'<div class="podman-modal-body"><div class="podman-log-pane" id="podman-log-modal-pane"></div></div>' +
|
||||||
|
'<div class="podman-modal-actions"><button type="button" class="podman-btn podman-btn-primary" data-role="close" disabled>Working…</button></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||||
|
const pane = backdrop.querySelector('#podman-log-modal-pane');
|
||||||
|
const closeBtn = backdrop.querySelector('[data-role="close"]');
|
||||||
|
|
||||||
|
function close() { backdrop.remove(); }
|
||||||
|
closeBtn.addEventListener('click', close);
|
||||||
|
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
|
||||||
|
document.addEventListener('keydown', function onKey(e) {
|
||||||
|
if (e.key === 'Escape' && !closeBtn.disabled) { close(); document.removeEventListener('keydown', onKey); }
|
||||||
|
});
|
||||||
|
|
||||||
|
function log(line) {
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.textContent = line;
|
||||||
|
pane.appendChild(row);
|
||||||
|
pane.scrollTop = pane.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function done(closeLabel) {
|
||||||
|
closeBtn.disabled = false;
|
||||||
|
closeBtn.textContent = closeLabel || 'Close';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { log: log, done: done };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Small anchored dropdown menu — used for secondary per-row actions
|
* Small anchored dropdown menu — used for secondary per-row actions
|
||||||
* (pause/kill/rename/...) that would otherwise clutter a table row with
|
* (pause/kill/rename/...) that would otherwise clutter a table row with
|
||||||
@@ -368,6 +415,7 @@ window.Podman = (function () {
|
|||||||
loadingRow: loadingRow,
|
loadingRow: loadingRow,
|
||||||
errorRow: errorRow,
|
errorRow: errorRow,
|
||||||
openFormModal: openFormModal,
|
openFormModal: openFormModal,
|
||||||
|
openLogModal: openLogModal,
|
||||||
openContextMenu: openContextMenu,
|
openContextMenu: openContextMenu,
|
||||||
registerPanel: registerPanel,
|
registerPanel: registerPanel,
|
||||||
activatePanel: activatePanel,
|
activatePanel: activatePanel,
|
||||||
|
|||||||
@@ -10,21 +10,36 @@
|
|||||||
let allContainers = [];
|
let allContainers = [];
|
||||||
let filter = 'all';
|
let filter = 'all';
|
||||||
let searchTerm = '';
|
let searchTerm = '';
|
||||||
|
// Keyed by image reference (not container id) — several containers
|
||||||
|
// commonly share the same image, and ajax/containers.php's
|
||||||
|
// check_updates action itself already dedupes registry requests the
|
||||||
|
// same way. Persists across load()/renderTable() refreshes so the
|
||||||
|
// badge doesn't disappear on the next auto-refresh; only re-running
|
||||||
|
// "Check for Updates" replaces it.
|
||||||
|
let imageUpdateStatus = {};
|
||||||
|
|
||||||
function iconLabel(name) {
|
function iconLabel(name) {
|
||||||
return P.escapeHtml(name.slice(0, 2).toUpperCase());
|
return P.escapeHtml(name.slice(0, 2).toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasUpdate(c) {
|
||||||
|
const status = imageUpdateStatus[c.image];
|
||||||
|
return !!(status && status.updateAvailable);
|
||||||
|
}
|
||||||
|
|
||||||
function rowHtml(c) {
|
function rowHtml(c) {
|
||||||
const cpuMem = c.state === 'running'
|
const cpuMem = c.state === 'running' && c.cpuPercent != null
|
||||||
? '<span class="podman-row-sub">running</span>'
|
? '<span class="tnum">' + c.cpuPercent.toFixed(1) + '%</span> <span class="podman-row-sub">/ ' + P.formatBytes(c.memUsageBytes) + '</span>'
|
||||||
: '<span class="podman-row-sub">—</span>';
|
: '<span class="podman-row-sub">—</span>';
|
||||||
|
const updateBadge = hasUpdate(c)
|
||||||
|
? ' <span class="podman-badge-update" title="A newer image is available">↑ Update</span>'
|
||||||
|
: '';
|
||||||
|
|
||||||
return '' +
|
return '' +
|
||||||
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
|
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
|
||||||
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
|
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
|
||||||
'<td><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
|
'<td><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
|
||||||
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button></td>' +
|
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button>' + updateBadge + '</td>' +
|
||||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
|
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
|
||||||
'<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>' +
|
||||||
@@ -34,18 +49,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function actionButtons(c) {
|
function actionButtons(c) {
|
||||||
|
const updateBtn = hasUpdate(c)
|
||||||
|
? '<button class="podman-btn podman-btn-icon" data-action="update" title="Update to the newer image">↑</button>'
|
||||||
|
: '';
|
||||||
if (c.state === 'running') {
|
if (c.state === 'running') {
|
||||||
return '' +
|
return updateBtn +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">↻</button>' +
|
'<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="stop" title="Stop">■</button>' +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||||
}
|
}
|
||||||
if (c.state === 'paused') {
|
if (c.state === 'paused') {
|
||||||
return '' +
|
return updateBtn +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">▶</button>' +
|
'<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>';
|
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||||
}
|
}
|
||||||
return '' +
|
return updateBtn +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||||
}
|
}
|
||||||
@@ -57,6 +75,7 @@
|
|||||||
items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
|
items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
|
||||||
}
|
}
|
||||||
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
|
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
|
||||||
|
items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } });
|
||||||
items.push('separator');
|
items.push('separator');
|
||||||
items.push({
|
items.push({
|
||||||
label: 'Remove',
|
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 -----------------------------------------------------------
|
// --- Detail view -----------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Fed entirely by the existing inspect action (raw libpod inspect JSON) —
|
// Fed entirely by the existing inspect action (raw libpod inspect JSON) —
|
||||||
@@ -305,17 +536,24 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object|null} prefill Optional template data (same shape
|
* @param {object|null} prefill Optional template data (same shape
|
||||||
* ajax/templates.php's "get" action returns) to seed the form with —
|
* ajax/templates.php's "get" action returns, plus "name"/"pod" which
|
||||||
* used by templates.js's "Use template" action. null/omitted opens a
|
* only inspectToPrefill() sets) to seed the form with — used by
|
||||||
* blank form, same as the toolbar's "+ New Container" button.
|
* 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 || {};
|
prefill = prefill || {};
|
||||||
const backdrop = document.createElement('div');
|
const backdrop = document.createElement('div');
|
||||||
backdrop.className = 'podman-modal-backdrop';
|
backdrop.className = 'podman-modal-backdrop';
|
||||||
backdrop.innerHTML = '' +
|
backdrop.innerHTML = '' +
|
||||||
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
|
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
|
||||||
'<div class="podman-modal-head"><h3>New Container</h3></div>' +
|
'<div class="podman-modal-head"><h3>' + (editing ? 'Edit Container' : 'New Container') + '</h3></div>' +
|
||||||
'<form class="podman-modal-body">' +
|
'<form class="podman-modal-body">' +
|
||||||
'<div class="podman-modal-field"><label>Image</label>' +
|
'<div class="podman-modal-field"><label>Image</label>' +
|
||||||
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' +
|
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' +
|
||||||
@@ -325,12 +563,16 @@
|
|||||||
'<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" id="cc-static-ip-field" style="display:none;"><label>Static IP (optional)</label>' +
|
||||||
|
'<input type="text" class="mono" id="cc-static-ip" placeholder="10.1.1.222">' +
|
||||||
|
'<div class="hint">Leave blank to let the network assign one automatically.</div></div>' +
|
||||||
'<div class="podman-modal-field"><label>Pod (optional)</label>' +
|
'<div class="podman-modal-field"><label>Pod (optional)</label>' +
|
||||||
'<select id="cc-pod"><option value="">None</option></select>' +
|
'<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="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" id="cc-ports-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 class="hint" id="cc-ports-macvlan-hint" style="display:none;">Not needed on a macvlan network — the container gets its own address on the LAN.</div></div>' +
|
||||||
'<div class="podman-modal-field"><label>Volumes</label>' +
|
'<div class="podman-modal-field"><label>Volumes</label>' +
|
||||||
'<div class="podman-row-group" id="cc-volumes"></div>' +
|
'<div class="podman-row-group" id="cc-volumes"></div>' +
|
||||||
'<button type="button" class="podman-btn podman-btn-ghost" data-add="volume">+ Add volume</button></div>' +
|
'<button type="button" class="podman-btn podman-btn-ghost" data-add="volume">+ Add volume</button></div>' +
|
||||||
@@ -340,6 +582,8 @@
|
|||||||
'<div class="podman-modal-field"><label>Restart policy</label>' +
|
'<div class="podman-modal-field"><label>Restart policy</label>' +
|
||||||
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
|
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
|
||||||
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
|
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
|
||||||
|
'<div class="podman-modal-field" id="cc-gpu-field" style="display:none;"><label>GPU passthrough</label>' +
|
||||||
|
'<select id="cc-gpu-select"><option value="">None</option></select></div>' +
|
||||||
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
||||||
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
|
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
|
||||||
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
||||||
@@ -355,14 +599,35 @@
|
|||||||
'</form>' +
|
'</form>' +
|
||||||
'<div class="podman-modal-actions">' +
|
'<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-ghost" data-role="cancel">Cancel</button>' +
|
||||||
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
|
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' + (editing ? 'Save & Recreate' : 'Create') + '</button>' +
|
||||||
'</div></div>';
|
'</div></div>';
|
||||||
|
|
||||||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||||
|
|
||||||
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
|
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
|
||||||
|
if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name;
|
||||||
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
|
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
|
||||||
|
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
|
||||||
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
|
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
|
||||||
|
if (prefill.staticIp) backdrop.querySelector('#cc-static-ip').value = prefill.staticIp;
|
||||||
|
|
||||||
|
// Macvlan containers get their own address directly on the LAN (see
|
||||||
|
// the ajax/networks.php macvlan work) — port mappings are meaningless
|
||||||
|
// for them (there's no host-side NAT to map through) and a static IP
|
||||||
|
// becomes a relevant option instead of a Bridge/Host/None-only
|
||||||
|
// concept. Toggled on network-select change and once up front below,
|
||||||
|
// driven by each <option>'s data-driver (set when the real network
|
||||||
|
// list loads — the three built-ins are never macvlan).
|
||||||
|
function updateNetworkFieldsVisibility() {
|
||||||
|
const select = backdrop.querySelector('#cc-network');
|
||||||
|
const selectedOption = select.options[select.selectedIndex];
|
||||||
|
const isMacvlan = !!(selectedOption && selectedOption.dataset.driver === 'macvlan');
|
||||||
|
backdrop.querySelector('#cc-static-ip-field').style.display = isMacvlan ? '' : 'none';
|
||||||
|
backdrop.querySelector('#cc-ports').style.display = isMacvlan ? 'none' : '';
|
||||||
|
backdrop.querySelector('[data-add="port"]').style.display = isMacvlan ? 'none' : '';
|
||||||
|
backdrop.querySelector('#cc-ports-macvlan-hint').style.display = isMacvlan ? '' : 'none';
|
||||||
|
}
|
||||||
|
backdrop.querySelector('#cc-network').addEventListener('change', updateNetworkFieldsVisibility);
|
||||||
|
|
||||||
const portsGroup = backdrop.querySelector('#cc-ports');
|
const portsGroup = backdrop.querySelector('#cc-ports');
|
||||||
const volumesGroup = backdrop.querySelector('#cc-volumes');
|
const volumesGroup = backdrop.querySelector('#cc-volumes');
|
||||||
@@ -386,9 +651,18 @@
|
|||||||
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
|
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
|
||||||
const opt = document.createElement('option');
|
const opt = document.createElement('option');
|
||||||
opt.value = n.name;
|
opt.value = n.name;
|
||||||
opt.textContent = n.name;
|
opt.textContent = n.name + (n.driver === 'macvlan' ? ' (macvlan)' : '');
|
||||||
|
opt.dataset.driver = n.driver;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
// Re-applied here (not just at load time above) because a custom
|
||||||
|
// network's <option> doesn't exist yet until this list comes back —
|
||||||
|
// setting .value to it any earlier would silently no-op and leave
|
||||||
|
// the select on its default "bridge" option instead. Matters for
|
||||||
|
// openEditContainerModal(): a container already on a custom network
|
||||||
|
// needs that option to exist before it can be selected.
|
||||||
|
if (prefill.networkMode) select.value = prefill.networkMode;
|
||||||
|
updateNetworkFieldsVisibility();
|
||||||
}).catch(function () { /* built-in modes still usable */ });
|
}).catch(function () { /* built-in modes still usable */ });
|
||||||
|
|
||||||
P.get('pods', 'list').then(function (pods) {
|
P.get('pods', 'list').then(function (pods) {
|
||||||
@@ -399,8 +673,37 @@
|
|||||||
opt.textContent = p.name;
|
opt.textContent = p.name;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
if (prefill.pod) select.value = prefill.pod;
|
||||||
}).catch(function () { /* pod selection stays optional */ });
|
}).catch(function () { /* pod selection stays optional */ });
|
||||||
|
|
||||||
|
// Only shown when the host actually has a passthrough-capable GPU
|
||||||
|
// (AMD/Intel via /dev/dri — see ajax/containers.php's gpu_list(); NVIDIA
|
||||||
|
// is deliberately excluded there since it needs a different runtime) —
|
||||||
|
// best-effort, same as networks/pods above.
|
||||||
|
P.get('containers', 'list_gpus').then(function (gpus) {
|
||||||
|
if (!gpus.length) return;
|
||||||
|
const field = backdrop.querySelector('#cc-gpu-field');
|
||||||
|
const select = backdrop.querySelector('#cc-gpu-select');
|
||||||
|
field.style.display = '';
|
||||||
|
gpus.forEach(function (gpu, i) {
|
||||||
|
const devices = [gpu.render, gpu.card].filter(Boolean).join(', ');
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = String(i);
|
||||||
|
opt.textContent = gpu.vendor + ' GPU (' + devices + ')';
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
select.dataset.gpus = JSON.stringify(gpus);
|
||||||
|
// Pre-select whichever detected GPU the container being edited is
|
||||||
|
// already using (matched by device path, not index — gpu_list()'s
|
||||||
|
// order isn't guaranteed stable across requests).
|
||||||
|
if (prefill.gpuDevices && prefill.gpuDevices.length) {
|
||||||
|
const matchIndex = gpus.findIndex(function (gpu) {
|
||||||
|
return prefill.gpuDevices.indexOf(gpu.render) !== -1 || prefill.gpuDevices.indexOf(gpu.card) !== -1;
|
||||||
|
});
|
||||||
|
if (matchIndex !== -1) select.value = String(matchIndex);
|
||||||
|
}
|
||||||
|
}).catch(function () { /* GPU passthrough stays unavailable */ });
|
||||||
|
|
||||||
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) {
|
||||||
@@ -420,6 +723,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
|
if (editing && !confirm(
|
||||||
|
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
|
||||||
|
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?'
|
||||||
|
)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const image = backdrop.querySelector('#cc-image').value.trim();
|
const image = backdrop.querySelector('#cc-image').value.trim();
|
||||||
if (!image) {
|
if (!image) {
|
||||||
showError('"Image" is required.');
|
showError('"Image" is required.');
|
||||||
@@ -433,7 +743,17 @@
|
|||||||
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
|
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
|
const networkSelect = backdrop.querySelector('#cc-network');
|
||||||
|
const selectedNetworkOption = networkSelect.options[networkSelect.selectedIndex];
|
||||||
|
const isMacvlan = !!(selectedNetworkOption && selectedNetworkOption.dataset.driver === 'macvlan');
|
||||||
|
// Port mappings map a host port to a container port through NAT —
|
||||||
|
// meaningless on a macvlan network, where the container already has
|
||||||
|
// its own real address on the LAN (see updateNetworkFieldsVisibility()
|
||||||
|
// above, which also hides the UI for this) — so none are sent even
|
||||||
|
// if some were left over from switching the network dropdown after
|
||||||
|
// adding a few.
|
||||||
|
const ports = isMacvlan ? [] : readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
|
||||||
|
const staticIp = isMacvlan ? backdrop.querySelector('#cc-static-ip').value.trim() : '';
|
||||||
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
|
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
|
||||||
const env = readRows(envGroup).filter(function (r) { return r.key; });
|
const env = readRows(envGroup).filter(function (r) { return r.key; });
|
||||||
|
|
||||||
@@ -446,20 +766,39 @@
|
|||||||
|
|
||||||
const networkMode = backdrop.querySelector('#cc-network').value;
|
const networkMode = backdrop.querySelector('#cc-network').value;
|
||||||
const privileged = backdrop.querySelector('#cc-privileged').checked;
|
const privileged = backdrop.querySelector('#cc-privileged').checked;
|
||||||
|
const gpuSelect = backdrop.querySelector('#cc-gpu-select');
|
||||||
|
const gpus = gpuSelect.dataset.gpus ? JSON.parse(gpuSelect.dataset.gpus) : [];
|
||||||
|
const selectedGpu = gpuSelect.value !== '' ? gpus[Number(gpuSelect.value)] : null;
|
||||||
|
const gpuDevices = selectedGpu ? [selectedGpu.render, selectedGpu.card].filter(Boolean) : [];
|
||||||
|
|
||||||
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
P.post('containers', 'create', {
|
|
||||||
image: image,
|
// Editing an existing container: no in-place "modify" API exists
|
||||||
name: backdrop.querySelector('#cc-name').value.trim(),
|
// (see the comment on openEditContainerModal above), so this stops
|
||||||
networkMode: networkMode,
|
// and removes the old one first — best-effort stop (it may already
|
||||||
pod: backdrop.querySelector('#cc-pod').value,
|
// be stopped) followed by a forced remove — before creating the
|
||||||
ports: ports,
|
// replacement under whatever name is in the form now.
|
||||||
volumes: volumes,
|
const removeOld = editing
|
||||||
env: env,
|
? P.post('containers', 'stop', { id: editing.id }).catch(function () { /* already stopped is fine */ })
|
||||||
restartPolicy: backdrop.querySelector('#cc-restart').value,
|
.then(function () { return P.post('containers', 'remove', { id: editing.id, force: true }); })
|
||||||
privileged: privileged,
|
: Promise.resolve();
|
||||||
startAfterCreate: backdrop.querySelector('#cc-start').checked,
|
|
||||||
|
removeOld.then(function () {
|
||||||
|
return P.post('containers', 'create', {
|
||||||
|
image: image,
|
||||||
|
name: backdrop.querySelector('#cc-name').value.trim(),
|
||||||
|
networkMode: networkMode,
|
||||||
|
staticIp: staticIp,
|
||||||
|
pod: backdrop.querySelector('#cc-pod').value,
|
||||||
|
ports: ports,
|
||||||
|
volumes: volumes,
|
||||||
|
env: env,
|
||||||
|
restartPolicy: backdrop.querySelector('#cc-restart').value,
|
||||||
|
gpuDevices: gpuDevices,
|
||||||
|
privileged: privileged,
|
||||||
|
startAfterCreate: backdrop.querySelector('#cc-start').checked,
|
||||||
|
});
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
// Best-effort: a template-save failure shouldn't undo or block
|
// Best-effort: a template-save failure shouldn't undo or block
|
||||||
// the container that was just successfully created.
|
// the container that was just successfully created.
|
||||||
@@ -483,7 +822,7 @@
|
|||||||
return load();
|
return load();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
submitBtn.disabled = false;
|
submitBtn.disabled = false;
|
||||||
showError(err.message);
|
showError((editing ? 'The old container may already be removed. ' : '') + err.message);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,11 +876,23 @@
|
|||||||
if (!btn || btn.disabled) return;
|
if (!btn || btn.disabled) return;
|
||||||
const row = btn.closest('tr');
|
const row = btn.closest('tr');
|
||||||
const id = row.dataset.id;
|
const id = row.dataset.id;
|
||||||
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details') {
|
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') {
|
||||||
const c = allContainers.find(function (x) { return x.id === id; });
|
const c = allContainers.find(function (x) { return x.id === id; });
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
if (btn.dataset.action === 'menu') {
|
if (btn.dataset.action === 'menu') {
|
||||||
openRowMenu(c, btn);
|
openRowMenu(c, btn);
|
||||||
|
} else if (btn.dataset.action === 'update') {
|
||||||
|
if (!confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
const modal = P.openLogModal('Updating ' + c.name);
|
||||||
|
updateContainer(c, modal.log).then(function () {
|
||||||
|
modal.done();
|
||||||
|
return load();
|
||||||
|
}).catch(function (err) {
|
||||||
|
modal.log('Failed: ' + err.message);
|
||||||
|
modal.done();
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
openDetailModal(c);
|
openDetailModal(c);
|
||||||
}
|
}
|
||||||
@@ -550,6 +901,9 @@
|
|||||||
handleAction(id, btn.dataset.action, btn);
|
handleAction(id, btn.dataset.action, btn);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates);
|
||||||
|
P.el('containers-update-all-btn').addEventListener('click', updateAll);
|
||||||
|
|
||||||
return load();
|
return load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@
|
|||||||
'<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"><div class="podman-actions-row"><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="tag" title="Add tag">🏷</button>' +
|
||||||
|
'<button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||||
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></div></td>' +
|
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></div></td>' +
|
||||||
'</tr>';
|
'</tr>';
|
||||||
}
|
}
|
||||||
@@ -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) {
|
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;
|
if (!btn || btn.disabled) return;
|
||||||
const id = btn.closest('tr').dataset.id;
|
const id = btn.closest('tr').dataset.id;
|
||||||
if (!confirm('Remove this image?')) return;
|
|
||||||
btn.disabled = true;
|
if (btn.dataset.action === 'tag') {
|
||||||
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
|
P.openFormModal({
|
||||||
alert('Remove failed: ' + err.message);
|
title: 'Add Tag',
|
||||||
btn.disabled = false;
|
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();
|
return load();
|
||||||
|
|||||||
@@ -43,21 +43,120 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
// Purpose-built modal (not app.js's generic openFormModal, which only
|
||||||
P.el('networks-create-btn').addEventListener('click', function () {
|
// supports flat always-visible text fields) — the parent-interface
|
||||||
P.openFormModal({
|
// dropdown and gateway field only make sense for "macvlan" and need to
|
||||||
title: 'New Network',
|
// show/hide based on the driver choice.
|
||||||
submitLabel: 'Create',
|
function openCreateNetworkModal() {
|
||||||
fields: [
|
const backdrop = document.createElement('div');
|
||||||
{ name: 'name', label: 'Network name', required: true, placeholder: 'my-network' },
|
backdrop.className = 'podman-modal-backdrop';
|
||||||
{ name: 'subnet', label: 'Subnet (optional)', placeholder: '10.89.2.0/24' },
|
backdrop.innerHTML = '' +
|
||||||
],
|
'<div class="podman-modal" role="dialog" aria-modal="true">' +
|
||||||
onSubmit: function (values) {
|
'<div class="podman-modal-head"><h3>New Network</h3></div>' +
|
||||||
return P.post('networks', 'create', { name: values.name, driver: 'bridge', subnet: values.subnet || undefined }).then(load);
|
'<form class="podman-modal-body">' +
|
||||||
},
|
'<div class="podman-modal-field"><label>Network name</label>' +
|
||||||
});
|
'<input type="text" id="cn-name" placeholder="my-network"></div>' +
|
||||||
|
'<div class="podman-modal-field"><label>Type</label>' +
|
||||||
|
'<select id="cn-driver">' +
|
||||||
|
'<option value="bridge">Bridge (isolated, NAT — default)</option>' +
|
||||||
|
'<option value="macvlan">Macvlan (containers get a real IP on your LAN)</option>' +
|
||||||
|
'</select></div>' +
|
||||||
|
'<div class="podman-modal-field" id="cn-parent-field" style="display:none;">' +
|
||||||
|
'<label>Parent interface</label><select id="cn-parent"></select>' +
|
||||||
|
'<div class="hint">Same interface Docker Manager\'s "Custom: br0"-style networks use.</div></div>' +
|
||||||
|
'<div class="podman-modal-field"><label id="cn-subnet-label">Subnet (optional)</label>' +
|
||||||
|
'<input type="text" class="mono" id="cn-subnet" placeholder="10.89.2.0/24"></div>' +
|
||||||
|
'<div class="podman-modal-field" id="cn-gateway-field" style="display:none;">' +
|
||||||
|
'<label>Gateway</label><input type="text" class="mono" id="cn-gateway" placeholder="10.1.1.1"></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);
|
||||||
|
|
||||||
|
let parentInterfaces = [];
|
||||||
|
P.get('networks', 'list_parent_interfaces').then(function (interfaces) {
|
||||||
|
parentInterfaces = interfaces;
|
||||||
|
const select = backdrop.querySelector('#cn-parent');
|
||||||
|
select.innerHTML = interfaces.map(function (i) {
|
||||||
|
return '<option value="' + P.escapeHtml(i.interface) + '">' + P.escapeHtml(i.label) + '</option>';
|
||||||
|
}).join('');
|
||||||
|
}).catch(function () { /* macvlan option just won't have anything to pick if this fails */ });
|
||||||
|
|
||||||
|
backdrop.querySelector('#cn-driver').addEventListener('change', function (e) {
|
||||||
|
const isMacvlan = e.target.value === 'macvlan';
|
||||||
|
backdrop.querySelector('#cn-parent-field').style.display = isMacvlan ? '' : 'none';
|
||||||
|
backdrop.querySelector('#cn-gateway-field').style.display = isMacvlan ? '' : 'none';
|
||||||
|
backdrop.querySelector('#cn-subnet-label').textContent = isMacvlan ? 'Subnet' : 'Subnet (optional)';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
backdrop.querySelector('#cn-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('#cn-name').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
showError('"Network name" is required.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const driver = backdrop.querySelector('#cn-driver').value;
|
||||||
|
const subnet = backdrop.querySelector('#cn-subnet').value.trim();
|
||||||
|
const gateway = backdrop.querySelector('#cn-gateway').value.trim();
|
||||||
|
const parentInterface = backdrop.querySelector('#cn-parent').value;
|
||||||
|
|
||||||
|
if (driver === 'macvlan') {
|
||||||
|
if (!subnet) {
|
||||||
|
showError('"Subnet" is required for a macvlan network.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!parentInterfaces.length) {
|
||||||
|
showError('No host bridge/VLAN interface available to attach to.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
P.post('networks', 'create', {
|
||||||
|
name: name,
|
||||||
|
driver: driver,
|
||||||
|
subnet: subnet || undefined,
|
||||||
|
gateway: gateway || undefined,
|
||||||
|
parentInterface: driver === 'macvlan' ? parentInterface : undefined,
|
||||||
|
}).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 init() {
|
||||||
|
P.el('networks-create-btn').addEventListener('click', openCreateNetworkModal);
|
||||||
|
|
||||||
P.el('networks-tbody').addEventListener('click', function (e) {
|
P.el('networks-tbody').addEventListener('click', function (e) {
|
||||||
const btn = e.target.closest('button[data-action="remove"]');
|
const btn = e.target.closest('button[data-action="remove"]');
|
||||||
if (!btn || btn.disabled) return;
|
if (!btn || btn.disabled) return;
|
||||||
|
|||||||
@@ -29,7 +29,6 @@
|
|||||||
'<div class="podman-template-body">' +
|
'<div class="podman-template-body">' +
|
||||||
'<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' +
|
'<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' +
|
||||||
'<div class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</div>' +
|
'<div class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</div>' +
|
||||||
(t.category ? '<span class="podman-badge">' + P.escapeHtml(t.category) + '</span>' : '') +
|
|
||||||
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
|
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="podman-template-actions">' +
|
'<div class="podman-template-actions">' +
|
||||||
|
|||||||
@@ -185,7 +185,12 @@
|
|||||||
.podman-table-wrap { overflow-x: auto; }
|
.podman-table-wrap { overflow-x: auto; }
|
||||||
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||||
.podman-row-name-btn {
|
.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;
|
color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left;
|
||||||
}
|
}
|
||||||
.podman-row-name-btn:hover { color: var(--accent-strong); }
|
.podman-row-name-btn:hover { color: var(--accent-strong); }
|
||||||
@@ -207,13 +212,47 @@
|
|||||||
.podman-actions { text-align: right; white-space: nowrap; }
|
.podman-actions { text-align: right; white-space: nowrap; }
|
||||||
.podman-actions-row { display: inline-flex; gap: 4px; justify-content: flex-end; }
|
.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 <div> around plain
|
||||||
|
* <button>s with no CSS of their own at all, so every option (not just the
|
||||||
|
* active one) showed Unraid's own default button border permanently,
|
||||||
|
* all three chips looking identically "selected". !important for the same
|
||||||
|
* site-wide-theme-override reason as .podman-btn-ghost/-primary.
|
||||||
|
*/
|
||||||
|
.podman-segmented { display: flex; gap: 2px; background: var(--surface-3); border: 1px solid var(--text-faint); padding: 3px; border-radius: 8px; }
|
||||||
|
.podman-segmented button {
|
||||||
|
appearance: none; border: none !important; background: transparent !important; color: var(--text-dim) !important;
|
||||||
|
padding: 6px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer;
|
||||||
|
font-family: var(--font-ui); transition: background .12s, color .12s;
|
||||||
|
}
|
||||||
|
.podman-segmented button:hover { color: var(--text) !important; }
|
||||||
|
/* Filled with the accent color (not just a slightly different neutral
|
||||||
|
shade) — the previous var(--surface) vs. var(--surface-2) contrast
|
||||||
|
between active/inactive was too close in the dark theme to notice at a
|
||||||
|
glance (found live). */
|
||||||
|
.podman-segmented button.active { background: var(--accent) !important; color: var(--accent-contrast) !important; box-shadow: var(--shadow); }
|
||||||
|
|
||||||
.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; }
|
||||||
.podman-usage-mini .track > span { display: block; height: 100%; background: var(--accent); }
|
.podman-usage-mini .track > span { display: block; height: 100%; background: var(--accent); }
|
||||||
.podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; }
|
.podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; }
|
||||||
|
|
||||||
.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
.podman-search { flex: 1; min-width: 180px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 11px; font-size: 13px; color: var(--text); font-family: var(--font-ui); }
|
/*
|
||||||
|
* !important throughout: Unraid's own webGui/styles/default-base.css
|
||||||
|
* targets input[type="text"] with an attribute selector (higher
|
||||||
|
* specificity than our single .podman-search class, :where() around it
|
||||||
|
* notwithstanding) forcing border-width:0 / border-bottom-width:1px /
|
||||||
|
* background:transparent — an underline-only text field, not a boxed one.
|
||||||
|
* Found live: our border/background were being silently dropped even
|
||||||
|
* though this rule appears later in the stylesheet.
|
||||||
|
*/
|
||||||
|
.podman-search {
|
||||||
|
flex: 1; min-width: 180px; max-width: 320px; font-size: 13px; color: var(--text); font-family: var(--font-ui);
|
||||||
|
background: var(--surface-3) !important; border: 1px solid var(--text-faint) !important;
|
||||||
|
border-radius: 7px !important; padding: 7px 11px !important;
|
||||||
|
}
|
||||||
.podman-search::placeholder { color: var(--text-faint); }
|
.podman-search::placeholder { color: var(--text-faint); }
|
||||||
|
|
||||||
.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; }
|
.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; }
|
||||||
@@ -247,8 +286,18 @@
|
|||||||
}
|
}
|
||||||
.podman-template-name { font-weight: 700; font-size: 13.5px; }
|
.podman-template-name { font-weight: 700; font-size: 13.5px; }
|
||||||
.podman-template-overview { font-size: 12px; color: var(--text-dim); line-height: 1.4; }
|
.podman-template-overview { font-size: 12px; color: var(--text-dim); line-height: 1.4; }
|
||||||
.podman-template-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 4px; }
|
.podman-template-actions { display: flex; gap: 6px; margin-top: auto; padding-top: 4px; }
|
||||||
.podman-template-actions .podman-btn { flex: 1; justify-content: center; padding: 6px 10px; font-size: 12px; }
|
/*
|
||||||
|
* min-width: 0 overrides the flex-item default of min-width: auto, which
|
||||||
|
* otherwise refuses to shrink a button below its own label's intrinsic
|
||||||
|
* width — without it, "Delete" (the widest label, and uppercased by
|
||||||
|
* Unraid's own site-wide button theme) pushed past the card's right edge
|
||||||
|
* instead of actually sharing the row evenly with Use/Export (found live).
|
||||||
|
*/
|
||||||
|
.podman-template-actions .podman-btn {
|
||||||
|
flex: 1; min-width: 0; justify-content: center; padding: 6px 8px; font-size: 11.5px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.podman-local-template-list { max-height: 220px; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; margin-top: 8px; }
|
.podman-local-template-list { max-height: 220px; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; margin-top: 8px; }
|
||||||
.podman-local-template-item {
|
.podman-local-template-item {
|
||||||
|
|||||||
Reference in New Issue
Block a user