diff --git a/webui/plugins/podman/Podman.page b/webui/plugins/podman/Podman.page index 88dad75..73e1c5f 100644 --- a/webui/plugins/podman/Podman.page +++ b/webui/plugins/podman/Podman.page @@ -86,11 +86,13 @@ function podman_asset_version(string $relPath): string
-
+
+ +
@@ -113,6 +115,7 @@ function podman_asset_version(string $relPath): string
+
@@ -167,7 +170,7 @@ function podman_asset_version(string $relPath): string
- + diff --git a/webui/plugins/podman/ajax/containers.php b/webui/plugins/podman/ajax/containers.php index 90f0e21..e1046df 100644 --- a/webui/plugins/podman/ajax/containers.php +++ b/webui/plugins/podman/ajax/containers.php @@ -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 -> {"": {"updateAvailable": bool, "error": "..."?}} for every image currently in use * create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"", + * "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": "", + * "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> 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> + */ +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":{"":{"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, ]; } diff --git a/webui/plugins/podman/ajax/images.php b/webui/plugins/podman/ajax/images.php index 4401a4d..1505003 100644 --- a/webui/plugins/podman/ajax/images.php +++ b/webui/plugins/podman/ajax/images.php @@ -8,6 +8,8 @@ * list GET -> normalized image list * pull POST {"reference": "docker.io/library/postgres:16"} * 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); @@ -40,6 +42,27 @@ switch ($action) { podman_json_response(['status' => 'removed']); 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: podman_json_error("Unknown action '{$action}'", 400); } diff --git a/webui/plugins/podman/ajax/networks.php b/webui/plugins/podman/ajax/networks.php index 3a236f6..6467f2a 100644 --- a/webui/plugins/podman/ajax/networks.php +++ b/webui/plugins/podman/ajax/networks.php @@ -7,9 +7,11 @@ * from Docker's own docker0/custom-network space. * * Actions (?action=...): - * list GET -> normalized network list with subnet/gateway/usage - * create POST {"name": "...", "driver": "bridge", "subnet": "...", "gateway": "..."} - * remove POST {"name": "...", "force": false} + * list GET -> normalized network list with subnet/gateway/usage + * list_parent_interfaces GET -> host bridge/VLAN interfaces available as a macvlan parent + * create POST {"name": "...", "driver": "bridge"|"macvlan", "subnet": "...", + * "gateway": "...", "parentInterface": "br0"} + * remove POST {"name": "...", "force": false} */ declare(strict_types=1); @@ -23,17 +25,40 @@ switch ($action) { podman_json_response(networks_list($client)); break; + case 'list_parent_interfaces': + podman_json_response(macvlan_parent_interfaces()); + break; + case 'create': $body = podman_read_json_body(); $name = (string) ($body['name'] ?? ''); if ($name === '') { 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( $name, - (string) ($body['driver'] ?? 'bridge'), + $driver, isset($body['subnet']) ? (string) $body['subnet'] : null, - isset($body['gateway']) ? (string) $body['gateway'] : null + isset($body['gateway']) ? (string) $body['gateway'] : null, + $parentInterface )); break; @@ -54,6 +79,61 @@ switch ($action) { 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 + */ +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> */ function networks_list(PodmanClient $client): array { diff --git a/webui/plugins/podman/include/PodmanClient.php b/webui/plugins/podman/include/PodmanClient.php index c9fefb2..b4e7030 100644 --- a/webui/plugins/podman/include/PodmanClient.php +++ b/webui/plugins/podman/include/PodmanClient.php @@ -114,7 +114,15 @@ final class PodmanClient */ 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'] ?? ''); } @@ -300,7 +308,14 @@ final class PodmanClient */ 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; 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); } + /** + * 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 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 // ------------------------------------------------------------------- @@ -374,12 +411,25 @@ final class PodmanClient 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]; if ($subnet !== null) { $body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])]; } + if ($parentInterface !== null && $parentInterface !== '') { + $body['network_interface'] = $parentInterface; + } return $this->request('POST', '/networks/create', [], false, $body); } @@ -400,9 +450,9 @@ final class PodmanClient * @param array|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one * @return array */ - 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) === '') { return []; } @@ -421,7 +471,7 @@ final class PodmanClient * @param array $query * @param array|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; if (!empty($query)) { @@ -434,7 +484,7 @@ final class PodmanClient CURLOPT_URL => $url, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => $this->timeoutSeconds, + CURLOPT_TIMEOUT => $timeoutSeconds ?? $this->timeoutSeconds, CURLOPT_HTTPHEADER => ['Accept: application/json'], ]); diff --git a/webui/plugins/podman/include/RegistryClient.php b/webui/plugins/podman/include/RegistryClient.php new file mode 100644 index 0000000..c8c1699 --- /dev/null +++ b/webui/plugins/podman/include/RegistryClient.php @@ -0,0 +1,172 @@ + '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,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]; + } +} diff --git a/webui/plugins/podman/include/bootstrap.php b/webui/plugins/podman/include/bootstrap.php index 7f7bb2b..415d000 100644 --- a/webui/plugins/podman/include/bootstrap.php +++ b/webui/plugins/podman/include/bootstrap.php @@ -20,6 +20,7 @@ declare(strict_types=1); require_once __DIR__ . '/PodmanClient.php'; require_once __DIR__ . '/Config.php'; require_once __DIR__ . '/helpers.php'; +require_once __DIR__ . '/RegistryClient.php'; set_exception_handler(static function (\Throwable $e): void { if ($e instanceof PodmanApiException) { diff --git a/webui/plugins/podman/javascript/app.js b/webui/plugins/podman/javascript/app.js index 9d6e050..beb24f1 100644 --- a/webui/plugins/podman/javascript/app.js +++ b/webui/plugins/podman/javascript/app.js @@ -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 = '' + + ''; + + (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 * (pause/kill/rename/...) that would otherwise clutter a table row with @@ -368,6 +415,7 @@ window.Podman = (function () { loadingRow: loadingRow, errorRow: errorRow, openFormModal: openFormModal, + openLogModal: openLogModal, openContextMenu: openContextMenu, registerPanel: registerPanel, activatePanel: activatePanel, diff --git a/webui/plugins/podman/javascript/containers.js b/webui/plugins/podman/javascript/containers.js index 287e512..4bcdf4b 100644 --- a/webui/plugins/podman/javascript/containers.js +++ b/webui/plugins/podman/javascript/containers.js @@ -10,21 +10,36 @@ let allContainers = []; let filter = 'all'; 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) { return P.escapeHtml(name.slice(0, 2).toUpperCase()); } + function hasUpdate(c) { + const status = imageUpdateStatus[c.image]; + return !!(status && status.updateAvailable); + } + function rowHtml(c) { - const cpuMem = c.state === 'running' - ? 'running' + const cpuMem = c.state === 'running' && c.cpuPercent != null + ? '' + c.cpuPercent.toFixed(1) + '% / ' + P.formatBytes(c.memUsageBytes) + '' : ''; + const updateBadge = hasUpdate(c) + ? ' ↑ Update' + : ''; return '' + '' + '' + P.escapeHtml(c.health || c.state) + '' + '' + + '' + iconLabel(c.name) + '' + P.escapeHtml(c.name) + '' + updateBadge + '' + '' + P.escapeHtml(c.image) + '' + '' + cpuMem + '' + '' + P.escapeHtml(c.ports.join(', ') || '—') + '' + @@ -34,18 +49,21 @@ } function actionButtons(c) { + const updateBtn = hasUpdate(c) + ? '' + : ''; if (c.state === 'running') { - return '' + + return updateBtn + '' + '' + ''; } if (c.state === 'paused') { - return '' + + return updateBtn + '' + ''; } - return '' + + return updateBtn + '' + ''; } @@ -57,6 +75,7 @@ items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } }); } items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } }); + items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } }); items.push('separator'); items.push({ 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 ----------------------------------------------------------- // // Fed entirely by the existing inspect action (raw libpod inspect JSON) — @@ -305,17 +536,24 @@ /** * @param {object|null} prefill Optional template data (same shape - * ajax/templates.php's "get" action returns) to seed the form with — - * used by templates.js's "Use template" action. null/omitted opens a - * blank form, same as the toolbar's "+ New Container" button. + * ajax/templates.php's "get" action returns, plus "name"/"pod" which + * only inspectToPrefill() sets) to seed the form with — used by + * 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 || {}; const backdrop = document.createElement('div'); backdrop.className = 'podman-modal-backdrop'; backdrop.innerHTML = '' + '