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:
@@ -18,10 +18,14 @@
|
||||
* kill POST {"id": "...", "signal": "SIGKILL"}
|
||||
* rename POST {"id": "...", "name": "..."}
|
||||
* 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>",
|
||||
* "staticIp": "10.1.1.222" (only meaningful with a custom/macvlan networkMode),
|
||||
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
|
||||
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
|
||||
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
|
||||
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"],
|
||||
* "privileged": false, "startAfterCreate": true}
|
||||
*/
|
||||
|
||||
@@ -105,6 +109,14 @@ switch ($action) {
|
||||
podman_json_response(['status' => 'renamed']);
|
||||
break;
|
||||
|
||||
case 'list_gpus':
|
||||
podman_json_response(gpu_list());
|
||||
break;
|
||||
|
||||
case 'check_updates':
|
||||
podman_json_response(check_image_updates($client));
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$body = podman_read_json_body();
|
||||
$image = trim((string) ($body['image'] ?? ''));
|
||||
@@ -138,6 +150,102 @@ switch ($action) {
|
||||
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
|
||||
* 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)) {
|
||||
$spec['netns'] = ['nsmode' => $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'] !== '') {
|
||||
@@ -238,6 +360,21 @@ function build_container_spec(string $image, array $body): array
|
||||
$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'] ?? ''));
|
||||
if ($pod !== '') {
|
||||
// "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);
|
||||
$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[] = [
|
||||
'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,
|
||||
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
|
||||
'createdAt' => podman_parse_time($c['Created'] ?? null),
|
||||
'cpuPercent' => $cpuPercent,
|
||||
'memUsageBytes' => $memUsageBytes,
|
||||
'memLimitBytes' => $memLimitBytes,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user