From 23898ff62e1d44fe7633e62ab57646bd0dbdeff9 Mon Sep 17 00:00:00 2001 From: magges Date: Mon, 13 Jul 2026 21:32:34 +0000 Subject: [PATCH] Rework Dashboard, add toasts, container folders/icons/WebUI links, detail tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard: - Plain-count stat tiles (Running/Stopped/Pods/Images/Volumes/Networks) separated from a single "Resource Usage" card (CPU/Memory/Swap/Storage meter rows) instead of forcing both into one tile grid, which produced awkward spanning-tile/dead-cell layouts. - Fixed CPU usage never changing (libpod's own cpuUtilization is computed once and never resampled) by computing it from /proc/stat deltas instead. - Fixed memory usage reading far too high by using /proc/meminfo's MemAvailable instead of libpod's raw (non-reclaimable-aware) memFree. - Added an Autostart Queue table reusing podman-autostart.sh's own failure-counter files. - Dashboard and Containers now auto-refresh every ~2s (paused when the tab is hidden or a modal is open). Toasts: - Real success/warn/error/info toast notifications replacing every alert() used for one-way feedback, across every panel. Container detail modal: - 5 new tabs: Resources, Logs, Console, Events, Healthcheck. Containers panel: - Folders to group containers (name + icon), stored in the plugin's own folders.json — a folder's header always shows an icon+name+status chip per member, matching Unraid's own Docker page folders. "Move to Folder" becomes "Remove from Folder" once a container is already grouped. - Containers can carry an icon URL and a WebUI URL (small button next to the name), both stored as container labels and auto-filled from templates where applicable. - Settings: an "Add container" control for the Autostart order table. Fixes: - Context menus now measure their own rendered size and flip above the anchor when there isn't room below, instead of running off-screen. - Containers table now uses table-layout:fixed with explicit column widths — auto layout was shifting every column (and the header) on every folder expand/collapse, and briefly again when a flex wrapper was mistakenly placed directly on a . Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 106 ++++ webui/plugins/podman/Podman.page | 42 +- webui/plugins/podman/ajax/containers.php | 38 +- webui/plugins/podman/ajax/folders.php | 91 ++++ webui/plugins/podman/ajax/settings.php | 30 +- webui/plugins/podman/ajax/system.php | 192 ++++++- webui/plugins/podman/include/Config.php | 2 + webui/plugins/podman/include/PodmanClient.php | 35 ++ webui/plugins/podman/include/helpers.php | 31 ++ webui/plugins/podman/javascript/app.js | 104 +++- webui/plugins/podman/javascript/compose.js | 17 +- webui/plugins/podman/javascript/containers.js | 489 +++++++++++++++++- webui/plugins/podman/javascript/dashboard.js | 116 ++++- webui/plugins/podman/javascript/images.js | 8 +- webui/plugins/podman/javascript/networks.js | 2 +- webui/plugins/podman/javascript/pods.js | 2 +- webui/plugins/podman/javascript/settings.js | 48 +- webui/plugins/podman/javascript/templates.js | 6 +- webui/plugins/podman/javascript/volumes.js | 2 +- webui/plugins/podman/styles/podman.css | 155 +++++- 20 files changed, 1416 insertions(+), 100 deletions(-) create mode 100644 webui/plugins/podman/ajax/folders.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1561e70..13e964d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,112 @@ see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#52-build-strategie)). ## [Unreleased] +### Added +- Containers panel: folders to group containers (name + optional icon + URL), purely cosmetic organizational metadata stored in the plugin's + own `folders.json` — podman itself has no such concept, same as + Unraid's own Docker page's folders. "+ New Folder", a "Move to Folder" + submenu on each container's row menu, and per-folder rename/delete + (deleting a folder only ungroups its containers, never touches them). + A folder with nothing assigned yet still shows up (so there's + somewhere to move a container into); one that's merely hidden by the + current search/filter does not. A folder's header always shows an + icon+name+status chip per member — collapsed or expanded, matching how + Unraid's own Docker page folders behave — rather than hiding everything + behind a bare count; clicking a chip opens the same row menu the "⋮" + button does (Details is now a menu item there too, alongside + Pause/Kill/Rename/Edit/Remove) rather than jumping straight to the + detail modal. "Move to Folder" only shows for a container that isn't + grouped yet — once it's in one, that item becomes a direct "Remove + from Folder" instead, since "move to a folder" reads as "add" and is + ambiguous/redundant once it's already in one. Containers can also + carry an icon URL (settable in the create/edit form, auto-filled when + creating from a template) shown in the table instead of the 2-letter + initials avatar. +- Containers can now carry a WebUI URL too (create/edit form, preserved + through Update/Update All the same way the icon URL is), shown as a + small open-in-new-tab button next to the name in the Containers table + when set. Both this and the icon are stored as the plugin's own + container labels (`podman-webui.weburl`/`.icon`), read straight off + the already-fetched container list — no extra per-container calls. +- Container detail modal: 5 new tabs (Resources, Logs, Console, Events, + Healthcheck), rounding it out from 6 to 11 of prompt.md's 12 requested + tabs (Volumes was left merged into the existing Mounts tab — same + underlying source/destination data, a separate tab would just repeat + it). Resources shows live CPU/memory alongside configured limits + (memory/swap/CPU/PIDs/block-I/O — a `0` in podman's own HostConfig + means "unlimited", not zero). Logs reuses the existing per-container + logs endpoint. Console opens a real ttyd/podman-exec session scoped to + the modal, cleaned up (killing the ttyd process server-side) on every + way of leaving — switching tabs, Close, backdrop click, or Escape — + not just on an explicit Disconnect, so peeking at a console doesn't + leak an orphaned ttyd process. Events and Healthcheck are both one-shot + historical queries (libpod's `/events?stream=false` for the last 7 + days, and inspect's own `State.Health.Log`) rather than a live stream, + which stays out of scope (see dashboard.js's own note on why). +- A real toast notification system (success/warning/error/info, auto- + dismissing, stacked bottom-right, dismissible early) replacing every + `alert()` used for one-way feedback across Containers, Images, Volumes, + Networks, Pods, Templates, Compose, and Settings — confirmations stay + native `confirm()` (a decision, not a notice), and long command output + (`podman compose up/down`, previously also an `alert()`) now goes to + the existing scrolling log-modal instead, which fits it better than a + toast ever could. +- Dashboard reworked: a clean 6-tile row of plain counts (Running, + Stopped, Pods, Images, Volumes, Networks — colored green/red on + Running/Stopped so fleet health reads at a glance), a single "Resource + Usage" card with aligned CPU/Memory/Swap/Storage meter rows (bars shift + to warn/bad colors above 75%/90% instead of staying accent-colored at + any value), and an "Autostart Queue" table (reusing + `podman-autostart.sh`'s own per-container failure counters, so it shows + exactly what that script would decide, not a second tracked history). + Bar-and-percentage metrics were kept out of the plain-count tile grid + entirely after an earlier attempt at combining them (a tile spanning + multiple grid columns) left dead, empty cells in the layout. +- Dashboard and Containers now auto-refresh every ~2s (paused while the + tab is hidden or a modal is open) instead of requiring a manual + Refresh click to see current state. +- Settings: an "Add container" dropdown + button above the Autostart + order table — previously that table could only reorder/remove + containers already in the chain, with no way to add one in the first + place. +- Containers: "Update All" now also removes the old, now-unused image + version each updated container leaves behind (only when at least one + container actually updated), instead of leaving stale images to pile up + on disk after every update run. + +### Fixed +- Dashboard memory usage was calculated from libpod's raw `memFree` (which + excludes reclaimable buffers/cache), showing usage far higher than + reality — e.g. 67% "used" where `free -h` reported 19%. Now reads + `/proc/meminfo`'s `MemAvailable` directly, matching what `free -h` and + most monitoring tools show. +- Dashboard CPU usage never changed — libpod's own `/info` computes + `cpuUtilization` once and never resamples it (confirmed live: three + calls seconds apart returned byte-identical numbers). Now computed from + `/proc/stat` deltas between successive requests, the same technique + `top`/`htop` use, which auto-refresh's ~2s cadence fits naturally. +- Context menus (a container's "⋮", a folder's "⋮") always dropped down + from the anchor at a fixed position — opened from a row near the + bottom of the viewport, the menu ran off-screen with its last items + unreachable. Now measures its own actual rendered size (which varies + with item count) after appending and flips above the anchor instead + when there isn't enough room below, clamped horizontally too. +- Expanding/collapsing a folder shifted every column (including the + header text) in the Containers table — its default `table-layout: + auto` sizes each column from the widest content among only the + currently-visible rows, so adding/removing a folder's rows changed + what counted as "widest" on every toggle. Columns now use explicit + fixed widths that don't depend on row content at all. +- The Name column's content (icon + name + WebUI button) was + misaligned with its own header, drifting further off with every row — + putting `display:flex` directly on the `` (to keep the WebUI + button on the same line as the name) pulls that cell out of + table-cell layout entirely, so with `table-layout:fixed` it stopped + respecting the column width its `` assigns. The flex layout now + lives on a `
` inside the `` instead, so the `` itself + stays a normal, fixed-width table cell. + ## [0.1.3] - 2026-07-12 ### Added diff --git a/webui/plugins/podman/Podman.page b/webui/plugins/podman/Podman.page index b9f009f..81ebb77 100644 --- a/webui/plugins/podman/Podman.page +++ b/webui/plugins/podman/Podman.page @@ -87,11 +87,30 @@ function podman_asset_version(string $relPath): string
Running
+
Stopped
Pods
Images
Volumes
Networks
-
Images on Disk
+
+ +
+
+

Resource Usage

Live CPU, memory, swap, and storage from the Podman host.
+
+
+
+ +
+
+

Autostart Queue

/boot/config/plugins/podman/autostart
+
+
+ + + +
#ContainerDelayLast Result
+
@@ -106,12 +125,21 @@ function podman_asset_version(string $relPath): string
- + +
- - +
StatusNameImageCPU/MemPortsUptime
+ + + + + + + + +
StatusNameImageCPU/MemPortsUptime
@@ -316,13 +344,17 @@ function podman_asset_version(string $relPath): string
+
+ + +
#Container
-
Saved immediately on reorder/remove — no separate save step.
+
Saved immediately on add/reorder/remove — no separate save step.
diff --git a/webui/plugins/podman/ajax/containers.php b/webui/plugins/podman/ajax/containers.php index e1046df..bdd9959 100644 --- a/webui/plugins/podman/ajax/containers.php +++ b/webui/plugins/podman/ajax/containers.php @@ -18,6 +18,11 @@ * kill POST {"id": "...", "signal": "SIGKILL"} * rename POST {"id": "...", "name": "..."} * logs GET (&id=...&tail=200) -> plain text + * events GET (&id=...&since=) -> array + * of this one container's already-happened events (create, + * start, stop, died, ...) — a bounded historical query, not a + * live stream; see PodmanClient::containerEvents()'s own doc + * comment for why that distinction matters here. * 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"|"", @@ -26,7 +31,8 @@ * "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} + * "privileged": false, "startAfterCreate": true, "icon": "https://..." (optional), + * "webuiUrl": "http://10.1.1.1:8080/" (optional)} */ declare(strict_types=1); @@ -57,6 +63,15 @@ switch ($action) { podman_json_response(['text' => $client->containerLogs($id, $tail)]); break; + case 'events': + $id = (string) ($_GET['id'] ?? ''); + if ($id === '') { + podman_json_error('Missing id', 400); + } + $since = (int) ($_GET['since'] ?? (time() - 7 * 86400)); + podman_json_response($client->containerEvents($id, $since)); + break; + case 'start': $body = podman_read_json_body(); $client->startContainer(require_id($body)); @@ -278,6 +293,19 @@ function build_container_spec(string $image, array $body): array $spec['name'] = $name; } + $icon = trim((string) ($body['icon'] ?? '')); + $webuiUrl = trim((string) ($body['webuiUrl'] ?? '')); + $labels = []; + if ($icon !== '') { + $labels['podman-webui.icon'] = $icon; + } + if ($webuiUrl !== '') { + $labels['podman-webui.weburl'] = $webuiUrl; + } + if ($labels !== []) { + $spec['labels'] = $labels; + } + $env = []; foreach (($body['env'] ?? []) as $row) { $key = trim((string) ($row['key'] ?? '')); @@ -470,6 +498,14 @@ function containers_list(PodmanClient $client): array 'cpuPercent' => $cpuPercent, 'memUsageBytes' => $memUsageBytes, 'memLimitBytes' => $memLimitBytes, + // Set at create time (see build_container_spec()) from either + // the template it was created from or a manually-entered URL — + // this plugin's own label, not Unraid's real Docker manager's + // net.unraid.docker.icon (this isn't Docker, so reusing that + // name would misleadingly imply real interop with tools that + // read it). + 'icon' => $c['Labels']['podman-webui.icon'] ?? null, + 'webUrl' => $c['Labels']['podman-webui.weburl'] ?? null, ]; } diff --git a/webui/plugins/podman/ajax/folders.php b/webui/plugins/podman/ajax/folders.php new file mode 100644 index 0000000..bbcf5ce --- /dev/null +++ b/webui/plugins/podman/ajax/folders.php @@ -0,0 +1,91 @@ + {"folders": [{"id": "...", "name": "...", "icon": "...", "containers": ["name", ...]}, ...]} + * save POST {"folders": [...]} -> persists the whole list (same + * whole-list-replace pattern settings.php's autostart_save uses + * — the frontend already holds the full, current structure in + * memory after any add/rename/reassign, so there's no need for + * narrower per-folder mutation endpoints) + */ + +declare(strict_types=1); + +require __DIR__ . '/../include/bootstrap.php'; + +$action = $_GET['action'] ?? ''; + +switch ($action) { + case 'list': + podman_json_response(['folders' => folders_read($podmanConfig)]); + break; + + case 'save': + $body = podman_read_json_body(); + podman_json_response(['folders' => folders_save($podmanConfig, is_array($body['folders'] ?? null) ? $body['folders'] : [])]); + break; + + default: + podman_json_error("Unknown action '{$action}'", 400); +} + +/** @return array> */ +function folders_read(PodmanConfig $config): array +{ + if (!is_readable($config->foldersFile)) { + return []; + } + $decoded = json_decode((string) file_get_contents($config->foldersFile), true); + return is_array($decoded) ? $decoded : []; +} + +/** + * Re-validates and normalizes before writing — a folder with no name (or + * whose name became empty through some client-side bug) is silently + * dropped rather than persisted as junk that would then need cleaning up + * by hand in the JSON file directly. + * + * @param array $folders + * @return array> the normalized list actually written + */ +function folders_save(PodmanConfig $config, array $folders): array +{ + $clean = []; + foreach ($folders as $f) { + if (!is_array($f)) { + continue; + } + $name = trim((string) ($f['name'] ?? '')); + if ($name === '') { + continue; + } + $containers = []; + foreach (($f['containers'] ?? []) as $n) { + $n = trim((string) $n); + if ($n !== '') { + $containers[] = $n; + } + } + $clean[] = [ + 'id' => (string) ($f['id'] ?? '') !== '' ? (string) $f['id'] : bin2hex(random_bytes(6)), + 'name' => $name, + 'icon' => trim((string) ($f['icon'] ?? '')), + 'containers' => $containers, + ]; + } + + if (file_put_contents($config->foldersFile, json_encode($clean, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) === false) { + podman_json_error("Could not write {$config->foldersFile}", 500); + } + + return $clean; +} diff --git a/webui/plugins/podman/ajax/settings.php b/webui/plugins/podman/ajax/settings.php index 16b5314..71e7965 100644 --- a/webui/plugins/podman/ajax/settings.php +++ b/webui/plugins/podman/ajax/settings.php @@ -131,7 +131,7 @@ function settings_get(PodmanConfig $config): array 'enabled' => $config->enabled, 'stopTimeoutSeconds' => $config->stopTimeoutSeconds, 'autostart' => autostart_read($config), - 'packageVersions' => installed_package_versions(), + 'packageVersions' => podman_read_installed_versions(), ]; } @@ -202,31 +202,3 @@ function autostart_save(PodmanConfig $config, array $names): void } } -/** - * Reads /usr/local/share/unraid-podman/installed-versions.env — the same - * manifest plugin/sbin/podman-verify-packages.sh and - * podman-update-packages.sh use (see plugin/podman.plg's postinstall step, - * which generates it) — so Settings shows exactly what those tools would - * report, not a second, possibly-diverging source of truth. - * - * @return array - */ -function installed_package_versions(): array -{ - $path = '/usr/local/share/unraid-podman/installed-versions.env'; - if (!is_readable($path)) { - return []; - } - $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; - $out = []; - foreach ($lines as $line) { - $line = trim($line); - if ($line === '' || str_starts_with($line, '#')) { - continue; - } - if (preg_match('/^([A-Z_][A-Z0-9_]*)="?([^"]*)"?$/', $line, $m)) { - $out[$m[1]] = $m[2]; - } - } - return $out; -} diff --git a/webui/plugins/podman/ajax/system.php b/webui/plugins/podman/ajax/system.php index e1c12c9..79617db 100644 --- a/webui/plugins/podman/ajax/system.php +++ b/webui/plugins/podman/ajax/system.php @@ -8,7 +8,11 @@ * doesn't have to make (and wait on) five separate round trips. * * Actions (?action=...): - * summary GET -> counts, storage usage, engine version, ping status + * summary GET -> counts, storage usage, engine version, ping status + * autostart_queue GET -> the autostart list in start order, each entry's + * configured delay, and its last-known outcome (reusing + * plugin/sbin/podman-autostart.sh's own failure-counter + * files rather than tracking a second copy of that state) */ declare(strict_types=1); @@ -22,6 +26,10 @@ switch ($action) { podman_json_response(system_summary($client, $podmanConfig)); break; + case 'autostart_queue': + podman_json_response(system_autostart_queue($client, $podmanConfig)); + break; + default: podman_json_error("Unknown action '{$action}'", 400); } @@ -56,6 +64,23 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array $imagesSize += (int) ($img['Size'] ?? 0); } + $host = $info['host'] ?? []; + $store = $info['store'] ?? []; + $meminfo = podman_read_meminfo(); + // libpod's host.memFree/swapFree are the kernel's raw "free" counters — + // they exclude reclaimable buffers/cache, so on a host that's been up a + // while they make used memory look dramatically higher than reality + // (found live: 67% "used" here vs. the 19% `free -h` actually reports). + // /proc/meminfo's MemAvailable is the same estimate `free -h`'s + // "available" column and most monitoring tools use, so read it + // directly instead of trusting libpod's numbers for this. + $memTotal = $meminfo['MemTotal'] ?? (int) ($host['memTotal'] ?? 0); + $memAvailable = $meminfo['MemAvailable'] ?? (int) ($host['memFree'] ?? 0); + $swapTotal = $meminfo['SwapTotal'] ?? (int) ($host['swapTotal'] ?? 0); + $swapFree = $meminfo['SwapFree'] ?? (int) ($host['swapFree'] ?? 0); + $graphAllocated = (int) ($store['graphRootAllocated'] ?? 0); + $graphUsed = (int) ($store['graphRootUsed'] ?? 0); + return [ 'reachable' => true, 'socketPath' => $config->socketPath, @@ -66,6 +91,7 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array 'containers' => [ 'total' => count($containers), 'running' => $running, + 'stopped' => count($containers) - $running, ], 'pods' => count($pods), 'images' => count($images), @@ -74,6 +100,170 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array 'storage' => [ 'imagesSizeBytes' => $imagesSize, 'imagesSizeFormatted' => podman_format_bytes($imagesSize), + 'graphUsedBytes' => $graphUsed, + 'graphAllocatedBytes' => $graphAllocated, + 'graphUsedFormatted' => podman_format_bytes($graphUsed), + 'graphAllocatedFormatted' => podman_format_bytes($graphAllocated), + 'graphUsedPercent' => $graphAllocated > 0 ? round($graphUsed / $graphAllocated * 100, 1) : null, + ], + 'host' => [ + 'cpuCount' => (int) ($host['cpus'] ?? 0), + 'cpuPercent' => podman_read_cpu_percent() ?? (isset($host['cpuUtilization']['idlePercent']) + ? round(100 - (float) $host['cpuUtilization']['idlePercent'], 1) + : null), + 'memUsedBytes' => max(0, $memTotal - $memAvailable), + 'memTotalBytes' => $memTotal, + 'memUsedFormatted' => podman_format_bytes(max(0, $memTotal - $memAvailable)), + 'memTotalFormatted' => podman_format_bytes($memTotal), + 'memPercent' => $memTotal > 0 ? round(($memTotal - $memAvailable) / $memTotal * 100, 1) : null, + 'swapUsedBytes' => max(0, $swapTotal - $swapFree), + 'swapTotalBytes' => $swapTotal, + 'uptime' => (string) ($host['uptime'] ?? ''), ], ]; } + +/** + * The Dashboard's "Autostart Queue" table: same list/order Settings already + * reads via autostart_read() in settings.php, enriched with each entry's + * configured post-start delay and its last-known outcome — derived from + * plugin/sbin/podman-autostart.sh's own per-container failure-counter + * files (under autostart-failures/) plus the container's actual + * current state, rather than a second, separately-tracked history. + * + * @return array + */ +function system_autostart_queue(PodmanClient $client, PodmanConfig $config): array +{ + if (!is_readable($config->autostartFile)) { + return ['entries' => []]; + } + + $names = []; + foreach (file($config->autostartFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + $line = trim(preg_replace('/#.*$/', '', $line) ?? ''); + if ($line !== '') { + $names[] = $line; + } + } + if (!$names) { + return ['entries' => []]; + } + + $delays = []; + if (is_readable($config->autostartDelayFile)) { + foreach (file($config->autostartDelayFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) { + if (preg_match('/^([^=]+)=(\d+)$/', trim($line), $m)) { + $delays[$m[1]] = (int) $m[2]; + } + } + } + + // Mirrors podman-autostart.sh's own MAX_CONSECUTIVE_FAILURES — that + // script is what actually decides when a container is Safe-Mode-paused; + // this only needs to agree on the same threshold to describe it + // accurately, not to make the decision itself. + $maxFailures = 3; + $failuresDir = $config->bootDir . '/autostart-failures'; + + $stateByName = []; + foreach ($client->listContainers(true) as $c) { + foreach (($c['Names'] ?? []) as $n) { + $stateByName[ltrim((string) $n, '/')] = strtolower((string) ($c['State'] ?? '')); + } + } + + $entries = []; + foreach ($names as $i => $name) { + $failCount = 0; + $failFile = $failuresDir . '/' . $name; + if (is_readable($failFile)) { + $failCount = (int) trim((string) file_get_contents($failFile)); + } + + if ($failCount >= $maxFailures) { + $status = 'safe-mode'; + $label = "Safe-Mode ({$failCount} failures)"; + } elseif ($failCount > 0) { + $status = 'failed'; + $label = "Failed ({$failCount}/{$maxFailures})"; + } elseif (($stateByName[$name] ?? '') === 'running') { + $status = 'started'; + $label = 'Started'; + } elseif (array_key_exists($name, $stateByName)) { + $status = 'stopped'; + $label = 'Not running'; + } else { + $status = 'unknown'; + $label = 'Container not found'; + } + + $entries[] = [ + 'position' => $i + 1, + 'name' => $name, + 'delaySeconds' => $delays[$name] ?? 0, + 'status' => $status, + 'statusLabel' => $label, + ]; + } + + return ['entries' => $entries]; +} + +/** + * Reads the fields we need out of /proc/meminfo directly, in bytes. + * ajax/*.php runs as PHP-FPM on the Unraid host itself (not inside a + * container), so this is just a local file read — no shelling out needed. + * + * @return array + */ +function podman_read_meminfo(): array +{ + $lines = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $out = []; + foreach ($lines as $line) { + if (preg_match('/^(MemTotal|MemAvailable|SwapTotal|SwapFree):\s*(\d+)\s*kB$/', $line, $m)) { + $out[$m[1]] = ((int) $m[2]) * 1024; + } + } + return $out; +} + +/** + * Live CPU usage via the classic /proc/stat delta technique: libpod's own + * /info.host.cpuUtilization turned out to be a value computed once and + * never resampled (verified live — three /info calls several seconds + * apart returned byte-identical numbers), which is why the Dashboard's + * CPU tile never moved. A single instantaneous read of /proc/stat can't + * give a percentage on its own either (its counters are cumulative + * jiffies since boot) — it needs two samples to diff. Since the Dashboard + * already re-fetches this endpoint every ~2s via auto-refresh, the + * previous sample is persisted to a small state file and diffed against + * the current one on each call, exactly like `top`/`htop` do between + * their own refresh ticks. Returns null (falls back to libpod's static + * figure) only on the very first call, before any previous sample exists. + */ +function podman_read_cpu_percent(): ?float +{ + $stat = @file_get_contents('/proc/stat'); + if ($stat === false || !preg_match('/^cpu\s+(\d+) (\d+) (\d+) (\d+) (\d+) (\d+) (\d+) (\d+)/m', $stat, $m)) { + return null; + } + [, $user, $nice, $system, $idle, $iowait, $irq, $softirq, $steal] = array_map('intval', $m); + $total = $user + $nice + $system + $idle + $iowait + $irq + $softirq + $steal; + $idleAll = $idle + $iowait; + + $stateFile = '/var/tmp/podman-cpu-sample'; + $prevRaw = @file_get_contents($stateFile); + @file_put_contents($stateFile, $total . ' ' . $idleAll, LOCK_EX); + + if ($prevRaw === false || !preg_match('/^(\d+) (\d+)$/', trim($prevRaw), $pm)) { + return null; + } + $deltaTotal = $total - (int) $pm[1]; + $deltaIdle = $idleAll - (int) $pm[2]; + if ($deltaTotal <= 0) { + return null; + } + return round((1 - $deltaIdle / $deltaTotal) * 100, 1); +} diff --git a/webui/plugins/podman/include/Config.php b/webui/plugins/podman/include/Config.php index 3865c9e..a7e609d 100644 --- a/webui/plugins/podman/include/Config.php +++ b/webui/plugins/podman/include/Config.php @@ -21,6 +21,7 @@ final class PodmanConfig public string $bootDir; public string $autostartFile; public string $autostartDelayFile; + public string $foldersFile; private function __construct() { @@ -33,6 +34,7 @@ final class PodmanConfig $this->socketPath = '/var/run/podman/podman.sock'; $this->autostartFile = $this->bootDir . '/autostart'; $this->autostartDelayFile = $this->bootDir . '/autostart-delay'; + $this->foldersFile = $this->bootDir . '/folders.json'; } public static function load(): self diff --git a/webui/plugins/podman/include/PodmanClient.php b/webui/plugins/podman/include/PodmanClient.php index a7ad1ed..546d56a 100644 --- a/webui/plugins/podman/include/PodmanClient.php +++ b/webui/plugins/podman/include/PodmanClient.php @@ -185,6 +185,41 @@ final class PodmanClient return self::demuxStream($raw); } + /** + * GET /events?stream=false&since=...&filters={"container":[""]} — + * a bounded, already-happened event history for one container, NOT a + * live stream (stream=false makes libpod return what already matched + * and close the connection immediately, verified live). This is + * enough for a container's "Events" detail tab without needing the + * persistent-connection event-streaming infrastructure a live, + * fleet-wide event feed would require (see dashboard.js's own header + * comment on why that's out of scope for now). + * + * @return array> + */ + public function containerEvents(string $id, int $sinceUnixSeconds): array + { + $query = [ + 'stream' => 'false', + 'since' => (string) $sinceUnixSeconds, + 'filters' => json_encode(['container' => [$id]]), + ]; + $raw = $this->requestRaw('GET', '/events', $query); + // Newline-delimited JSON, not a single JSON array — each line is + // its own event object. + $events = []; + foreach (explode("\n", trim($raw)) as $line) { + if ($line === '') { + continue; + } + $decoded = json_decode($line, true); + if (is_array($decoded)) { + $events[] = $decoded; + } + } + return $events; + } + // ------------------------------------------------------------------- // Exec — see webui/plugins/podman/ajax/exec.php for the important // caveat: this implements one-shot "run a command, return its output" diff --git a/webui/plugins/podman/include/helpers.php b/webui/plugins/podman/include/helpers.php index e93a8e4..ad62415 100644 --- a/webui/plugins/podman/include/helpers.php +++ b/webui/plugins/podman/include/helpers.php @@ -11,6 +11,37 @@ declare(strict_types=1); +/** + * Reads /usr/local/share/unraid-podman/installed-versions.env — the same + * manifest plugin/sbin/podman-verify-packages.sh and + * podman-update-packages.sh use (see plugin/podman.plg's postinstall step, + * which generates it) — shared here so settings.php (package version + * chips) and system.php (Dashboard's plugin version chip) both report + * exactly what those tools would, not two possibly-diverging readers of + * the same file. + * + * @return array + */ +function podman_read_installed_versions(): array +{ + $path = '/usr/local/share/unraid-podman/installed-versions.env'; + if (!is_readable($path)) { + return []; + } + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: []; + $out = []; + foreach ($lines as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, '#')) { + continue; + } + if (preg_match('/^([A-Z_][A-Z0-9_]*)="?([^"]*)"?$/', $line, $m)) { + $out[$m[1]] = $m[2]; + } + } + return $out; +} + function podman_format_bytes(int $bytes): string { if ($bytes <= 0) { diff --git a/webui/plugins/podman/javascript/app.js b/webui/plugins/podman/javascript/app.js index beb24f1..4d1eede 100644 --- a/webui/plugins/podman/javascript/app.js +++ b/webui/plugins/podman/javascript/app.js @@ -126,6 +126,60 @@ window.Podman = (function () { return '' + escapeHtml(message) + ''; } + // --- Toast notifications ---------------------------------------------------- + // + // Replaces alert() for one-way feedback ("Saved.", "Removed 3 image(s)", + // "Save failed: ..."). Confirmations stay native confirm() — a toast is + // for telling the user something happened, not for asking them a + // yes/no question. Command output that can run to hundreds of lines + // (e.g. `podman compose up`) stays in the existing openLogModal() + // pattern instead — a toast has to stay short and auto-dismiss, which + // doesn't fit a scrolling log. + + const TOAST_ICON = { success: '✓', warn: '!', error: '✕', info: 'ℹ' }; + const TOAST_DURATION_MS = { success: 4000, warn: 5000, error: 7000, info: 4000 }; + let toastContainer = null; + + function toastRoot() { + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.className = 'podman-toast-container'; + (document.querySelector('.podman-plugin') || document.body).appendChild(toastContainer); + } + return toastContainer; + } + + /** + * @param {string} message + * @param {('success'|'warn'|'error'|'info')} [type='info'] + */ + function toast(message, type) { + const kind = TOAST_ICON[type] ? type : 'info'; + const root = toastRoot(); + + const node = document.createElement('div'); + node.className = 'podman-toast podman-toast-' + kind; + node.innerHTML = + '' + TOAST_ICON[kind] + '' + + '' + + ''; + node.querySelector('.msg').textContent = message; + root.appendChild(node); + + let dismissed = false; + function dismiss() { + if (dismissed) return; + dismissed = true; + node.classList.add('leaving'); + // Not animationend — that never fires when prefers-reduced-motion + // disables the animation (podman.css sets animation:none for it), + // which would leave the toast stuck on screen forever. + setTimeout(function () { node.remove(); }, 160); + } + node.querySelector('.close').addEventListener('click', dismiss); + setTimeout(dismiss, TOAST_DURATION_MS[kind]); + } + // --- Modal form dialog ----------------------------------------------------- /** @@ -299,12 +353,32 @@ window.Podman = (function () { (item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + ''; }).join(''); + (document.querySelector('.podman-plugin') || document.body).appendChild(menu); + // Viewport-relative (see the "position: fixed" comment on // .podman-context-menu in podman.css) — no scrollY/scrollX added. + // Measured AFTER appending (not assumed) since the menu's height + // varies with its item count — a long menu (e.g. a container's row + // menu with Details/Pause/Kill/Rename/Edit/Remove) opened from a row + // near the bottom of the viewport used to run off-screen with no way + // to reach its last few items. Flips above the anchor instead when + // there isn't enough room below, and clamps horizontally the same way. const rect = anchorEl.getBoundingClientRect(); - menu.style.top = (rect.bottom + 4) + 'px'; - menu.style.left = (rect.right - 180) + 'px'; - (document.querySelector('.podman-plugin') || document.body).appendChild(menu); + const menuRect = menu.getBoundingClientRect(); + const margin = 8; + + let top = rect.bottom + 4; + if (top + menuRect.height > window.innerHeight - margin) { + top = rect.top - menuRect.height - 4; + } + top = Math.max(margin, top); + + let left = rect.right - menuRect.width; + left = Math.min(left, window.innerWidth - menuRect.width - margin); + left = Math.max(margin, left); + + menu.style.top = top + 'px'; + menu.style.left = left + 'px'; // menu.children includes the separator
s too, so indexing into it // directly (by a counter that only advances for real items) drifts by @@ -399,6 +473,29 @@ window.Podman = (function () { // (Dashboard, by default — see Podman.page). const initial = document.querySelector('.podman-subnav button.active'); activatePanel(initial ? initial.dataset.panel : 'dashboard'); + + setInterval(autoRefreshTick, AUTO_REFRESH_INTERVAL_MS); + } + + // Only panels that opt in via `autoRefresh: true` (Dashboard, Containers) + // get polled — most panels (Settings, Compose, Terminal, ...) have + // in-progress forms or connections an unexpected refresh would disrupt. + // Paused while the tab is hidden (nothing to look at) and while any + // modal is open (a full-panel re-render mid-edit would be jarring), + // rather than fighting those cases with more state. + const AUTO_REFRESH_INTERVAL_MS = 2000; + + function autoRefreshTick() { + if (document.hidden) return; + if (document.querySelector('.podman-modal-backdrop')) return; + + const activeBtn = document.querySelector('.podman-subnav button.active'); + if (!activeBtn) return; + const name = activeBtn.dataset.panel; + const module = panelModules[name]; + if (module && module.autoRefresh && initialized[name] && typeof module.refresh === 'function') { + module.refresh(); + } } document.addEventListener('DOMContentLoaded', boot); @@ -414,6 +511,7 @@ window.Podman = (function () { stateChipClass: stateChipClass, loadingRow: loadingRow, errorRow: errorRow, + toast: toast, openFormModal: openFormModal, openLogModal: openLogModal, openContextMenu: openContextMenu, diff --git a/webui/plugins/podman/javascript/compose.js b/webui/plugins/podman/javascript/compose.js index ef31531..075e177 100644 --- a/webui/plugins/podman/javascript/compose.js +++ b/webui/plugins/podman/javascript/compose.js @@ -85,11 +85,17 @@ if (!selected) return; const btn = P.el('compose-action-' + action); btn.disabled = true; + // `podman compose` output can run to many lines — a log modal (same + // pattern as Settings' service Start/Stop/Restart) fits that; a toast + // has to stay short and auto-dismiss. + const modal = P.openLogModal('podman compose ' + action + ' — ' + selected); P.post('compose', action, { project: selected }).then(function (data) { - alert((data.output || 'Done.').slice(0, 2000)); + (data.output || 'Done.').split('\n').forEach(function (line) { modal.log(line); }); + modal.done(); return loadProjects(); }).catch(function (err) { - alert('podman compose ' + action + ' failed: ' + err.message); + modal.log('Failed: ' + err.message); + modal.done('Close'); }).finally(function () { btn.disabled = false; }); @@ -100,9 +106,10 @@ const btn = P.el('compose-action-save'); btn.disabled = true; P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () { + P.toast('Saved ' + selected + '.', 'success'); return loadProjects(); }).catch(function (err) { - alert('Save failed: ' + err.message); + P.toast('Save failed: ' + err.message, 'error'); }).finally(function () { btn.disabled = false; }); @@ -113,11 +120,13 @@ if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return; const btn = P.el('compose-action-delete'); btn.disabled = true; + const name = selected; P.post('compose', 'remove', { project: selected }).then(function () { selected = null; + P.toast('Deleted ' + name + '.', 'success'); return loadProjects(); }).catch(function (err) { - alert('Delete failed: ' + err.message); + P.toast('Delete failed: ' + err.message, 'error'); btn.disabled = false; }); } diff --git a/webui/plugins/podman/javascript/containers.js b/webui/plugins/podman/javascript/containers.js index 4bcdf4b..0a799e0 100644 --- a/webui/plugins/podman/javascript/containers.js +++ b/webui/plugins/podman/javascript/containers.js @@ -17,11 +17,156 @@ // badge doesn't disappear on the next auto-refresh; only re-running // "Check for Updates" replaces it. let imageUpdateStatus = {}; + // Container folders — purely cosmetic grouping (podman itself has no + // such concept), backed by ajax/folders.php. Collapse state is + // intentionally in-memory only (not persisted): it's a per-visit UI + // convenience, not data worth a config-file round trip. + let folders = []; + let collapsedFolders = {}; function iconLabel(name) { return P.escapeHtml(name.slice(0, 2).toUpperCase()); } + // The fallback text goes into a data-* attribute (plain HTML-attribute + // escaping) and is read back via .dataset in the error handler, rather + // than being concatenated into the onerror string as JS source — that + // second approach only stays safe as long as the text can never contain + // a quote, which is true for container names today (letters/digits/ + // ./_/- only) but not for the free-text folder names below, so both + // use this same safer pattern rather than having two different rules + // depending on which kind of name is involved. + function iconWithFallbackHtml(iconUrl, fallbackText) { + const fallback = P.escapeHtml(fallbackText); + if (!iconUrl) { + return '' + fallback + ''; + } + return ''; + } + + function containerIconHtml(c) { + return iconWithFallbackHtml(c.icon, c.name.slice(0, 2).toUpperCase()); + } + + // --- Folders ----------------------------------------------------------- + + function saveFolders() { + return P.post('folders', 'save', { folders: folders }).then(function (data) { + folders = data.folders; + }).catch(function (err) { + P.toast('Could not save folders: ' + err.message, 'error'); + }); + } + + function openNewFolderModal(containerNameToAssign) { + P.openFormModal({ + title: 'New Folder', + submitLabel: 'Create', + fields: [ + { name: 'name', label: 'Folder name', required: true, placeholder: 'Media' }, + { name: 'icon', label: 'Icon URL (optional)', placeholder: 'https://...' }, + ], + onSubmit: function (values) { + folders.push({ + id: '', + name: values.name, + icon: values.icon || '', + containers: containerNameToAssign ? [containerNameToAssign] : [], + }); + return saveFolders().then(renderTable); + }, + }); + } + + function openEditFolderModal(f) { + P.openFormModal({ + title: 'Edit Folder', + submitLabel: 'Save', + fields: [ + { name: 'name', label: 'Folder name', required: true, placeholder: f.name }, + { name: 'icon', label: 'Icon URL (optional)', placeholder: f.icon || 'https://...' }, + ], + onSubmit: function (values) { + f.name = values.name; + f.icon = values.icon || ''; + return saveFolders().then(renderTable); + }, + }); + } + + function deleteFolder(f) { + if (!confirm('Delete folder "' + f.name + '"? Its containers are not affected — they just become ungrouped.')) return; + folders = folders.filter(function (x) { return x.id !== f.id; }); + saveFolders().then(renderTable); + } + + function assignToFolder(containerName, folderId) { + folders.forEach(function (f) { + const idx = f.containers.indexOf(containerName); + if (idx !== -1) f.containers.splice(idx, 1); + }); + if (folderId) { + const target = folders.find(function (f) { return f.id === folderId; }); + if (target) target.containers.push(containerName); + } + saveFolders().then(renderTable); + } + + function openMoveToFolderMenu(c, anchorBtn) { + const currentFolder = folders.find(function (f) { return f.containers.indexOf(c.name) !== -1; }); + const items = folders.map(function (f) { + return { + label: (f.id === (currentFolder && currentFolder.id) ? '✓ ' : '') + f.name, + onClick: function () { assignToFolder(c.name, f.id); }, + }; + }); + if (items.length) items.push('separator'); + if (currentFolder) { + items.push({ label: 'Remove from folder', onClick: function () { assignToFolder(c.name, null); } }); + } + items.push({ label: '+ New folder…', onClick: function () { openNewFolderModal(c.name); } }); + P.openContextMenu(anchorBtn, items); + } + + function openFolderMenu(f, anchorBtn) { + P.openContextMenu(anchorBtn, [ + { label: 'Rename / Edit Icon', onClick: function () { openEditFolderModal(f); } }, + { label: 'Delete Folder', danger: true, onClick: function () { deleteFolder(f); } }, + ]); + } + + // Matches Unraid's own Docker page folder rows: the header always + // shows a compact icon+name+status chip per member — collapsed or + // expanded — so folding a group away doesn't hide its state entirely. + // Expand/collapse only controls whether the FULL per-container detail + // rows also render underneath (see renderTable()). + function folderMemberChipHtml(c) { + return ''; + } + + function folderHeaderHtml(f, members) { + const collapsed = !!collapsedFolders[f.id]; + const runningCount = members.filter(function (c) { return c.state === 'running'; }).length; + return '' + + '' + + '
' + + '' + + '
' + members.map(folderMemberChipHtml).join('') + '
' + + '' + + '
'; + } + function hasUpdate(c) { const status = imageUpdateStatus[c.image]; return !!(status && status.updateAvailable); @@ -34,12 +179,18 @@ const updateBadge = hasUpdate(c) ? ' ↑ Update' : ''; + // A plain , not a data-action button — the tbody's click handler + // only ever looks for button[data-action], so this just navigates + // normally with no extra wiring. + const webuiLink = c.webUrl + ? ' URL' + : ''; return '' + '' + '' + P.escapeHtml(c.health || c.state) + '' + - '' + updateBadge + '' + + '
' + webuiLink + updateBadge + '
' + '' + P.escapeHtml(c.image) + '' + '' + cpuMem + '' + '' + P.escapeHtml(c.ports.join(', ') || '—') + '' + @@ -70,12 +221,24 @@ function openRowMenu(c, anchorBtn) { const items = []; + items.push({ label: 'Details', onClick: function () { openDetailModal(c); } }); if (c.state === 'running') { items.push({ label: 'Pause', onClick: function () { handleAction(c.id, 'pause'); } }); items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } }); } items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } }); items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } }); + // Once a container is already grouped, "Move to Folder" (which reads + // as "add to a folder") is redundant and ambiguous — the one action + // that actually makes sense from here is taking it back out. Moving + // it to a *different* folder still works, just via ungrouping first; + // that's a deliberately rarer path than "add" or "remove". + const currentFolder = folders.find(function (f) { return f.containers.indexOf(c.name) !== -1; }); + if (currentFolder) { + items.push({ label: 'Remove from Folder', onClick: function () { assignToFolder(c.name, null); } }); + } else { + items.push({ label: 'Move to Folder', onClick: function () { openMoveToFolderMenu(c, anchorBtn); } }); + } items.push('separator'); items.push({ label: 'Remove', @@ -173,6 +336,8 @@ volumes: volumes, env: env, gpuDevices: gpuDevices, + icon: c.icon || '', + webUrl: c.webUrl || '', }; } @@ -180,7 +345,7 @@ 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); + P.toast('Could not load container config: ' + err.message, 'error'); }); } @@ -226,6 +391,8 @@ restartPolicy: prefill.restartPolicy, gpuDevices: prefill.gpuDevices, privileged: prefill.privileged, + icon: prefill.icon, + webuiUrl: prefill.webUrl, startAfterCreate: true, }); }).then(function () { @@ -298,6 +465,25 @@ modal.log(failures.length ? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.' : 'All ' + targets.length + ' updated.'); + + // The image(s) each updated container used before are now + // superseded (recreate() points it at the freshly-pulled one) and + // have zero containers referencing them — the same "unused" + // definition images.js's own Prune button uses. Only worth doing + // if at least one container actually updated; skipped entirely if + // every update failed, since nothing changed to clean up. + if (failures.length < targets.length) { + modal.log(''); + modal.log('Removing old, now-unused images…'); + return P.post('images', 'prune').then(function (result) { + modal.log(result.removedCount + ? 'Removed ' + result.removedCount + ' old image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.' + : 'No unused images left to remove.'); + }).catch(function (err) { + modal.log('Image cleanup failed: ' + err.message); + }); + } + }).then(function () { modal.done(); btn.disabled = false; return load(); @@ -311,11 +497,15 @@ // --- Detail view ----------------------------------------------------------- // - // Fed entirely by the existing inspect action (raw libpod inspect JSON) — - // no new backend endpoint needed, just slicing that one payload into - // tabs. Field names below (Config.Env, Config.Labels, Mounts, - // NetworkSettings.Networks, HostConfig.RestartPolicy, ...) were checked - // live against a real inspect response, not assumed from docs. + // Most tabs are sliced straight from the one inspect payload already + // fetched when the modal opens (Config.Env, Config.Labels, Mounts, + // NetworkSettings.Networks, HostConfig.RestartPolicy, State.Health.Log, + // ... all checked live against a real inspect response, not assumed + // from docs). A few need more: Logs and Events fetch lazily when their + // tab is first opened (a render() may return a Promise instead + // of a string — see showTab() below), and Console opens a real + // ttyd/podman-exec session instead of just rendering (see + // renderConsoleTab/wireConsoleTab). function kvTable(rows) { if (rows.length === 0) return '
None.
'; @@ -387,12 +577,165 @@ return '
' + P.escapeHtml(JSON.stringify(d, null, 2)) + '
'; } + // Live stats (cpuPercent/memUsageBytes/memLimitBytes) come from the row + // data already fetched for the table (containers.php's list action) — + // no extra request needed, and it's a one-shot snapshot either way + // (this modal doesn't auto-refresh internally). Configured limits come + // from the inspect payload's HostConfig; a 0 in any of these fields is + // podman's own way of saying "unlimited", not a real zero. + function renderResourcesTab(d, c) { + const hostCfg = d.HostConfig || {}; + const rows = []; + if (c.state === 'running') { + rows.push(['CPU usage', c.cpuPercent != null ? c.cpuPercent.toFixed(1) + '%' : '—']); + // memLimitBytes is podman's cgroup-reported limit, which is the + // HOST's total memory when no real limit is configured (found + // live: showed "110 MB / 38.6 GB" for a container with no memory + // limit set, right above a "Memory limit: unlimited" row that + // contradicted it) — only show a "/ limit" suffix when HostConfig + // says a limit was actually configured. + rows.push(['Memory usage', c.memUsageBytes != null + ? P.formatBytes(c.memUsageBytes) + (hostCfg.Memory > 0 ? ' / ' + P.formatBytes(c.memLimitBytes) : '') + : '—']); + } + const nanoCpus = hostCfg.NanoCpus || 0; + const cpuQuota = hostCfg.CpuQuota || 0; + const cpuPeriod = hostCfg.CpuPeriod || 0; + rows.push( + ['Memory limit', hostCfg.Memory > 0 ? P.formatBytes(hostCfg.Memory) : 'unlimited'], + ['Swap limit', hostCfg.MemorySwap > 0 ? P.formatBytes(hostCfg.MemorySwap) : 'unlimited'], + ['CPU limit', nanoCpus > 0 ? (nanoCpus / 1e9) + ' core(s)' : (cpuQuota > 0 && cpuPeriod > 0 ? (cpuQuota / cpuPeriod).toFixed(2) + ' core(s)' : 'unlimited')], + ['CPU shares', hostCfg.CpuShares > 0 ? String(hostCfg.CpuShares) : 'default (1024)'], + ['PIDs limit', hostCfg.PidsLimit > 0 ? String(hostCfg.PidsLimit) : 'unlimited'], + ['Block I/O weight', hostCfg.BlkioWeight > 0 ? String(hostCfg.BlkioWeight) : 'default'] + ); + return kvTable(rows); + } + + function renderLogsTab(d, c) { + return P.get('containers', 'logs', { id: c.id, tail: 300 }).then(function (data) { + const lines = (data.text || '').split('\n').filter(function (l) { return l.length > 0; }); + if (!lines.length) return '
No log output.
'; + return '
' + lines.map(function (line) { + const cls = /\berror\b/i.test(line) ? 'lvl-error' : (/\bwarn(ing)?\b/i.test(line) ? 'lvl-warn' : ''); + return '
' + P.escapeHtml(line) + '
'; + }).join('') + '
'; + }); + } + + // Bounded, one-shot history (last 7 days) via PodmanClient::containerEvents() + // — NOT a live stream, see that method's own doc comment. Good enough for + // "what happened to this container recently" without the persistent- + // connection infrastructure a live feed would need. + function renderEventsTab(d, c) { + return P.get('containers', 'events', { id: c.id }).then(function (events) { + if (!events.length) return '
No events in the last 7 days.
'; + const rows = events.slice().reverse(); + return '' + + '' + + rows.map(function (e) { + const when = new Date(e.time * 1000).toLocaleString(); + return '' + + ''; + }).join('') + '
TimeEvent
' + P.escapeHtml(P.formatRelativeTime(e.time)) + '' + P.escapeHtml(e.Action || e.status || '') + '
'; + }); + } + + function renderHealthTab(d) { + const health = (d.State && d.State.Health) || null; + if (!health || !Array.isArray(health.Log) || !health.Log.length) { + return '
No healthcheck configured for this container.
'; + } + const rows = health.Log.slice().reverse(); + return '' + + '' + + rows.map(function (entry) { + const ok = entry.ExitCode === 0; + return '' + + '' + + ''; + }).join('') + '
TimeResultOutput
' + P.escapeHtml(entry.Start || '') + '' + + (ok ? 'ok' : 'exit ' + entry.ExitCode) + '' + P.escapeHtml((entry.Output || '').trim().slice(0, 300)) + '
'; + } + + // Console is the one tab that isn't a static render — it opens a real + // ttyd/podman-exec session (same mechanism as the standalone Terminal + // panel, see terminal.js's own header comment for why this can't be a + // true persistent PTY over plain HTTP). Returns a cleanup function the + // modal calls when leaving this tab or closing altogether, so a session + // opened just to peek at a container's console doesn't leak an orphaned + // ttyd process the way a stale one did before terminal.js's own fix + // earlier this project (see openLiveTerminal()'s closeCurrent()). + function renderConsoleTab(d, c) { + if (c.state !== 'running') { + return '
Container must be running to open a console.
'; + } + return '' + + '
' + + '' + + '' + + '' + + '
' + + '

Pick a shell and click "Open Console".

'; + } + + function wireConsoleTab(body, d, c) { + const openBtn = body.querySelector('#detail-term-open-btn'); + if (!openBtn) { + return null; + } + const disconnectBtn = body.querySelector('#detail-term-disconnect-btn'); + let sessionOpen = false; + + function closeSession() { + if (!sessionOpen) return Promise.resolve(); + sessionOpen = false; + return P.post('exec', 'close', { name: c.name }).catch(function () {}); + } + + openBtn.addEventListener('click', function () { + const shell = body.querySelector('#detail-term-shell').value; + const wrap = body.querySelector('#detail-term-frame-wrap'); + wrap.innerHTML = '

Opening console…

'; + openBtn.disabled = true; + closeSession().then(function () { + return P.post('exec', 'open', { name: c.name, shell: shell }); + }).then(function (data) { + sessionOpen = true; + disconnectBtn.disabled = false; + // Same brief delay openLiveTerminal() uses — ttyd needs a moment + // to bind its socket before nginx can proxy to it. + setTimeout(function () { + wrap.innerHTML = ''; + }, 200); + }).catch(function (err) { + wrap.innerHTML = '

Could not open console: ' + P.escapeHtml(err.message) + '

'; + }).finally(function () { + openBtn.disabled = false; + }); + }); + + disconnectBtn.addEventListener('click', function () { + disconnectBtn.disabled = true; + closeSession().finally(function () { + body.querySelector('#detail-term-frame-wrap').innerHTML = '

Disconnected.

'; + }); + }); + + return closeSession; + } + const DETAIL_TABS = [ { id: 'overview', label: 'Overview', render: renderOverviewTab }, + { id: 'resources', label: 'Resources', render: renderResourcesTab }, + { id: 'logs', label: 'Logs', render: renderLogsTab }, + { id: 'console', label: 'Console', render: renderConsoleTab, wire: wireConsoleTab }, + { id: 'networks', label: 'Networks', render: renderNetworksTab }, + { id: 'mounts', label: 'Mounts', render: renderMountsTab }, { id: 'env', label: 'Environment', render: renderEnvTab }, { id: 'labels', label: 'Labels', render: renderLabelsTab }, - { id: 'mounts', label: 'Mounts', render: renderMountsTab }, - { id: 'networks', label: 'Networks', render: renderNetworksTab }, + { id: 'events', label: 'Events', render: renderEventsTab }, + { id: 'health', label: 'Healthcheck', render: renderHealthTab }, { id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab }, ]; @@ -410,17 +753,46 @@ '
'; (document.querySelector('.podman-plugin') || document.body).appendChild(backdrop); - backdrop.querySelector('[data-role="cancel"]').addEventListener('click', function () { backdrop.remove(); }); - backdrop.addEventListener('click', function (e) { if (e.target === backdrop) backdrop.remove(); }); + + // Any tab can leave behind something that needs cleanup on the way + // out (currently only Console's ttyd session) — tracked here so + // every way of leaving the modal (switching tabs, Close button, + // backdrop click, Escape) goes through the same cleanup path. + let activeCleanup = null; + function closeModal() { + const cleanup = activeCleanup; + activeCleanup = null; + Promise.resolve(cleanup ? cleanup() : null).finally(function () { backdrop.remove(); }); + } + + backdrop.querySelector('[data-role="cancel"]').addEventListener('click', closeModal); + backdrop.addEventListener('click', function (e) { if (e.target === backdrop) closeModal(); }); document.addEventListener('keydown', function onKey(e) { - if (e.key === 'Escape') { backdrop.remove(); document.removeEventListener('keydown', onKey); } + if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', onKey); } }); const body = backdrop.querySelector('.podman-detail-body'); P.get('containers', 'inspect', { id: c.id }).then(function (data) { function showTab(tabId) { + if (activeCleanup) { + const cleanup = activeCleanup; + activeCleanup = null; + cleanup(); + } const tab = DETAIL_TABS.find(function (t) { return t.id === tabId; }); - body.innerHTML = tab.render(data); + const result = tab.render(data, c); + if (result && typeof result.then === 'function') { + body.innerHTML = '
Loading…
'; + result.then(function (html) { + body.innerHTML = html; + if (tab.wire) activeCleanup = tab.wire(body, data, c) || null; + }).catch(function (err) { + body.innerHTML = '
' + P.escapeHtml(err.message) + '
'; + }); + } else { + body.innerHTML = result; + if (tab.wire) activeCleanup = tab.wire(body, data, c) || null; + } } backdrop.querySelectorAll('[data-tab]').forEach(function (btn) { btn.addEventListener('click', function () { @@ -447,9 +819,40 @@ function renderTable() { const tbody = P.el('containers-tbody'); const visible = applyFilters(); - tbody.innerHTML = visible.length - ? visible.map(rowHtml).join('') - : 'No containers match.'; + if (!visible.length) { + tbody.innerHTML = 'No containers match.'; + return; + } + + // No folders defined at all: render the flat list exactly as before — + // nobody using this feature for the first time sees any change. + if (!folders.length) { + tbody.innerHTML = visible.map(rowHtml).join(''); + return; + } + + const visibleByName = {}; + visible.forEach(function (c) { visibleByName[c.name] = c; }); + + const assigned = {}; + let html = ''; + folders.forEach(function (f) { + const members = f.containers.map(function (name) { return visibleByName[name]; }).filter(Boolean); + // Hide a folder only when the current filter/search hid every one of + // its actual members — a genuinely empty folder (nothing assigned + // yet, right after creating it) still needs to show up so there's + // somewhere to drag/assign a container into. + if (!members.length && f.containers.length > 0) return; + members.forEach(function (c) { assigned[c.name] = true; }); + html += folderHeaderHtml(f, members); + if (!collapsedFolders[f.id]) { + html += members.map(rowHtml).join(''); + } + }); + const ungrouped = visible.filter(function (c) { return !assigned[c.name]; }); + html += ungrouped.map(rowHtml).join(''); + + tbody.innerHTML = html || 'No containers match.'; } function renderCounts() { @@ -461,7 +864,13 @@ function load() { const tbody = P.el('containers-tbody'); - tbody.innerHTML = P.loadingRow(7); + // Only show the loading placeholder on the very first load — once + // rows are already on screen, auto-refresh (every ~2s) and manual + // Refresh clicks should swap data in place, not flash back to a + // spinner and lose the user's place every cycle. + if (!allContainers.length) { + tbody.innerHTML = P.loadingRow(7); + } return P.get('containers', 'list').then(function (data) { allContainers = data; renderCounts(); @@ -560,6 +969,12 @@ '
' + '' + '
Letters, digits, ".", "_", "-" only — no spaces.
' + + '
' + + '' + + '
Shown in the Containers table and Folders. Filled in automatically from a template.
' + + '
' + + '' + + '
Adds a small open-in-new-tab button next to the name in the Containers table.
' + '
' + '
' + @@ -606,6 +1021,8 @@ if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image; if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name; + if (prefill.icon) backdrop.querySelector('#cc-icon').value = prefill.icon; + if (prefill.webUrl) backdrop.querySelector('#cc-weburl').value = prefill.webUrl; 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; @@ -797,6 +1214,8 @@ restartPolicy: backdrop.querySelector('#cc-restart').value, gpuDevices: gpuDevices, privileged: privileged, + icon: backdrop.querySelector('#cc-icon').value.trim(), + webuiUrl: backdrop.querySelector('#cc-weburl').value.trim(), startAfterCreate: backdrop.querySelector('#cc-start').checked, }); }).then(function () { @@ -815,7 +1234,7 @@ category: backdrop.querySelector('#cc-template-category').value.trim(), overview: backdrop.querySelector('#cc-template-overview').value.trim(), }).catch(function (err) { - alert('Container created, but saving the template failed: ' + err.message); + P.toast('Container created, but saving the template failed: ' + err.message, 'warn'); }); }).then(function () { close(); @@ -839,7 +1258,7 @@ const doIt = function (extra) { if (btn) btn.disabled = true; return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) { - alert('Action failed: ' + err.message); + P.toast('Action failed: ' + err.message, 'error'); if (btn) btn.disabled = false; }); }; @@ -875,7 +1294,24 @@ const btn = e.target.closest('button[data-action]'); if (!btn || btn.disabled) return; const row = btn.closest('tr'); - const id = row.dataset.id; + + if (btn.dataset.action === 'toggle-folder' || btn.dataset.action === 'folder-menu') { + const folderId = row.dataset.folderId; + const f = folders.find(function (x) { return x.id === folderId; }); + if (!f) return; + if (btn.dataset.action === 'toggle-folder') { + collapsedFolders[folderId] = !collapsedFolders[folderId]; + renderTable(); + } else { + openFolderMenu(f, btn); + } + return; + } + + // A folder-header's member chips (see folderMemberChipHtml()) carry + // their own data-id directly on the