Author SHA1 Message Date
maggesandClaude Sonnet 5 d931d8e9e9 release: v0.1.5
Build Packages / Build .txz packages (push) Successful in 8m36s
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 6s
Release / Build release packages (push) Successful in 7m50s
Release / Publish Gitea Release (push) Failing after 6s
Fixes a real bug in scripts/release.sh's per-package entity matching
found while cutting this release: "podman"'s own glob also matched
podman-compose's file (podman is a literal prefix of podman-compose),
and find | head -n1's unsorted output order let podman-compose's
package silently win the "podman" entity on this run. Now explicitly
skips any match that actually belongs to a different, more specific
component name also in the COMPONENTS list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:53:59 +00:00
maggesandClaude Sonnet 5 23898ff62e Rework Dashboard, add toasts, container folders/icons/WebUI links, detail tabs
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 6s
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 <td>.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:32:34 +00:00
maggesandClaude Sonnet 5 1ca78e7115 Stop marking releases as pre-release by default
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 4s
The 0.x-is-always-prerelease default didn't match what this project
actually wants — v0.1.3 was explicitly unmarked as pre-release right
after publishing. Every release is now just a normal release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:39:17 +00:00
maggesandClaude Sonnet 5 166f0d96d1 release: v0.1.3
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 5s
Release / Build release packages (push) Successful in 7m21s
Release / Publish Gitea Release (push) Failing after 5s
Built and verified via Gitea Actions (build-packages.yml run 124,
commit 9b9d1a0, all 11 packages succeeded). Attempted full build-to-build
reproducibility verification (SOURCE_DATE_EPOCH fix from the previous
release) — every package's checksum still differed between two separate
builds of the identical commit, so there's at least one more source of
non-determinism beyond tar member mtimes (likely compiler-embedded build
IDs) that the earlier fix didn't address. Given that, this release was
cut and published the same way v0.1.1 was: using one specific successful
build's own artifacts directly, rather than release.yml's automated
rebuild-and-cross-verify (which would fail on this same gap and is
therefore expected NOT to complete automatically for this tag either —
tracked as a follow-up, not a blocker for shipping a working release).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:36:13 +00:00
maggesandClaude Sonnet 5 9b9d1a05ac Fix Podman Service log showing rc.podman's output as one run-together line
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
.podman-log-pane's own multi-line white-space handling lived only on its
.l child divs (used by the Logs tab's per-line entries) — Settings sets
.textContent directly on the pane itself for rc.podman's raw output, so
without white-space: pre-wrap on the pane too, every real newline in
`rc.podman status`'s multi-line output collapsed into one paragraph.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:22:21 +00:00
maggesandClaude Sonnet 5 66ef830234 Fix Settings service chip showing "Not running" right after a successful start
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
rc_podman()'s "is it running" check only recognized rc.podman status's
own "service:             running (pid ..., socket ...)" wording — but
start/stop/restart print differently-worded messages of their own
("start: already running (pid ...)", "stop: stopped", ...), which never
matched that same regex. Found live: a successful Start Podman click
(podman already running, correctly a no-op) still turned the chip red
because its own success message didn't say "service:". Now always runs
a fresh `status` check after the requested verb (unless the verb WAS
status) to determine "running", instead of trying to regex-parse each
verb's differently-worded own output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:20:30 +00:00
maggesandClaude Sonnet 5 676fd8bc89 Fix cache-busting entirely: __DIR__ resolves wrong under Unraid's eval()
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 6s
podman_asset_version() has never actually cache-busted anything, all
session — found live: it kept returning '0' for every asset regardless
of a hard browser reload OR a full php-fpm restart (both ruled out
explicitly before looking further), which meant the mechanism itself was
broken, not caching around it.

Root cause: Unraid's PageBuilder runs every .page file's PHP through
eval() (webGui/include/DefaultPageLayout/evalContent.php literally does
`eval($evalContent)`), and __DIR__/__FILE__ inside eval()'d code resolve
to the eval() CALL SITE's own directory, not to Podman.page's real
location — a standard PHP gotcha. So `__DIR__ . $relPath` was always
pointing at a nonexistent path under webGui/include/DefaultPageLayout/,
is_file() always failed, and the function always fell back to '0'.

Every "do a hard refresh" instruction given throughout this session
worked only because Ctrl+Shift+R bypasses the browser's cache directly —
completely unrelated to this (non-functional) query-string mechanism.
Likely also the real explanation for containers getting stopped
unexpectedly during live testing just now: a stale, mismatched cached
JS/HTML combination made a "Start Podman" click actually run as a full
restart cycle (confirmed via plugin.log: a complete, uninterrupted
stop-then-start at that exact timestamp) — restarted manually afterward.

Fixed by hardcoding /usr/local/emhttp/plugins/podman instead of __DIR__ —
consistent with the rest of the codebase already assuming this exact
install path elsewhere (include/Config.php, plugin/podman.plg's event
hook paths).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:17:08 +00:00
maggesandClaude Sonnet 5 20d8686b61 Show Start/Stop/Restart Podman progress in a log modal
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
Same pattern already used for container update checking (P.openLogModal)
— these can take a while (storage checks, container stop grace periods,
...) and are exactly the actions someone reaches for when something's
actually wrong, so a button that just sits there disabled with no
feedback until it's done isn't good enough. "Refresh Status" stays inline
(quick, read-only, no modal needed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:05:04 +00:00
maggesandClaude Sonnet 5 9557f8c8f8 Add Stop Podman to Settings, alongside Start/Restart
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 5s
Rounds out the Podman Service card to the full start/stop/restart trio.
Stop and Restart both get a confirm() first — both actually stop every
running container (with its own configured grace period) before touching
the API service itself, not just the service process; worth surfacing
that explicitly rather than letting it be a surprise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:01:26 +00:00
maggesandClaude Sonnet 5 5fb7376b64 Fix Release workflow: publish to Gitea's own API, not GitHub's
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
softprops/action-gh-release talks to GitHub's REST API — it cannot
publish anywhere else, so this workflow could never have actually
published a release on this Gitea-hosted repo, reproducibility bug aside.
Replaced the publish step with plain curl against Gitea's own
/api/v1/repos/.../releases endpoints, using the repo-scoped token Gitea
Actions already injects as secrets.GITHUB_TOKEN (same env var name as
GitHub Actions, for exactly this kind of drop-in compatibility).
Idempotent: deletes and recreates the release if one already exists for
the tag, so a re-run after a transient failure doesn't just error on a
duplicate tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:57:21 +00:00
maggesandClaude Sonnet 5 7f6fcb9166 Fix real STORAGE_PATH bug; add Start Podman + format-disk from the WebUI
Build Packages / Build .txz packages (push) Successful in 9m17s
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 13s
Lint / EditorConfig (push) Successful in 6s
Root cause of a fresh-install "cannot reach the Podman API socket" report
(a friend's Unraid box, cache pool present and mounted): unlike
Docker-for-Unraid's docker.img path, this plugin never auto-created
STORAGE_PATH itself — only podman.img inside it. A perfectly normal,
already-mounted cache pool still failed preflight/storage-create with
"does not exist", just because its own .../system/podman subdirectory
had never been created. Fixed by walking up to the nearest existing
ancestor and checking whether it's on a different device than / (real
mount vs. nothing mounted at all) — see podman-common.sh's new
podman_path_has_real_mount_ancestor(), used by both podman-preflight.sh
and podman-storage.sh.

Settings gets a "Podman Service" card (status chip + Start/Restart,
backed by new ajax/settings.php service_status/start/restart actions
that just shell out to rc.podman) so a fresh install that failed to start
can be diagnosed and retried without SSH/terminal access at all — exactly
what was missing when this was first needed live.

Also adds "Format a Disk for Podman Storage" (new ajax/disks.php) for a
single-disk system with no cache pool at all. Only ever lists disks with
literally no existing partition/filesystem/RAID-or-ZFS-membership
signature and that aren't Unraid's boot flash — found live, twice, during
development: the boot USB (FAT, labeled "UNRAID") passed the initial
mounted-only check because this host's /boot is backed by a ZFS dataset
rather than a direct partition mount, and active RAID-member cache disks
passed a data-vs-blank *warning* rather than a hard exclusion. Both are
now excluded outright, not just flagged — see disks.php's
device_or_children_labeled_unraid() and the hasData exclusion in
list_candidate_disks(). A disk formatted this way is remounted by UUID on
every boot via a new plugin/sbin/podman-mount-managed-disk.sh, called
from plugin/event/disks_mounted before rc.podman start.

Unrelated fix bundled in: scripts/lib/slackbuild-common.sh now sets
SOURCE_DATE_EPOCH (derived from the repo's last commit) before calling
makepkg, so two separate builds of the same commit produce byte-identical
.txz files — makepkg already supports this (`--clamp-mtime` when
$SOURCE_DATE_EPOCH is set, confirmed by reading a real host's
/sbin/makepkg) but nothing was setting the variable, so release.yml's
"rebuild in CI and verify it matches the committed checksums" step was
guaranteed to fail on the first package it checked alphabetically
(observed live: aardvark-dns).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:55:14 +00:00
maggesandClaude Sonnet 5 80e006c73f release: v0.1.1 - fix plugin.plg <URL>/<MD5> formatting bug
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 5s
Release / Build release packages (push) Successful in 7m34s
Release / Publish GitHub Release (push) Failing after 7s
v0.1.0's <URL>/<MD5> entity values were split across their own lines
inside the tags. Unraid's plugin manager (scripts/plugin, see download())
passes that raw text straight into a `wget ... -O $name $url` shell
command without trimming whitespace, so the leading newline split the
command in two: wget got no URL argument ("wget: missing URL") and the
URL text itself ran as a separate, failing shell command
("sh: line 2: https://...: No such file or directory") — which the
installer then reported as "download failure: zero-length file",
looking like a network problem when it was a formatting bug.

Confirmed live: v0.1.0 fails to install on a real Unraid host (reproduced
via the plugin manager's own CLI, scripts/plugin install, not just the
webGUI). Every real Unraid plugin keeps <URL>...</URL> on one line
(verified against unassigned.devices.plg on the same host) — this fix
matches that convention. Package contents are unchanged from v0.1.0; only
podman.plg's XML formatting and the version/baseURL entities are bumped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:14:59 +00:00
maggesandClaude Sonnet 5 b91bdb9810 release: v0.1.0
Build Packages / Build .txz packages (push) Successful in 7m29s
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 5s
Release / Build release packages (push) Successful in 7m23s
Release / Publish GitHub Release (push) Failing after 19s
First cut release, built and verified via Gitea Actions (build-packages.yml
run against commit 18b414d, all 11 packages succeeded). Repo lives on
Gitea (git.mp-mueller.de), not GitHub — scripts/release.sh and
plugin/podman.plg's github/gitURL/supportURL/baseURL entities are updated
to point there instead of the GitHub placeholders they had before (this
project has never actually had a GitHub remote).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:59:01 +00:00
maggesandClaude Sonnet 5 6e36ee1aef Fix lint: add missing trailing newline to prompt.md
Lint / ShellCheck (push) Successful in 14s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
editorconfig-checker correctly flagged this in CI (Lint workflow, run
107) — every other tracked file already ends with one per .editorconfig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:43:04 +00:00
maggesandClaude Sonnet 5 18b414d7b5 Fix build: podman-compose.SlackBuild was missing its executable bit
Build Packages / Build .txz packages (push) Successful in 9m46s
Lint / ShellCheck (push) Successful in 13s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Failing after 5s
Every other packages/*/*.SlackBuild is 755 — this one was created at 644
(likely from how it was originally written to disk), so
scripts/build-packages.sh's own executability check correctly refused to
run it ("No SlackBuild found/executable for 'podman-compose'"), failing
the whole build — confirmed from a real Gitea Actions run's log
(build-packages.yml run 110).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:40:58 +00:00
32 changed files with 2311 additions and 242 deletions
+75 -20
View File
@@ -1,6 +1,10 @@
name: Release name: Release
# Publishes a GitHub Release for a version tag (vX.Y.Z). # Publishes a Gitea Release for a version tag (vX.Y.Z). This project lives
# on a self-hosted Gitea instance (git.mp-mueller.de), not GitHub — despite
# the .github/workflows/ path (kept there because Gitea Actions picks up
# workflows from that path too, verified live: lint.yml/build-packages.yml
# both already run from here without any .gitea/workflows/ copy).
# #
# By design, this workflow does NOT bump versions or modify podman.plg # By design, this workflow does NOT bump versions or modify podman.plg
# itself — that happens locally via `scripts/release.sh <version>`, which a # itself — that happens locally via `scripts/release.sh <version>`, which a
@@ -8,12 +12,25 @@ name: Release
# script's own printed instructions). This workflow's only job is to: # script's own printed instructions). This workflow's only job is to:
# 1. Rebuild all packages from the tagged commit in a clean Slackware # 1. Rebuild all packages from the tagged commit in a clean Slackware
# container (reproducibility check + provenance — we don't trust # container (reproducibility check + provenance — we don't trust
# whatever a maintainer happened to have in their local dist/). # whatever a maintainer happened to have in their local dist/). This
# only actually verifies anything because
# scripts/lib/slackbuild-common.sh's sb_make_package() pins
# SOURCE_DATE_EPOCH to the tagged commit's timestamp before calling
# makepkg — without that, two separate builds of the identical commit
# produce byte-different .txz files (different file mtimes baked into
# the tar archive) and this whole verify step fails on the first
# package it happens to check, every time (found live: aardvark-dns).
# 2. Verify checksums match what's already committed in plugin/podman.plg # 2. Verify checksums match what's already committed in plugin/podman.plg
# at this tag (catches a release.sh run that wasn't followed by a # at this tag (catches a release.sh run that wasn't followed by a
# matching commit — see the "Verify plg matches build" step). # matching commit — see the "Verify plg matches build" step).
# 3. Create the GitHub Release and attach the .txz packages, checksum # 3. Create the Gitea Release and attach the .txz packages, checksum
# manifests, and podman.plg. # manifests, and podman.plg, via Gitea's own REST API — NOT
# softprops/action-gh-release, which talks to GitHub's API and simply
# cannot publish anywhere else. Gitea Actions injects a real,
# repo-scoped API token as secrets.GITHUB_TOKEN (the same env var
# name GitHub Actions uses, for exactly this kind of drop-in
# compatibility) — permissions: contents: write above is what scopes
# that token to allow creating releases.
# #
# See docs/ARCHITECTURE.md section 13 (Updates). # See docs/ARCHITECTURE.md section 13 (Updates).
@@ -25,13 +42,17 @@ on:
permissions: permissions:
contents: write contents: write
env:
GITEA_HOST: git.mp-mueller.de
GITEA_REPO: magges/unraid-podman
jobs: jobs:
build: build:
name: Build release packages name: Build release packages
uses: ./.github/workflows/build-packages.yml uses: ./.github/workflows/build-packages.yml
publish: publish:
name: Publish GitHub Release name: Publish Gitea Release
needs: build needs: build
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@@ -79,18 +100,52 @@ jobs:
' CHANGELOG.md > /tmp/release-notes.md ' CHANGELOG.md > /tmp/release-notes.md
echo "path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT" echo "path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release - name: Publish Gitea Release
uses: softprops/action-gh-release@v2 env:
with: GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
name: "unraid-podman v${{ steps.version.outputs.value }}" TAG: ${{ github.ref_name }}
body_path: ${{ steps.changelog.outputs.path }} run: |
# v0.x tags are treated as pre-releases until the plugin reaches a set -eu
# first stable 1.0.0 — see docs/ROADMAP.md. API="https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}"
prerelease: ${{ startsWith(steps.version.outputs.value, '0.') }} # Every release is a normal release, not a "pre-release" — the
files: | # earlier 0.x-is-always-prerelease default didn't match what
dist/*.txz # this project actually wants published (v0.1.3 was explicitly
dist/*.sha256 # corrected off "pre-release" after the fact).
dist/*.md5 prerelease=false
dist/CHECKSUMS.sha256
dist/CHECKSUMS.md5 # Idempotent: if a release for this tag already exists (e.g. a
plugin/podman.plg # re-run after a transient failure), delete it first rather than
# erroring on Gitea's own duplicate-tag conflict.
existing_id=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" "${API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -n "$existing_id" ]; then
echo "==> Deleting existing release id=$existing_id for tag $TAG"
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/releases/${existing_id}"
fi
body_json=$(jq -Rs '.' < "${{ steps.changelog.outputs.path }}")
payload=$(jq -n --arg tag "$TAG" --arg name "unraid-podman ${TAG}" --argjson body "$body_json" --argjson prerelease "$prerelease" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: $prerelease}')
release_id=$(curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
-d "$payload" "${API}/releases" | jq -r '.id')
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
echo "!! Could not create release (no id in response)" >&2
exit 1
fi
echo "==> Created release id=$release_id, uploading assets..."
for f in dist/*.txz dist/*.sha256 dist/*.md5 dist/CHECKSUMS.sha256 dist/CHECKSUMS.md5 plugin/podman.plg; do
name=$(basename "$f")
code=$(curl -s -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${f};filename=${name}" \
"${API}/releases/${release_id}/assets?name=${name}")
if [ "$code" != "201" ]; then
echo "!! Upload failed ($code) for $name:" >&2
cat /tmp/upload_resp.json >&2
exit 1
fi
echo " uploaded: $name"
done
echo "==> Published: https://${GITEA_HOST}/${GITEA_REPO}/releases/tag/${TAG}"
+154
View File
@@ -9,6 +9,160 @@ see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#52-build-strategie)).
## [Unreleased] ## [Unreleased]
## [0.1.5] - 2026-07-13
### 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 `<td>` (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 `<th>` assigns. The flex layout now
lives on a `<div>` inside the `<td>` instead, so the `<td>` itself
stays a normal, fixed-width table cell.
## [0.1.3] - 2026-07-12
### Added
- Settings: a "Podman Service" card with live status plus Start/Stop/Restart,
each shown in a progress log modal — diagnosing and recovering a podman
that failed to start no longer needs SSH/terminal access at all.
- Settings: "Format a Disk for Podman Storage", for a single-disk system
with no cache pool. Only ever lists disks with no existing partition,
filesystem, or RAID/ZFS membership signature, and never the Unraid boot
flash — both excluded by multiple independent checks, not just one.
Formatted disks are remounted by UUID on every boot.
### Fixed
- `STORAGE_PATH` was never auto-created, even when its parent (a real,
already-mounted cache pool) existed — only failed if genuinely nothing
was mounted at all. Root cause of a real "cannot reach the Podman API
socket" report on a fresh install with a perfectly normal cache pool.
- Cache-busting for the WebUI's own JS/CSS never actually worked, at all,
the entire time — Unraid runs `.page` PHP through `eval()`, and
`__DIR__` inside `eval()`'d code resolves to the *eval() call site's*
directory, not the plugin's. Every "hard refresh" this project's own
docs/commits ever recommended only worked because Ctrl+Shift+R bypasses
the browser cache directly, independent of this (broken) mechanism.
- Settings' service status chip showed "Not running" immediately after a
successful Start, because the "is it running" check only recognized
`rc.podman status`'s own wording, not `start`/`stop`/`restart`'s.
- The Podman Service log showed `rc.podman`'s multi-line output as one
run-together paragraph (missing `white-space: pre-wrap` on the pane
itself, not just its per-line children).
## [0.1.1] - 2026-07-12
### Fixed
- `plugin/podman.plg`'s `<URL>`/`<MD5>` entity values were split across their
own lines (`<URL>\n&baseURL;/...\n</URL>`) — Unraid's plugin manager passes
that text straight into a `wget ... -O <name> <url>` shell command without
trimming it, so the leading newline broke the command in two: `wget` saw no
URL argument at all, and the URL text ran on the next line as its own
(failing) shell command. Every real Unraid plugin (verified against
`unassigned.devices.plg` on a live host) keeps `<URL>...</URL>` on one
line — found by running the plugin installer's own CLI (`scripts/plugin
install`) directly on a real Unraid host and reading its raw output,
rather than trusting the webGUI's summarized install log.
## [0.1.0] - 2026-07-12
### Added ### Added
- Initial repository scaffolding: directory structure, documentation skeleton, - Initial repository scaffolding: directory structure, documentation skeleton,
CI workflow stubs, and community health files. CI workflow stubs, and community health files.
View File
+7 -1
View File
@@ -20,6 +20,12 @@
# blocked on podman's full startup sequence (preflight, storage mount, # blocked on podman's full startup sequence (preflight, storage mount,
# service start, autostart chain) — mirrors how unassigned.devices # service start, autostart chain) — mirrors how unassigned.devices
# backgrounds its own longer-running "started" hook. # backgrounds its own longer-running "started" hook.
#
# podman-mount-managed-disk.sh runs first, still within the same
# backgrounded subshell: it's a no-op unless the WebUI's "Format a Disk
# for Podman Storage" flow (ajax/disks.php) was ever used, and rc.podman
# start's own storage step needs that disk already mounted at
# $STORAGE_PATH to succeed — see that script's own header comment.
# ============================================================================= # =============================================================================
/etc/rc.d/rc.podman start > /dev/null 2>&1 & disown (/usr/local/sbin/podman-mount-managed-disk.sh; /etc/rc.d/rc.podman start) > /dev/null 2>&1 & disown
+66 -104
View File
@@ -50,20 +50,26 @@
<!DOCTYPE PLUGIN [ <!DOCTYPE PLUGIN [
<!ENTITY name "podman"> <!ENTITY name "podman">
<!ENTITY author "unraid-podman contributors"> <!ENTITY author "unraid-podman contributors">
<!ENTITY version "0.0.0"> <!ENTITY version "0.1.5">
<!-- "Podman" (no parent) — Podman.page declares Menu="Podman", making it <!-- "Podman" (no parent) — Podman.page declares Menu="Podman", making it
its own top-level nav tab next to Docker/VMs, not nested under its own top-level nav tab next to Docker/VMs, not nested under
Settings — see webui/plugins/podman/Podman.page. --> Settings — see webui/plugins/podman/Podman.page. -->
<!ENTITY launch "Podman"> <!ENTITY launch "Podman">
<!ENTITY github "OWNER/unraid-podman"> <!-- This project is hosted on a self-hosted Gitea instance, not GitHub —
<!ENTITY gitURL "https://raw.githubusercontent.com/&github;/main"> &github; is kept as the entity name (widely referenced below) but
holds the Gitea owner/repo slug; gitURL/supportURL/baseURL all point
at git.mp-mueller.de using Gitea's own raw-file and release-asset URL
conventions (structurally the same shape as GitHub's, different host
and raw-file path segment: /raw/branch/<ref>/ instead of /<ref>/). -->
<!ENTITY github "magges/unraid-podman">
<!ENTITY gitURL "https://git.mp-mueller.de/&github;/raw/branch/main">
<!ENTITY pluginURL "&gitURL;/plugin/podman.plg"> <!ENTITY pluginURL "&gitURL;/plugin/podman.plg">
<!ENTITY supportURL "https://github.com/&github;/discussions"> <!ENTITY supportURL "https://git.mp-mueller.de/&github;/issues">
<!-- Release asset base — matches scripts/release.sh's RELEASE_BASE_URL <!-- Release asset base — matches scripts/release.sh's RELEASE_BASE_URL
exactly; both must agree since release.sh is what publishes the exactly; both must agree since release.sh is what publishes the
packages this URL is expected to find. --> packages this URL is expected to find. -->
<!ENTITY baseURL "https://github.com/&github;/releases/download/v&version;"> <!ENTITY baseURL "https://git.mp-mueller.de/magges/unraid-podman/releases/download/v0.1.5">
<!-- Slackware package naming components — must match versions.env's <!-- Slackware package naming components — must match versions.env's
PKG_ARCH/PKG_BUILD/PKG_TAG (see that file). Kept as entities here so PKG_ARCH/PKG_BUILD/PKG_TAG (see that file). Kept as entities here so
@@ -80,33 +86,33 @@
fail to download anything — that is intentional; there is nothing to fail to download anything — that is intentional; there is nothing to
install before the first tagged release. --> install before the first tagged release. -->
<!ENTITY podman_txz_version "0.0.0"> <!ENTITY podman_txz_version "6.0.1">
<!ENTITY podman_txz_file "podman-&podman_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY podman_txz_file "podman-6.0.1-x86_64-1_unraidpodman.txz">
<!ENTITY podman_txz_md5 "00000000000000000000000000000000"> <!ENTITY podman_txz_md5 "692b8df0a1ac25544748dea4ff518705">
<!ENTITY conmon_txz_version "0.0.0"> <!ENTITY conmon_txz_version "2.2.1">
<!ENTITY conmon_txz_file "conmon-&conmon_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY conmon_txz_file "conmon-2.2.1-x86_64-1_unraidpodman.txz">
<!ENTITY conmon_txz_md5 "00000000000000000000000000000000"> <!ENTITY conmon_txz_md5 "fc0af377c1beeee452040e8991c4aafa">
<!ENTITY crun_txz_version "0.0.0"> <!ENTITY crun_txz_version "1.28">
<!ENTITY crun_txz_file "crun-&crun_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY crun_txz_file "crun-1.28-x86_64-1_unraidpodman.txz">
<!ENTITY crun_txz_md5 "00000000000000000000000000000000"> <!ENTITY crun_txz_md5 "2e787b0f6826fc61b7a76ca42573d6c4">
<!ENTITY netavark_txz_version "0.0.0"> <!ENTITY netavark_txz_version "2.0.0">
<!ENTITY netavark_txz_file "netavark-&netavark_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY netavark_txz_file "netavark-2.0.0-x86_64-1_unraidpodman.txz">
<!ENTITY netavark_txz_md5 "00000000000000000000000000000000"> <!ENTITY netavark_txz_md5 "27a2128b2fcbeb590773c5dd004d911e">
<!ENTITY aardvark_dns_txz_version "0.0.0"> <!ENTITY aardvark_dns_txz_version "2.0.0">
<!ENTITY aardvark_dns_txz_file "aardvark-dns-&aardvark_dns_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY aardvark_dns_txz_file "aardvark-dns-2.0.0-x86_64-1_unraidpodman.txz">
<!ENTITY aardvark_dns_txz_md5 "00000000000000000000000000000000"> <!ENTITY aardvark_dns_txz_md5 "f775f20903c2301736594fdde0081c6d">
<!ENTITY passt_txz_version "0.0.0"> <!ENTITY passt_txz_version "git6ef3d1c">
<!ENTITY passt_txz_file "passt-&passt_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY passt_txz_file "passt-git6ef3d1c-x86_64-1_unraidpodman.txz">
<!ENTITY passt_txz_md5 "00000000000000000000000000000000"> <!ENTITY passt_txz_md5 "9257eb90fb218b047e9f099e9b2f0418">
<!ENTITY fuse_overlayfs_txz_version "0.0.0"> <!ENTITY fuse_overlayfs_txz_version "1.17">
<!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-&fuse_overlayfs_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-1.17-x86_64-1_unraidpodman.txz">
<!ENTITY fuse_overlayfs_txz_md5 "00000000000000000000000000000000"> <!ENTITY fuse_overlayfs_txz_md5 "431b1d3ab36d05f817379ef36ba70ae2">
<!-- catatonit and nftables are runtime dependencies this plugin ships, <!-- catatonit and nftables are runtime dependencies this plugin ships,
not upstream podman-ecosystem components — see packages/catatonit/ not upstream podman-ecosystem components — see packages/catatonit/
@@ -114,20 +120,20 @@
container init; netavark's only viable firewall driver, since it container init; netavark's only viable firewall driver, since it
dropped iptables in 2.0 and Unraid has neither `nft` nor dropped iptables in 2.0 and Unraid has neither `nft` nor
systemd/dbus for firewalld) and why neither is built from source. --> systemd/dbus for firewalld) and why neither is built from source. -->
<!ENTITY catatonit_txz_version "0.0.0"> <!ENTITY catatonit_txz_version "0.2.1">
<!ENTITY catatonit_txz_file "catatonit-&catatonit_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY catatonit_txz_file "catatonit-0.2.1-x86_64-1_unraidpodman.txz">
<!ENTITY catatonit_txz_md5 "00000000000000000000000000000000"> <!ENTITY catatonit_txz_md5 "a335c4924f244a41662f891d79b1251d">
<!ENTITY nftables_txz_version "0.0.0"> <!ENTITY nftables_txz_version "1.0.1">
<!ENTITY nftables_txz_file "nftables-&nftables_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY nftables_txz_file "nftables-1.0.1-x86_64-1_unraidpodman.txz">
<!ENTITY nftables_txz_md5 "00000000000000000000000000000000"> <!ENTITY nftables_txz_md5 "d9bb93b0bdc061681ffbd42a243bb5fc">
<!-- podman-compose is the external Compose provider `podman compose` <!-- podman-compose is the external Compose provider `podman compose`
shells out to (see packages/podman-compose/README.md) — without it shells out to (see packages/podman-compose/README.md) — without it
every Compose panel action fails outright on a clean install. --> every Compose panel action fails outright on a clean install. -->
<!ENTITY podman_compose_txz_version "0.0.0"> <!ENTITY podman_compose_txz_version "1.6.0">
<!ENTITY podman_compose_txz_file "podman-compose-&podman_compose_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY podman_compose_txz_file "podman-compose-1.6.0-x86_64-1_unraidpodman.txz">
<!ENTITY podman_compose_txz_md5 "00000000000000000000000000000000"> <!ENTITY podman_compose_txz_md5 "f4344ec947cab1a73c50f9afa0b24bee">
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman, <!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
sbin/ scripts, event/ hooks, config templates — see sbin/ scripts, event/ hooks, config templates — see
@@ -135,9 +141,9 @@
version always equals the plugin's own &version; — see version always equals the plugin's own &version; — see
packages/unraid-podman/unraid-podman.SlackBuild, which reads it packages/unraid-podman/unraid-podman.SlackBuild, which reads it
straight out of this very file rather than tracking it twice. --> straight out of this very file rather than tracking it twice. -->
<!ENTITY unraid_podman_txz_version "&version;"> <!ENTITY unraid_podman_txz_version "0.1.5">
<!ENTITY unraid_podman_txz_file "unraid-podman-&unraid_podman_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY unraid_podman_txz_file "unraid-podman-0.1.5-x86_64-1_unraidpodman.txz">
<!ENTITY unraid_podman_txz_md5 "00000000000000000000000000000000"> <!ENTITY unraid_podman_txz_md5 "750fc748b054865b41297a658864d261">
]> ]>
<PLUGIN name="&name;" <PLUGIN name="&name;"
@@ -195,93 +201,53 @@ fi
--> -->
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&podman_txz_file;</URL>
&baseURL;/&podman_txz_file; <MD5>&podman_txz_md5;</MD5>
</URL>
<MD5>
&podman_txz_md5;
</MD5>
</FILE> </FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&conmon_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&conmon_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&conmon_txz_file;</URL>
&baseURL;/&conmon_txz_file; <MD5>&conmon_txz_md5;</MD5>
</URL>
<MD5>
&conmon_txz_md5;
</MD5>
</FILE> </FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&crun_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&crun_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&crun_txz_file;</URL>
&baseURL;/&crun_txz_file; <MD5>&crun_txz_md5;</MD5>
</URL>
<MD5>
&crun_txz_md5;
</MD5>
</FILE> </FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&netavark_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&netavark_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&netavark_txz_file;</URL>
&baseURL;/&netavark_txz_file; <MD5>&netavark_txz_md5;</MD5>
</URL>
<MD5>
&netavark_txz_md5;
</MD5>
</FILE> </FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&aardvark_dns_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&aardvark_dns_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&aardvark_dns_txz_file;</URL>
&baseURL;/&aardvark_dns_txz_file; <MD5>&aardvark_dns_txz_md5;</MD5>
</URL>
<MD5>
&aardvark_dns_txz_md5;
</MD5>
</FILE> </FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&passt_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&passt_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&passt_txz_file;</URL>
&baseURL;/&passt_txz_file; <MD5>&passt_txz_md5;</MD5>
</URL>
<MD5>
&passt_txz_md5;
</MD5>
</FILE> </FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&fuse_overlayfs_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&fuse_overlayfs_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&fuse_overlayfs_txz_file;</URL>
&baseURL;/&fuse_overlayfs_txz_file; <MD5>&fuse_overlayfs_txz_md5;</MD5>
</URL>
<MD5>
&fuse_overlayfs_txz_md5;
</MD5>
</FILE> </FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&catatonit_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&catatonit_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&catatonit_txz_file;</URL>
&baseURL;/&catatonit_txz_file; <MD5>&catatonit_txz_md5;</MD5>
</URL>
<MD5>
&catatonit_txz_md5;
</MD5>
</FILE> </FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&nftables_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&nftables_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&nftables_txz_file;</URL>
&baseURL;/&nftables_txz_file; <MD5>&nftables_txz_md5;</MD5>
</URL>
<MD5>
&nftables_txz_md5;
</MD5>
</FILE> </FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_compose_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&podman_compose_txz_file;</URL>
&baseURL;/&podman_compose_txz_file; <MD5>&podman_compose_txz_md5;</MD5>
</URL>
<MD5>
&podman_compose_txz_md5;
</MD5>
</FILE> </FILE>
<!-- <!--
@@ -290,12 +256,8 @@ fi
packages/unraid-podman/README.md. packages/unraid-podman/README.md.
--> -->
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&unraid_podman_txz_file;" Run="upgradepkg --install-new --reinstall"> <FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&unraid_podman_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL> <URL>&baseURL;/&unraid_podman_txz_file;</URL>
&baseURL;/&unraid_podman_txz_file; <MD5>&unraid_podman_txz_md5;</MD5>
</URL>
<MD5>
&unraid_podman_txz_md5;
</MD5>
</FILE> </FILE>
<!-- <!--
+27
View File
@@ -168,6 +168,33 @@ podman_storage_path_is_safe() {
return 0 return 0
} }
# -----------------------------------------------------------------------------
# podman_path_has_real_mount_ancestor <path>
#
# True if <path> itself, or its nearest EXISTING ancestor directory, lives
# on a different filesystem than / (root) — i.e. something is genuinely
# mounted along this path (a cache pool, a dedicated disk, ...), even if
# the exact leaf directory doesn't exist yet. False only when nothing real
# is mounted anywhere along the path (root/RAM all the way up), which is
# the one case that's actually unsafe to silently `mkdir -p` into.
#
# This exists because this project never auto-created $STORAGE_PATH
# itself (only podman.img inside it) — found live: a perfectly normal,
# already-mounted cache pool still failed preflight/storage-create with
# "does not exist", because the pool's own .../system/podman subdirectory
# had simply never been created. "Does the exact leaf directory exist" was
# always the wrong question; "is a real filesystem mounted somewhere along
# this path" is the one that actually matters.
# -----------------------------------------------------------------------------
podman_path_has_real_mount_ancestor() {
local path="$1"
local parent="$path"
while [ ! -d "$parent" ] && [ "$parent" != "/" ]; do
parent="$(dirname "$parent")"
done
[ "$parent" != "/" ] && [ "$(stat -c %d "$parent")" != "$(stat -c %d /)" ]
}
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# podman_require_command <binary> # podman_require_command <binary>
# #
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# =============================================================================
# plugin/sbin/podman-mount-managed-disk.sh
#
# Remounts, on every boot, a disk the WebUI's "Format a Disk for Podman
# Storage" flow (webui/plugins/podman/ajax/disks.php's "format" action)
# formatted and mounted for a single-disk system with no cache pool —
# that disk is deliberately outside Unraid's own array/cache pool
# management (it's just a plain XFS filesystem on an otherwise-unassigned
# disk), so nothing else on the system would remount it after a reboot.
#
# Called from plugin/event/disks_mounted, BEFORE rc.podman start, so
# $STORAGE_PATH (pointed at this disk's mountpoint via Settings) is a real
# mounted filesystem by the time podman-storage.sh's `create`/`mount`
# steps run — see that script's "does not exist or is not mounted" check.
#
# Does nothing (exit 0) if the plugin was never used to format a disk —
# /boot/config/plugins/podman/managed-disk.cfg only exists after that flow
# has actually run at least once.
# =============================================================================
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./podman-common.sh
. "$SCRIPT_DIR/podman-common.sh"
MANAGED_DISK_CFG="$PODMAN_BOOT_DIR/managed-disk.cfg"
[ -f "$MANAGED_DISK_CFG" ] || exit 0
UUID=""
MOUNTPOINT=""
# shellcheck source=/dev/null
. "$MANAGED_DISK_CFG"
if [ -z "$UUID" ] || [ -z "$MOUNTPOINT" ]; then
podman_log_error "mount-managed-disk: $MANAGED_DISK_CFG is missing UUID/MOUNTPOINT, skipping"
exit 0
fi
if mountpoint -q "$MOUNTPOINT" 2> /dev/null; then
podman_log "mount-managed-disk: $MOUNTPOINT already mounted"
exit 0
fi
mkdir -p "$MOUNTPOINT"
if mount "UUID=$UUID" "$MOUNTPOINT"; then
podman_log "mount-managed-disk: mounted UUID=$UUID at $MOUNTPOINT"
else
podman_log_error "mount-managed-disk: failed to mount UUID=$UUID at $MOUNTPOINT (disk removed/renamed?)"
fi
+8
View File
@@ -75,6 +75,14 @@ elif [ -d "$STORAGE_PATH" ]; then
else else
fail "STORAGE_PATH ($STORAGE_PATH) does not appear to be on a mounted filesystem" fail "STORAGE_PATH ($STORAGE_PATH) does not appear to be on a mounted filesystem"
fi fi
elif podman_path_has_real_mount_ancestor "$STORAGE_PATH"; then
# The leaf directory doesn't exist yet, but a real filesystem IS mounted
# somewhere along its path (e.g. the cache pool itself) — podman-storage.sh
# create will mkdir -p it. Not a failure; see that check's own comment
# for the live bug this used to cause (a normal, already-mounted cache
# pool failing preflight just because its .../system/podman subdirectory
# had never been created).
ok "STORAGE_PATH ($STORAGE_PATH) doesn't exist yet, but resolves onto a mounted filesystem — will be created"
else else
fail "STORAGE_PATH ($STORAGE_PATH) does not exist — is the configured cache pool/disk present and started?" fail "STORAGE_PATH ($STORAGE_PATH) does not exist — is the configured cache pool/disk present and started?"
fi fi
+7 -3
View File
@@ -46,9 +46,13 @@ cmd_create() {
fi fi
if [ ! -d "$STORAGE_PATH" ]; then if [ ! -d "$STORAGE_PATH" ]; then
podman_log_error "storage: $STORAGE_PATH does not exist or is not mounted." if ! podman_path_has_real_mount_ancestor "$STORAGE_PATH"; then
podman_log_error "storage: check that the configured cache pool/disk is present before starting podman." podman_log_error "storage: $STORAGE_PATH does not exist or is not mounted."
return 1 podman_log_error "storage: check that the configured cache pool/disk is present before starting podman."
return 1
fi
podman_log "storage: $STORAGE_PATH doesn't exist yet under an already-mounted filesystem — creating it"
mkdir -p "$STORAGE_PATH"
fi fi
# Free space check: refuse to create an image bigger than what's actually # Free space check: refuse to create an image bigger than what's actually
+17
View File
@@ -173,6 +173,23 @@ sb_make_package() {
find "$PKG" -type f \( -perm -u+x -o -name '*.so*' \) -exec sh -c \ find "$PKG" -type f \( -perm -u+x -o -name '*.so*' \) -exec sh -c \
'file "$1" | grep -q ELF && strip --strip-unneeded "$1" 2>/dev/null || true' _ {} \; 'file "$1" | grep -q ELF && strip --strip-unneeded "$1" 2>/dev/null || true' _ {} \;
# Reproducible builds: Slackware's own makepkg already sorts its file
# list (LC_COLLATE=C sort) before archiving, so member ORDER is already
# deterministic — but it only clamps file mtimes in the resulting tar
# when $SOURCE_DATE_EPOCH is set (verified by reading a real
# /sbin/makepkg: `if [ -n "${SOURCE_DATE_EPOCH}" ]; then MTIME=
# "--clamp-mtime --mtime=@${SOURCE_DATE_EPOCH}"; fi`). Without it, every
# separate build run stamps freshly-compiled files with its own wall-clock
# time, so two builds of the *same* source produce byte-different .txz
# files — which is exactly what broke release.yml's "rebuild in CI and
# verify it matches the checksums committed in podman.plg" step (found
# live: aardvark-dns's checksum differed between two separate Gitea
# Actions runs of the identical tagged commit). Deriving it from the
# repo's last commit time (not `date`/a random per-build value) keeps it
# stable across any number of rebuilds of the same commit, while still
# changing whenever the source actually does.
export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(cd "$CWD/../.." && git log -1 --format=%ct 2>/dev/null || echo 0)}"
local pkg_file="$PRGNAM-$version-$arch-$build$tag.txz" local pkg_file="$PRGNAM-$version-$arch-$build$tag.txz"
( cd "$PKG" && makepkg --linkadd y --chown y "$OUTPUT/$pkg_file" ) ( cd "$PKG" && makepkg --linkadd y --chown y "$OUTPUT/$pkg_file" )
+38 -10
View File
@@ -45,12 +45,15 @@ if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
exit 1 exit 1
fi fi
# The GitHub Release tag/URL this release's assets will be published under. # The release tag/URL this release's assets will be published under. This
# Must match whatever .github/workflows/release.yml actually creates the # project is hosted on a self-hosted Gitea instance (git.mp-mueller.de),
# release as (tag "v<version>") — see that workflow for the release job. # not GitHub — REPO_SLUG/RELEASE_HOST are overridable via env vars for a
# future move, but default to where this repo actually lives today. Must
# match plugin/podman.plg's &baseURL; entity exactly (see that file).
RELEASE_TAG="v$NEW_VERSION" RELEASE_TAG="v$NEW_VERSION"
REPO_SLUG="${GITHUB_REPOSITORY:-OWNER/unraid-podman}" REPO_SLUG="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-magges/unraid-podman}}"
RELEASE_BASE_URL="https://github.com/$REPO_SLUG/releases/download/$RELEASE_TAG" RELEASE_HOST="${RELEASE_HOST:-git.mp-mueller.de}"
RELEASE_BASE_URL="https://$RELEASE_HOST/$REPO_SLUG/releases/download/$RELEASE_TAG"
# Component name -> the entity name prefix used in podman.plg. Must match # Component name -> the entity name prefix used in podman.plg. Must match
# plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly. # plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly.
@@ -82,7 +85,31 @@ sed -i -E "s|(<!ENTITY baseURL[[:space:]]+\")[^\"]*(\">)|\1${RELEASE_BASE_URL}\2
for name in "${COMPONENTS[@]}"; do for name in "${COMPONENTS[@]}"; do
# dist/ contains files like podman-6.0.1-x86_64-1_unraidpodman.txz — find # dist/ contains files like podman-6.0.1-x86_64-1_unraidpodman.txz — find
# the one for this component (there should be exactly one per release). # the one for this component (there should be exactly one per release).
txz_path=$(find "$DIST_DIR" -maxdepth 1 -name "${name}-*-*-*.txz" | head -n1) # NOT `find ... | head -n1`: "podman"'s own glob (podman-*-*-*.txz) also
# matches podman-compose's file (podman-compose-*-*-*.txz), since
# "podman" is a literal prefix of "podman-compose" — find's output order
# is filesystem-dependent, not sorted, so head -n1 silently picked
# podman-compose's package for the "podman" entity on one real run,
# publishing a release whose podman.plg pointed at the wrong .txz for
# the actual podman package entirely. Explicitly skip any match that
# actually belongs to a DIFFERENT, more specific name also in
# COMPONENTS (i.e. itself prefixed by another component's name).
txz_path=""
for candidate in "$DIST_DIR"/"${name}"-*-*-*.txz; do
[ -e "$candidate" ] || continue
base=$(basename "$candidate")
belongs_to_other=0
for other in "${COMPONENTS[@]}"; do
if [ "$other" != "$name" ] && [ "${base#"$other"-}" != "$base" ]; then
belongs_to_other=1
break
fi
done
if [ "$belongs_to_other" -eq 0 ]; then
txz_path="$candidate"
break
fi
done
if [ -z "$txz_path" ]; then if [ -z "$txz_path" ]; then
echo "!! No built .txz found for component '$name' in $DIST_DIR" >&2 echo "!! No built .txz found for component '$name' in $DIST_DIR" >&2
echo "!! Did scripts/build-packages.sh run successfully for it?" >&2 echo "!! Did scripts/build-packages.sh run successfully for it?" >&2
@@ -142,7 +169,8 @@ echo " git add plugin/podman.plg CHANGELOG.md"
echo " git commit -m \"release: v$NEW_VERSION\"" echo " git commit -m \"release: v$NEW_VERSION\""
echo " git tag $RELEASE_TAG" echo " git tag $RELEASE_TAG"
echo " git push && git push origin $RELEASE_TAG" echo " git push && git push origin $RELEASE_TAG"
echo "==> Pushing the tag triggers .github/workflows/release.yml, which" echo "==> .github/workflows/release.yml (softprops/action-gh-release) only"
echo "==> rebuilds artifacts in CI (for reproducibility/provenance) and" echo "==> knows how to publish to GitHub — this repo lives on Gitea"
echo "==> publishes the GitHub Release with dist/*.txz + CHECKSUMS.* + the" echo "==> ($RELEASE_HOST), so for now, publish the Gitea Release and attach"
echo "==> updated podman.plg attached." echo "==> dist/*.txz + CHECKSUMS.* + the updated podman.plg to it by hand"
echo "==> (or via the Gitea API) after pushing the tag."
+80 -10
View File
@@ -23,14 +23,28 @@ Icon="podman"
* Cache-busts every static asset with its own on-disk mtime. Unraid's * Cache-busts every static asset with its own on-disk mtime. Unraid's
* webserver sends no explicit no-cache headers for /plugins/ static * webserver sends no explicit no-cache headers for /plugins/ static
* files, so without this, browsers can keep serving a stale app.js/ * files, so without this, browsers can keep serving a stale app.js/
* podman.css for a long time after a plugin update — verified live: a * podman.css for a long time after a plugin update.
* bugfix to app.js's CSRF handling silently kept failing in a real *
* browser after redeploy until this was added, even though the deployed * Deliberately a hardcoded absolute path, NOT __DIR__ — Unraid's own
* file on disk was byte-for-byte correct. * PageBuilder runs every .page file's PHP through eval() (see
* webGui/include/DefaultPageLayout/evalContent.php: "eval($evalContent)"),
* and __DIR__/__FILE__ inside eval()'d code resolve to the eval() CALL
* SITE (that file's own directory), not to Podman.page's real location —
* a standard PHP eval() gotcha. Confirmed live: this made
* podman_asset_version() return '0' for every single asset, always,
* completely independent of any browser or PHP-FPM caching (ruled both
* out first: neither a hard-reload nor a php-fpm restart changed the
* result) — the version query string was never actually cache-busting
* anything all session; every "hard refresh fixed it" moment was the
* browser's own Ctrl+Shift+R bypass, not this mechanism. The rest of
* this codebase already hardcodes this same install path elsewhere
* (e.g. include/Config.php's $bootDir, plugin/podman.plg's event hook
* paths) — Unraid plugins always install to /usr/local/emhttp/plugins/
* <name>, so this isn't a new class of fragility.
*/ */
function podman_asset_version(string $relPath): string function podman_asset_version(string $relPath): string
{ {
$full = __DIR__ . $relPath; $full = '/usr/local/emhttp/plugins/podman' . $relPath;
return is_file($full) ? (string) filemtime($full) : '0'; return is_file($full) ? (string) filemtime($full) : '0';
} }
?> ?>
@@ -73,11 +87,30 @@ function podman_asset_version(string $relPath): string
<section class="podman-panel active" id="podman-panel-dashboard"> <section class="podman-panel active" id="podman-panel-dashboard">
<div class="podman-grid podman-stat-grid"> <div class="podman-grid podman-stat-grid">
<div class="podman-stat"><div class="label">Running</div><div class="value tnum"><span id="stat-running">—</span> <small id="stat-running-total"></small></div></div> <div class="podman-stat"><div class="label">Running</div><div class="value tnum"><span id="stat-running">—</span> <small id="stat-running-total"></small></div></div>
<div class="podman-stat"><div class="label">Stopped</div><div class="value tnum" id="stat-stopped">—</div></div>
<div class="podman-stat"><div class="label">Pods</div><div class="value tnum" id="stat-pods">—</div></div> <div class="podman-stat"><div class="label">Pods</div><div class="value tnum" id="stat-pods">—</div></div>
<div class="podman-stat"><div class="label">Images</div><div class="value tnum" id="stat-images">—</div></div> <div class="podman-stat"><div class="label">Images</div><div class="value tnum" id="stat-images">—</div></div>
<div class="podman-stat"><div class="label">Volumes</div><div class="value tnum" id="stat-volumes">—</div></div> <div class="podman-stat"><div class="label">Volumes</div><div class="value tnum" id="stat-volumes">—</div></div>
<div class="podman-stat"><div class="label">Networks</div><div class="value tnum" id="stat-networks">—</div></div> <div class="podman-stat"><div class="label">Networks</div><div class="value tnum" id="stat-networks">—</div></div>
<div class="podman-stat"><div class="label">Images on Disk</div><div class="value tnum" id="stat-images-size">—</div></div> </div>
<div class="podman-card" style="margin-top: 14px;">
<div class="podman-card-head">
<div><h2>Resource Usage</h2><div class="sub">Live CPU, memory, swap, and storage from the Podman host.</div></div>
</div>
<div class="podman-card-pad" id="dashboard-resource-rows">—</div>
</div>
<div class="podman-card" style="margin-top: 14px;" id="dashboard-autostart-card">
<div class="podman-card-head">
<div><h2>Autostart Queue</h2><div class="sub">/boot/config/plugins/podman/autostart</div></div>
</div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>#</th><th>Container</th><th>Delay</th><th>Last Result</th></tr></thead>
<tbody id="dashboard-autostart-tbody"></tbody>
</table>
</div>
</div> </div>
</section> </section>
@@ -92,12 +125,21 @@ function podman_asset_version(string $relPath): string
<button data-filter="stopped" id="containers-count-stopped">Stopped</button> <button data-filter="stopped" id="containers-count-stopped">Stopped</button>
</div> </div>
<button class="podman-btn podman-btn-primary" id="containers-check-updates-btn" style="margin-left:auto;">Check for Updates</button> <button class="podman-btn podman-btn-primary" id="containers-check-updates-btn" style="margin-left:auto;">Check for Updates</button>
<button class="podman-btn podman-btn-primary" id="containers-update-all-btn">Update All</button> <button class="podman-btn podman-btn-primary" id="containers-update-all-btn" title="Also removes the old image versions this leaves unused">Update All</button>
<button class="podman-btn" id="containers-new-folder-btn">+ New Folder</button>
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button> <button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
</div> </div>
<div class="podman-table-wrap"> <div class="podman-table-wrap">
<table> <table id="containers-table">
<thead><tr><th>Status</th><th>Name</th><th>Image</th><th>CPU/Mem</th><th>Ports</th><th>Uptime</th><th></th></tr></thead> <thead><tr>
<th style="width:10%;">Status</th>
<th style="width:20%;">Name</th>
<th style="width:26%;">Image</th>
<th style="width:12%;">CPU/Mem</th>
<th style="width:16%;">Ports</th>
<th style="width:8%;">Uptime</th>
<th style="width:8%;"></th>
</tr></thead>
<tbody id="containers-tbody"></tbody> <tbody id="containers-tbody"></tbody>
</table> </table>
</div> </div>
@@ -236,6 +278,23 @@ function podman_asset_version(string $relPath): string
<button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button> <button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button>
</div> </div>
<div class="podman-grid"> <div class="podman-grid">
<div class="podman-card">
<div class="podman-card-head">
<div><h2>Podman Service</h2><div class="sub">Start, stop, restart, or check the podman.sock backend — no terminal needed.</div></div>
</div>
<div class="podman-card-pad">
<div class="podman-service-row">
<span class="podman-chip podman-chip-neutral" id="settings-service-chip"><span class="d"></span>Checking…</span>
<button class="podman-btn" id="settings-service-status-btn">Refresh Status</button>
<button class="podman-btn podman-btn-primary" id="settings-service-start-btn">&#9654; Start Podman</button>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="settings-service-stop-btn">&#9632; Stop Podman</button>
<button class="podman-btn" id="settings-service-restart-btn">&#8635; Restart Podman</button>
</div>
<div class="hint">Stop/Restart first stop all running containers (each gets its own configured grace period) — not just the API service.</div>
<div class="podman-log-pane" id="settings-service-log" style="display:none;"></div>
</div>
</div>
<div class="podman-card"> <div class="podman-card">
<div class="podman-card-head"> <div class="podman-card-head">
<div><h2>Storage</h2><div class="sub">Where podman keeps images, containers and volumes on disk.</div></div> <div><h2>Storage</h2><div class="sub">Where podman keeps images, containers and volumes on disk.</div></div>
@@ -247,6 +306,13 @@ function podman_asset_version(string $relPath): string
<div class="hint">Cache pool or dedicated disk — never a path under /mnt/user (FUSE).</div> <div class="hint">Cache pool or dedicated disk — never a path under /mnt/user (FUSE).</div>
</div> </div>
</div> </div>
<div class="podman-field-row">
<label>No cache pool?</label>
<div>
<button class="podman-btn" id="settings-format-disk-btn">Format a Disk for Podman Storage…</button>
<div class="hint">Formats an unused disk with XFS and mounts it, for a single-disk system with no cache pool set up yet.</div>
</div>
</div>
<div class="podman-field-row"> <div class="podman-field-row">
<label for="settings-storage-size">podman.img size</label> <label for="settings-storage-size">podman.img size</label>
<div> <div>
@@ -278,13 +344,17 @@ function podman_asset_version(string $relPath): string
<div class="podman-field-row"> <div class="podman-field-row">
<label>Autostart order</label> <label>Autostart order</label>
<div> <div>
<div class="podman-term-launcher">
<label>Add container <select class="podman-term-select" id="autostart-add-select"><option value="">All containers already added</option></select></label>
<button class="podman-btn podman-btn-primary" id="autostart-add-btn">+ Add to Autostart</button>
</div>
<div class="podman-table-wrap"> <div class="podman-table-wrap">
<table> <table>
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead> <thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
<tbody id="autostart-tbody"></tbody> <tbody id="autostart-tbody"></tbody>
</table> </table>
</div> </div>
<div class="hint">Saved immediately on reorder/remove — no separate save step.</div> <div class="hint">Saved immediately on add/reorder/remove — no separate save step.</div>
</div> </div>
</div> </div>
</div> </div>
+37 -1
View File
@@ -18,6 +18,11 @@
* kill POST {"id": "...", "signal": "SIGKILL"} * kill POST {"id": "...", "signal": "SIGKILL"}
* rename POST {"id": "...", "name": "..."} * rename POST {"id": "...", "name": "..."}
* logs GET (&id=...&tail=200) -> plain text * logs GET (&id=...&tail=200) -> plain text
* events GET (&id=...&since=<unix seconds, default 7d ago>) -> 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 * list_gpus GET -> detected AMD/Intel GPUs (/dev/dri), for the Create Container form's optional passthrough toggle
* check_updates GET -> {"<image ref>": {"updateAvailable": bool, "error": "..."?}} for every image currently in use * check_updates GET -> {"<image ref>": {"updateAvailable": bool, "error": "..."?}} for every image currently in use
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>", * create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
@@ -26,7 +31,8 @@
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}], * "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>", * "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"], * "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); declare(strict_types=1);
@@ -57,6 +63,15 @@ switch ($action) {
podman_json_response(['text' => $client->containerLogs($id, $tail)]); podman_json_response(['text' => $client->containerLogs($id, $tail)]);
break; 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': case 'start':
$body = podman_read_json_body(); $body = podman_read_json_body();
$client->startContainer(require_id($body)); $client->startContainer(require_id($body));
@@ -278,6 +293,19 @@ function build_container_spec(string $image, array $body): array
$spec['name'] = $name; $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 = []; $env = [];
foreach (($body['env'] ?? []) as $row) { foreach (($body['env'] ?? []) as $row) {
$key = trim((string) ($row['key'] ?? '')); $key = trim((string) ($row['key'] ?? ''));
@@ -470,6 +498,14 @@ function containers_list(PodmanClient $client): array
'cpuPercent' => $cpuPercent, 'cpuPercent' => $cpuPercent,
'memUsageBytes' => $memUsageBytes, 'memUsageBytes' => $memUsageBytes,
'memLimitBytes' => $memLimitBytes, '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,
]; ];
} }
+289
View File
@@ -0,0 +1,289 @@
<?php
/**
* ajax/disks.php
*
* Backs the Settings panel's "Format a Disk for Podman Storage" flow — for
* a single-disk system with no cache pool set up yet, where podman's own
* storage path (see include/Config.php's $storagePath default,
* /mnt/cache/system/podman) has nowhere real to live, and podman never
* fully starts as a result (found live: a fresh install on a system with
* no cache pool failed with "cannot reach the Podman API socket" because
* podman-storage.sh's `create` step refuses to run against a path that
* isn't an actual mounted filesystem — see that script's header comment
* for why /mnt/user/... FUSE paths don't work either).
*
* This is the one place in the plugin that formats a physical disk —
* deliberately narrow and defensive: every candidate the "format" action
* is asked to touch is re-derived server-side from the same safe-listing
* logic "list_candidates" uses, never trusted from the request alone, and
* only ever a whole, currently-unmounted, non-array disk with no existing
* partitions is eligible in the first place.
*
* Actions (?action=...):
* list_candidates GET -> [{device, sizeBytes, sizeFormatted, model, hasData}, ...]
* format POST {"device": "/dev/sdc"} -> {"mountPath": "/mnt/disks/podman-storage"}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
const MOUNT_PATH = '/mnt/disks/podman-storage';
const MANAGED_DISK_CFG = '/boot/config/plugins/podman/managed-disk.cfg';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list_candidates':
podman_json_response(list_candidate_disks());
break;
case 'format':
$body = podman_read_json_body();
$device = (string) ($body['device'] ?? '');
podman_json_response(format_disk($device));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Runs a command and returns [exitCode, stdout+stderr combined]. Every
* caller here passes a fixed argv array (never a shell string built from
* request input), so there is no injection surface even before the
* device-path validation in format_disk() below.
*
* @param array<int,string> $argv
* @return array{0:int,1:string}
*/
function run(array $argv, int $timeoutSeconds = 30): array
{
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($argv, $descriptors, $pipes);
if (!is_resource($process)) {
return [127, "could not start {$argv[0]}"];
}
stream_set_timeout($pipes[1], $timeoutSeconds);
$out = (stream_get_contents($pipes[1]) ?: '') . (stream_get_contents($pipes[2]) ?: '');
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process);
return [$code, trim($out)];
}
/**
* Every whole disk (not partition) libpod's host isn't already using —
* mounted anywhere (itself or any partition), part of Unraid's own
* array/cache pool (cross-checked against /var/local/emhttp/disks.ini,
* the same state file Unraid's own array management writes — the boot
* flash device is covered by this same check, since Unraid lists it there
* too), or the disk backing the currently-booted root/flash filesystem.
*
* @return array<int,array<string,mixed>>
*/
function list_candidate_disks(): array
{
// Nested partitions come back automatically as a "children" array in
// -J's JSON tree — CHILDREN is not a real -o column (lsblk itself
// rejects it: "unknown column: CHILDREN"; verified live against a
// real host's lsblk). LABEL is included specifically to catch the
// Unraid boot flash drive, which FAT-labels itself "UNRAID" — see the
// safety note below for why that check exists at all.
[$code, $out] = run(['lsblk', '-J', '-b', '-p', '-o', 'NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,LABEL'], 10);
if ($code !== 0) {
podman_json_error('Could not list block devices: ' . $out, 500);
}
$tree = json_decode($out, true);
if (!is_array($tree) || !isset($tree['blockdevices'])) {
podman_json_error('Unexpected lsblk output', 500);
}
$arrayDevices = unraid_array_device_names();
$candidates = [];
foreach ($tree['blockdevices'] as $dev) {
if (($dev['type'] ?? '') !== 'disk') {
continue;
}
$name = (string) ($dev['name'] ?? '');
$baseName = basename($name);
if (in_array($baseName, $arrayDevices, true)) {
continue;
}
// zram devices report TYPE "disk" too (RAM-backed, not persistent
// storage — pointless and misleading to offer for this) and an
// empty card-reader slot with no card inserted reports as a real
// "disk" at 0 bytes; both are excluded outright rather than left
// for the size-based hasData check below.
if (str_starts_with($baseName, 'zram') || (int) ($dev['size'] ?? 0) <= 0) {
continue;
}
if (device_or_children_mounted($dev)) {
continue;
}
if (device_or_children_labeled_unraid($dev)) {
continue;
}
// Deliberately excludes (not just warns about) ANY disk that
// already has a filesystem, partition, or other signature on it
// or any of its partitions — not just ones lsblk reports as
// currently mounted. Found live: a real host's cache pool disks
// showed up here as "safe" with only a soft warning, because
// they're ZFS pool members (fstype "zfs_member"/partition
// present) rather than plain mounts, so the earlier
// mounted-only check missed them entirely — the same blind spot
// that also let the Unraid boot USB stick's own partition
// through (FAT, not reported as "mounted" by lsblk either). A
// disk being reused for podman storage must be genuinely blank;
// asking a user to wipe it themselves first is a small price for
// this being impossible to get wrong.
if (!empty($dev['fstype']) || !empty($dev['children'])) {
continue;
}
$candidates[] = [
'device' => $name,
'sizeBytes' => (int) ($dev['size'] ?? 0),
'sizeFormatted' => podman_format_bytes((int) ($dev['size'] ?? 0)),
'model' => trim((string) ($dev['model'] ?? '')) ?: null,
];
}
return $candidates;
}
function device_or_children_mounted(array $dev): bool
{
if (!empty($dev['mountpoint'])) {
return true;
}
foreach ($dev['children'] ?? [] as $child) {
if (device_or_children_mounted($child)) {
return true;
}
}
return false;
}
/**
* Catches the Unraid boot flash drive specifically: it FAT-labels itself
* "UNRAID" (verified live: `blkid` on a real host's boot partition shows
* LABEL_FATBOOT="UNRAID" LABEL="UNRAID") and — on at least one real,
* modern Unraid setup — /boot is actually backed by a ZFS dataset
* ("flash/boot"), not a direct mount of that partition, so it does NOT
* show up as "mounted" via lsblk's own MOUNTPOINT column at all. This
* label check is a second, independent layer specifically because that
* gap meant the boot drive briefly passed every other check here during
* development — never rely on a single signal for something this
* destructive.
*/
function device_or_children_labeled_unraid(array $dev): bool
{
if (strtoupper(trim((string) ($dev['label'] ?? ''))) === 'UNRAID') {
return true;
}
foreach ($dev['children'] ?? [] as $child) {
if (device_or_children_labeled_unraid($child)) {
return true;
}
}
return false;
}
/**
* @return array<int,string> bare device names ("sda", "nvme0n1", ...) Unraid
* itself has assigned to the array or a cache pool, read from the same
* /var/local/emhttp/disks.ini Unraid's own array management writes —
* NOT parsed/guessed, so this stays correct across whatever array
* layout a given host actually has.
*/
function unraid_array_device_names(): array
{
$path = '/var/local/emhttp/disks.ini';
if (!is_readable($path)) {
return [];
}
$ini = @parse_ini_file($path, true);
if (!is_array($ini)) {
return [];
}
$names = [];
foreach ($ini as $section) {
if (is_array($section) && !empty($section['device'])) {
$names[] = basename((string) $section['device']);
}
}
return $names;
}
/** @return array<string,mixed> */
function format_disk(string $device): array
{
if (!preg_match('#^/dev/(sd[a-z]+|nvme\d+n\d+|vd[a-z]+)$#', $device)) {
podman_json_error('Invalid or unsupported device path', 400);
}
$allowed = array_column(list_candidate_disks(), 'device');
if (!in_array($device, $allowed, true)) {
podman_json_error("{$device} is not a currently-eligible disk (already in use, part of the array, or not found) — refusing to format it.", 400);
}
$partDevice = preg_match('#nvme\d+n\d+$#', $device) ? "{$device}p1" : "{$device}1";
[$code, $out] = run(['wipefs', '-a', $device], 30);
if ($code !== 0) {
podman_json_error("wipefs failed: {$out}", 500);
}
[$code, $out] = run(['parted', '-s', $device, 'mklabel', 'gpt', 'mkpart', 'primary', '1MiB', '100%'], 30);
if ($code !== 0) {
podman_json_error("parted failed: {$out}", 500);
}
run(['partprobe', $device], 10);
// Partition device nodes can take a moment to appear after partprobe.
for ($i = 0; $i < 20 && !file_exists($partDevice); $i++) {
usleep(250000);
}
if (!file_exists($partDevice)) {
podman_json_error("Partition {$partDevice} did not appear after partitioning {$device}", 500);
}
[$code, $out] = run(['mkfs.xfs', '-f', '-n', 'ftype=1', '-L', 'podmanstorage', $partDevice], 60);
if ($code !== 0) {
podman_json_error("mkfs.xfs failed: {$out}", 500);
}
[$code, $uuid] = run(['blkid', '-s', 'UUID', '-o', 'value', $partDevice], 10);
$uuid = trim($uuid);
if ($code !== 0 || $uuid === '') {
podman_json_error('Could not determine filesystem UUID after formatting', 500);
}
if (!is_dir(MOUNT_PATH) && !mkdir(MOUNT_PATH, 0755, true) && !is_dir(MOUNT_PATH)) {
podman_json_error('Could not create ' . MOUNT_PATH, 500);
}
[$code, $out] = run(['mount', 'UUID=' . $uuid, MOUNT_PATH], 15);
if ($code !== 0) {
podman_json_error("mount failed: {$out}", 500);
}
// Persisted so plugin/sbin/podman-mount-managed-disk.sh (called from
// the disks_mounted event hook, before rc.podman start) can remount
// this same disk by UUID on every future boot — nothing else on the
// system knows about this disk, since it's deliberately outside
// Unraid's own array/cache pool management.
$cfgDir = dirname(MANAGED_DISK_CFG);
if (!is_dir($cfgDir) && !mkdir($cfgDir, 0755, true) && !is_dir($cfgDir)) {
podman_json_error('Could not create ' . $cfgDir, 500);
}
$cfgContent = "# Written by the unraid-podman WebUI (ajax/disks.php) — do not edit by hand.\n"
. 'UUID="' . $uuid . "\"\n"
. 'MOUNTPOINT="' . MOUNT_PATH . "\"\n";
if (file_put_contents(MANAGED_DISK_CFG, $cfgContent, LOCK_EX) === false) {
podman_json_error('Formatted and mounted, but could not persist ' . MANAGED_DISK_CFG . ' for future boots', 500);
}
return ['mountPath' => MOUNT_PATH];
}
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* ajax/folders.php
*
* Container folders — a purely cosmetic, plugin-owned organizational
* feature for the Containers panel. podman/libpod itself has no concept
* of "folders"; this is grouping metadata only (name + icon + which
* container names belong to it), similar to Unraid's own Docker page's
* folders. Stored as JSON at PodmanConfig::$foldersFile — not a
* PodmanClient/libpod concern, see Config.php.
*
* Actions (?action=...):
* list GET -> {"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<int,array<string,mixed>> */
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<int,mixed> $folders
* @return array<int,array<string,mixed>> 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;
}
+74 -29
View File
@@ -16,12 +16,18 @@
* save POST {"storagePath": "...", "storageImageSizeGb": 20, * save POST {"storagePath": "...", "storageImageSizeGb": 20,
* "enabled": true, "stopTimeoutSeconds": 10} * "enabled": true, "stopTimeoutSeconds": 10}
* autostart_save POST {"names": ["postgres", "nextcloud", ...]} * autostart_save POST {"names": ["postgres", "nextcloud", ...]}
* service_status GET -> {"running": bool, "output": "..."}
* service_start POST -> {"running": bool, "output": "..."}
* service_stop POST -> {"running": bool, "output": "..."}
* service_restart POST -> {"running": bool, "output": "..."}
*/ */
declare(strict_types=1); declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php'; require __DIR__ . '/../include/bootstrap.php';
const RC_PODMAN = '/etc/rc.d/rc.podman';
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
switch ($action) { switch ($action) {
@@ -45,10 +51,77 @@ switch ($action) {
podman_json_response(['status' => 'saved']); podman_json_response(['status' => 'saved']);
break; break;
case 'service_status':
podman_json_response(rc_podman('status', 15));
break;
case 'service_start':
podman_json_response(rc_podman('start', 120));
break;
case 'service_stop':
podman_json_response(rc_podman('stop', 120));
break;
case 'service_restart':
podman_json_response(rc_podman('restart', 120));
break;
default: default:
podman_json_error("Unknown action '{$action}'", 400); podman_json_error("Unknown action '{$action}'", 400);
} }
/**
* Shells out to /etc/rc.d/rc.podman <verb> — the plugin's own real
* start/stop/status script, the SAME one the array-start event hook and
* a terminal `rc.podman status` use (see plugin/rc.d/rc.podman's header
* comment). This exists specifically so a fresh install where podman
* failed to start (e.g. no cache pool configured yet, see
* podman-storage.sh's "does not exist or is not mounted" error) can be
* diagnosed and retried from the WebUI itself — no SSH/terminal access
* needed, which is exactly what was missing when this was first needed
* live (a fresh install on a different Unraid box with nobody able to
* reach a terminal to run `rc.podman start` by hand).
*
* @return array{running: bool, output: string}
*/
function rc_podman(string $verb, int $timeoutSeconds): array
{
$output = run_rc_podman($verb, $timeoutSeconds);
// Only `rc.podman status` prints the "service: running
// (pid ..., socket ...)" line this regex looks for — start/stop/
// restart's OWN messages are worded differently ("start: already
// running (pid ...)", "stop: stopped", ...), so relying on THIS same
// regex against THEIR output silently reported "not running" right
// after a successful start (found live: a start that printed "start:
// already running" turned the status chip red). Always running a
// fresh `status` afterward — regardless of which verb was actually
// requested — is the one output format this check can trust.
$statusOutput = $verb === 'status' ? $output : run_rc_podman('status', 15);
$running = (bool) preg_match('/service:\s+running/', $statusOutput);
return ['running' => $running, 'output' => $output];
}
function run_rc_podman(string $verb, int $timeoutSeconds): string
{
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open([RC_PODMAN, $verb], $descriptors, $pipes);
if (!is_resource($process)) {
podman_json_error("Could not run rc.podman {$verb}", 500);
}
stream_set_timeout($pipes[1], $timeoutSeconds);
$stdout = stream_get_contents($pipes[1]) ?: '';
$stderr = stream_get_contents($pipes[2]) ?: '';
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return trim($stdout . $stderr);
}
/** @return array<string,mixed> */ /** @return array<string,mixed> */
function settings_get(PodmanConfig $config): array function settings_get(PodmanConfig $config): array
{ {
@@ -58,7 +131,7 @@ function settings_get(PodmanConfig $config): array
'enabled' => $config->enabled, 'enabled' => $config->enabled,
'stopTimeoutSeconds' => $config->stopTimeoutSeconds, 'stopTimeoutSeconds' => $config->stopTimeoutSeconds,
'autostart' => autostart_read($config), 'autostart' => autostart_read($config),
'packageVersions' => installed_package_versions(), 'packageVersions' => podman_read_installed_versions(),
]; ];
} }
@@ -129,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<string,string>
*/
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;
}
+191 -1
View File
@@ -8,7 +8,11 @@
* doesn't have to make (and wait on) five separate round trips. * doesn't have to make (and wait on) five separate round trips.
* *
* Actions (?action=...): * 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); declare(strict_types=1);
@@ -22,6 +26,10 @@ switch ($action) {
podman_json_response(system_summary($client, $podmanConfig)); podman_json_response(system_summary($client, $podmanConfig));
break; break;
case 'autostart_queue':
podman_json_response(system_autostart_queue($client, $podmanConfig));
break;
default: default:
podman_json_error("Unknown action '{$action}'", 400); podman_json_error("Unknown action '{$action}'", 400);
} }
@@ -56,6 +64,23 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
$imagesSize += (int) ($img['Size'] ?? 0); $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 [ return [
'reachable' => true, 'reachable' => true,
'socketPath' => $config->socketPath, 'socketPath' => $config->socketPath,
@@ -66,6 +91,7 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
'containers' => [ 'containers' => [
'total' => count($containers), 'total' => count($containers),
'running' => $running, 'running' => $running,
'stopped' => count($containers) - $running,
], ],
'pods' => count($pods), 'pods' => count($pods),
'images' => count($images), 'images' => count($images),
@@ -74,6 +100,170 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
'storage' => [ 'storage' => [
'imagesSizeBytes' => $imagesSize, 'imagesSizeBytes' => $imagesSize,
'imagesSizeFormatted' => podman_format_bytes($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/<name>) plus the container's actual
* current state, rather than a second, separately-tracked history.
*
* @return array<string,mixed>
*/
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<string,int>
*/
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);
}
+2
View File
@@ -21,6 +21,7 @@ final class PodmanConfig
public string $bootDir; public string $bootDir;
public string $autostartFile; public string $autostartFile;
public string $autostartDelayFile; public string $autostartDelayFile;
public string $foldersFile;
private function __construct() private function __construct()
{ {
@@ -33,6 +34,7 @@ final class PodmanConfig
$this->socketPath = '/var/run/podman/podman.sock'; $this->socketPath = '/var/run/podman/podman.sock';
$this->autostartFile = $this->bootDir . '/autostart'; $this->autostartFile = $this->bootDir . '/autostart';
$this->autostartDelayFile = $this->bootDir . '/autostart-delay'; $this->autostartDelayFile = $this->bootDir . '/autostart-delay';
$this->foldersFile = $this->bootDir . '/folders.json';
} }
public static function load(): self public static function load(): self
@@ -185,6 +185,41 @@ final class PodmanClient
return self::demuxStream($raw); return self::demuxStream($raw);
} }
/**
* GET /events?stream=false&since=...&filters={"container":["<id>"]} —
* 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<int,array<string,mixed>>
*/
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 // Exec — see webui/plugins/podman/ajax/exec.php for the important
// caveat: this implements one-shot "run a command, return its output" // caveat: this implements one-shot "run a command, return its output"
+31
View File
@@ -11,6 +11,37 @@
declare(strict_types=1); 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<string,string>
*/
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 function podman_format_bytes(int $bytes): string
{ {
if ($bytes <= 0) { if ($bytes <= 0) {
+101 -3
View File
@@ -126,6 +126,60 @@ window.Podman = (function () {
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>'; return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
} }
// --- 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 =
'<span class="ico">' + TOAST_ICON[kind] + '</span>' +
'<span class="msg"></span>' +
'<button type="button" class="close" aria-label="Dismiss">&#10005;</button>';
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 ----------------------------------------------------- // --- Modal form dialog -----------------------------------------------------
/** /**
@@ -299,12 +353,32 @@ window.Podman = (function () {
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>'; (item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
}).join(''); }).join('');
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
// Viewport-relative (see the "position: fixed" comment on // Viewport-relative (see the "position: fixed" comment on
// .podman-context-menu in podman.css) — no scrollY/scrollX added. // .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(); const rect = anchorEl.getBoundingClientRect();
menu.style.top = (rect.bottom + 4) + 'px'; const menuRect = menu.getBoundingClientRect();
menu.style.left = (rect.right - 180) + 'px'; const margin = 8;
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
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 <div>s too, so indexing into it // menu.children includes the separator <div>s too, so indexing into it
// directly (by a counter that only advances for real items) drifts by // 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). // (Dashboard, by default — see Podman.page).
const initial = document.querySelector('.podman-subnav button.active'); const initial = document.querySelector('.podman-subnav button.active');
activatePanel(initial ? initial.dataset.panel : 'dashboard'); 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); document.addEventListener('DOMContentLoaded', boot);
@@ -414,6 +511,7 @@ window.Podman = (function () {
stateChipClass: stateChipClass, stateChipClass: stateChipClass,
loadingRow: loadingRow, loadingRow: loadingRow,
errorRow: errorRow, errorRow: errorRow,
toast: toast,
openFormModal: openFormModal, openFormModal: openFormModal,
openLogModal: openLogModal, openLogModal: openLogModal,
openContextMenu: openContextMenu, openContextMenu: openContextMenu,
+13 -4
View File
@@ -85,11 +85,17 @@
if (!selected) return; if (!selected) return;
const btn = P.el('compose-action-' + action); const btn = P.el('compose-action-' + action);
btn.disabled = true; 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) { 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(); return loadProjects();
}).catch(function (err) { }).catch(function (err) {
alert('podman compose ' + action + ' failed: ' + err.message); modal.log('Failed: ' + err.message);
modal.done('Close');
}).finally(function () { }).finally(function () {
btn.disabled = false; btn.disabled = false;
}); });
@@ -100,9 +106,10 @@
const btn = P.el('compose-action-save'); const btn = P.el('compose-action-save');
btn.disabled = true; btn.disabled = true;
P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () { P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () {
P.toast('Saved ' + selected + '.', 'success');
return loadProjects(); return loadProjects();
}).catch(function (err) { }).catch(function (err) {
alert('Save failed: ' + err.message); P.toast('Save failed: ' + err.message, 'error');
}).finally(function () { }).finally(function () {
btn.disabled = false; btn.disabled = false;
}); });
@@ -113,11 +120,13 @@
if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return; if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return;
const btn = P.el('compose-action-delete'); const btn = P.el('compose-action-delete');
btn.disabled = true; btn.disabled = true;
const name = selected;
P.post('compose', 'remove', { project: selected }).then(function () { P.post('compose', 'remove', { project: selected }).then(function () {
selected = null; selected = null;
P.toast('Deleted ' + name + '.', 'success');
return loadProjects(); return loadProjects();
}).catch(function (err) { }).catch(function (err) {
alert('Delete failed: ' + err.message); P.toast('Delete failed: ' + err.message, 'error');
btn.disabled = false; btn.disabled = false;
}); });
} }
+467 -22
View File
@@ -17,11 +17,156 @@
// badge doesn't disappear on the next auto-refresh; only re-running // badge doesn't disappear on the next auto-refresh; only re-running
// "Check for Updates" replaces it. // "Check for Updates" replaces it.
let imageUpdateStatus = {}; 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) { function iconLabel(name) {
return P.escapeHtml(name.slice(0, 2).toUpperCase()); 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 '<span class="ico">' + fallback + '</span>';
}
return '<span class="ico"><img src="' + P.escapeHtml(iconUrl) + '" alt="" loading="lazy" data-fallback="' + fallback + '" ' +
'onerror="this.replaceWith(document.createTextNode(this.dataset.fallback))"></span>';
}
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 '<button type="button" class="podman-folder-member" data-action="menu" data-id="' + P.escapeHtml(c.id) + '">' +
iconWithFallbackHtml(c.icon, c.name.slice(0, 2).toUpperCase()) +
'<span class="name">' + P.escapeHtml(c.name) + '</span>' +
'<span class="dot ' + (c.state === 'running' ? 'good' : 'bad') + '"></span>' +
'<span class="state">' + P.escapeHtml(c.state) + '</span>' +
'</button>';
}
function folderHeaderHtml(f, members) {
const collapsed = !!collapsedFolders[f.id];
const runningCount = members.filter(function (c) { return c.state === 'running'; }).length;
return '<tr class="podman-folder-row" data-folder-id="' + P.escapeHtml(f.id) + '">' +
'<td colspan="7">' +
'<div class="podman-folder-head">' +
'<button type="button" class="podman-folder-toggle" data-action="toggle-folder">' +
'<span class="chevron">' + (collapsed ? '▸' : '▾') + '</span>' +
iconWithFallbackHtml(f.icon, f.name.slice(0, 2).toUpperCase()) +
'<span class="name">' + P.escapeHtml(f.name) + '</span>' +
'<span class="count">' + runningCount + '/' + members.length + ' running</span>' +
'</button>' +
'<div class="podman-folder-members">' + members.map(folderMemberChipHtml).join('') + '</div>' +
'<button type="button" class="podman-btn podman-btn-icon" data-action="folder-menu" title="Folder options">&#8942;</button>' +
'</div></td></tr>';
}
function hasUpdate(c) { function hasUpdate(c) {
const status = imageUpdateStatus[c.image]; const status = imageUpdateStatus[c.image];
return !!(status && status.updateAvailable); return !!(status && status.updateAvailable);
@@ -34,12 +179,18 @@
const updateBadge = hasUpdate(c) const updateBadge = hasUpdate(c)
? ' <span class="podman-badge-update" title="A newer image is available">&#8593; Update</span>' ? ' <span class="podman-badge-update" title="A newer image is available">&#8593; Update</span>'
: ''; : '';
// A plain <a>, 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
? ' <a class="podman-webui-link" href="' + P.escapeHtml(c.webUrl) + '" target="_blank" rel="noopener noreferrer" title="Open WebUI">URL</a>'
: '';
return '' + return '' +
'<tr data-id="' + P.escapeHtml(c.id) + '">' + '<tr data-id="' + P.escapeHtml(c.id) + '">' +
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' + '<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
'<td><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' + '<td><div class="podman-name-cell"><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button>' + updateBadge + '</td>' + containerIconHtml(c) + '<span class="text">' + P.escapeHtml(c.name) + '</span></button>' + webuiLink + updateBadge + '</div></td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' + '<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
'<td>' + cpuMem + '</td>' + '<td>' + cpuMem + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' + '<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' +
@@ -70,12 +221,24 @@
function openRowMenu(c, anchorBtn) { function openRowMenu(c, anchorBtn) {
const items = []; const items = [];
items.push({ label: 'Details', onClick: function () { openDetailModal(c); } });
if (c.state === 'running') { if (c.state === 'running') {
items.push({ label: 'Pause', onClick: function () { handleAction(c.id, 'pause'); } }); 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: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
} }
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } }); items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } }); items.push({ 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('separator');
items.push({ items.push({
label: 'Remove', label: 'Remove',
@@ -173,6 +336,8 @@
volumes: volumes, volumes: volumes,
env: env, env: env,
gpuDevices: gpuDevices, gpuDevices: gpuDevices,
icon: c.icon || '',
webUrl: c.webUrl || '',
}; };
} }
@@ -180,7 +345,7 @@
P.get('containers', 'inspect', { id: c.id }).then(function (d) { P.get('containers', 'inspect', { id: c.id }).then(function (d) {
openCreateContainerModal(inspectToPrefill(c, d), { id: c.id }); openCreateContainerModal(inspectToPrefill(c, d), { id: c.id });
}).catch(function (err) { }).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, restartPolicy: prefill.restartPolicy,
gpuDevices: prefill.gpuDevices, gpuDevices: prefill.gpuDevices,
privileged: prefill.privileged, privileged: prefill.privileged,
icon: prefill.icon,
webuiUrl: prefill.webUrl,
startAfterCreate: true, startAfterCreate: true,
}); });
}).then(function () { }).then(function () {
@@ -298,6 +465,25 @@
modal.log(failures.length modal.log(failures.length
? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.' ? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.'
: 'All ' + targets.length + ' updated.'); : '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(); modal.done();
btn.disabled = false; btn.disabled = false;
return load(); return load();
@@ -311,11 +497,15 @@
// --- Detail view ----------------------------------------------------------- // --- Detail view -----------------------------------------------------------
// //
// Fed entirely by the existing inspect action (raw libpod inspect JSON) — // Most tabs are sliced straight from the one inspect payload already
// no new backend endpoint needed, just slicing that one payload into // fetched when the modal opens (Config.Env, Config.Labels, Mounts,
// tabs. Field names below (Config.Env, Config.Labels, Mounts, // NetworkSettings.Networks, HostConfig.RestartPolicy, State.Health.Log,
// NetworkSettings.Networks, HostConfig.RestartPolicy, ...) were checked // ... all checked live against a real inspect response, not assumed
// live against a real inspect response, not assumed from docs. // from docs). A few need more: Logs and Events fetch lazily when their
// tab is first opened (a render() may return a Promise<string> 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) { function kvTable(rows) {
if (rows.length === 0) return '<div class="podman-detail-empty">None.</div>'; if (rows.length === 0) return '<div class="podman-detail-empty">None.</div>';
@@ -387,12 +577,165 @@
return '<div class="podman-detail-json">' + P.escapeHtml(JSON.stringify(d, null, 2)) + '</div>'; return '<div class="podman-detail-json">' + P.escapeHtml(JSON.stringify(d, null, 2)) + '</div>';
} }
// 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 '<div class="podman-detail-empty">No log output.</div>';
return '<div class="podman-log-pane">' + lines.map(function (line) {
const cls = /\berror\b/i.test(line) ? 'lvl-error' : (/\bwarn(ing)?\b/i.test(line) ? 'lvl-warn' : '');
return '<div class="l' + (cls ? ' ' + cls : '') + '">' + P.escapeHtml(line) + '</div>';
}).join('') + '</div>';
});
}
// 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 '<div class="podman-detail-empty">No events in the last 7 days.</div>';
const rows = events.slice().reverse();
return '<table class="podman-detail-table">' +
'<tr><td>Time</td><td>Event</td></tr>' +
rows.map(function (e) {
const when = new Date(e.time * 1000).toLocaleString();
return '<tr><td class="mono" title="' + P.escapeHtml(when) + '">' + P.escapeHtml(P.formatRelativeTime(e.time)) + '</td>' +
'<td>' + P.escapeHtml(e.Action || e.status || '') + '</td></tr>';
}).join('') + '</table>';
});
}
function renderHealthTab(d) {
const health = (d.State && d.State.Health) || null;
if (!health || !Array.isArray(health.Log) || !health.Log.length) {
return '<div class="podman-detail-empty">No healthcheck configured for this container.</div>';
}
const rows = health.Log.slice().reverse();
return '<table class="podman-detail-table">' +
'<tr><td>Time</td><td>Result</td><td>Output</td></tr>' +
rows.map(function (entry) {
const ok = entry.ExitCode === 0;
return '<tr><td class="mono">' + P.escapeHtml(entry.Start || '') + '</td>' +
'<td><span class="podman-chip ' + (ok ? 'podman-chip-good' : 'podman-chip-bad') + '"><span class="d"></span>' +
(ok ? 'ok' : 'exit ' + entry.ExitCode) + '</span></td>' +
'<td class="mono">' + P.escapeHtml((entry.Output || '').trim().slice(0, 300)) + '</td></tr>';
}).join('') + '</table>';
}
// 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 '<div class="podman-detail-empty">Container must be running to open a console.</div>';
}
return '' +
'<div class="podman-term-launcher">' +
'<label>Shell <select class="podman-term-select" id="detail-term-shell"><option value="bash" selected>bash</option><option value="sh">sh</option></select></label>' +
'<button type="button" class="podman-btn podman-btn-primary" id="detail-term-open-btn">&#9654; Open Console</button>' +
'<button type="button" class="podman-btn podman-btn-ghost podman-btn-danger" id="detail-term-disconnect-btn" disabled>&#9632; Disconnect</button>' +
'</div>' +
'<div id="detail-term-frame-wrap"><p class="podman-empty-note">Pick a shell and click "Open Console".</p></div>';
}
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 = '<p class="podman-empty-note">Opening console…</p>';
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 = '<iframe class="podman-term-frame" src="/logterminal/' + encodeURIComponent(data.sockName) + '/"></iframe>';
}, 200);
}).catch(function (err) {
wrap.innerHTML = '<p class="podman-empty-note">Could not open console: ' + P.escapeHtml(err.message) + '</p>';
}).finally(function () {
openBtn.disabled = false;
});
});
disconnectBtn.addEventListener('click', function () {
disconnectBtn.disabled = true;
closeSession().finally(function () {
body.querySelector('#detail-term-frame-wrap').innerHTML = '<p class="podman-empty-note">Disconnected.</p>';
});
});
return closeSession;
}
const DETAIL_TABS = [ const DETAIL_TABS = [
{ id: 'overview', label: 'Overview', render: renderOverviewTab }, { 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: 'env', label: 'Environment', render: renderEnvTab },
{ id: 'labels', label: 'Labels', render: renderLabelsTab }, { id: 'labels', label: 'Labels', render: renderLabelsTab },
{ id: 'mounts', label: 'Mounts', render: renderMountsTab }, { id: 'events', label: 'Events', render: renderEventsTab },
{ id: 'networks', label: 'Networks', render: renderNetworksTab }, { id: 'health', label: 'Healthcheck', render: renderHealthTab },
{ id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab }, { id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab },
]; ];
@@ -410,17 +753,46 @@
'</div>'; '</div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop); (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) { 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'); const body = backdrop.querySelector('.podman-detail-body');
P.get('containers', 'inspect', { id: c.id }).then(function (data) { P.get('containers', 'inspect', { id: c.id }).then(function (data) {
function showTab(tabId) { function showTab(tabId) {
if (activeCleanup) {
const cleanup = activeCleanup;
activeCleanup = null;
cleanup();
}
const tab = DETAIL_TABS.find(function (t) { return t.id === tabId; }); 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 = '<div class="podman-loading">Loading…</div>';
result.then(function (html) {
body.innerHTML = html;
if (tab.wire) activeCleanup = tab.wire(body, data, c) || null;
}).catch(function (err) {
body.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
} else {
body.innerHTML = result;
if (tab.wire) activeCleanup = tab.wire(body, data, c) || null;
}
} }
backdrop.querySelectorAll('[data-tab]').forEach(function (btn) { backdrop.querySelectorAll('[data-tab]').forEach(function (btn) {
btn.addEventListener('click', function () { btn.addEventListener('click', function () {
@@ -447,9 +819,40 @@
function renderTable() { function renderTable() {
const tbody = P.el('containers-tbody'); const tbody = P.el('containers-tbody');
const visible = applyFilters(); const visible = applyFilters();
tbody.innerHTML = visible.length if (!visible.length) {
? visible.map(rowHtml).join('') tbody.innerHTML = '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
: '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>'; 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 || '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
} }
function renderCounts() { function renderCounts() {
@@ -461,7 +864,13 @@
function load() { function load() {
const tbody = P.el('containers-tbody'); 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) { return P.get('containers', 'list').then(function (data) {
allContainers = data; allContainers = data;
renderCounts(); renderCounts();
@@ -560,6 +969,12 @@
'<div class="podman-modal-field"><label>Name (optional)</label>' + '<div class="podman-modal-field"><label>Name (optional)</label>' +
'<input type="text" id="cc-name" placeholder="my-container">' + '<input type="text" id="cc-name" placeholder="my-container">' +
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' + '<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
'<div class="podman-modal-field"><label>Icon URL (optional)</label>' +
'<input type="text" id="cc-icon" placeholder="https://...">' +
'<div class="hint">Shown in the Containers table and Folders. Filled in automatically from a template.</div></div>' +
'<div class="podman-modal-field"><label>WebUI URL (optional)</label>' +
'<input type="text" id="cc-weburl" placeholder="http://10.1.1.1:8080/">' +
'<div class="hint">Adds a small open-in-new-tab button next to the name in the Containers table.</div></div>' +
'<div class="podman-modal-field"><label>Network</label>' + '<div class="podman-modal-field"><label>Network</label>' +
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' + '<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
'<option value="host">Host</option><option value="none">None</option></select></div>' + '<option value="host">Host</option><option value="none">None</option></select></div>' +
@@ -606,6 +1021,8 @@
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image; if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name; if (prefill.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.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy; if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true; if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
@@ -797,6 +1214,8 @@
restartPolicy: backdrop.querySelector('#cc-restart').value, restartPolicy: backdrop.querySelector('#cc-restart').value,
gpuDevices: gpuDevices, gpuDevices: gpuDevices,
privileged: privileged, privileged: privileged,
icon: backdrop.querySelector('#cc-icon').value.trim(),
webuiUrl: backdrop.querySelector('#cc-weburl').value.trim(),
startAfterCreate: backdrop.querySelector('#cc-start').checked, startAfterCreate: backdrop.querySelector('#cc-start').checked,
}); });
}).then(function () { }).then(function () {
@@ -815,7 +1234,7 @@
category: backdrop.querySelector('#cc-template-category').value.trim(), category: backdrop.querySelector('#cc-template-category').value.trim(),
overview: backdrop.querySelector('#cc-template-overview').value.trim(), overview: backdrop.querySelector('#cc-template-overview').value.trim(),
}).catch(function (err) { }).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 () { }).then(function () {
close(); close();
@@ -839,7 +1258,7 @@
const doIt = function (extra) { const doIt = function (extra) {
if (btn) btn.disabled = true; if (btn) btn.disabled = true;
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) { 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; if (btn) btn.disabled = false;
}); });
}; };
@@ -875,7 +1294,24 @@
const btn = e.target.closest('button[data-action]'); const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return; if (!btn || btn.disabled) return;
const row = btn.closest('tr'); const row = btn.closest('tr');
const id = row.dataset.id;
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 <button>, since the row they
// sit in is the folder header (data-folder-id), not that container.
const id = btn.dataset.id || row.dataset.id;
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') { if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') {
const c = allContainers.find(function (x) { return x.id === id; }); const c = allContainers.find(function (x) { return x.id === id; });
if (!c) return; if (!c) return;
@@ -903,6 +1339,15 @@
P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates); P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates);
P.el('containers-update-all-btn').addEventListener('click', updateAll); P.el('containers-update-all-btn').addEventListener('click', updateAll);
P.el('containers-new-folder-btn').addEventListener('click', function () { openNewFolderModal(null); });
// Fetched once here, not inside load() — folders change rarely
// compared to container state, so there's no reason for every ~2s
// auto-refresh tick to re-fetch and re-render them too.
P.get('folders', 'list').then(function (data) {
folders = data.folders || [];
renderTable();
}).catch(function () { /* folders just stay empty — not fatal to the panel */ });
return load(); return load();
} }
@@ -912,5 +1357,5 @@
// (see Podman.page's script list), so this is already set by then. // (see Podman.page's script list), so this is already set by then.
P.openCreateContainerModal = openCreateContainerModal; P.openCreateContainerModal = openCreateContainerModal;
P.registerPanel('containers', { init: init, refresh: load }); P.registerPanel('containers', { init: init, refresh: load, autoRefresh: true });
})(); })();
+104 -12
View File
@@ -1,15 +1,18 @@
/** /**
* javascript/dashboard.js * javascript/dashboard.js
* *
* Dashboard panel: summary stat tiles fed by ajax/system.php?action=summary. * Dashboard panel: plain-count stat tiles (Running/Stopped/Pods/Images/
* The Activity list and the CPU/Memory sparkline in the mockup were * Volumes/Networks) plus a single "Resource Usage" card (CPU/Memory/Swap/
* illustrative sample data with no backing API (libpod has no "recent * Storage as meter rows) — kept as two visually distinct groups rather
* events for a container fleet" convenience endpoint beyond raw * than forcing bar-and-percentage metrics into the same tile shape as
* /events streaming, which is a separate follow-up — see the note * simple counts, which is what produced the awkward spanning-tile/dead-
* rendered in place of it below) — rather than fake data pretending to be * grid-cell layout this replaced. Both fed by ajax/system.php?action=
* live, this real implementation shows what's genuinely available now * summary, plus the Autostart Queue table fed by action=autostart_queue.
* (the summary counts) and a clear placeholder for what needs the events * A live scrolling event feed is deliberately out of scope here — libpod
* stream, so nobody mistakes a mock for a working feature. * has no "recent events for a container fleet" convenience endpoint, only
* raw /events streaming, which is a separate feature (its own connection
* lifecycle, not a snapshot this summary call can produce) rather than
* something to fake with sample data.
*/ */
(function () { (function () {
'use strict'; 'use strict';
@@ -26,12 +29,16 @@
} }
P.el('stat-running').textContent = summary.containers.running; P.el('stat-running').textContent = summary.containers.running;
P.el('stat-running').className = summary.containers.running > 0 ? 'tone-good' : '';
P.el('stat-running-total').textContent = '/ ' + summary.containers.total; P.el('stat-running-total').textContent = '/ ' + summary.containers.total;
P.el('stat-stopped').textContent = summary.containers.stopped;
P.el('stat-stopped').classList.toggle('tone-bad', summary.containers.stopped > 0);
P.el('stat-pods').textContent = summary.pods; P.el('stat-pods').textContent = summary.pods;
P.el('stat-images').textContent = summary.images; P.el('stat-images').textContent = summary.images;
P.el('stat-volumes').textContent = summary.volumes; P.el('stat-volumes').textContent = summary.volumes;
P.el('stat-networks').textContent = summary.networks; P.el('stat-networks').textContent = summary.networks;
P.el('stat-images-size').textContent = summary.storage.imagesSizeFormatted;
renderResourceUsage(summary.host, summary.storage);
const meta = P.el('podman-header-meta'); const meta = P.el('podman-header-meta');
if (meta) { if (meta) {
@@ -42,11 +49,96 @@
} }
} }
// Bars stay accent-colored under normal load and only shift to warn/bad
// once usage is high enough to actually be worth noticing at a glance —
// a bar that's always the same color regardless of value doesn't tell
// you anything a number alone didn't.
function barSeverityClass(percent) {
if (percent === null || percent === undefined) {
return '';
}
if (percent >= 90) {
return 'bad';
}
if (percent >= 75) {
return 'warn';
}
return '';
}
function resourceRow(label, valueText, percent) {
const pct = percent === null || percent === undefined ? 0 : percent;
return (
'<div class="podman-resource-row">' +
'<span class="k">' + P.escapeHtml(label) + '</span>' +
'<div class="podman-usage-mini" style="flex:1;">' +
'<div class="track"><span class="' + barSeverityClass(percent) + '" style="width:' + pct + '%"></span></div>' +
'<span class="num">' + pct + '%</span>' +
'</div>' +
'<span class="v">' + P.escapeHtml(valueText) + '</span>' +
'</div>'
);
}
function renderResourceUsage(host, storage) {
if (!host || !storage) {
return;
}
let html = resourceRow('CPU (' + host.cpuCount + ')', '', host.cpuPercent) +
resourceRow('Memory', P.formatBytes(host.memUsedBytes) + ' / ' + P.formatBytes(host.memTotalBytes), host.memPercent);
if (host.swapTotalBytes > 0) {
html += resourceRow('Swap', P.formatBytes(host.swapUsedBytes) + ' / ' + P.formatBytes(host.swapTotalBytes),
Math.round(host.swapUsedBytes / host.swapTotalBytes * 100));
}
html += resourceRow('Storage',
storage.graphUsedFormatted + (storage.graphAllocatedBytes ? ' / ' + storage.graphAllocatedFormatted : '') +
' (' + storage.imagesSizeFormatted + ' images)',
storage.graphUsedPercent);
if (host.uptime) {
html += '<div class="hint" style="margin-top:10px;">Uptime: ' + P.escapeHtml(host.uptime) + '</div>';
}
P.el('dashboard-resource-rows').innerHTML = html;
}
const STATUS_CHIP_CLASS = {
started: 'podman-chip-good',
stopped: 'podman-chip-neutral',
failed: 'podman-chip-bad',
'safe-mode': 'podman-chip-warn',
unknown: 'podman-chip-neutral',
};
function renderAutostartQueue(data) {
const tbody = P.el('dashboard-autostart-tbody');
if (!tbody) {
return;
}
const entries = data.entries || [];
if (!entries.length) {
tbody.innerHTML = '<tr><td colspan="4" class="podman-empty-note">No containers configured for autostart.</td></tr>';
return;
}
tbody.innerHTML = entries.map(function (e) {
const chipClass = STATUS_CHIP_CLASS[e.status] || 'podman-chip-neutral';
return '<tr>' +
'<td class="tnum">' + e.position + '</td>' +
'<td>' + P.escapeHtml(e.name) + '</td>' +
'<td class="tnum">' + (e.delaySeconds > 0 ? e.delaySeconds + 's' : '—') + '</td>' +
'<td><span class="podman-chip ' + chipClass + '"><span class="d"></span>' + P.escapeHtml(e.statusLabel) + '</span></td>' +
'</tr>';
}).join('');
}
function load() { function load() {
return P.get('system', 'summary').then(render).catch(function (err) { return P.get('system', 'summary').then(function (summary) {
render(summary);
if (summary.reachable) {
return P.get('system', 'autostart_queue').then(renderAutostartQueue);
}
}).catch(function (err) {
P.el('podman-panel-dashboard').innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>'; P.el('podman-panel-dashboard').innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
}); });
} }
P.registerPanel('dashboard', { init: load, refresh: load }); P.registerPanel('dashboard', { init: load, refresh: load, autoRefresh: true });
})(); })();
+4 -4
View File
@@ -68,7 +68,7 @@
// removes every image on the host. // removes every image on the host.
const unused = images.filter(function (img) { return img.usedBy === 0; }); const unused = images.filter(function (img) { return img.usedBy === 0; });
if (!unused.length) { if (!unused.length) {
alert('No unused images to remove — every image is referenced by at least one container.'); P.toast('No unused images to remove — every image is referenced by at least one container.', 'info');
return; return;
} }
const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0); const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
@@ -81,11 +81,11 @@
btn.disabled = true; btn.disabled = true;
P.post('images', 'prune').then(function (result) { P.post('images', 'prune').then(function (result) {
btn.disabled = false; btn.disabled = false;
alert('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.'); P.toast('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.', 'success');
return load(); return load();
}).catch(function (err) { }).catch(function (err) {
btn.disabled = false; btn.disabled = false;
alert('Prune failed: ' + err.message); P.toast('Prune failed: ' + err.message, 'error');
}); });
}); });
@@ -113,7 +113,7 @@
if (!confirm('Remove this image?')) return; if (!confirm('Remove this image?')) return;
btn.disabled = true; btn.disabled = true;
P.post('images', 'remove', { id: id }).then(load).catch(function (err) { P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message); P.toast('Remove failed: ' + err.message, 'error');
btn.disabled = false; btn.disabled = false;
}); });
} }
+1 -1
View File
@@ -164,7 +164,7 @@
if (!confirm('Remove network "' + name + '"?')) return; if (!confirm('Remove network "' + name + '"?')) return;
btn.disabled = true; btn.disabled = true;
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) { P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message); P.toast('Remove failed: ' + err.message, 'error');
btn.disabled = false; btn.disabled = false;
}); });
}); });
+1 -1
View File
@@ -168,7 +168,7 @@
function handleAction(name, action, extra) { function handleAction(name, action, extra) {
return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) { return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) {
alert('Action failed: ' + err.message); P.toast('Action failed: ' + err.message, 'error');
}); });
} }
+177 -5
View File
@@ -9,6 +9,7 @@
'use strict'; 'use strict';
const P = window.Podman; const P = window.Podman;
let autostartNames = []; let autostartNames = [];
let allContainerNames = [];
function renderAutostart() { function renderAutostart() {
const tbody = P.el('autostart-tbody'); const tbody = P.el('autostart-tbody');
@@ -24,11 +25,24 @@
'</div></td></tr>'; '</div></td></tr>';
}).join('') }).join('')
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>'; : '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
renderAddOptions();
}
// Repopulated after every add/remove so it only ever offers containers
// that exist and aren't already in the chain — avoids duplicate entries
// and offering a container name that no longer exists.
function renderAddOptions() {
const select = P.el('autostart-add-select');
const available = allContainerNames.filter(function (name) { return autostartNames.indexOf(name) === -1; });
select.innerHTML = available.length
? available.map(function (name) { return '<option value="' + P.escapeHtml(name) + '">' + P.escapeHtml(name) + '</option>'; }).join('')
: '<option value="">All containers already added</option>';
P.el('autostart-add-btn').disabled = !available.length;
} }
function saveAutostart() { function saveAutostart() {
return P.post('settings', 'autostart_save', { names: autostartNames }).catch(function (err) { return P.post('settings', 'autostart_save', { names: autostartNames }).catch(function (err) {
alert('Could not save autostart order: ' + err.message); P.toast('Could not save autostart order: ' + err.message, 'error');
}); });
} }
@@ -52,11 +66,147 @@
} }
function load() { function load() {
return P.get('settings', 'get').then(fillForm).catch(function (err) { return Promise.all([
alert('Could not load settings: ' + err.message); P.get('settings', 'get').then(fillForm),
// Needed for the "Add container" dropdown — a separate, tolerant
// fetch so a containers.php hiccup doesn't also break the rest of
// Settings (autostart reorder/remove don't need this list at all).
P.get('containers', 'list').then(function (containers) {
allContainerNames = containers.map(function (c) { return c.name; }).sort();
renderAddOptions();
}).catch(function () {
allContainerNames = [];
}),
]).catch(function (err) {
P.toast('Could not load settings: ' + err.message, 'error');
}); });
} }
// --- Podman service status/start/stop/restart ------------------------------
function renderServiceChip(data) {
const chip = P.el('settings-service-chip');
chip.className = 'podman-chip ' + (data.running ? 'podman-chip-good' : 'podman-chip-bad');
chip.innerHTML = '<span class="d"></span>' + (data.running ? 'Running' : 'Not running');
}
function serviceButtons() {
return ['settings-service-status-btn', 'settings-service-start-btn', 'settings-service-stop-btn', 'settings-service-restart-btn']
.map(function (id) { return P.el(id); });
}
// "Refresh Status" is quick and read-only — shown inline, no modal needed.
function refreshServiceStatus() {
const buttons = serviceButtons();
buttons.forEach(function (b) { b.disabled = true; });
return P.get('settings', 'service_status').then(function (data) {
renderServiceChip(data);
const log = P.el('settings-service-log');
log.style.display = '';
log.textContent = data.output || '';
log.scrollTop = log.scrollHeight;
}).catch(function (err) {
P.el('settings-service-chip').className = 'podman-chip podman-chip-bad';
P.el('settings-service-chip').innerHTML = '<span class="d"></span>Unknown';
P.el('settings-service-log').style.display = '';
P.el('settings-service-log').textContent = err.message;
}).finally(function () {
buttons.forEach(function (b) { b.disabled = false; });
});
}
// Start/Stop/Restart can take a while (storage checks, container
// stop grace periods, ...) and are exactly the actions someone reaches
// for when something's actually wrong — a small log modal (same pattern
// as container update checking) shows what's happening instead of
// leaving the button just spinning with no feedback.
function runServiceAction(action, title) {
const buttons = serviceButtons();
buttons.forEach(function (b) { b.disabled = true; });
const modal = P.openLogModal(title);
return P.post('settings', action, {}).then(function (data) {
renderServiceChip(data);
(data.output || '').split('\n').forEach(function (line) { modal.log(line); });
modal.done();
}).catch(function (err) {
modal.log('Error: ' + err.message);
modal.done('Close');
}).finally(function () {
buttons.forEach(function (b) { b.disabled = false; });
});
}
// --- Format a disk for podman storage --------------------------------------
function openFormatDiskModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>Format a Disk for Podman Storage</h3></div>' +
'<div class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Disk</label><select id="fd-device"><option value="">Loading…</option></select>' +
'<div class="hint" id="fd-warning">Only disks with no existing partitions or filesystem are listed — anything already in use, part of the array/cache, or the boot flash is never shown here.</div></div>' +
'<div class="podman-modal-field">' +
'<label style="display:flex; align-items:flex-start; gap:8px; font-weight:400;">' +
'<input type="checkbox" id="fd-confirm" style="margin-top:3px;">' +
'<span>I understand this permanently erases all data on this disk, with no undo.</span>' +
'</label></div>' +
'</div>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-ghost podman-btn-danger" data-role="submit" disabled>Format Disk</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const select = backdrop.querySelector('#fd-device');
const warning = backdrop.querySelector('#fd-warning');
const confirmBox = backdrop.querySelector('#fd-confirm');
const submitBtn = backdrop.querySelector('[data-role="submit"]');
function updateSubmitEnabled() {
submitBtn.disabled = !(select.value && confirmBox.checked);
}
P.get('disks', 'list_candidates').then(function (list) {
select.innerHTML = list.length
? list.map(function (d) {
return '<option value="' + P.escapeHtml(d.device) + '">' + P.escapeHtml(d.device) +
' — ' + P.escapeHtml(d.sizeFormatted) + (d.model ? ' (' + P.escapeHtml(d.model) + ')' : '') + '</option>';
}).join('')
: '<option value="">No eligible blank disks found</option>';
updateSubmitEnabled();
}).catch(function (err) {
select.innerHTML = '<option value="">Error loading disks</option>';
warning.textContent = err.message;
});
select.addEventListener('change', updateSubmitEnabled);
confirmBox.addEventListener('change', updateSubmitEnabled);
function close() { backdrop.remove(); }
submitBtn.addEventListener('click', function () {
if (!select.value || !confirmBox.checked) return;
if (!confirm('Format ' + select.value + '? This cannot be undone.')) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Formatting…';
P.post('disks', 'format', { device: select.value }).then(function (data) {
close();
P.el('settings-storage-path').value = data.mountPath;
P.toast('Formatted and mounted at ' + data.mountPath + '. Click "Save Settings" below, then Restart Podman.', 'warn');
}).catch(function (err) {
submitBtn.disabled = false;
submitBtn.textContent = 'Format Disk';
P.toast('Format failed: ' + err.message, 'error');
});
});
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
}
function save() { function save() {
const body = { const body = {
storagePath: P.el('settings-storage-path').value.trim(), storagePath: P.el('settings-storage-path').value.trim(),
@@ -65,15 +215,24 @@
stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10, stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10,
}; };
return P.post('settings', 'save', body).then(function () { return P.post('settings', 'save', body).then(function () {
alert('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.'); P.toast('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.', 'warn');
}).catch(function (err) { }).catch(function (err) {
alert('Save failed: ' + err.message); P.toast('Save failed: ' + err.message, 'error');
}); });
} }
function init() { function init() {
P.el('settings-save-btn').addEventListener('click', save); P.el('settings-save-btn').addEventListener('click', save);
P.el('autostart-add-btn').addEventListener('click', function () {
const select = P.el('autostart-add-select');
const name = select.value;
if (!name) return;
autostartNames.push(name);
renderAutostart();
saveAutostart();
});
P.el('autostart-tbody').addEventListener('click', function (e) { P.el('autostart-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action]'); const btn = e.target.closest('button[data-action]');
if (!btn) return; if (!btn) return;
@@ -91,6 +250,19 @@
saveAutostart(); saveAutostart();
}); });
P.el('settings-service-status-btn').addEventListener('click', refreshServiceStatus);
P.el('settings-service-start-btn').addEventListener('click', function () { runServiceAction('service_start', 'Starting Podman'); });
P.el('settings-service-stop-btn').addEventListener('click', function () {
if (!confirm('Stop podman? All running containers will be stopped first (each with its own configured grace period).')) return;
runServiceAction('service_stop', 'Stopping Podman');
});
P.el('settings-service-restart-btn').addEventListener('click', function () {
if (!confirm('Restart podman? All running containers will be stopped and podman.sock will be unavailable until it comes back up.')) return;
runServiceAction('service_restart', 'Restarting Podman');
});
P.el('settings-format-disk-btn').addEventListener('click', openFormatDiskModal);
refreshServiceStatus();
return load(); return load();
} }
+3 -3
View File
@@ -79,7 +79,7 @@
P.openCreateContainerModal(config); P.openCreateContainerModal(config);
}).catch(function (err) { }).catch(function (err) {
btn.disabled = false; btn.disabled = false;
alert('Could not load template: ' + err.message); P.toast('Could not load template: ' + err.message, 'error');
}); });
return; return;
} }
@@ -97,7 +97,7 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}).catch(function (err) { }).catch(function (err) {
btn.disabled = false; btn.disabled = false;
alert('Export failed: ' + err.message); P.toast('Export failed: ' + err.message, 'error');
}); });
return; return;
} }
@@ -107,7 +107,7 @@
btn.disabled = true; btn.disabled = true;
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) { P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
btn.disabled = false; btn.disabled = false;
alert('Delete failed: ' + err.message); P.toast('Delete failed: ' + err.message, 'error');
}); });
} }
} }
+1 -1
View File
@@ -73,7 +73,7 @@
if (!confirm('Remove volume "' + name + '"? This deletes its data.')) return; if (!confirm('Remove volume "' + name + '"? This deletes its data.')) return;
btn.disabled = true; btn.disabled = true;
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) { P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message); P.toast('Remove failed: ' + err.message, 'error');
btn.disabled = false; btn.disabled = false;
}); });
}); });
+158 -6
View File
@@ -186,12 +186,25 @@
@media (max-width: 1080px) { .podman-stat-grid { grid-template-columns: repeat(3, 1fr); } } @media (max-width: 1080px) { .podman-stat-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 620px) { .podman-stat-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 620px) { .podman-stat-grid { grid-template-columns: repeat(2, 1fr); } }
.podman-stat { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; box-shadow: var(--shadow); } .podman-stat {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px;
box-shadow: var(--shadow); transition: transform .12s ease, box-shadow .12s ease;
}
.podman-stat:hover { transform: translateY(-1px); box-shadow: 0 2px 4px rgba(20, 22, 26, .08), 0 8px 20px rgba(20, 22, 26, .08); }
.podman-stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; } .podman-stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; }
.podman-stat .value { font-size: 24px; font-weight: 700; margin-top: 6px; } .podman-stat .value { font-size: 24px; font-weight: 700; margin-top: 6px; }
.podman-stat .value small { font-size: 13px; color: var(--text-dim); font-weight: 600; } .podman-stat .value small { font-size: 13px; color: var(--text-dim); font-weight: 600; }
.podman-stat .bar { height: 5px; border-radius: 3px; background: var(--surface-3); margin-top: 10px; overflow: hidden; } .podman-stat .tone-good { color: var(--good); }
.podman-stat .bar > span { display: block; height: 100%; background: var(--accent); border-radius: 3px; } .podman-stat .tone-warn { color: var(--warn); }
.podman-stat .tone-bad { color: var(--bad); }
.podman-usage-mini .track > span.warn { background: var(--warn); }
.podman-usage-mini .track > span.bad { background: var(--bad); }
.podman-resource-rows { margin-top: 8px; }
.podman-resource-row { display: flex; align-items: center; gap: 12px; padding: 7px 0; }
.podman-resource-row + .podman-resource-row { border-top: 1px solid var(--border); }
.podman-resource-row .k { font-size: 12.5px; color: var(--text-dim); font-weight: 600; min-width: 84px; flex: none; }
.podman-resource-row .v { font-size: 12.5px; color: var(--text-faint); font-weight: 600; white-space: nowrap; flex: none; font-variant-numeric: tabular-nums; }
.podman-chip { .podman-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 100px; display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 100px;
@@ -212,7 +225,46 @@
.podman-plugin tbody tr:last-child td { border-bottom: none; } .podman-plugin tbody tr:last-child td { border-bottom: none; }
.podman-plugin tbody tr:hover { background: var(--surface-2); } .podman-plugin tbody tr:hover { background: var(--surface-2); }
.podman-table-wrap { overflow-x: auto; } .podman-table-wrap { overflow-x: auto; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; } /*
* table-layout: auto (the default) sizes every column from the widest
* content across only the CURRENTLY VISIBLE rows — so expanding/
* collapsing a folder (which adds/removes rows) changed what counted as
* "widest" and shifted every column, including the header text, on
* every toggle (found live). Fixed widths (set on the <th>s in
* Podman.page) make column sizing depend only on those, never on row
* content, so toggling a folder can no longer move anything.
*/
#containers-table { table-layout: fixed; }
/* Only the plain-text columns truncate with an ellipsis (Image/Ports —
both are just escaped text directly in the <td>, so ellipsis renders
correctly) — NOT the Name column (its text sits inside a flex
button+icon, where overflow:hidden would hard-clip instead of
ellipsis-truncate), the folder-header row's colspan cell (its member
chips need to wrap, see .podman-folder-members), or the actions
column (icon buttons, not text, would otherwise get clipped if they
ever didn't quite fit). */
#containers-table tbody > tr:not(.podman-folder-row) > td:nth-child(3),
#containers-table tbody > tr:not(.podman-folder-row) > td:nth-child(5) {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* .podman-name-cell is a <div> INSIDE the <td>, not the <td> itself —
display:flex directly on a table cell pulls it out of table-cell
layout entirely, so with table-layout:fixed it stops respecting the
column width the <th> assigns and throws off alignment with every
other column (found live: the whole Name column visibly drifted).
The div gives the same flex row (so a WebUI link, see
.podman-webui-link, sits on the same line as the name button instead
of wrapping below it — .podman-row-name-btn's own display:flex makes
IT block-level by default, which would otherwise push everything
after it in the cell onto its own line) without that side effect.
min-width:0 lets the button actually shrink instead of forcing the
cell wider than its fixed column width; .text (not the plain text
node it used to be) is what actually ellipsis-truncates, since
text-overflow only applies to the element whose own inline content
overflows, not a nested flex child's. */
.podman-name-cell { display: flex; align-items: center; gap: 6px; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; min-width: 0; }
.podman-row-name .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.podman-row-name-btn { .podman-row-name-btn {
/* !important for the same reason as .podman-btn-ghost/-primary — Unraid's /* !important for the same reason as .podman-btn-ghost/-primary — Unraid's
own site-wide button theme otherwise still shows its default border own site-wide button theme otherwise still shows its default border
@@ -220,14 +272,16 @@
click away from every table row still looked like a bordered button click away from every table row still looked like a bordered button
forever, not a plain label. */ forever, not a plain label. */
appearance: none; border: none !important; background: none !important; padding: 0; cursor: pointer; appearance: none; border: none !important; background: none !important; padding: 0; cursor: pointer;
color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left; color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left; min-width: 0;
} }
.podman-row-name-btn:hover { color: var(--accent-strong); } .podman-row-name-btn:hover { color: var(--accent-strong); }
.podman-row-name-btn:hover .ico { border-color: var(--accent); } .podman-row-name-btn:hover .ico { border-color: var(--accent); }
.podman-row-name .ico { .podman-row-name .ico {
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3); width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim); display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
overflow: hidden;
} }
.podman-row-name .ico img { width: 100%; height: 100%; object-fit: cover; }
.podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; } .podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; }
/* /*
* The actions <td> itself stays a plain table-cell (default display) so * The actions <td> itself stays a plain table-cell (default display) so
@@ -265,7 +319,7 @@
.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; } .podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; } .podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
.podman-usage-mini .track > span { display: block; height: 100%; background: var(--accent); } .podman-usage-mini .track > span { display: block; height: 100%; background: var(--accent); }
.podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; } .podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; font-variant-numeric: tabular-nums; }
.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; } .podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
/* /*
@@ -345,6 +399,12 @@
.podman-log-pane { .podman-log-pane {
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.3px; background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.3px;
padding: 14px 16px; height: 400px; overflow-y: auto; line-height: 1.65; padding: 14px 16px; height: 400px; overflow-y: auto; line-height: 1.65;
/* Settings' service log (and the log modal) set .textContent directly
on this element rather than wrapping every line in a child .l div —
needs its own white-space here too, or real newlines in rc.podman's
output collapse into one run-together line (found live: the whole
multi-line `rc.podman status` output rendered as a single paragraph). */
white-space: pre-wrap;
} }
.podman-log-pane .l { white-space: pre-wrap; word-break: break-word; } .podman-log-pane .l { white-space: pre-wrap; word-break: break-word; }
.podman-log-pane .ts { color: #6b7280; } .podman-log-pane .ts { color: #6b7280; }
@@ -418,6 +478,9 @@
.podman-input-suffix input[type="number"] { max-width: 100px; width: auto; } .podman-input-suffix input[type="number"] { max-width: 100px; width: auto; }
.podman-input-suffix span { font-size: 12px; color: var(--text-dim); } .podman-input-suffix span { font-size: 12px; color: var(--text-dim); }
.podman-service-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.podman-service-row:last-child { margin-bottom: 0; }
/* /*
* Toggle switch — a plain checkbox reads as a leftover form control next * Toggle switch — a plain checkbox reads as a leftover form control next
* to everything else in this panel getting a designed treatment; this * to everything else in this panel getting a designed treatment; this
@@ -450,6 +513,14 @@
.podman-badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; } .podman-badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; }
.podman-webui-link {
display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 7px;
border-radius: 5px; color: var(--text-faint); text-decoration: none; font-size: 10px; font-weight: 700;
letter-spacing: .03em; flex: none; border: 1px solid var(--border); background: var(--surface-3);
vertical-align: middle;
}
.podman-webui-link:hover { color: var(--accent-strong); border-color: var(--accent); }
.podman-loading, .podman-error { padding: 32px 18px; text-align: center; color: var(--text-faint); font-size: 13px; } .podman-loading, .podman-error { padding: 32px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
/** /**
@@ -572,3 +643,84 @@
padding: 14px 16px; border-radius: 8px; white-space: pre-wrap; word-break: break-all; line-height: 1.6; padding: 14px 16px; border-radius: 8px; white-space: pre-wrap; word-break: break-all; line-height: 1.6;
} }
.podman-detail-empty { color: var(--text-faint); font-size: 12.5px; padding: 8px 0; } .podman-detail-empty { color: var(--text-faint); font-size: 12.5px; padding: 8px 0; }
/*
* Toast notifications — replaces alert() for one-way feedback (saved,
* removed, failed, ...) across every panel. Confirmations stay native
* confirm() (a decision, not a notice); long-running command output
* stays in the existing log-modal pattern (a toast has to stay short to
* be readable while it's animating/auto-dismissing). z-index above the
* modal backdrop (1000) so a toast fired from within a modal's own
* action (e.g. "Save failed") is never hidden behind it.
*/
.podman-toast-container {
position: fixed; right: 20px; bottom: 20px; z-index: 1100;
display: flex; flex-direction: column-reverse; gap: 10px; pointer-events: none;
max-width: min(380px, calc(100vw - 40px));
}
.podman-toast {
pointer-events: auto; display: flex; align-items: flex-start; gap: 10px;
background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--neutral);
border-radius: 9px; box-shadow: var(--shadow); padding: 12px 14px; font-size: 13px; color: var(--text);
animation: podman-toast-in .18s ease-out;
}
.podman-toast.leaving { animation: podman-toast-out .16s ease-in forwards; }
.podman-toast-success { border-left-color: var(--good); }
.podman-toast-warn { border-left-color: var(--warn); }
.podman-toast-error { border-left-color: var(--bad); }
.podman-toast .ico { flex: none; margin-top: 1px; font-size: 15px; line-height: 1; }
.podman-toast-success .ico { color: var(--good); }
.podman-toast-warn .ico { color: var(--warn); }
.podman-toast-error .ico { color: var(--bad); }
.podman-toast .msg { flex: 1; line-height: 1.45; word-break: break-word; }
.podman-toast .close {
appearance: none; background: none; border: none; color: var(--text-faint); cursor: pointer;
font-size: 15px; line-height: 1; padding: 0; flex: none; font-family: var(--font-ui);
}
.podman-toast .close:hover { color: var(--text); }
@keyframes podman-toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes podman-toast-out { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) {
.podman-toast, .podman-toast.leaving { animation: none; }
}
/* Container folders — a group header row spanning the full table width,
sitting between normal <tr>s in the same <tbody> (see containers.js's
renderTable()) rather than a separate nested table, so column
alignment with member rows stays free. */
.podman-folder-row td { padding: 0; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.podman-folder-head { display: flex; align-items: center; gap: 14px; padding: 9px 16px; flex-wrap: wrap; }
.podman-folder-toggle {
appearance: none; background: none; border: none; display: flex; align-items: center; gap: 10px;
cursor: pointer; font-family: var(--font-ui); color: var(--text); padding: 0; flex: none; text-align: left;
}
.podman-folder-toggle .chevron { color: var(--text-faint); font-size: 10px; width: 10px; flex: none; }
.podman-folder-toggle .ico {
width: 22px; height: 22px; border-radius: 6px; flex: none; background: var(--surface-3);
display: grid; place-items: center; font-size: 10.5px; border: 1px solid var(--border); color: var(--text-dim);
overflow: hidden;
}
.podman-folder-toggle .ico img { width: 100%; height: 100%; object-fit: cover; }
.podman-folder-toggle .name { font-weight: 700; font-size: 12.5px; }
.podman-folder-toggle .count {
font-size: 11px; color: var(--text-faint); font-weight: 700; background: var(--surface-3);
padding: 1px 8px; border-radius: 100px; font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* The always-visible per-member preview inside a folder's header row —
see containers.js's folderMemberChipHtml(). Each chip opens that
container's detail modal directly, without needing to expand the
folder first. */
.podman-folder-members { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex: 1; min-width: 0; }
.podman-folder-member {
appearance: none; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border);
background: var(--surface); border-radius: 100px; padding: 3px 10px 3px 4px; cursor: pointer;
font-family: var(--font-ui); color: var(--text-dim); font-size: 11.5px;
}
.podman-folder-member:hover { border-color: var(--accent); color: var(--text); }
.podman-folder-member .ico { width: 18px; height: 18px; border-radius: 5px; font-size: 9px; }
.podman-folder-member .name { font-weight: 600; }
.podman-folder-member .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }
.podman-folder-member .dot.good { background: var(--good); }
.podman-folder-member .dot.bad { background: var(--bad); }
.podman-folder-member .state { color: var(--text-faint); }