24 Commits
Author SHA1 Message Date
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
maggesandClaude Sonnet 5 46a8503498 Rework Terminal into a real live console; polish danger buttons and Settings
Build Packages / Build .txz packages (push) Failing after 8m53s
Lint / ShellCheck (push) Successful in 43s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Failing after 6s
Terminal panel now opens a genuinely interactive shell (ttyd bound to a
unix socket, proxied through Unraid's own /logterminal/ nginx location —
the same mechanism Unraid's own Docker "Console" button uses) instead of
one-shot exec calls, shown inline with a Disconnect action; bash is the
default shell. Container/shell selectors and action buttons are now
correctly bottom-aligned (root cause: Unraid's theme puts a 10px margin
on every <button>, never reset before).

Destructive actions (Disconnect, Compose/Template Delete, Volumes/Images/
Networks Remove) get a consistent, solid red treatment at rest instead of
only tinting on hover, via new --bad-strong/--bad-contrast tokens.

Settings panel restructured: a real save toolbar instead of a button
buried in an empty-label row, card subtitles, a toggle switch instead of
a bare checkbox, and installed-package versions shown as chips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:19:47 +00:00
maggesandClaude Sonnet 5 e92f67ebba Make Compose panel editable: create/edit/delete projects
The YAML view was read-only with no way to create a new project at
all. Add save/remove AJAX actions (validated via a real `podman
compose ... config` dry-run, written to a .new sibling and only
renamed into place on success) and a New Project/Save/Delete UI
backed by an editable textarea instead of a <pre>. Also strip ANSI
escape codes from compose command output so podman's own provider
banner doesn't show as literal garbage in error alerts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 19:07:09 +00:00
maggesandClaude Sonnet 5 2b79411b68 Replace vendored docker-compose with podman-compose
podman-compose and docker-compose aren't discovered the same way by
`podman compose` - verified live (a fake-binary test reading podman's own
provider-search error output) that docker-compose is searched for by
exact path across a fixed list of CLI-plugin directories, while
podman-compose is instead looked up as a plain command on $PATH. This
package installs to /usr/local/bin/podman-compose accordingly, not under
any cli-plugins/ directory.

Unlike docker-compose (a single static Go binary), podman-compose is a
Python script with two runtime dependencies neither of which ship with
Unraid's own Python3 - PyYAML and python-dotenv, vendored here as plain
pure-Python source (no C extension build; PyYAML's own fallback handles
its optional C accelerator being absent).

Verified end-to-end on a real host: with the previous docker-compose
binary temporarily moved aside to confirm podman-compose was actually the
one invoked, `podman compose up/ps/down` ran a real compose project
correctly, including a live HTTP check against the started service.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:35:38 +00:00
maggesandClaude Sonnet 5 ca62577a8b Add GPU/macvlan passthrough, container edit/update, image prune/tag
Create Container form:
- GPU passthrough dropdown (AMD/Intel via /dev/dri detection, NVIDIA
  excluded since it needs a different runtime) - device paths strictly
  validated server-side against the host's own detected list.
- Macvlan network support: selecting a macvlan network reveals a static
  IP field and hides port mappings (meaningless once the container has
  its own LAN address), matching Unraid Docker Manager's "Custom: br0"
  behavior. Networks panel gained a matching macvlan network-creation
  flow, with the parent-interface dropdown read from Unraid's own
  network.cfg so it lists exactly what Docker Manager itself offers.

Containers panel:
- Edit: reopens the create form pre-filled from the container's current
  config (image/ports/volumes/env/network/restart policy/GPU/static IP);
  saving stops+removes the old container and recreates it under the same
  settings, since podman/Docker have no in-place "modify" API for most of
  this.
- Update: same stop/remove/recreate flow, but pulls the current image
  first. "Check for Updates" compares each in-use image's local digest
  against its origin registry (Docker Hub/GHCR/self-hosted registries all
  verified live) with no podman-side feature backing it - implemented via
  the registry's own HTTP API. A small log-modal shows progress for both
  actions instead of a silent wait.
- Fixed a real bug hit live: PodmanClient's flat 15s HTTP timeout aborted
  real image pulls/container creates mid-request; bumped to 600s (nginx
  already allows up to 640s for this plugin's requests).

Images panel:
- "Prune unused" (removes every image with zero containers referencing
  it, not just dangling ones - confirmation copy says so explicitly since
  this is more aggressive than it sounds) and per-image "Tag".

Also several real UI bugs found via live screenshots: unused-image prune
having no visible effect until reloaded, table action-button columns
drifting row to row (a bare "display:flex" on a <td> was fighting the
table layout algorithm), Templates category badges dumping raw multi-tag
strings from real Unraid templates, and low-contrast search/filter
controls that were nearly invisible against the card background.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 18:34:57 +00:00
maggesandClaude Sonnet 5 8ac9cde621 Add Pod lifecycle management, fix nav registration and context-menu bugs
Pods panel could previously only list pods - there was no way to create
one, start/stop/restart it, or attach a container to it from the UI.
Adds a "New Pod" modal (name + port mappings), a per-pod lifecycle menu
(start/stop/restart/remove), and an optional "Pod" field on the Create
Container modal to join an existing pod's network namespace. Backend
verified live against the real podman socket (/pods/create, /pods/{name}/
restart, container "pod" field).

Also fixes three real bugs found via live testing:
- Podman.page used Menu="Podman" instead of Menu="Tasks:<rank>", so the
  plugin never actually appeared in Unraid's top navigation (traced through
  PageBuilder.php/DefaultPageLayout.php/Navigation/Main.php - only pages
  registered under "Tasks" become top-level tabs).
- app.js's shared context-menu component mis-mapped every item positioned
  after a 'separator' entry to the wrong DOM element (an off-by-one against
  menu.children, which includes the separator <div>s) - so "Remove", which
  always sits after a separator, silently did nothing when clicked. Fixed
  by indexing into querySelectorAll('button') instead.
- That same menu was positioned via "position: absolute" math that assumed
  a viewport-relative containing block, but Unraid's own page wrapper
  (webGui/styles/default-base.css's ".content") sets position:relative,
  so the menu rendered far from its anchor button. Switched to
  "position: fixed" with viewport-relative coordinates.

Incidentally, pods add a hidden "infra" container that was leaking into
the plain Containers list with no working lifecycle of its own (always
"running", so its own Remove was permanently disabled) - now filtered out
via libpod's IsInfra flag. And every action-buttons table cell used
"display: flex" directly on the <td>, which browsers can size
inconsistently row to row - moved onto an inner wrapper div instead, and
bumped .podman-btn-icon's touch target size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 16:26:49 +00:00
maggesandClaude Sonnet 5 45e27f8575 Add Create Container UI and Templates (Unraid XML) feature
Lets users create containers from the WebUI (image/name/ports/volumes/
env/network mode/restart policy/privileged) instead of only managing
existing ones, and adds a Templates panel to save/reuse those configs
as Unraid-Docker-compatible template XML, including browsing and
importing the host's own existing Docker Manager templates directly.

Also fixes bugs found via live testing along the way: container names
with spaces/invalid characters now get a clear client- and server-side
error with a suggested fix instead of podman's raw API error, the New
Container modal's backdrop no longer renders transparent (was being
appended outside the .podman-plugin CSS scope), and modal buttons now
have real visual hierarchy (ghost/primary/danger) after Unraid's own
site-wide button theme was found to override plain single-class rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 13:02:50 +00:00
maggesandClaude Sonnet 5 151d93c12d Add container detail view (Overview/Environment/Labels/Mounts/Networks/Inspect tabs)
Clicking a container's name now opens a tabbed detail modal, fed entirely
by the existing inspect action's raw libpod data — no new backend needed.
Field names (Config.Env, Config.Labels, Mounts[].Source/Destination/RW,
NetworkSettings.Networks{}.IPAddress/Gateway/MacAddress, HostConfig.
RestartPolicy) verified against a real inspect response before building
the tabs around them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:02:05 +00:00
maggesandClaude Sonnet 5 993e187f59 Add container pause/resume/kill/rename + reusable context-menu component
First increment of the big WebUI feature-parity spec (see task list) —
row-level actions were about to run out of icon-button space, so this adds
a small anchored dropdown menu (app.js openContextMenu) for secondary
per-container actions instead of cramming more buttons into every row.

All four new actions verified live against the real podman API before
wiring up the UI (same discipline as the CSRF/pull/compose bugs found
earlier — field names and response shapes checked against the running
socket, not assumed from docs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 11:56:46 +00:00
maggesandClaude Sonnet 5 5b47b4cc0a Add catatonit/nftables/docker-compose packages, fix CSRF/streaming/storage bugs found by live testing
- Package #9-11: catatonit (pod infra init), nftables (netavark firewall
  backend), docker-compose (external compose provider for `podman compose`)
  — all vendored prebuilt binaries, versions.env pinned, propagated through
  build-packages.sh/release.sh/podman.plg/verify+update-packages.sh.
- Fix WebUI: every POST action was silently failing (empty response body)
  because Unraid's own CSRF protection was never satisfied — app.js now
  sends the page's csrf_token as X-CSRF-Token.
- Fix WebUI: PodmanClient::pullImage() assumed a single JSON response, but
  /images/pull actually streams newline-delimited JSON — every successful
  pull was throwing "Expected a JSON object/array response".
- Fix WebUI: compose.php's up/down status detection had the same
  single-JSON-vs-NDJSON bug for `podman compose ps`, plus stderr was
  corrupting the parse.
- Add cache-busting (?v=<mtime>) to Podman.page's script/style tags so a
  redeployed JS/CSS fix isn't served stale from browser cache.
- Add a reusable modal dialog (app.js openFormModal) replacing
  prompt()/alert() for New Volume/Network/Pull Image.
- Add host-path (bind-mount) support when creating a named volume.
- Add Create Container (image, name, network mode incl. custom networks,
  ports, volumes, env, restart policy, privileged, start-after-create),
  auto-pulling the image on first use since /containers/create doesn't.

All fixes verified live against a real podman system service and, where
reachable, via the actual WebUI over the real socket — not just unit-level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 11:51:17 +00:00
maggesandClaude Sonnet 5 5944ddf722 Fix WebUI: podman_parse_time() rejected int timestamps from list endpoints
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
Found by exercising the AJAX endpoints directly against a real running
podman service (php -d display_errors=1 -r '...containers.php...') —
containers.php's list action crashed with an uncaught TypeError the
moment a real container existed. podman's libpod API is inconsistent
about container/volume timestamp encoding: inspect-style endpoints
return RFC3339 strings, but list-style endpoints (containers/json,
volumes/json) return raw Unix-epoch integers for the same logical
field. podman_parse_time() only accepted ?string, so any list call
with a real container blew up outright rather than merely
mis-rendering. Widened it to string|int|null and handle both.

Verified: containers.php's list action now returns correct JSON for
real running/exited containers, including their createdAt timestamps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 23:12:04 +00:00
maggesandClaude Sonnet 5 51b7262b72 Fix five real bugs found by actually installing and running the plugin
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 4s
First live end-to-end install on real Unraid hardware (all 8 built
packages installed via upgradepkg, rc.podman started, containers
pulled/run/networked/port-mapped) — surfaced five genuine bugs no
amount of container-based CI testing could have caught, since none of
them exist inside the vbatts/slackware:15.0 build container:

1. rc.podman never created $PODMAN_LOG_DIR before redirecting the
   podman system service's output into it, so the service failed to
   even start ("No such file or directory"). Added it alongside the
   existing PODMAN_RUN_DIR mkdir.

2. config/storage.conf hardcoded a [storage] table, and
   podman-config.sh's `sync` step appended a second one at boot with
   the real graphroot/runroot — TOML forbids defining the same table
   twice. Removed the template's [storage] entirely; sync already
   generates the whole thing.

3. config/policy.json had a "_comment" pseudo-field for
   documentation, but containers/image's policy parser rejects any
   unknown top-level key outright. JSON has no comment syntax; moved
   the rationale into docs/ARCHITECTURE.md instead.

4. netavark >= 2.0 dropped its iptables firewall driver entirely
   (verified: passing "iptables" is flatly rejected) — nftables or
   firewalld are the only remaining options, and firewalld needs
   systemd/dbus, which Unraid has neither of. Set firewall_driver =
   "nftables" explicitly and documented that Unraid OS doesn't ship
   the `nft` binary this needs (a slackware64 nftables package works;
   not yet wired into the build/install pipeline — see follow-up).

5. Every container failed with "crun: pivot_root: Invalid argument".
   Root cause: Unraid's / is permanently the kernel's initial "rootfs"
   pseudo-filesystem (Unraid never pivots to a real one at boot — the
   whole OS runs from RAM), and pivot_root(2) unconditionally rejects
   that as the old root. This is not new: Docker/runc hits the exact
   same kernel restriction on this exact host and silently falls back
   to an MS_MOVE-based chroot; crun has no such fallback, only a
   --no-pivot flag with no config-file equivalent. Added
   plugin/sbin/crun-no-pivot.sh, a thin wrapper that scans crun's full
   argument list (podman puts global flags before the subcommand, so
   the subcommand isn't reliably $1) and injects --no-pivot right
   after create/run, and pointed containers.conf's crun runtime at it.
   Also fixed the podman.plg postinstall's chmod glob
   (`podman-*.sh` -> `*.sh`), which would have skipped this new
   non-podman-prefixed sbin script.

Verified end-to-end on the real host: pull, run, real network
connectivity (wget through the container's bridge), and a published
port actually serving HTTP (curl through -p 8099:80 to nginx) all
work. --no-pivot's security tradeoff (disabling one particular
container-escape mitigation) was explicitly discussed with and
approved by the user before committing, given it must be the default
for any container to start at all on this platform.

Follow-up not yet done: nftables (needed for #4) is not yet a
packages/ component in the reproducible build pipeline — it was only
installed manually on the test host for this verification run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 23:06:53 +00:00
maggesandClaude Sonnet 5 6cd541de23 Fix CI: downgrade upload/download-artifact to v3 for Gitea compatibility
Build Packages / Build .txz packages (push) Successful in 7m28s
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 4s
All 8 packages built and verified successfully in task 96 — the only
remaining failure was the very last step: actions/upload-artifact@v4
errored with GHESNotSupportedError. Gitea Actions' built-in artifact
storage doesn't implement the newer v2 upload/download API that
upload-artifact@v4/download-artifact@v4 require. Downgraded both (they
must match — v3 and v4 artifacts aren't cross-compatible) to v3, which
uses the older protocol Gitea does support.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:26:57 +00:00
maggesandClaude Sonnet 5 c3b8ae8ed9 Fix CI: json-c for crun, and fetch crun's git submodules as pinned tarballs
Build Packages / Build .txz packages (push) Failing after 7m21s
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
Verified end-to-end with a full local build of all 8 packages on the
actual runner host (docker exec against vbatts/slackware:15.0, bind-
mounted repo on the cache pool) instead of round-tripping through
Gitea Actions for each fix — much faster for a chain of issues this
deep. All 8 packages now build successfully from a clean checkout.

Three fixes, all in crun (the last package still failing):

1. "Package requirements (json-c >= 0.14) were not met" — added
   json-c to the toolchain bootstrap.

2. crun depends on the libocispec git submodule, which in turn depends
   on the image-spec and runtime-spec git submodules. GitHub's source
   archive tarball never includes submodule content (no .git directory
   for `git submodule update` to work against — the existing `|| true`
   masked this failing silently). Fetched all three as their own
   pinned tarballs instead, at the exact commits crun 1.28 references
   (cross-checked via the GitHub contents API), matching how every
   other dependency in this project is already pinned.

3. crun.c unconditionally #includes git-version.h, which crun's own
   Makefile only generates via `git describe` (again, no .git) or from
   a pre-existing .tarball-git-version.h — the file crun's own `make
   dist` would have written, which we never run. Write that file
   ourselves in the exact format the Makefile already expects; this is
   the documented fallback path bundled release tarballs rely on, not
   a workaround around it.

Also includes the podman go-md2man pre-seed fix and setup-slackware-
buildenv.sh python3 addition from the previous commit's follow-up
testing (both already verified working in this same local build run).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:17:02 +00:00
maggesandClaude Sonnet 5 f1962acc8c Fix CI: correct the pinned Go toolchain SHA256
Build Packages / Build .txz packages (push) Failing after 6m3s
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 13s
Lint / EditorConfig (push) Successful in 5s
GO_SRC_SHA256 was wrong since it was first pinned in Task 1 — this
path had never actually been exercised in any prior CI run or local
test because every earlier failure happened before reaching the Go
install step, or (once it did run) nothing had checked the pinned
value against go.dev's own published checksum yet. Task 88's log
caught it: the real go1.26.5 linux-amd64 tarball hashes to
5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053
(cross-checked against https://go.dev/dl/?mode=json directly), not
the previously pinned value. Also independently re-verified
LIBSECCOMP_SRC_SHA256 against the real v2.6.1 tarball while looking at
this — that one was already correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 21:37:48 +00:00
maggesandClaude Sonnet 5 23a1ab22a6 Fix CI: unshadow the pinned Go, and add python3/protoc/go-md2man
Build Packages / Build .txz packages (push) Failing after 2m59s
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 5s
Task 84's log got further than any run so far — 4 of 8 packages
(aardvark-dns, passt, fuse-overlayfs, unraid-podman) built successfully
— and surfaced four distinct, genuine build-time issues for the rest:

1. podman: go.mod parsing failed with "invalid go version '1.25.6':
   must match format 1.23". Root cause: slackpkg's batch-mode
   `install gcc` (verified directly) pulls in every gcc-<lang> sibling
   package Slackware's gcc SlackBuild produces — including gcc-go, an
   ancient bundled go1.16.5. Our Go bootstrap only installed the pinned
   $GO_VERSION when `command -v go` found nothing, so gcc-go's go1.16.5
   silently won. Fixed by always installing/overwriting the pinned Go
   and prepending it to PATH, regardless of what else provides `go`.

2. crun: "no suitable Python interpreter found" — added python3.

3. netavark: build.rs (via prost-build) needs a `protoc` binary;
   Slackware packages no protobuf/protoc at all. Added the official
   prebuilt release binary, pinned + checksummed (no `unzip` on this
   image either, so extracted with `python3 -m zipfile` instead of
   adding yet another package).

4. conmon: `make install`'s docs target needs go-md2man, not packaged
   by Slackware and no prebuilt release exists upstream. `go install`
   it, pinned to a tagged release, now that our own Go is reliably on
   PATH.

All four verified directly against vbatts/slackware:15.0 on the actual
runner host before this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 21:32:27 +00:00
maggesandClaude Sonnet 5 e912180e8d Fix two real bugs in the package build scripts, found via a live CI run
Build Packages / Build .txz packages (push) Failing after 5m5s
Lint / ShellCheck (push) Successful in 13s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 4s
The toolchain bootstrap fixes finally got build-packages.sh far enough
to attempt actual package builds, surfacing two genuine bugs (not
environment/toolchain issues) in task 80's log:

1. sb_fetch_and_verify() echoed its "Fetching..."/"SHA256 verified..."
   progress messages to stdout, same stream its return value (the
   tarball path) is returned on. Every caller captures that return value
   via `tarball=$(sb_fetch_and_verify ...)`, so command substitution
   swallowed the progress lines into $tarball too, and the resulting
   multi-line garbage got handed to `tar -xf` as a single bogus
   filename. Fixed by sending the progress echoes to stderr.

2. unraid-podman.SlackBuild read plugin/podman.plg's version via
   `grep -oP` with a variable-length lookbehind ({1,10} to match
   flexible whitespace). PCRE requires fixed-length lookbehind; this
   works on a PCRE2 grep (e.g. most dev machines) but fails outright
   ("lookbehind assertion is not fixed length") on Slackware's PCRE1
   grep. Replaced with a portable sed capture group.

Both verified directly against vbatts/slackware:15.0 on the runner host
before this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 15:14:45 +00:00
maggesandClaude Sonnet 5 3133d45d74 Fix CI: add cmake and its transitive runtime deps to the toolchain bootstrap
Build Packages / Build .txz packages (push) Failing after 3m40s
Lint / ShellCheck (push) Successful in 9s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 10s
yajl (needed by conmon, bootstrapped from source since Slackware ships
no package for it) builds via CMake, not the cmake-free autoconf script
its ./configure wrapper name suggests. Tracing the failure through the
actual vbatts/slackware:15.0 image on the runner host surfaced a chain
of packages slackpkg does not auto-resolve (Slackware packages carry no
dependency metadata at all): cmake needs libarchive, which needs lz4 and
libxml2; the patched make/gmake this mirror serves needs guile, which
needs gc; compiling anything needs kernel-headers for <linux/errno.h>;
and this build's binutils (ar/ranlib) needs flex, while objdump needs
elfutils. All added to the same slackpkg install list as the rest of
the toolchain, keeping everything on one mutually consistent version
set. Verified end-to-end against vbatts/slackware:15.0 on the actual
runner host at each step of this dependency chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 15:07:18 +00:00
maggesandClaude Sonnet 5 c6947007aa Fix CI: install Node.js so actions/checkout can run in the container
Build Packages / Build .txz packages (push) Failing after 3m30s
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 5s
The git fix alone got further, but actions/checkout@v4 (and later,
actions/upload-artifact@v4) are Node-based actions — Gitea Actions
execs their JS bundle with the "node" binary from inside the job's
own container rather than injecting a runtime of its own, and
vbatts/slackware:15.0 has no nodejs package anywhere on the official
Slackware mirror. Install a pinned, checksum-verified Node.js release
straight from nodejs.org in the same "Install git" step, matching the
existing Go/Rust bootstrap style in setup-slackware-buildenv.sh.
Verified end-to-end (node --version succeeds after a fresh install) in
vbatts/slackware:15.0 on the actual runner host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 14:30:12 +00:00
maggesandClaude Sonnet 5 acb9fb704b Fix CI: bootstrap the full Slackware build toolchain via slackpkg
Build Packages / Build .txz packages (push) Failing after 41s
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
The build-packages.yml run was failing for two compounding reasons,
found by testing directly against vbatts/slackware:15.0 on the actual
runner host:

1. The image ships neither git nor its HTTPS runtime libs, so
   actions/checkout failed immediately.
2. It's a minimal rootfs with none of the 'D' (development) series —
   no gcc, make, autoconf, pkg-config, curl, glib2, libcap, or fuse3 —
   contrary to setup-slackware-buildenv.sh's assumption that a "full"
   Slackware install already provides these.

An earlier fix attempt hand-pinned git + its deps (nghttp2, brotli,
cyrus-sasl) by exact file + SHA256 from the base 15.0 release
directory. That drifted out of sync with the newer, patched curl
slackpkg installs later in the same container — same shared library,
two different builds, causing a runtime symbol lookup error. Both
steps now resolve every package through slackpkg's own prioritized
mirror instead, keeping the whole toolchain on one mutually consistent
version set. Verified end-to-end (git ls-remote and curl both succeed
over HTTPS against the real Gitea instance, full toolchain present)
in a fresh vbatts/slackware:15.0 container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 14:11:29 +00:00
maggesandClaude Sonnet 5 f56704a7fb Fix CI: install git in the Slackware build container before checkout
vbatts/slackware:15.0 ships neither git nor its runtime shared libs
(nghttp2, brotli, cyrus-sasl) or ca-certificates, so actions/checkout
was failing immediately with "base image is missing git/tar". Install
git and its dependencies from Slackware's own official mirror, pinned
by exact filename and verified SHA256, and build the CA bundle so git
can trust HTTPS remotes. Verified end-to-end against the real Gitea
instance (git ls-remote succeeds) in a fresh vbatts/slackware:15.0
container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 13:57:19 +00:00
57 changed files with 5048 additions and 473 deletions
+82 -9
View File
@@ -1,9 +1,10 @@
name: Build Packages
# Builds the seven Slackware .txz packages defined under packages/
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs)
# inside a Slackware container, verifies + consolidates their checksums, and
# uploads the result as a workflow artifact.
# Builds the eleven Slackware .txz packages defined under packages/
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
# catatonit, nftables, podman-compose, unraid-podman) inside a Slackware
# container, verifies + consolidates their checksums, and uploads the
# result as a workflow artifact.
#
# Intentionally does NOT commit any built binary back to the repository —
# packages/**, *.txz, dist/ are all git-ignored (see .gitignore). Artifacts
@@ -31,7 +32,7 @@ on:
inputs:
packages:
description: >
Space-separated package names to build (default: all seven).
Space-separated package names to build (default: all eleven).
Example: "podman conmon"
required: false
default: ""
@@ -46,8 +47,41 @@ on:
# pin by digest) if the project standardizes on a different/self-hosted
# base image. See scripts/ci/setup-slackware-buildenv.sh for how missing
# build dependencies are bootstrapped on top of whatever this image ships.
#
# vbatts/slackware:15.0 ships tar but NOT git (needed by actions/checkout),
# nor any of the shared libraries git's HTTPS transport needs. The
# "Install git" step below uses slackpkg (already present and pre-
# configured with a mirror in this image) rather than hand-picking package
# files: git's HTTPS support pulls in nghttp2/brotli/cyrus-sasl, and
# hand-pinning those separately from the base 15.0 release directory (as
# an earlier version of this step did) silently drifted out of sync with
# the newer, patched curl that scripts/ci/setup-slackware-buildenv.sh
# installs later in the same container — same library, two different
# builds, resulting in a symbol lookup error at runtime. Letting slackpkg
# resolve everything from the same prioritized repo set (patches over
# main, see /etc/slackpkg/slackpkg.conf's PRIORITY) keeps every package on
# this image on a mutually consistent version set.
#
# actions/checkout and actions/upload-artifact are both Node-based actions
# — Gitea Actions execs their JS bundle with the "node" binary found
# inside the job's own container, it does not inject a runtime of its
# own. Slackware has no nodejs package at all (checked: not in any of the
# main/extra/pasture/testing repos), so the "Install git" step also
# installs a pinned, checksum-verified Node.js release directly from
# nodejs.org — the same style already used for Go/Rust in
# scripts/ci/setup-slackware-buildenv.sh.
#
# upload-artifact is pinned to v3, not v4 — Gitea Actions' built-in
# artifact storage does not implement the newer v2 upload/download API
# that actions/upload-artifact@v4 and actions/download-artifact@v4
# require; uploading fails outright with "GHESNotSupportedError". v3 uses
# the older, still-supported protocol. release.yml's download-artifact
# must stay on the matching v3 — the two protocols aren't compatible with
# each other.
env:
SLACKWARE_IMAGE: "vbatts/slackware:15.0"
NODE_VERSION: "20.20.2"
NODE_SHA256: "df770b2a6f130ed8627c9782c988fda9669fa23898329a61a871e32f965e007d"
jobs:
build:
@@ -58,10 +92,49 @@ jobs:
outputs:
artifact-name: ${{ steps.artifact-name.outputs.value }}
steps:
- name: Install git and tar (needed before actions/checkout can run)
- name: Install git and Node.js (needed before actions/checkout can run)
run: |
(command -v git && command -v tar) || \
(echo "!! base image is missing git/tar — see SLACKWARE_IMAGE in this workflow" && exit 1)
set -eu
command -v tar > /dev/null || (echo "!! base image is missing tar — see SLACKWARE_IMAGE in this workflow" && exit 1)
# CHECKGPG is turned off: slackpkg's default GPG-key bootstrap
# fetches Slackware's signing key from www.slackware.com, which
# is not reachable from every CI network (observed to hang on
# this project's self-hosted Gitea Actions runner). CHECKMD5
# (on by default) still verifies every package against the
# mirror's own CHECKSUMS.md5 as a transit-integrity check.
sed -i 's/^CHECKGPG=on/CHECKGPG=off/' /etc/slackpkg/slackpkg.conf
slackpkg -batch=on -default_answer=y update
# git and curl's HTTPS transport need nghttp2/brotli/cyrus-sasl at
# runtime, but slackpkg does not resolve shared-library
# dependencies (Slackware packages carry no such metadata) — list
# them explicitly so they come from the same slackpkg pass (and
# therefore the same mutually-consistent build) as git itself.
slackpkg -batch=on -default_answer=y install \
git ca-certificates nghttp2 brotli cyrus-sasl
# ca-certificates ships individual certs under
# /usr/share/ca-certificates/ — this builds the combined bundle
# git (and later, curl) need to actually trust HTTPS remotes.
update-ca-certificates
echo "GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt" >> "$GITHUB_ENV"
echo "CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt" >> "$GITHUB_ENV"
git --version
# curl isn't installed until scripts/ci/setup-slackware-buildenv.sh
# runs, later — use wget (present in the base image) here instead.
# wget does not consult the system CA bundle on its own (unlike
# curl/git, which respect CURL_CA_BUNDLE/GIT_SSL_CAINFO), so pass
# it explicitly via --ca-certificate.
node_tarball="node-v${NODE_VERSION}-linux-x64.tar.xz"
wget -q --tries=3 --ca-certificate=/etc/ssl/certs/ca-certificates.crt \
-O "/tmp/$node_tarball" "https://nodejs.org/dist/v${NODE_VERSION}/$node_tarball"
echo "${NODE_SHA256} /tmp/$node_tarball" | sha256sum -c -
mkdir -p /usr/local/lib/nodejs
tar -xf "/tmp/$node_tarball" -C /usr/local/lib/nodejs
echo "/usr/local/lib/nodejs/node-v${NODE_VERSION}-linux-x64/bin" >> "$GITHUB_PATH"
"/usr/local/lib/nodejs/node-v${NODE_VERSION}-linux-x64/bin/node" --version
- uses: actions/checkout@v4
@@ -79,7 +152,7 @@ jobs:
run: echo "value=podman-packages-${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: ${{ steps.artifact-name.outputs.value }}
path: |
+1 -1
View File
@@ -38,7 +38,7 @@ jobs:
- uses: actions/checkout@v4
- name: Download built packages
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: ${{ needs.build.outputs.artifact-name }}
path: dist
+16
View File
@@ -9,6 +9,22 @@ see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#52-build-strategie)).
## [Unreleased]
## [0.1.1] - 2026-07-13
### 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
- Initial repository scaffolding: directory structure, documentation skeleton,
CI workflow stubs, and community health files.
+26 -2
View File
@@ -18,6 +18,30 @@
# TODO: static_dir / volume_path / runroot overrides pointing at the cache-pool
# backed storage location instead of RAM-root defaults.
[engine.runtimes]
# Unraid's / is the kernel's initial 'rootfs' pseudo-filesystem — Unraid
# never pivots to a real one during boot, the whole OS runs from RAM — and
# pivot_root(2) unconditionally rejects that as the "old root" (EINVAL).
# runc (what Docker uses) silently falls back to an MS_MOVE-based chroot
# in that situation; crun has no such fallback, only a --no-pivot flag on
# `create`/`run` with no config-file equivalent — so podman is pointed at
# a thin wrapper (installed by the unraid-podman package, see
# plugin/sbin/crun-no-pivot.sh) that injects it, instead of crun directly.
# Verified live: without this, every container fails with
# "crun: pivot_root: Invalid argument: OCI runtime error".
crun = ["/usr/local/sbin/crun-no-pivot.sh"]
[network]
# TODO: default network backend (netavark), default subnet range distinct from
# Docker's docker0 range — see docs/ARCHITECTURE.md section 8.
# TODO: default subnet range distinct from Docker's docker0 range — see
# docs/ARCHITECTURE.md section 8.
#
# netavark >= 2.0 dropped its iptables firewall driver entirely — only
# nftables and firewalld remain (verified against the actual netavark
# binary; "iptables" is rejected with "Must provide a valid firewall
# backend"). firewalld needs systemd/dbus, which Unraid has neither of, so
# nftables (netavark's own default — explicit here so that stays true even
# if netavark's default ever changes) is the only viable driver. Unraid OS
# does not ship the `nft` binary this needs — see docs/ARCHITECTURE.md
# section 8 for how it's provisioned.
network_backend = "netavark"
firewall_driver = "nftables"
-1
View File
@@ -1,5 +1,4 @@
{
"_comment": "Default container image signature verification policy. Placeholder: accepts all images without signature verification, matching Docker's default trust model on Unraid today. See docs/ARCHITECTURE.md section 10 (Images). Revisit before a 1.0 release if signed-image verification becomes a goal.",
"default": [
{ "type": "insecureAcceptAnything" }
],
+7 -8
View File
@@ -7,14 +7,13 @@
#
# Full reference: https://github.com/containers/storage/blob/main/docs/containers-storage.conf.5.md
[storage]
driver = "overlay"
# runroot and graphroot are set at runtime by rc.podman based on
# /boot/config/plugins/podman/podman.cfg (configurable storage location),
# not hardcoded here. TODO: document the exact substitution mechanism once
# rc.podman is implemented.
# graphroot = "/var/lib/containers/storage"
# runroot = "/var/run/containers/storage"
# The [storage] table itself (driver, graphroot, runroot) is intentionally
# NOT defined here — podman-config.sh's `sync` command appends it in full
# at sync time, since graphroot depends on STORAGE_PATH from
# /boot/config/plugins/podman/podman.cfg (configurable storage location) and
# can't be known statically. A second [storage] table here would be a TOML
# duplicate-key error once sync appends its own — see podman-config.sh's
# cmd_sync for the generated content.
[storage.options]
# TODO: overlay-specific mount options once the loopback filesystem
+14
View File
@@ -232,6 +232,8 @@ wählen können — mit deutlicher GUI-Warnung bzgl. Performance und Spin-up-Ver
| `fuse-overlayfs` | Fallback-Storage-Driver | Für Rootless-Phase 2 vorbereitet, in Phase 1 optional |
| `passt`/`pasta` | Rootless-Networking | Nachfolger von slirp4netns, Phase 2, aber Paket schon mitbauen (geringe Kosten) |
| `catatonit` oder `tini` | Init-Prozess in Containern (optional, falls von Templates genutzt) | |
| `nftables` | Firewall-Backend für `netavark` | Pflicht seit netavark 2.0 (iptables-Treiber entfernt); Unraid liefert kein `nft` mit — als offizielles Slackware-Paket vendored, nicht selbst gebaut |
| `podman-compose` | External-Compose-Provider für `podman compose` | `podman compose` hat keine eigene Compose-Implementierung, sondern sucht ein Kommando namens `podman-compose` auf `$PATH` (live verifiziert — anders als das ältere, ebenfalls unterstützte `docker-compose`, das stattdessen in festen CLI-Plugin-Pfaden gesucht wird); ohne dieses Paket schlägt jede Compose-Panel-Aktion auf einem frischen Unraid-Install fehl. Python-Skript, vendored zusammen mit PyYAML/python-dotenv als reines Python-Source (kein C-Build) |
### 5.2 Build-Strategie
@@ -339,6 +341,12 @@ Unraid-Plugins.
## 8. Netzwerke
- **Backend**: `netavark` + `aardvark-dns` (Podman-Default seit 4.x), kein CNI in Phase 1.
- **Firewall-Treiber**: `netavark` >= 2.0 hat seinen `iptables`-Treiber ersatzlos entfernt —
nur noch `nftables` oder `firewalld` (Letzteres braucht systemd/dbus, hat Unraid nicht).
`nftables` ist also zwingend, aber Unraid OS bringt das `nft`-Binary selbst nicht mit
(nur das ältere `iptables`/`iptables-nft`) — muss vom Plugin bereitgestellt werden
(verifiziert per Live-Test: `podman run` mit Port-Publishing scheitert ohne `nft`
mit „Must provide a valid firewall backend“).
- **Default-Bridge**: eigene Bridge `podman0` (nicht `docker0`), eigener privater
Adressraum (konfigurierbar, Default-Vorschlag außerhalb von Dockers Default-Range,
um Kollisionen bei Parallelbetrieb zu vermeiden).
@@ -397,6 +405,12 @@ Unraid-Plugins.
- **Community-Applications-Kompatibilität**: kein automatischer Import von
Docker-Templates in Phase 1 (eigenes, separates Vorhaben); Podman-Images werden
zunächst über CLI/eigene, minimale Template-Definition verwaltet.
- **Signatur-Policy** (`config/policy.json`): `insecureAcceptAnything` als Default —
akzeptiert Images ohne Signaturprüfung, entspricht Dockers heutigem
Standard-Vertrauensmodell auf Unraid. Vor einem 1.0-Release erneut bewerten, falls
signierte Images ein Ziel werden. Die Datei selbst darf keine Kommentarfelder
enthalten (`containers/image`s Policy-Parser lehnt unbekannte Top-Level-Keys wie
`_comment` strikt ab) — Begründung lebt deshalb hier, nicht in der Datei.
---
+20
View File
@@ -0,0 +1,20 @@
# packages/catatonit/
Pinned version: see `CATATONIT_VERSION` in [versions.env](../../versions.env).
Not built from source — `catatonit.SlackBuild` fetches and repackages
upstream's own prebuilt static x86_64 release binary. catatonit ships no
GitHub release asset for source-tarball builds that's meaningfully
different from just using the binary directly, and it's a small, purely
static ELF with zero runtime library dependencies (verified: `ldd` reports
"not a dynamic executable").
Required by `podman pod create` — without it, pod creation fails with
`finding catatonit binary: exec: catatonit: executable file not found in
$PATH`. Installed to `/usr/libexec/podman/catatonit`, alongside
netavark/aardvark-dns, which podman's `helper_binaries_dir` search already
covers.
Found by live-testing this plugin end-to-end against a real Unraid
install, not from reading podman's docs — see
[docs/ARCHITECTURE.md, section 8](../../docs/ARCHITECTURE.md#8-netzwerke).
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# =============================================================================
# packages/catatonit/catatonit.SlackBuild
#
# Packages the official prebuilt catatonit release binary — not built from
# source, see versions.env's CATATONIT_* block for why. catatonit is the
# init process podman runs inside every pod's infra container to reap
# zombies; without it, `podman pod create` fails outright with "finding
# catatonit binary: exec: catatonit: executable file not found in $PATH"
# (found by live-testing pod creation against a real Unraid install).
#
# Installed alongside netavark/aardvark-dns under /usr/libexec/podman/ —
# podman's helper_binaries_dir search path already covers that directory,
# matching where the other two helper binaries this project ships already
# live (see packages/netavark, packages/aardvark-dns).
# =============================================================================
set -eu
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION="$CATATONIT_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "catatonit"
binary=$(sb_fetch_and_verify "$CATATONIT_SRC_URL" "$CATATONIT_SRC_SHA256" "catatonit-$VERSION")
install -D -m 0755 "$binary" "$PKG/usr/libexec/podman/catatonit"
# The release only ships the raw binary (+ checksum/signature files, no
# LICENSE/README asset) — write minimal doc metadata by hand instead of
# using sb_install_docs, which expects real files to copy from disk.
docdir="$PKG/usr/doc/catatonit-$VERSION"
mkdir -p "$docdir"
{
echo "catatonit $VERSION"
echo "https://github.com/openSUSE/catatonit"
echo "Prebuilt static binary, packaged as-is by unraid-podman — see"
echo "versions.env for the pinned release URL and SHA256."
} > "$docdir/README"
{
echo "Built by unraid-podman from upstream's prebuilt release binary."
echo "Package: catatonit $VERSION"
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$docdir/unraid-podman.build-info"
sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG"
+19
View File
@@ -0,0 +1,19 @@
# HOW TO EDIT THIS FILE:
# The "handy ruler" below makes it easier to edit a package description.
# Line up the first '|' above the ':' following the base package name, and
# the '|' on the right side marks the last column you can put a character in.
# You must make exactly 11 lines for the formatting to be correct. It's also
# customary to leave one space after the ':' except on otherwise blank lines.
|-----handy-ruler------------------------------------------------|
catatonit: catatonit (init process for OCI pod infra containers)
catatonit:
catatonit: A minimal init that reaps zombie processes inside a pod's infra
catatonit: container. Required by `podman pod create` — packaged here from
catatonit: upstream's prebuilt static binary release for the unraid-podman
catatonit: plugin, not built from source (see versions.env).
catatonit:
catatonit: Homepage: https://github.com/openSUSE/catatonit
catatonit:
catatonit:
catatonit:
+35 -7
View File
@@ -37,13 +37,41 @@ done
cd "$srcdir"
# GitHub's source archive does not include vendored git submodules
# (libocispec) that crun's release tarballs normally bundle; init/update
# them explicitly.
if [ -f .gitmodules ]; then
echo "==> [crun] fetching submodules"
git -c protocol.file.allow=always submodule update --init --recursive || true
fi
# GitHub's source archive has no .git directory, so `git submodule
# update` can never work against it (it needs an actual git repo to
# resolve against) — it silently fails and leaves libocispec/ empty,
# which only surfaces much later as a confusing "No rule to make target
# 'all'" in that subdirectory. Fetch libocispec as its own pinned source
# instead, matching every other dependency in this project, and unpack it
# directly over the empty submodule directory.
echo "==> [crun] fetching libocispec (git submodule, pinned separately — see versions.env)"
libocispec_tarball=$(sb_fetch_and_verify "$LIBOCISPEC_SRC_URL" "$LIBOCISPEC_SRC_SHA256" "libocispec-$LIBOCISPEC_COMMIT.tar.gz")
rm -rf "$srcdir/libocispec"
mkdir -p "$srcdir/libocispec"
tar -xf "$libocispec_tarball" -C "$srcdir/libocispec" --strip-components=1
# libocispec has its own two git submodules (image-spec, runtime-spec) —
# same problem, one level deeper. Same fix, same reasoning.
echo "==> [crun] fetching libocispec's image-spec/runtime-spec submodules"
imagespec_tarball=$(sb_fetch_and_verify "$IMAGE_SPEC_SRC_URL" "$IMAGE_SPEC_SRC_SHA256" "image-spec-$IMAGE_SPEC_COMMIT.tar.gz")
rm -rf "$srcdir/libocispec/image-spec"
mkdir -p "$srcdir/libocispec/image-spec"
tar -xf "$imagespec_tarball" -C "$srcdir/libocispec/image-spec" --strip-components=1
runtimespec_tarball=$(sb_fetch_and_verify "$RUNTIME_SPEC_SRC_URL" "$RUNTIME_SPEC_SRC_SHA256" "runtime-spec-$RUNTIME_SPEC_COMMIT.tar.gz")
rm -rf "$srcdir/libocispec/runtime-spec"
mkdir -p "$srcdir/libocispec/runtime-spec"
tar -xf "$runtimespec_tarball" -C "$srcdir/libocispec/runtime-spec" --strip-components=1
# crun.c unconditionally #includes git-version.h. Its own Makefile only
# generates that file from `git describe` (needs .git, which a plain
# tarball checkout never has) or, failing that, from a pre-existing
# .tarball-git-version.h — the file crun's own `make dist` would have
# written. We're not running `make dist`, so write it ourselves in the
# exact format that Makefile target expects; this is the documented
# fallback path, not a workaround around it.
printf '/* autogenerated. */\n#ifndef GIT_VERSION\n# define GIT_VERSION "%s"\n#endif\n' "$VERSION" \
> .tarball-git-version.h
echo "==> [crun] autogen + configure"
./autogen.sh
+19
View File
@@ -0,0 +1,19 @@
# packages/nftables/
Pinned version: see `NFTABLES_VERSION` in [versions.env](../../versions.env).
Not built from source, and no `slack-desc` here (unlike this project's
other packages) — `nftables.SlackBuild` fetches Slackware's own official
`nftables` package and re-hosts it as-is under this project's naming and
checksum convention. It's already a correctly-built Slackware package
(built by the Slackware team for exactly this OS/glibc/arch); the
slack-desc bundled inside it travels along unchanged.
netavark >= 2.0 dropped its iptables firewall driver entirely — nftables
(via the `nft` binary this package provides) is the only firewall backend
that works on Unraid (firewalld needs systemd/dbus, which Unraid has
neither of). Unraid OS itself ships no `nft` binary.
Found by live-testing this plugin end-to-end against a real Unraid
install, not from reading netavark's docs — see
[docs/ARCHITECTURE.md, section 8](../../docs/ARCHITECTURE.md#8-netzwerke).
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# =============================================================================
# packages/nftables/nftables.SlackBuild
#
# Vendors Slackware's own official nftables package as-is — not rebuilt
# from source, see versions.env's NFTABLES_* block for why. Unlike every
# other package here, there is no compile step: the fetched .txz is
# already a correctly-built Slackware package (built by the Slackware
# team for exactly this OS/glibc/arch), so it is re-hosted under this
# project's naming/checksum convention rather than unpacked and restaged
# through makepkg, which would add risk (differing compression/metadata)
# for no benefit.
#
# netavark >= 2.0 requires the nftables firewall driver (its iptables
# driver was removed entirely) but Unraid OS ships no `nft` binary — see
# config/containers.conf and docs/ARCHITECTURE.md section 8. Without this
# package, every `podman run`/`podman pod create` that touches networking
# fails with "netavark: Must provide a valid firewall backend" (found by
# live-testing against a real Unraid install).
# =============================================================================
set -eu
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION="$NFTABLES_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "nftables"
official_pkg=$(sb_fetch_and_verify "$NFTABLES_SRC_URL" "$NFTABLES_SRC_SHA256" "nftables-$VERSION-official.txz")
pkg_file="nftables-$VERSION-$ARCH-$BUILD$TAG.txz"
cp "$official_pkg" "$OUTPUT/$pkg_file"
( cd "$OUTPUT" && sha256sum "$pkg_file" > "$pkg_file.sha256" )
( cd "$OUTPUT" && md5sum "$pkg_file" > "$pkg_file.md5" )
echo "==> [nftables] vendored official Slackware package as $OUTPUT/$pkg_file"
+34
View File
@@ -0,0 +1,34 @@
# packages/podman-compose/
Pinned versions: see `PODMAN_COMPOSE_VERSION`/`PYYAML_VERSION`/
`PYTHON_DOTENV_VERSION` in [versions.env](../../versions.env).
`podman compose` (backing `webui/plugins/podman/ajax/compose.php`, the
WebUI's Compose panel) has no compose implementation of its own — it
needs an external "compose provider" command. This project previously
vendored `docker/compose` (the Go CLI-plugin binary) for that role;
this package replaces it with `podman-compose` instead.
The two aren't discovered the same way — verified live against a real
podman install (placing a fake executable and reading podman's own
provider-search error output): `docker-compose` is searched for by exact
path across a fixed list of CLI-plugin directories, while `podman-compose`
is looked up as a plain command on `$PATH`. That's why this package
installs to `/usr/local/bin/podman-compose` rather than under any
`cli-plugins/` directory.
Unlike `docker-compose`, `podman-compose` is a single Python script, not a
compiled binary. Unraid ships Python3 itself but neither of its two
runtime dependencies, so this package also vendors:
- `PyYAML` — only the pure-Python `yaml/` package, not the `_yaml` C
extension (which would need libyaml plus a compiler). `yaml/__init__.py`
falls back gracefully when the C accelerator isn't importable, so the
pure-Python source is sufficient for what podman-compose needs from it.
- `python-dotenv` — pure Python throughout, no C extensions at all.
Verified end-to-end on a real Unraid host: the vendored bundle correctly
runs `podman compose up`/`ps`/`down` against a real compose project
(with the pre-existing `docker-compose` binary temporarily moved aside
to confirm `podman-compose` was the one actually being invoked, not a
leftover), including a live HTTP check against the started service.
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# =============================================================================
# packages/podman-compose/podman-compose.SlackBuild
#
# Packages podman-compose (github.com/containers/podman-compose) — the
# external "compose provider" `podman compose` shells out to (see
# versions.env's PODMAN_COMPOSE_* block for the full story, including why
# this replaces the project's earlier vendored docker-compose). Verified
# live against a real podman install that podman-compose is looked up as a
# plain $PATH command, unlike docker-compose's fixed CLI-plugin-directory
# search — so this installs a wrapper at /usr/local/bin/podman-compose.
#
# podman-compose itself is a single Python script (not a compiled binary),
# with two runtime dependencies — PyYAML and python-dotenv — vendored here
# as plain pure-Python source (no C extension build) since Unraid ships
# Python3 but neither of those modules.
# =============================================================================
set -eu
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION="$PODMAN_COMPOSE_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "podman-compose"
script=$(sb_fetch_and_verify "$PODMAN_COMPOSE_SRC_URL" "$PODMAN_COMPOSE_SRC_SHA256" "podman_compose-$VERSION.py")
pyyaml_tarball=$(sb_fetch_and_verify "$PYYAML_SRC_URL" "$PYYAML_SRC_SHA256" "pyyaml-$PYYAML_VERSION.tar.gz")
dotenv_tarball=$(sb_fetch_and_verify "$PYTHON_DOTENV_SRC_URL" "$PYTHON_DOTENV_SRC_SHA256" "python-dotenv-$PYTHON_DOTENV_VERSION.tar.gz")
libdir="$PKG/usr/local/lib/podman-compose"
mkdir -p "$libdir"
install -m 0644 "$script" "$libdir/podman_compose.py"
# Only the pure-Python "yaml" package, not the "_yaml" C extension (which
# would need libyaml plus a compiler toolchain this build doesn't otherwise
# require) — see the header comment on why the pure-Python fallback is
# sufficient for what podman-compose actually needs from it.
tar -xzf "$pyyaml_tarball" -C "$TMP" "pyyaml-$PYYAML_VERSION/lib/yaml"
cp -r "$TMP/pyyaml-$PYYAML_VERSION/lib/yaml" "$libdir/yaml"
tar -xzf "$dotenv_tarball" -C "$TMP" "python_dotenv-$PYTHON_DOTENV_VERSION/src/dotenv"
cp -r "$TMP/python_dotenv-$PYTHON_DOTENV_VERSION/src/dotenv" "$libdir/dotenv"
# A thin wrapper, not a symlink or bare shebang: podman_compose.py's own
# shebang (whatever upstream wrote, a plain "#!/usr/bin/env python3") has
# no idea the vendored yaml/dotenv sit right next to it, so PYTHONPATH has
# to be set by whatever actually invokes the script.
install -d "$PKG/usr/local/bin"
cat > "$PKG/usr/local/bin/podman-compose" <<'WRAPPER'
#!/bin/sh
exec env PYTHONPATH="/usr/local/lib/podman-compose${PYTHONPATH:+:$PYTHONPATH}" \
/usr/bin/python3 /usr/local/lib/podman-compose/podman_compose.py "$@"
WRAPPER
chmod 0755 "$PKG/usr/local/bin/podman-compose"
docdir="$PKG/usr/doc/podman-compose-$VERSION"
mkdir -p "$docdir"
{
echo "podman-compose $VERSION"
echo "https://github.com/containers/podman-compose"
echo
echo "Vendored alongside its two runtime dependencies (bundled as plain"
echo "pure-Python source, no C extensions built):"
echo " PyYAML $PYYAML_VERSION - https://pypi.org/project/PyYAML/"
echo " python-dotenv $PYTHON_DOTENV_VERSION - https://pypi.org/project/python-dotenv/"
echo
echo "See versions.env for pinned source URLs and SHA256 checksums."
} > "$docdir/README"
{
echo "Built by unraid-podman from upstream source."
echo "Package: podman-compose $VERSION"
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$docdir/unraid-podman.build-info"
sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG"
+19
View File
@@ -0,0 +1,19 @@
# HOW TO EDIT THIS FILE:
# The "handy ruler" below makes it easier to edit a package description.
# Line up the first '|' above the ':' following the base package name, and
# the '|' on the right side marks the last column you can put a character in.
# You must make exactly 11 lines for the formatting to be correct. It's also
# customary to leave one space after the ':' except on otherwise blank lines.
|-----handy-ruler------------------------------------------------|
podman-compose: podman-compose (external Compose provider for podman compose)
podman-compose:
podman-compose: A Python script that implements Docker Compose file support
podman-compose: on top of podman, installed to /usr/local/bin so
podman-compose: `podman compose` finds it as its external provider.
podman-compose: Required by the WebUI's Compose panel. Bundled with its
podman-compose: two runtime dependencies (PyYAML, python-dotenv) as plain
podman-compose: pure-Python source.
podman-compose:
podman-compose: Homepage: https://github.com/containers/podman-compose
podman-compose:
+12
View File
@@ -50,6 +50,18 @@ export BUILDTAGS="seccomp exclude_graphdriver_btrfs exclude_graphdriver_devicema
export CGO_ENABLED=1
export GOFLAGS="${GOFLAGS:--mod=mod}"
# podman's own Makefile wants to build ITS bundled copy of go-md2man from
# test/tools/vendor/ for doc generation, but that vendor tree isn't
# consistent enough to build standalone outside podman's own module scope
# ("without -mod=vendor, directory ... has no package path"). Its Makefile
# only attempts that build if test/tools/build/go-md2man doesn't already
# exist — pre-seed it with our own system go-md2man (installed in
# scripts/ci/setup-slackware-buildenv.sh) to skip the broken vendor build
# entirely; it's the same tool doing the same job.
mkdir -p test/tools/build
cp "$(command -v go-md2man)" test/tools/build/go-md2man
chmod +x test/tools/build/go-md2man
echo "==> [podman] make (BUILDTAGS=$BUILDTAGS)"
make BUILDTAGS="$BUILDTAGS" GO_BUILD_FLAGS="-ldflags -s"
+1 -1
View File
@@ -1,7 +1,7 @@
# packages/unraid-podman
Packaging recipe for the plugin's own scaffolding — **not** an upstream
component like the other seven package directories. See
component or vendored dependency like the other ten package directories. See
`unraid-podman.SlackBuild`'s header comment for the full rationale.
Bundles:
+15 -10
View File
@@ -2,15 +2,15 @@
# =============================================================================
# packages/unraid-podman/unraid-podman.SlackBuild
#
# Unlike the other seven packages, this one does not compile anything from
# an external upstream source — it packages THIS repository's own plugin
# scaffolding (rc.podman, the sbin/ helper scripts, the official Unraid
# event/ hooks, and the default config templates) into a single .txz,
# exactly matching how real-world Unraid plugins bundle their own files
# (verified against the actual unassigned.devices.plg / package layout —
# see docs/ARCHITECTURE.md section 3.2 for the reference check that led to
# this design). plugin/podman.plg installs this alongside the seven
# compiled component packages, all via the same
# Unlike the other ten packages, this one does not fetch anything from
# an external upstream source at all — it packages THIS repository's own
# plugin scaffolding (rc.podman, the sbin/ helper scripts, the official
# Unraid event/ hooks, and the default config templates) into a single
# .txz, exactly matching how real-world Unraid plugins bundle their own
# files (verified against the actual unassigned.devices.plg / package
# layout — see docs/ARCHITECTURE.md section 3.2 for the reference check
# that led to this design). plugin/podman.plg installs this alongside the
# other ten packages, all via the same
# `upgradepkg --install-new --reinstall` mechanism.
#
# Version: taken directly from plugin/podman.plg's own <!ENTITY version>,
@@ -28,7 +28,12 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION=$(grep -oP '(?<=<!ENTITY version[[:space:]]{1,10}")[^"]+' "$REPO_ROOT/plugin/podman.plg" | head -n1)
# sed, not `grep -oP` with a lookbehind: the lookbehind here needed a
# variable-length quantifier ({1,10}) to match the entity's flexible
# whitespace, but that requires PCRE2 — the PCRE1 grep ships on Slackware
# (and many other distros) rejects it outright ("lookbehind assertion is
# not fixed length"). sed's basic regex has no such restriction.
VERSION=$(sed -n 's/.*<!ENTITY version[[:space:]]\+"\([^"]*\)".*/\1/p' "$REPO_ROOT/plugin/podman.plg" | head -n1)
if [ -z "$VERSION" ]; then
echo "!! Could not read <!ENTITY version> from $REPO_ROOT/plugin/podman.plg" >&2
exit 1
+106 -86
View File
@@ -32,14 +32,16 @@
Structure of this file:
1. DOCTYPE entity block — plugin metadata + one version/file/md5 triple
per package (the seven upstream components plus this project's own
"unraid-podman" scaffolding package, see packages/unraid-podman/).
per package (the seven upstream components, catatonit/nftables/
podman-compose as vendored runtime dependencies, plus this
project's own "unraid-podman" scaffolding package, see
packages/unraid-podman/).
Entities are rewritten automatically by scripts/release.sh; never
hand-edit a *_txz_version/_file/_md5 entity — see that script.
2. <PLUGIN> body:
a. <CHANGES> — kept in sync with CHANGELOG.md by hand for now.
b. Pre-install architecture sanity check.
c. Eight <FILE> package install/update blocks.
c. Eleven <FILE> package install/update blocks.
d. Postinstall <FILE Run="/bin/bash"> — directory/config seeding,
install-manifest generation, first start.
e. <FILE Run="/bin/bash" Method="remove"> — uninstall.
@@ -48,25 +50,31 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "podman">
<!ENTITY author "unraid-podman contributors">
<!ENTITY version "0.0.0">
<!ENTITY version "0.1.1">
<!-- "Podman" (no parent) — Podman.page declares Menu="Podman", making it
its own top-level nav tab next to Docker/VMs, not nested under
Settings — see webui/plugins/podman/Podman.page. -->
<!ENTITY launch "Podman">
<!ENTITY github "OWNER/unraid-podman">
<!ENTITY gitURL "https://raw.githubusercontent.com/&github;/main">
<!-- This project is hosted on a self-hosted Gitea instance, not GitHub —
&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 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
exactly; both must agree since release.sh is what publishes the
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.1">
<!-- Slackware package naming components — must match versions.env's
PKG_ARCH/PKG_BUILD/PKG_TAG (see that file). Kept as entities here so
the eight removepkg calls in the Method="remove" block don't have to
repeat "x86_64-1_unraidpodman" eight times by hand. -->
the eleven removepkg calls in the Method="remove" block don't have to
repeat "x86_64-1_unraidpodman" eleven times by hand. -->
<!ENTITY pkgArch "x86_64">
<!ENTITY pkgBuild "1">
<!ENTITY pkgTag "_unraidpodman">
@@ -78,33 +86,54 @@
fail to download anything — that is intentional; there is nothing to
install before the first tagged release. -->
<!ENTITY podman_txz_version "0.0.0">
<!ENTITY podman_txz_file "podman-&podman_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY podman_txz_md5 "00000000000000000000000000000000">
<!ENTITY podman_txz_version "6.0.1">
<!ENTITY podman_txz_file "podman-6.0.1-x86_64-1_unraidpodman.txz">
<!ENTITY podman_txz_md5 "4a9fdb25800fac506876903b09f64e7b">
<!ENTITY conmon_txz_version "0.0.0">
<!ENTITY conmon_txz_file "conmon-&conmon_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY conmon_txz_md5 "00000000000000000000000000000000">
<!ENTITY conmon_txz_version "2.2.1">
<!ENTITY conmon_txz_file "conmon-2.2.1-x86_64-1_unraidpodman.txz">
<!ENTITY conmon_txz_md5 "358136a2fbc8e629d50863466aed5cd3">
<!ENTITY crun_txz_version "0.0.0">
<!ENTITY crun_txz_file "crun-&crun_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY crun_txz_md5 "00000000000000000000000000000000">
<!ENTITY crun_txz_version "1.28">
<!ENTITY crun_txz_file "crun-1.28-x86_64-1_unraidpodman.txz">
<!ENTITY crun_txz_md5 "4108cca9a2e0673d1206e15a3d51cf73">
<!ENTITY netavark_txz_version "0.0.0">
<!ENTITY netavark_txz_file "netavark-&netavark_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY netavark_txz_md5 "00000000000000000000000000000000">
<!ENTITY netavark_txz_version "2.0.0">
<!ENTITY netavark_txz_file "netavark-2.0.0-x86_64-1_unraidpodman.txz">
<!ENTITY netavark_txz_md5 "4970584505c056fd18995daf2728cb56">
<!ENTITY aardvark_dns_txz_version "0.0.0">
<!ENTITY aardvark_dns_txz_file "aardvark-dns-&aardvark_dns_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY aardvark_dns_txz_md5 "00000000000000000000000000000000">
<!ENTITY aardvark_dns_txz_version "2.0.0">
<!ENTITY aardvark_dns_txz_file "aardvark-dns-2.0.0-x86_64-1_unraidpodman.txz">
<!ENTITY aardvark_dns_txz_md5 "168bbe9298db17fe51d6849b0b670dd4">
<!ENTITY passt_txz_version "0.0.0">
<!ENTITY passt_txz_file "passt-&passt_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY passt_txz_md5 "00000000000000000000000000000000">
<!ENTITY passt_txz_version "git6ef3d1c">
<!ENTITY passt_txz_file "passt-git6ef3d1c-x86_64-1_unraidpodman.txz">
<!ENTITY passt_txz_md5 "cedd2d4b4ff1c2c22b13947bb754d427">
<!ENTITY fuse_overlayfs_txz_version "0.0.0">
<!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-&fuse_overlayfs_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY fuse_overlayfs_txz_md5 "00000000000000000000000000000000">
<!ENTITY fuse_overlayfs_txz_version "1.17">
<!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-1.17-x86_64-1_unraidpodman.txz">
<!ENTITY fuse_overlayfs_txz_md5 "95bf694c5480be2069a581748b2dfb09">
<!-- catatonit and nftables are runtime dependencies this plugin ships,
not upstream podman-ecosystem components — see packages/catatonit/
and packages/nftables/ READMEs for why each is needed (pod infra
container init; netavark's only viable firewall driver, since it
dropped iptables in 2.0 and Unraid has neither `nft` nor
systemd/dbus for firewalld) and why neither is built from source. -->
<!ENTITY catatonit_txz_version "0.2.1">
<!ENTITY catatonit_txz_file "catatonit-0.2.1-x86_64-1_unraidpodman.txz">
<!ENTITY catatonit_txz_md5 "a3fe1f08981a7fb07337838ba7d00de3">
<!ENTITY nftables_txz_version "1.0.1">
<!ENTITY nftables_txz_file "nftables-1.0.1-x86_64-1_unraidpodman.txz">
<!ENTITY nftables_txz_md5 "d9bb93b0bdc061681ffbd42a243bb5fc">
<!-- podman-compose is the external Compose provider `podman compose`
shells out to (see packages/podman-compose/README.md) — without it
every Compose panel action fails outright on a clean install. -->
<!ENTITY podman_compose_txz_version "1.6.0">
<!ENTITY podman_compose_txz_file "podman-compose-1.6.0-x86_64-1_unraidpodman.txz">
<!ENTITY podman_compose_txz_md5 "a4e1bdbfe397f1b693815e003e371990">
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
sbin/ scripts, event/ hooks, config templates — see
@@ -112,9 +141,9 @@
version always equals the plugin's own &version; — see
packages/unraid-podman/unraid-podman.SlackBuild, which reads it
straight out of this very file rather than tracking it twice. -->
<!ENTITY unraid_podman_txz_version "&version;">
<!ENTITY unraid_podman_txz_file "unraid-podman-&unraid_podman_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY unraid_podman_txz_md5 "00000000000000000000000000000000">
<!ENTITY unraid_podman_txz_version "0.1.1">
<!ENTITY unraid_podman_txz_file "unraid-podman-0.1.1-x86_64-1_unraidpodman.txz">
<!ENTITY unraid_podman_txz_md5 "25e5933e113e09bfa07c0d5169ccac18">
]>
<PLUGIN name="&name;"
@@ -145,7 +174,7 @@
<!--
Pre-install sanity check: this project only builds/ships x86_64 packages
(see versions.env's PKG_ARCH) — fail with a clear message on any other
architecture rather than letting eight package downloads 404 one by one.
architecture rather than letting eleven package downloads 404 one by one.
-->
<FILE Run="/bin/bash">
<INLINE>
@@ -157,7 +186,9 @@ fi
</FILE>
<!--
The seven upstream component packages. Each is downloaded straight into
The seven upstream component packages, plus catatonit, nftables, and
podman-compose (runtime dependencies vendored as-is — see the entity
block above for why). Each is downloaded straight into
its backup slot under /boot/config/plugins/&name;/backup/packages/&version;/
(grouped by PLUGIN version, not each component's own version — a rollback
targets "this plugin release" as one unit, see
@@ -170,66 +201,53 @@ fi
-->
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&podman_txz_file;
</URL>
<MD5>
&podman_txz_md5;
</MD5>
<URL>&baseURL;/&podman_txz_file;</URL>
<MD5>&podman_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&conmon_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&conmon_txz_file;
</URL>
<MD5>
&conmon_txz_md5;
</MD5>
<URL>&baseURL;/&conmon_txz_file;</URL>
<MD5>&conmon_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&crun_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&crun_txz_file;
</URL>
<MD5>
&crun_txz_md5;
</MD5>
<URL>&baseURL;/&crun_txz_file;</URL>
<MD5>&crun_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&netavark_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&netavark_txz_file;
</URL>
<MD5>
&netavark_txz_md5;
</MD5>
<URL>&baseURL;/&netavark_txz_file;</URL>
<MD5>&netavark_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&aardvark_dns_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&aardvark_dns_txz_file;
</URL>
<MD5>
&aardvark_dns_txz_md5;
</MD5>
<URL>&baseURL;/&aardvark_dns_txz_file;</URL>
<MD5>&aardvark_dns_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&passt_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&passt_txz_file;
</URL>
<MD5>
&passt_txz_md5;
</MD5>
<URL>&baseURL;/&passt_txz_file;</URL>
<MD5>&passt_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&fuse_overlayfs_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&fuse_overlayfs_txz_file;
</URL>
<MD5>
&fuse_overlayfs_txz_md5;
</MD5>
<URL>&baseURL;/&fuse_overlayfs_txz_file;</URL>
<MD5>&fuse_overlayfs_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&catatonit_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>&baseURL;/&catatonit_txz_file;</URL>
<MD5>&catatonit_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&nftables_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>&baseURL;/&nftables_txz_file;</URL>
<MD5>&nftables_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>&baseURL;/&podman_compose_txz_file;</URL>
<MD5>&podman_compose_txz_md5;</MD5>
</FILE>
<!--
@@ -238,16 +256,12 @@ fi
packages/unraid-podman/README.md.
-->
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&unraid_podman_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&unraid_podman_txz_file;
</URL>
<MD5>
&unraid_podman_txz_md5;
</MD5>
<URL>&baseURL;/&unraid_podman_txz_file;</URL>
<MD5>&unraid_podman_txz_md5;</MD5>
</FILE>
<!--
Postinstall: everything that has to happen AFTER the eight packages above
Postinstall: everything that has to happen AFTER the eleven packages above
are on disk, but isn't itself package content — directory/config
seeding, the install-manifest that plugin/sbin/podman-verify-packages.sh
and podman-update-packages.sh read, and the first start. Runs on both
@@ -274,7 +288,7 @@ set -u
echo "Setting permissions..."
chmod 0755 /etc/rc.d/rc.podman
chmod 0755 /usr/local/sbin/podman-*.sh
chmod 0755 /usr/local/sbin/*.sh
chmod 0755 /usr/local/emhttp/plugins/podman/event/disks_mounted
chmod 0755 /usr/local/emhttp/plugins/podman/event/stopping
@@ -291,6 +305,9 @@ echo "NETAVARK_INSTALLED_VERSION=\"&netavark_txz_version;\"" >> "$MANIFEST"
echo "AARDVARK_DNS_INSTALLED_VERSION=\"&aardvark_dns_txz_version;\"" >> "$MANIFEST"
echo "PASST_INSTALLED_VERSION=\"&passt_txz_version;\"" >> "$MANIFEST"
echo "FUSE_OVERLAYFS_INSTALLED_VERSION=\"&fuse_overlayfs_txz_version;\"" >> "$MANIFEST"
echo "CATATONIT_INSTALLED_VERSION=\"&catatonit_txz_version;\"" >> "$MANIFEST"
echo "NFTABLES_INSTALLED_VERSION=\"&nftables_txz_version;\"" >> "$MANIFEST"
echo "PODMAN_COMPOSE_INSTALLED_VERSION=\"&podman_compose_txz_version;\"" >> "$MANIFEST"
echo "UNRAID_PODMAN_INSTALLED_VERSION=\"&unraid_podman_txz_version;\"" >> "$MANIFEST"
echo "Seeding /boot/config/plugins/podman/ configuration (existing files left untouched)..."
@@ -351,6 +368,9 @@ removepkg &netavark_txz_file;
removepkg &aardvark_dns_txz_file;
removepkg &passt_txz_file;
removepkg &fuse_overlayfs_txz_file;
removepkg &catatonit_txz_file;
removepkg &nftables_txz_file;
removepkg &podman_compose_txz_file;
removepkg &unraid_podman_txz_file;
echo ""
+1 -1
View File
@@ -98,7 +98,7 @@ podman_start() {
# a shared process gives consistent state and event streaming.
# --time=0 disables the idle-shutdown timeout (this is a long-running
# daemon under our process management, not an on-demand activation).
mkdir -p "$PODMAN_RUN_DIR"
mkdir -p "$PODMAN_RUN_DIR" "$PODMAN_LOG_DIR"
podman_log "start: starting podman system service on unix://$PODMAN_SOCKET"
nohup podman system service --time=0 "unix://$PODMAN_SOCKET" \
> "$PODMAN_LOG_DIR/podman-service.log" 2>&1 &
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# =============================================================================
# plugin/sbin/crun-no-pivot.sh
#
# Thin OCI-runtime wrapper around crun, injecting --no-pivot on `create`/
# `run`. Unraid's / is the kernel's initial "rootfs" pseudo-filesystem —
# Unraid never pivots to a real one during boot, the whole OS runs from
# RAM — and pivot_root(2) unconditionally rejects that as the "old root"
# (EINVAL). runc (what Docker uses) silently falls back to an MS_MOVE-based
# chroot in that situation; crun has no such fallback and no config-file
# equivalent, only this per-invocation flag — so config/containers.conf
# points podman's crun runtime at this wrapper instead of crun directly.
# See docs/ARCHITECTURE.md section 8.
#
# Verified live: without this, every container fails with
# "crun: pivot_root: Invalid argument: OCI runtime error".
#
# --no-pivot is only a valid flag on crun's create/run subcommands (see
# `crun run --help`) — every other subcommand (delete, exec, kill, list,
# ...) is passed straight through unmodified. podman invokes crun with its
# own global flags BEFORE the subcommand (e.g.
# `crun --log-format=json --log <path> create --bundle <path> ...`), so
# the subcommand is not reliably $1 — scan every argument instead of only
# checking the first one, and insert --no-pivot immediately after
# create/run wherever it actually appears.
# =============================================================================
args=()
found=0
for arg in "$@"; do
args+=("$arg")
if [ "$found" -eq 0 ] && { [ "$arg" = create ] || [ "$arg" = run ]; }; then
args+=("--no-pivot")
found=1
fi
done
exec /usr/bin/crun "${args[@]}"
+2 -2
View File
@@ -23,7 +23,7 @@
# needed — see docs/ARCHITECTURE.md section 13.1.
#
# Usage:
# podman-update-packages.sh # reconcile all 7 packages
# podman-update-packages.sh # reconcile all 11 packages
# podman-update-packages.sh podman # reconcile a single package
# =============================================================================
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman"
ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
if [ ! -f "$INSTALLED_VERSIONS_FILE" ]; then
podman_log_error "update-packages: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found"
+3 -3
View File
@@ -2,7 +2,7 @@
# =============================================================================
# plugin/sbin/podman-verify-packages.sh
#
# "Pakete prüfen" — verifies the seven packages this plugin ships are
# "Pakete prüfen" — verifies the eleven packages this plugin ships are
# actually installed, at the version the plugin expects, and that the
# backed-up .txz copies (see podman-backup.sh) haven't bit-rotted on disk.
#
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman"
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
QUIET=0
[ "${1:-}" = "--quiet" ] && QUIET=1
@@ -95,7 +95,7 @@ for name in $PACKAGES; do
esac
# --- Check 3: backup artifact integrity, if present --------------------
# Packages of all 7 components released together as one plugin version
# Packages of all components released together as one plugin version
# are grouped under a single PLUGIN_VERSION directory (not per-component
# version) — a rollback targets "go back to plugin release X", matching
# podman-backup.sh's restore-packages <plugin-version>.
+354
View File
@@ -0,0 +1,354 @@
# Unraid Podman Plugin Anforderungen an die WebUI
Du bist ein Senior UX Designer und Senior Full-Stack Entwickler mit Erfahrung in Unraid, Podman und Self-Hosting.
Entwirf eine moderne, performante und vollständig in Unraid integrierte WebUI für ein natives Podman-Plugin.
Die Oberfläche soll sich optisch und funktional wie ein offizieller Bestandteil von Unraid anfühlen und keine Fremdanwendung darstellen.
## Ziel
Die WebUI soll alle Funktionen bieten, die Administratoren für die Verwaltung von Podman benötigen, ohne auf die Kommandozeile angewiesen zu sein.
Die Bedienung soll sowohl für Einsteiger als auch für erfahrene Nutzer geeignet sein.
---
# Allgemeine Anforderungen
* Responsives Design
* Dark-Mode kompatibel
* Passend zum Unraid-Design
* Sehr schnelle Ladezeiten
* Modular aufgebaut
* Erweiterbar
* AJAX/WebSocket statt kompletter Seitenreloads
* Suchfunktion auf allen Listen
* Sortierung aller Tabellen
* Filtermöglichkeiten
* Mehrfachauswahl
* Kontextmenüs
* Bestätigungsdialoge
* Benachrichtigungen (Success, Warning, Error)
* Ladeanimationen
* Fortschrittsanzeigen
* Automatische Aktualisierung wichtiger Statusinformationen
* Mehrsprachigkeit vorbereiten
---
# Dashboard
Das Dashboard soll auf einen Blick den Zustand der gesamten Podman-Umgebung zeigen.
Anzeigen:
* Anzahl Container
* Laufende Container
* Gestoppte Container
* Pods
* Images
* Netzwerke
* Volumes
* CPU-Auslastung
* RAM-Auslastung
* Storage-Auslastung
* Schreib-/Lese-I/O
* Netzwerkdurchsatz
* Podman-Version
* Conmon-Version
* crun-Version
* Netavark-Version
* Plugin-Version
Zusätzlich:
* Letzte Ereignisse
* Fehler
* Warnungen
* Updates verfügbar
* Container mit Problemen
* Container ohne Healthcheck
* Nicht verwendete Images
* Nicht verwendete Volumes
* Nicht verwendete Netzwerke
---
# Containerverwaltung
Für jeden Container:
* Start
* Stop
* Restart
* Pause
* Resume
* Kill
* Löschen
* Umbenennen
* Duplizieren
* Exportieren
* Commit als Image
* Snapshot (falls unterstützt)
Informationen:
* Name
* Image
* Status
* Uptime
* CPU
* RAM
* PID
* IP-Adresse
* Netzwerk
* Ports
* Volumes
* Labels
* Environment
* Health Status
* Restart Policy
Tabs:
* Übersicht
* Logs
* Konsole (TTY)
* Ressourcen
* Netzwerke
* Volumes
* Environment
* Mounts
* Labels
* Events
* Inspect (JSON)
* Healthcheck-Verlauf
---
# Pod-Verwaltung
* Pod erstellen
* Pod löschen
* Container hinzufügen
* Container entfernen
* Pod starten
* Pod stoppen
* Neustarten
* Logs
* Portübersicht
* Netzwerkübersicht
---
# Image-Verwaltung
* Images anzeigen
* Pull
* Push
* Taggen
* Löschen
* Prune
* Registry auswählen
* Größe anzeigen
* Erstellungsdatum
* Layer anzeigen
* Historie anzeigen
* Sicherheitsinformationen (wenn verfügbar)
---
# Registry-Unterstützung
Unterstützung für:
* Docker Hub
* GitHub Container Registry
* Quay.io
* GitLab Registry
* Harbor
* Eigene Registries
Funktionen:
* Login
* Logout
* Zugangsdaten speichern
* TLS-Konfiguration
* Unsichere Registry optional aktivieren
---
# Netzwerke
* Netzwerke erstellen
* Löschen
* Bearbeiten
* Verbundene Container anzeigen
* Treiber anzeigen
* Subnetz
* Gateway
* DNS
* MTU
* VLAN (falls unterstützt)
---
# Volumes
* Erstellen
* Löschen
* Mountpunkte anzeigen
* Speicherverbrauch
* Zugeordnete Container
* Backup starten
* Wiederherstellen
* Exportieren
---
# Compose-Unterstützung
Unterstützung für:
* podman compose
Funktionen:
* Compose-Datei importieren
* Stack erstellen
* Start
* Stop
* Neustart
* Logs
* Bearbeiten
* Aktualisieren
* Löschen
* YAML-Editor mit Syntax-Highlighting
* Validierung vor dem Speichern
---
# Template-Unterstützung
Kompatibilität mit Unraid-Templates.
Unterstützung für:
* XML importieren
* XML exportieren
* Template erstellen
* Template bearbeiten
* Icons
* Kategorien
* Repository-Links
---
# Container-Erstellung
Wizard mit mehreren Schritten:
* Name
* Image auswählen
* Registry auswählen
* Ports
* Volumes
* Environment
* Labels
* Geräte
* GPU
* USB
* Netzwerk
* Ressourcen
* Healthcheck
* Restart Policy
* Zusammenfassung
* Validierung
* Container erzeugen
---
# Logs
* Live-Logs
* Suchfunktion
* Filter
* Zeitfilter
* Auto-Scroll
* Download
* Kopieren
* ANSI-Farben korrekt darstellen
---
# Konsole
Browser-Terminal mit:
* Vollbildmodus
* Kopieren/Einfügen
* Mehrere Sitzungen
* Größenanpassung
* UTF-8-Unterstützung
---
# Ressourcenverwaltung
Container-Limits bearbeiten:
* CPU
* RAM
* Swap
* PIDs
* Block-I/O
* Geräte
* HugePages (falls unterstützt)
Visualisierung:
* CPU
* RAM
* Netzwerk
* Festplatten-I/O
---
# Ereignisse
Live-Event-Ansicht:
* Container gestartet
* Container gestoppt
* Image geladen
* Fehler
* Netzwerkänderungen
* Volumeänderungen
Filter nach:
* Container
* Typ
* Zeitraum
* Schweregrad
---
# Einstellungen
Plugin-Einstellungen:
* Storage-Pfad
* API
* Socket
* Registry-Einstellungen
* Standardnetzwerk
* Standard-Runtime
* Logging
* Autostart
* Updates
* Debugmodus
---
+17 -10
View File
@@ -2,10 +2,11 @@
# =============================================================================
# scripts/build-packages.sh
#
# Orchestrates building all seven Slackware .txz packages this plugin ships
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs), by
# running each package's <name>.SlackBuild in turn. See
# docs/ARCHITECTURE.md section 5.2 (Build-Strategie).
# Orchestrates building all Slackware .txz packages this plugin ships
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
# catatonit, nftables, podman-compose), by running each package's
# <name>.SlackBuild in turn. See docs/ARCHITECTURE.md section 5.2
# (Build-Strategie).
#
# This script itself does not containerize anything — it assumes it is
# already running inside a Slackware-compatible build environment (see
@@ -37,13 +38,19 @@ DIST_DIR="$REPO_ROOT/dist"
# see packages/unraid-podman/README.md) is built last since it's by far the
# fastest and has nothing useful to report on failure that earlier package
# failures wouldn't already explain.
ALL_PACKAGES=(conmon crun netavark aardvark-dns passt fuse-overlayfs podman unraid-podman)
ALL_PACKAGES=(catatonit nftables podman-compose conmon crun netavark aardvark-dns passt fuse-overlayfs podman unraid-podman)
# Podman is listed second-to-last on purpose: among the seven upstream
# components it is the slowest build and the one most likely to fail on a
# dependency/tag mistake, so faster packages surface problems first during
# local iteration. unraid-podman is genuinely last since it packages this
# repo's own files and has no compile step at all.
# catatonit, nftables, and podman-compose are listed first since none of
# them involve a compiler — catatonit/nftables are a plain
# fetch-and-repackage of an already-built upstream artifact, and
# podman-compose is vendored pure-Python source with nothing to compile
# (see their own README.md/SlackBuild for why) — fastest possible signal
# if a pinned URL/checksum in versions.env ever goes stale.
# Podman is listed second-to-last on purpose: among the compiled
# components it is the slowest build and the one most likely to fail on
# a dependency/tag mistake, so faster packages surface problems first
# during local iteration. unraid-podman is genuinely last since it
# packages this repo's own files and has no compile step at all.
requested=("$@")
if [ "${#requested[@]}" -eq 0 ]; then
+13 -1
View File
@@ -16,7 +16,7 @@ GO_VERSION="1.26.5"
GO_SRC_URL="https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz"
# Checksum as published by go.dev itself (`curl -s https://go.dev/dl/?mode=json`)
# — must be re-derived from the same source whenever GO_VERSION changes.
GO_SRC_SHA256="88c162b204e6eefcc32499453b492e80209f4a4c78c33092636901c540fb0d05"
GO_SRC_SHA256="5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053"
# Rust toolchain (builds netavark, aardvark-dns) — installed via rustup
# rather than a pinned tarball, since rustup itself provides reproducible,
@@ -44,3 +44,15 @@ LIBSECCOMP_SRC_SHA256="f9a13e4c633d319a9240189760ca348caa0837c0ebe2a09b17061da8c
YAJL_VERSION="2.1.0"
YAJL_SRC_URL="https://github.com/lloyd/yajl/archive/refs/tags/${YAJL_VERSION}.tar.gz"
YAJL_SRC_SHA256="3fb73364a5a30efe615046d07e6db9d09fd2b41c763c5f7d3bfb121cd5c5ac5a"
# protoc (netavark's build.rs shells out to it via the prost-build crate) —
# Slackware ships no protobuf/protoc package at all, official prebuilt
# release binary used instead of a from-source C++ build.
PROTOC_VERSION="35.1"
PROTOC_SRC_URL="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip"
PROTOC_SRC_SHA256="6930ebf62bd4ea607b98fff052596c6ee564b9835b4ce172c75a3f53ae9d91b7"
# go-md2man (conmon's `make install` shells out to it to generate its man
# page) — installed via `go install`, pinned to a tagged release rather
# than @latest so this build stays reproducible.
GO_MD2MAN_VERSION="2.0.7"
+117 -23
View File
@@ -3,16 +3,25 @@
# scripts/ci/setup-slackware-buildenv.sh
#
# Prepares a Slackware container (see .github/workflows/build-packages.yml)
# to build all seven packages under packages/. Idempotent and safe to re-run.
# to build all eleven packages under packages/. Idempotent and safe to re-run.
#
# Strategy: detect what's already present (a stock "full" Slackware 15.0
# install already provides gcc/make/autotools/glib2/libcap/fuse3) and only
# bootstrap what's genuinely missing (libseccomp, yajl — neither ships in
# stock Slackware — plus the Go and Rust toolchains, which no Slackware
# install ships). This makes the script tolerant of small differences
# between Slackware base image variants instead of assuming one exact image
# layout, while still failing loudly if something we cannot self-provision
# (a C compiler, basically) is missing.
# Strategy: vbatts/slackware:15.0 (the image build-packages.yml runs this
# in) is a minimal rootfs — it ships none of the 'D' (development) series,
# nor glib2/libcap/fuse3/curl. Step 0 below uses slackpkg (already present
# and pre-configured with a mirror in that image) to install the toolchain
# packages by name. Slackware packages carry no dependency metadata at all
# (unlike apt/dnf), so slackpkg does NOT resolve dependencies — the list
# below must name every package explicitly, including curl's HTTPS
# runtime libs (nghttp2, brotli, cyrus-sasl), or you get a shared-library
# error at the first invocation, not an install-time failure. What's left
# after this (libseccomp, yajl, protoc — none ship in stock Slackware —
# plus the Go/Rust toolchains and go-md2man, which no Slackware install
# ships) is bootstrapped from source or official upstream releases,
# further down. This makes the script tolerant
# of small differences between Slackware base image variants (it skips
# anything slackpkg reports as already installed) instead of assuming one
# exact image layout, while still failing loudly if something we cannot
# self-provision is missing.
#
# Exits non-zero with a clear message if a required tool cannot be found or
# provisioned — this script is meant to run early in CI so failures surface
@@ -49,6 +58,31 @@ require_pkgconfig() {
return 0
}
# -----------------------------------------------------------------------------
# 0. Bootstrap the Slackware toolchain packages via slackpkg, if missing.
#
# CHECKGPG is turned off here: slackpkg's default GPG-key bootstrap fetches
# Slackware's signing key from www.slackware.com, which is not reachable
# from every CI network (observed to hang/fail on the self-hosted Gitea
# Actions runner this project builds on). slackpkg's CHECKMD5 (on by
# default) still verifies every package against the mirror's own
# CHECKSUMS.md5 as a transit-integrity check. This is build-toolchain
# provisioning, not the shipped artifacts — those are independently
# checksummed by scripts/checksums.sh.
# -----------------------------------------------------------------------------
if command -v slackpkg > /dev/null 2>&1; then
echo "==> bootstrapping build toolchain via slackpkg"
sed -i 's/^CHECKGPG=on/CHECKGPG=off/' /etc/slackpkg/slackpkg.conf
slackpkg -batch=on -default_answer=y update
slackpkg -batch=on -default_answer=y install \
gcc gcc-g++ binutils make m4 perl autoconf automake libtool pkg-config \
curl nghttp2 brotli cyrus-sasl ca-certificates glib2 libcap fuse3 \
cmake libarchive lz4 libxml2 guile gc kernel-headers flex elfutils \
python3 json-c
else
echo "==> slackpkg not found, assuming toolchain is already provided by the base image"
fi
# -----------------------------------------------------------------------------
# 1. Baseline toolchain expected to already be present in the base image.
# -----------------------------------------------------------------------------
@@ -62,6 +96,7 @@ require_binary git "Needed to fetch crun's git submodules."
require_binary curl "Needed to fetch pinned source tarballs."
require_binary makepkg "Slackware's own packaging tool (pkgtools); should always be present."
require_binary strip "Part of binutils; part of Slackware's 'D' series."
require_binary python3 "Needed by crun's configure script (checks for Python >= 3)."
# -----------------------------------------------------------------------------
# 2. C library dependencies expected to already be present.
@@ -69,6 +104,7 @@ require_binary strip "Part of binutils; part of Slackware's 'D' series."
require_pkgconfig glib-2.0 "Install Slackware's glib2 package (needed by conmon)."
require_pkgconfig libcap "Install Slackware's libcap package (needed by crun)." || true
require_pkgconfig fuse3 "Install Slackware's fuse3 package (needed by fuse-overlayfs)." || true
require_pkgconfig json-c "Install Slackware's json-c package (needed by crun, >= 0.14)." || true
# -----------------------------------------------------------------------------
# 3. libseccomp — not part of stock Slackware, build from source if missing.
@@ -103,7 +139,9 @@ if ! pkg-config --exists yajl 2>/dev/null; then
exit 1
}
mkdir -p "$d/src" && tar -xf "$d/src.tar.gz" -C "$d/src" --strip-components=1
# yajl uses its own cmake-free ./configure wrapper script.
# yajl's ./configure is a thin wrapper around CMake (not a cmake-free
# autoconf script, despite its name) — cmake must already be on PATH,
# see the slackpkg install list in step 0 above.
( cd "$d/src" && ./configure -p /usr && make -C build install )
ldconfig 2>/dev/null || true
else
@@ -113,27 +151,54 @@ fi
# -----------------------------------------------------------------------------
# 5. Go toolchain (podman) — official upstream tarball.
# -----------------------------------------------------------------------------
if ! command -v go > /dev/null 2>&1; then
echo "==> Go not found, installing $GO_VERSION"
curl -fL --retry 3 -o "$WORK/go.tar.gz" "$GO_SRC_URL"
actual=$(sha256sum "$WORK/go.tar.gz" | awk '{print $1}')
[ "$actual" = "$GO_SRC_SHA256" ] || {
# Always install our pinned Go, unconditionally — do NOT skip this just
# because `command -v go` finds something. On this image, slackpkg's
# batch-mode "install gcc" (step 0 above) pulls in every gcc-<lang>
# sibling package built from the same Slackware gcc SlackBuild, including
# gcc-go, which ships an ancient bundled Go (gccgo, go1.16.5) at
# /usr/bin/go — old enough that its go.mod parser rejects the 3-component
# "go 1.25.x" directive modern modules use, and would silently shadow our
# intended $GO_VERSION if we only installed when `go` was missing.
# Overwriting /usr/local/go and prepending it to PATH/GITHUB_PATH here
# guarantees the pinned toolchain wins regardless of what else provides a
# `go` binary.
echo "==> installing Go $GO_VERSION (unconditionally, see comment above)"
curl -fL --retry 3 -o "$WORK/go.tar.gz" "$GO_SRC_URL"
actual=$(sha256sum "$WORK/go.tar.gz" | awk '{print $1}')
[ "$actual" = "$GO_SRC_SHA256" ] || {
echo "!! Go toolchain checksum mismatch (expected $GO_SRC_SHA256, got $actual)" >&2
exit 1
}
rm -rf /usr/local/go
tar -C /usr/local -xf "$WORK/go.tar.gz"
export PATH="/usr/local/go/bin:$PATH"
# Persist PATH for subsequent steps in the same GitHub Actions job.
if [ -n "${GITHUB_PATH:-}" ]; then
}
rm -rf /usr/local/go
tar -C /usr/local -xf "$WORK/go.tar.gz"
export PATH="/usr/local/go/bin:$PATH"
# Persist PATH for subsequent steps in the same GitHub Actions job.
if [ -n "${GITHUB_PATH:-}" ]; then
echo "/usr/local/go/bin" >> "$GITHUB_PATH"
fi
echo "==> using: $(go version)"
# -----------------------------------------------------------------------------
# 6. go-md2man (conmon) — its `make install` shells out to this to render
# docs/conmon.8.md into a man page; not packaged by Slackware, and no
# prebuilt binary release exists upstream, so `go install` it (now that
# our pinned Go from step 5 is on PATH). Pinned to a tagged release
# rather than @latest to keep this build reproducible.
# -----------------------------------------------------------------------------
if ! command -v go-md2man > /dev/null 2>&1; then
echo "==> installing go-md2man v$GO_MD2MAN_VERSION"
go install "github.com/cpuguy83/go-md2man/v2@v${GO_MD2MAN_VERSION}"
gobin="$(go env GOPATH)/bin"
export PATH="$gobin:$PATH"
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$gobin" >> "$GITHUB_PATH"
fi
else
echo "==> Go already present: $(go version)"
echo "==> go-md2man already present: $(command -v go-md2man)"
fi
# -----------------------------------------------------------------------------
# 6. Rust toolchain (netavark, aardvark-dns) — via rustup.
# 7. Rust toolchain (netavark, aardvark-dns) — via rustup.
# -----------------------------------------------------------------------------
if ! command -v cargo > /dev/null 2>&1; then
echo "==> Rust/cargo not found, installing via rustup ($RUST_CHANNEL channel)"
@@ -148,5 +213,34 @@ else
echo "==> Rust already present: $(cargo --version)"
fi
# -----------------------------------------------------------------------------
# 8. protoc (netavark) — its build.rs (via the prost-build crate) shells
# out to a `protoc` binary to compile .proto files; Slackware packages no
# protobuf/protoc at all (checked: not in any of the main/extra/pasture/
# testing repos). Official prebuilt release binary used instead of a
# from-source C++ build. No `unzip` on this image either, so extract with
# `python3 -m zipfile` (python3 is already installed, see step 0/1, for
# crun's configure script) rather than adding yet another package.
# -----------------------------------------------------------------------------
if ! command -v protoc > /dev/null 2>&1; then
echo "==> installing protoc v$PROTOC_VERSION"
curl -fL --retry 3 -o "$WORK/protoc.zip" "$PROTOC_SRC_URL"
actual=$(sha256sum "$WORK/protoc.zip" | awk '{print $1}')
[ "$actual" = "$PROTOC_SRC_SHA256" ] || {
echo "!! protoc checksum mismatch (expected $PROTOC_SRC_SHA256, got $actual)" >&2
exit 1
}
rm -rf /usr/local/protoc
mkdir -p /usr/local/protoc
python3 -m zipfile -e "$WORK/protoc.zip" /usr/local/protoc
chmod +x /usr/local/protoc/bin/protoc
export PATH="/usr/local/protoc/bin:$PATH"
if [ -n "${GITHUB_PATH:-}" ]; then
echo "/usr/local/protoc/bin" >> "$GITHUB_PATH"
fi
else
echo "==> protoc already present: $(protoc --version)"
fi
echo
echo "==> Build environment ready."
+11 -4
View File
@@ -6,8 +6,9 @@
# Centralizing this logic means each individual SlackBuild only has to
# describe *how to compile* its component — fetching, checksum verification,
# and final .txz packaging are implemented once, here, and used the same way
# by all seven packages. This is what keeps the seven build scripts
# consistent and short instead of each reinventing (and potentially
# by all ten packages (unraid-podman's own SlackBuild has no upstream
# source to fetch, so it doesn't need this). This is what keeps the build
# scripts consistent and short instead of each reinventing (and potentially
# forgetting) checksum verification or Slackware package metadata.
#
# Every SlackBuild is expected to:
@@ -68,7 +69,13 @@ sb_fetch_and_verify() {
local dest_name="$3"
local dest_path="$TMP/$dest_name"
echo "==> [$PRGNAM] Fetching $url"
# All progress/diagnostic output here must go to stderr, not stdout:
# callers capture this function's return value via `tarball=$(sb_fetch_and_verify ...)`,
# and command substitution captures everything written to stdout — a
# stray stdout echo above the final `echo "$dest_path"` would get
# concatenated into that captured value instead of just printing to the
# log.
echo "==> [$PRGNAM] Fetching $url" >&2
curl -fL --retry 3 --retry-delay 2 -o "$dest_path" "$url"
local actual_sha256
@@ -84,7 +91,7 @@ sb_fetch_and_verify() {
exit 1
fi
echo "==> [$PRGNAM] SHA256 verified ($actual_sha256)"
echo "==> [$PRGNAM] SHA256 verified ($actual_sha256)" >&2
echo "$dest_path"
}
+20 -14
View File
@@ -4,7 +4,7 @@
#
# Cuts a release of the plugin itself:
# 1. Bumps the &version; entity in plugin/podman.plg to <new-version>.
# 2. Builds all seven packages (scripts/build-packages.sh) unless
# 2. Builds all eleven packages (scripts/build-packages.sh) unless
# SKIP_BUILD=1 is set (useful when CI already built them in a prior job
# and only wants this script to do the .plg/CHANGELOG bookkeeping).
# 3. Verifies + consolidates checksums (scripts/checksums.sh).
@@ -45,19 +45,24 @@ if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
exit 1
fi
# The GitHub Release tag/URL this release's assets will be published under.
# Must match whatever .github/workflows/release.yml actually creates the
# release as (tag "v<version>") — see that workflow for the release job.
# The release tag/URL this release's assets will be published under. This
# project is hosted on a self-hosted Gitea instance (git.mp-mueller.de),
# 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"
REPO_SLUG="${GITHUB_REPOSITORY:-OWNER/unraid-podman}"
RELEASE_BASE_URL="https://github.com/$REPO_SLUG/releases/download/$RELEASE_TAG"
REPO_SLUG="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-magges/unraid-podman}}"
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
# plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly.
# unraid-podman is the plugin's own scaffolding package (see
# packages/unraid-podman/README.md), not an upstream component, but it's
# released and entity-updated exactly like the other seven.
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman)
# catatonit, nftables, and podman-compose are vendored runtime
# dependencies (not built from source, see their own packages/*/README.md)
# and unraid-podman is the plugin's own scaffolding package (see
# packages/unraid-podman/README.md), not an upstream component, but all
# four are released and entity-updated exactly like the seven upstream ones.
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman)
echo "==> Releasing unraid-podman plugin v$NEW_VERSION (packages tag: $RELEASE_TAG)"
@@ -140,7 +145,8 @@ echo " git add plugin/podman.plg CHANGELOG.md"
echo " git commit -m \"release: v$NEW_VERSION\""
echo " git tag $RELEASE_TAG"
echo " git push && git push origin $RELEASE_TAG"
echo "==> Pushing the tag triggers .github/workflows/release.yml, which"
echo "==> rebuilds artifacts in CI (for reproducibility/provenance) and"
echo "==> publishes the GitHub Release with dist/*.txz + CHECKSUMS.* + the"
echo "==> updated podman.plg attached."
echo "==> .github/workflows/release.yml (softprops/action-gh-release) only"
echo "==> knows how to publish to GitHub — this repo lives on Gitea"
echo "==> ($RELEASE_HOST), so for now, publish the Gitea Release and attach"
echo "==> dist/*.txz + CHECKSUMS.* + the updated podman.plg to it by hand"
echo "==> (or via the Gitea API) after pushing the tag."
+83
View File
@@ -37,6 +37,31 @@ CRUN_VERSION="1.28"
CRUN_SRC_URL="https://github.com/containers/crun/archive/refs/tags/${CRUN_VERSION}.tar.gz"
CRUN_SRC_SHA256="90284c7f097f8ee72a6447978c263e1b1355727c2f2ca0ac667e6d57788f46f5"
# crun's build depends on the libocispec git submodule, which GitHub's
# source archive (fetched above) never includes — a plain tarball has no
# .git directory for `git submodule update` to work against, so that has
# to be fetched as its own separate pinned source instead. Commit pinned
# to exactly what crun 1.28 references (verified via
# `curl https://api.github.com/repos/containers/crun/contents/libocispec?ref=1.28`);
# re-derive it the same way whenever CRUN_VERSION changes.
LIBOCISPEC_COMMIT="8034d0ecd27f646ba3ffae5ff24db234ce062825"
LIBOCISPEC_SRC_URL="https://github.com/containers/libocispec/archive/${LIBOCISPEC_COMMIT}.tar.gz"
LIBOCISPEC_SRC_SHA256="3e9170e54ddf487dc087ff1b88d0722e134a206cf36bba87cc946819ccf036ab"
# libocispec itself has two more git submodules (same problem, one level
# deeper) — its own generate.py needs both schema trees present at build
# time. Commits pinned to exactly what the LIBOCISPEC_COMMIT above
# references (verified the same way, via
# `curl https://api.github.com/repos/containers/libocispec/contents/image-spec?ref=$LIBOCISPEC_COMMIT`
# and .../runtime-spec?ref=...).
IMAGE_SPEC_COMMIT="26647a49f642c7d22a1cd3aa0a48e4650a542269"
IMAGE_SPEC_SRC_URL="https://github.com/opencontainers/image-spec/archive/${IMAGE_SPEC_COMMIT}.tar.gz"
IMAGE_SPEC_SRC_SHA256="8668357de6a1162220b2d1fb654a4182a55844b90ad2774c3b99640eec7e2f54"
RUNTIME_SPEC_COMMIT="d64c1d945da7cf6970061c7c9ff4391fafdf2a15"
RUNTIME_SPEC_SRC_URL="https://github.com/opencontainers/runtime-spec/archive/${RUNTIME_SPEC_COMMIT}.tar.gz"
RUNTIME_SPEC_SRC_SHA256="1698ebaa7ff07f8409c084fe9539d0391820e71b7a6e6d877aa1ce8b383a4b50"
# --- netavark --------------------------------------------------------------
# https://github.com/containers/netavark
NETAVARK_VERSION="2.0.0"
@@ -68,6 +93,64 @@ PASST_VERSION="git${PASST_COMMIT:0:7}"
PASST_SRC_URL="https://passt.top/passt/snapshot/passt-${PASST_COMMIT}.tar.gz"
PASST_SRC_SHA256="4c58a77504a77d613464dddf22ae69d749a5ba64cb87e44c3b8c252333e209fc"
# --- catatonit -----------------------------------------------------------
# https://github.com/openSUSE/catatonit — the init process podman runs
# inside every pod's infra container to reap zombies; not built by
# podman's own Makefile, not packaged by Slackware, needed at runtime on
# every target Unraid install (found by live-testing `podman pod create`
# against a real install: "finding catatonit binary: exec: catatonit: no
# such file or directory"). Upstream publishes a prebuilt static
# (non-dynamic-linked) x86_64 binary release asset — no from-source build
# needed, no runtime library surprises like the crun/yajl chain had.
CATATONIT_VERSION="0.2.1"
CATATONIT_SRC_URL="https://github.com/openSUSE/catatonit/releases/download/v${CATATONIT_VERSION}/catatonit.x86_64"
CATATONIT_SRC_SHA256="8293951eaa7767fa411e3b89777bd01bc5e56db9ba6d145ad10cc4d05b01e961"
# --- nftables --------------------------------------------------------------
# netavark >= 2.0 dropped its iptables firewall driver entirely (see
# config/containers.conf and docs/ARCHITECTURE.md section 8) — nftables is
# now the only viable firewall backend on Unraid (no systemd/dbus for
# firewalld), but Unraid OS ships no `nft` binary. Slackware — Unraid's own
# base distro — already builds and ships this as an official package, so
# it is vendored through as-is (verified working live) rather than
# rebuilt from source: nftables pulls in its own dependency chain
# (libmnl, libnftnl, gmp, ...) for no benefit over the distro's own build,
# which is already correctly built for this exact environment.
NFTABLES_VERSION="1.0.1"
NFTABLES_SRC_URL="http://slackware.osuosl.org/slackware64-15.0/slackware64/n/nftables-${NFTABLES_VERSION}-x86_64-1.txz"
NFTABLES_SRC_SHA256="239e70d48edd6667ce875ff0d339b6f63c1fc94c472524d58772310b1006d31c"
# --- podman-compose -----------------------------------------------------------
# https://github.com/containers/podman-compose — `podman compose` (backing
# webui/plugins/podman/ajax/compose.php, the WebUI's Compose panel) has no
# compose implementation of its own; it needs an external "compose
# provider" command. This project previously vendored docker/compose (the
# Go CLI-plugin binary) for that role, found by podman searching a fixed
# set of CLI-plugin directories for a binary named exactly
# "docker-compose". podman-compose is looked up differently — verified
# live (placing a fake executable and watching podman's own error output
# list its search order) that it's found as a plain command on $PATH,
# not from those same CLI-plugin directories — so it's installed as
# /usr/local/bin/podman-compose, not under any cli-plugins/ path.
#
# Unlike docker-compose, podman-compose is a single Python script, not a
# compiled binary — Unraid ships Python3 itself, but not either of its two
# runtime dependencies (PyYAML, python-dotenv), so those are vendored
# alongside it as plain pure-Python source (no C extension build; PyYAML's
# own __init__.py falls back gracefully when its optional C accelerator
# isn't importable — verified by reading it, not assumed).
PODMAN_COMPOSE_VERSION="1.6.0"
PODMAN_COMPOSE_SRC_URL="https://raw.githubusercontent.com/containers/podman-compose/v${PODMAN_COMPOSE_VERSION}/podman_compose.py"
PODMAN_COMPOSE_SRC_SHA256="10df1662477a673dc803c03e89c1bc1fba6c8c091e716fb6c7dd09c0081e1255"
PYYAML_VERSION="6.0.3"
PYYAML_SRC_URL="https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-${PYYAML_VERSION}.tar.gz"
PYYAML_SRC_SHA256="d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"
PYTHON_DOTENV_VERSION="1.2.2"
PYTHON_DOTENV_SRC_URL="https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-${PYTHON_DOTENV_VERSION}.tar.gz"
PYTHON_DOTENV_SRC_SHA256="2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
# =============================================================================
# Slackware package BUILD number (not upstream version). Bump this if a
# package must be rebuilt without an upstream version change (e.g. a
+1 -1
View File
@@ -4,7 +4,7 @@ Dynamix-style WebUI pages, following Unraid's plugin GUI convention of
`/usr/local/emhttp/plugins/<name>/`. `webui/plugins/podman/` is staged to
that path by the `unraid-podman` scaffolding package (see
`packages/unraid-podman/unraid-podman.SlackBuild`), which podman.plg installs
alongside the seven compiled components.
alongside the other ten packages.
**Status: implemented**, covering all ten sections from
[docs/ARCHITECTURE.md, section 18](../docs/ARCHITECTURE.md#18-zukünftige-webui):
+91 -33
View File
@@ -1,4 +1,6 @@
Menu="Podman"
Menu="Tasks:66"
Type="xmenu"
Tabs="false"
Title="Podman"
Icon="podman"
---
@@ -16,8 +18,23 @@ Icon="podman"
* page's markup mirrors (same structure, same CSS classes, real data
* instead of static samples).
*/
/**
* Cache-busts every static asset with its own on-disk mtime. Unraid's
* webserver sends no explicit no-cache headers for /plugins/ static
* 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
* bugfix to app.js's CSRF handling silently kept failing in a real
* browser after redeploy until this was added, even though the deployed
* file on disk was byte-for-byte correct.
*/
function podman_asset_version(string $relPath): string
{
$full = __DIR__ . $relPath;
return is_file($full) ? (string) filemtime($full) : '0';
}
?>
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css">
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css?v=<?=podman_asset_version('/styles/podman.css')?>">
<div class="podman-plugin">
@@ -39,6 +56,7 @@ Icon="podman"
<nav class="podman-subnav">
<button class="active" data-panel="dashboard">Dashboard</button>
<button data-panel="containers">Containers</button>
<button data-panel="templates">Templates</button>
<button data-panel="pods">Pods</button>
<button data-panel="images">Images</button>
<button data-panel="volumes">Volumes</button>
@@ -68,11 +86,14 @@ Icon="podman"
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" id="containers-search" type="text" placeholder="Search containers by name or image…">
<div class="filterset" id="containers-filterset" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<div class="podman-segmented" id="containers-filterset">
<button class="active" data-filter="all" id="containers-count-all">All</button>
<button data-filter="running" id="containers-count-running">Running</button>
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
</div>
<button class="podman-btn podman-btn-primary" id="containers-check-updates-btn" style="margin-left:auto;">Check for Updates</button>
<button class="podman-btn podman-btn-primary" id="containers-update-all-btn">Update All</button>
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
</div>
<div class="podman-table-wrap">
<table>
@@ -83,6 +104,9 @@ Icon="podman"
</div>
</section>
<!-- ============================= TEMPLATES ============================= -->
<section class="podman-panel" id="podman-panel-templates"></section>
<!-- ============================= PODS ============================= -->
<section class="podman-panel" id="podman-panel-pods"></section>
@@ -91,6 +115,7 @@ Icon="podman"
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" type="text" placeholder="Search images…" disabled title="Client-side filtering not yet wired up for Images">
<button class="podman-btn" id="images-prune-btn" style="margin-left:auto;">Prune unused</button>
<button class="podman-btn" id="images-pull-btn">&#11015; Pull Image</button>
</div>
<div class="podman-table-wrap">
@@ -145,7 +170,7 @@ Icon="podman"
<div>
<div class="podman-toolbar">
<input class="podman-search" id="logs-filter" type="text" placeholder="Filter log output…" style="max-width:280px;">
<span id="logs-follow-toggle" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<span class="podman-segmented" id="logs-follow-toggle">
<button class="active" data-follow="true">Follow</button>
<button data-follow="false">Paused</button>
</span>
@@ -159,12 +184,22 @@ Icon="podman"
<!-- ============================= TERMINAL ============================= -->
<section class="podman-panel" id="podman-panel-terminal">
<div class="podman-card">
<div class="podman-card-head"><h2>Live Terminal</h2></div>
<div class="podman-card-pad">
<div style="display:flex; gap:8px; align-items:center; margin-bottom:12px; font-size:12.5px; color:var(--text-dim);">
Exec into: <select id="term-container-select"></select>
<div class="podman-term-launcher">
<label>Container <select class="podman-term-select" id="term-container-select"></select></label>
<label>Shell
<select class="podman-term-select" id="term-shell-select">
<option value="bash" selected>bash</option>
<option value="sh">sh</option>
</select>
</label>
<button class="podman-btn podman-btn-primary" id="term-open-btn">&#9654; Open Terminal</button>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="term-disconnect-btn" disabled>&#9632; Disconnect</button>
</div>
<div id="term-frame-wrap">
<p class="podman-empty-note">Pick a running container and click "Open Terminal" — the same live, fully interactive terminal Unraid's own Docker "Console" button opens (arrow-key history, tab completion, vim, etc. all work).</p>
</div>
<div class="podman-term" id="term-output"></div>
<input class="podman-term-input" id="term-input" type="text" placeholder="Type a command and press Enter… (one-shot exec — see Compose panel note on API scope)" autocomplete="off">
</div>
</div>
</section>
@@ -173,15 +208,22 @@ Icon="podman"
<section class="podman-panel" id="podman-panel-compose">
<div class="podman-card">
<div class="podman-compose-layout">
<div class="podman-compose-side" id="compose-sidebar"></div>
<div class="podman-compose-side">
<div class="podman-toolbar" style="border-bottom:1px solid var(--border); padding:10px;">
<button class="podman-btn podman-btn-primary" id="compose-new-btn" style="width:100%; justify-content:center;">+ New Project</button>
</div>
<div id="compose-sidebar"></div>
</div>
<div>
<div class="podman-toolbar">
<strong id="compose-title" style="flex:1;">—</strong>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="compose-action-delete">Delete</button>
<button class="podman-btn" id="compose-action-pull">&#11015; Pull</button>
<button class="podman-btn" id="compose-action-down">&#9632; Down</button>
<button class="podman-btn podman-btn-primary" id="compose-action-up">&#9654; Up</button>
<button class="podman-btn podman-btn-primary" id="compose-action-save">Save</button>
</div>
<pre class="podman-yaml" id="compose-yaml"></pre>
<textarea class="podman-yaml podman-yaml-editor mono" id="compose-yaml" spellcheck="false"></textarea>
</div>
</div>
</div>
@@ -189,9 +231,15 @@ Icon="podman"
<!-- ============================= SETTINGS ============================= -->
<section class="podman-panel" id="podman-panel-settings">
<div class="podman-settings-actions">
<span class="hint" id="settings-save-hint">Changes to storage/enabled/timeout need <span class="mono">rc.podman restart</span> to take effect.</span>
<button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button>
</div>
<div class="podman-grid">
<div class="podman-card">
<div class="podman-card-head"><h2>Storage</h2></div>
<div class="podman-card-head">
<div><h2>Storage</h2><div class="sub">Where podman keeps images, containers and volumes on disk.</div></div>
</div>
<div class="podman-field-row">
<label for="settings-storage-path">Storage path</label>
<div>
@@ -201,40 +249,52 @@ Icon="podman"
</div>
<div class="podman-field-row">
<label for="settings-storage-size">podman.img size</label>
<div><input type="number" id="settings-storage-size" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">GB</span></div>
<div>
<div class="podman-input-suffix"><input type="number" id="settings-storage-size" min="1"> <span>GB</span></div>
<div class="hint">Overlay filesystem image size. Only applies the first time podman initializes storage at this path.</div>
</div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Autostart &amp; Lifecycle</h2></div>
<div class="podman-card-head">
<div><h2>Autostart &amp; Lifecycle</h2><div class="sub">What runs when the array starts, and how containers shut down.</div></div>
</div>
<div class="podman-field-row">
<label for="settings-enabled">Start podman on array start</label>
<div><input type="checkbox" id="settings-enabled"></div>
<div>
<label class="podman-switch">
<input type="checkbox" id="settings-enabled"><span class="podman-switch-track"><span class="podman-switch-thumb"></span></span>
</label>
</div>
</div>
<div class="podman-field-row">
<label for="settings-stop-timeout">Container stop timeout</label>
<div><input type="number" id="settings-stop-timeout" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">seconds</span></div>
<div>
<div class="podman-input-suffix"><input type="number" id="settings-stop-timeout" min="0"> <span>seconds</span></div>
<div class="hint">Grace period before a stop/restart escalates to SIGKILL.</div>
</div>
</div>
<div class="podman-field-row">
<label>Autostart order</label>
<div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
<tbody id="autostart-tbody"></tbody>
</table>
</div>
<div class="hint">Saved immediately on reorder/remove — no separate save step.</div>
</div>
<div class="podman-field-row">
<label></label>
<div><button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button></div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Installed Packages</h2></div>
<div class="podman-field-row">
<label>Versions</label>
<div class="hint mono" id="settings-package-versions" style="max-width:none;">—</div>
<div class="podman-card-head">
<div><h2>Installed Packages</h2><div class="sub">Versions currently installed on this system.</div></div>
</div>
<div class="podman-card-pad">
<div class="podman-version-chips" id="settings-package-versions">—</div>
</div>
</div>
</div>
@@ -243,14 +303,12 @@ Icon="podman"
</main>
</div>
<script src="/plugins/podman/javascript/app.js"></script>
<script src="/plugins/podman/javascript/dashboard.js"></script>
<script src="/plugins/podman/javascript/containers.js"></script>
<script src="/plugins/podman/javascript/pods.js"></script>
<script src="/plugins/podman/javascript/images.js"></script>
<script src="/plugins/podman/javascript/volumes.js"></script>
<script src="/plugins/podman/javascript/networks.js"></script>
<script src="/plugins/podman/javascript/logs.js"></script>
<script src="/plugins/podman/javascript/terminal.js"></script>
<script src="/plugins/podman/javascript/compose.js"></script>
<script src="/plugins/podman/javascript/settings.js"></script>
<?php
foreach ([
'app', 'dashboard', 'containers', 'templates', 'pods', 'images', 'volumes',
'networks', 'logs', 'terminal', 'compose', 'settings',
] as $podmanJsModule) {
$podmanJsPath = "/javascript/{$podmanJsModule}.js";
echo '<script src="/plugins/podman' . $podmanJsPath . '?v=' . podman_asset_version($podmanJsPath) . '"></script>' . "\n";
}
?>
+110 -7
View File
@@ -27,6 +27,8 @@
* Actions (?action=...):
* list GET -> known projects with up/down status
* get GET (&project=...) -> raw compose.yaml content
* save POST {"project": "...", "yaml": "..."} -> creates or overwrites a project's compose.yaml
* remove POST {"project": "..."} -> `down` (best-effort) then deletes the project's directory
* up POST {"project": "..."}
* down POST {"project": "..."}
* pull POST {"project": "..."}
@@ -49,6 +51,15 @@ switch ($action) {
podman_json_response(['yaml' => compose_read($composeDir, $project)]);
break;
case 'save':
$body = podman_read_json_body();
podman_json_response(compose_save($composeDir, require_project($body), (string) ($body['yaml'] ?? '')));
break;
case 'remove':
podman_json_response(compose_remove($composeDir, require_project(podman_read_json_body())));
break;
case 'up':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d']));
break;
@@ -121,8 +132,92 @@ function compose_status(string $composeDir, string $project): string
if ($result['exitCode'] !== 0) {
return 'unknown';
}
$decoded = json_decode($result['output'], true);
return (is_array($decoded) && count($decoded) > 0) ? 'up' : 'down';
// `podman compose ps --format json` emits one JSON object PER LINE
// (JSONL), not a single JSON array — decoding the whole blob in one
// json_decode() call fails silently (-> null) as soon as a project has
// more than one service (verified live with a 2-service project).
// stdout only, too: the "external compose provider" banner goes to
// stderr and would otherwise corrupt this either way.
$running = 0;
foreach (explode("\n", trim($result['stdout'])) as $line) {
if (trim($line) !== '' && is_array(json_decode($line, true))) {
$running++;
}
}
return $running > 0 ? 'up' : 'down';
}
/**
* Creates a new project (directory doesn't exist yet) or overwrites an
* existing one's compose.yaml. Validated via the real tool — `podman
* compose ... config` parses and resolves the file, exiting non-zero with
* a specific line/column message on invalid YAML/schema (verified live)
* — rather than a hand-rolled YAML parser, since PHP has no YAML
* extension available here to begin with. Written to a *.new sibling
* file first and only renamed into place once validation passes, so a
* bad edit never corrupts a previously-working compose.yaml.
*
* @return array<string,mixed>
*/
function compose_save(string $composeDir, string $project, string $yaml): array
{
if (trim($yaml) === '') {
podman_json_error('compose.yaml content cannot be empty', 400);
}
$projectDir = $composeDir . '/' . $project;
if (!is_dir($projectDir) && !mkdir($projectDir, 0755, true) && !is_dir($projectDir)) {
podman_json_error("Could not create project directory for '{$project}'", 500);
}
$yamlPath = $projectDir . '/compose.yaml';
$tmpName = 'compose.yaml.new';
if (file_put_contents($projectDir . '/' . $tmpName, $yaml) === false) {
podman_json_error('Could not write compose.yaml', 500);
}
$result = run_compose_command($composeDir, $project, ['config'], 30, $tmpName);
if ($result['exitCode'] !== 0) {
@unlink($projectDir . '/' . $tmpName);
podman_json_error("Invalid compose file:\n" . trim($result['output']), 400);
}
if (!rename($projectDir . '/' . $tmpName, $yamlPath)) {
podman_json_error('Could not save compose.yaml', 500);
}
return ['status' => 'saved'];
}
/**
* Best-effort `down` (ignored if it fails — e.g. already down, or the
* file was mid-edit and invalid) so deleting a running project's files
* doesn't leave orphaned containers/networks behind, then deletes just
* that one project's own directory. $project is validated by
* require_project() before this is ever called, so $projectDir can't
* escape $composeDir.
*
* @return array<string,mixed>
*/
function compose_remove(string $composeDir, string $project): array
{
$projectDir = $composeDir . '/' . $project;
if (!is_dir($projectDir)) {
podman_json_error("Project '{$project}' not found", 404);
}
run_compose_command($composeDir, $project, ['down'], 60);
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($projectDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($projectDir);
return ['status' => 'removed'];
}
function compose_read(string $composeDir, string $project): string
@@ -155,17 +250,17 @@ function compose_run(string $composeDir, string $project, array $subcommand): ar
* surface even though $project has already been validated above too).
*
* @param array<int,string> $subcommand
* @return array{exitCode:int,output:string}
* @return array{exitCode:int,stdout:string,output:string}
*/
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds, string $yamlFile = 'compose.yaml'): array
{
$yamlPath = $composeDir . '/' . $project . '/compose.yaml';
$yamlPath = $composeDir . '/' . $project . '/' . $yamlFile;
$argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project);
if (!is_resource($process)) {
return ['exitCode' => 127, 'output' => 'Could not start podman compose process'];
return ['exitCode' => 127, 'stdout' => '', 'output' => 'Could not start podman compose process'];
}
stream_set_timeout($pipes[1], $timeoutSeconds);
@@ -175,5 +270,13 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
fclose($pipes[2]);
$exitCode = proc_close($process);
return ['exitCode' => $exitCode, 'output' => trim($stdout . $stderr)];
// 'stdout' (raw) for callers that need to parse machine-readable
// output (e.g. compose_status()'s JSON); 'output' (combined, trimmed,
// ANSI-stripped) for human-facing success/error messages, where seeing
// podman's own stderr banner/warnings is actually useful context —
// just not the raw \x1b[4m/\x1b[0m escape codes wrapping it (found
// live: they showed up as literal garbage characters in the WebUI's
// error alerts).
$combined = preg_replace('/\x1b\[[0-9;]*m/', '', $stdout . $stderr) ?? ($stdout . $stderr);
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($combined)];
}
+346
View File
@@ -13,7 +13,20 @@
* stop POST {"id": "...", "timeout": 10}
* restart POST {"id": "...", "timeout": 10}
* remove POST {"id": "...", "force": false}
* pause POST {"id": "..."}
* unpause POST {"id": "..."}
* kill POST {"id": "...", "signal": "SIGKILL"}
* rename POST {"id": "...", "name": "..."}
* logs GET (&id=...&tail=200) -> plain text
* list_gpus GET -> detected AMD/Intel GPUs (/dev/dri), for the Create Container form's optional passthrough toggle
* check_updates GET -> {"<image ref>": {"updateAvailable": bool, "error": "..."?}} for every image currently in use
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
* "staticIp": "10.1.1.222" (only meaningful with a custom/macvlan networkMode),
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"],
* "privileged": false, "startAfterCreate": true}
*/
declare(strict_types=1);
@@ -68,10 +81,311 @@ switch ($action) {
podman_json_response(['status' => 'removed']);
break;
case 'pause':
$body = podman_read_json_body();
$client->pauseContainer(require_id($body));
podman_json_response(['status' => 'paused']);
break;
case 'unpause':
$body = podman_read_json_body();
$client->unpauseContainer(require_id($body));
podman_json_response(['status' => 'unpaused']);
break;
case 'kill':
$body = podman_read_json_body();
$client->killContainer(require_id($body), (string) ($body['signal'] ?? 'SIGKILL'));
podman_json_response(['status' => 'killed']);
break;
case 'rename':
$body = podman_read_json_body();
$newName = trim((string) ($body['name'] ?? ''));
if ($newName === '') {
podman_json_error('Missing name in request body', 400);
}
$client->renameContainer(require_id($body), $newName);
podman_json_response(['status' => 'renamed']);
break;
case 'list_gpus':
podman_json_response(gpu_list());
break;
case 'check_updates':
podman_json_response(check_image_updates($client));
break;
case 'create':
$body = podman_read_json_body();
$image = trim((string) ($body['image'] ?? ''));
if ($image === '') {
podman_json_error('Missing image in request body', 400);
}
$spec = build_container_spec($image, $body);
// Unlike `podman run`, /containers/create does NOT auto-pull a
// missing image — it fails outright with a 404 "no such image"
// (found by live-testing the Create Container form against a
// freshly-typed image reference that wasn't pulled yet). Retry
// once after an explicit pull rather than always pulling
// up-front, so re-creating with an image the user already has
// stays fast and offline-friendly.
try {
$id = $client->createContainer($spec);
} catch (PodmanApiException $e) {
if ($e->httpStatus !== 404) {
throw $e;
}
$client->pullImage($image);
$id = $client->createContainer($spec);
}
if ($body['startAfterCreate'] ?? true) {
$client->startContainer($id);
}
podman_json_response(['id' => $id, 'status' => ($body['startAfterCreate'] ?? true) ? 'started' : 'created']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Checks every image currently backing a (non-infra) container against its
* origin registry — see RegistryClient for how, and why this isn't a
* podman/libpod feature at all. Deduplicated per unique image reference
* first (several containers commonly share the same image), so a host
* with e.g. five containers all on the same base image only makes one
* real registry request for it, not five.
*
* @return array<string,array<string,mixed>> keyed by image reference
*/
function check_image_updates(PodmanClient $client): array
{
$digestByImageId = [];
foreach ($client->listImages() as $img) {
$digestByImageId[(string) ($img['Id'] ?? '')] = (string) ($img['Digest'] ?? '');
}
$localDigestByRef = [];
foreach ($client->listContainers(true) as $c) {
if ($c['IsInfra'] ?? false) {
continue;
}
$ref = (string) ($c['Image'] ?? '');
$imageId = (string) ($c['ImageID'] ?? '');
if ($ref === '' || !isset($digestByImageId[$imageId])) {
continue;
}
$localDigestByRef[$ref] = $digestByImageId[$imageId];
}
$out = [];
foreach ($localDigestByRef as $ref => $localDigest) {
$out[$ref] = $localDigest === ''
? ['error' => 'No local digest recorded for this image.']
: RegistryClient::checkForUpdate($ref, $localDigest);
}
return $out;
}
/**
* Detects AMD/Intel GPUs via /dev/dri + sysfs — NOT via any podman/libpod
* API (libpod has no GPU inventory endpoint; this is plain host hardware
* detection). NVIDIA is deliberately excluded: it needs the separate
* nvidia-container-toolkit runtime, not a plain /dev/dri device passthrough,
* so listing it here would offer a checkbox that doesn't actually work.
* Verified live: card/render pairs from the same GPU share a "device"
* symlink target under /sys/class/drm, which is how they're grouped below;
* vendor 0x1002 = AMD, 0x8086 = Intel (PCI SIG IDs).
*
* @return array<int,array<string,mixed>>
*/
function gpu_list(): array
{
if (!is_dir('/sys/class/drm')) {
return [];
}
$byDevice = [];
foreach (scandir('/sys/class/drm') ?: [] as $entry) {
if (!preg_match('/^(card\d+|renderD\d+)$/', $entry)) {
continue;
}
$devicePath = "/sys/class/drm/{$entry}/device";
$target = @readlink($devicePath);
if ($target === false) {
continue;
}
$vendorFile = "{$devicePath}/vendor";
if (!is_file($vendorFile)) {
continue;
}
$vendorId = trim((string) @file_get_contents($vendorFile));
$byDevice[$target]['vendorId'] ??= $vendorId;
$byDevice[$target][str_starts_with($entry, 'card') ? 'card' : 'render'] = "/dev/dri/{$entry}";
}
$vendorNames = ['0x1002' => 'AMD', '0x8086' => 'Intel', '0x10de' => 'NVIDIA'];
$out = [];
foreach ($byDevice as $group) {
$vendorId = $group['vendorId'] ?? '';
$vendorName = $vendorNames[$vendorId] ?? $vendorId;
// NVIDIA needs the nvidia-container-toolkit runtime, not a plain
// /dev/dri passthrough — excluded so the checkbox we offer always
// actually works (see function comment).
if ($vendorName === 'NVIDIA' || !isset($group['render'])) {
continue;
}
$out[] = [
'vendor' => $vendorName,
'card' => $group['card'] ?? null,
'render' => $group['render'],
];
}
return $out;
}
/**
* Builds a libpod SpecGenerator body (POST /containers/create) from the
* WebUI's Create Container form fields. Field names/shapes here
* (portmappings, netns, networks, mounts, volumes, restart_policy) were
* verified live against a real podman system service — see
* PodmanClient::createContainer()'s header comment.
*
* @param array<string,mixed> $body
* @return array<string,mixed>
*/
function build_container_spec(string $image, array $body): array
{
$spec = ['image' => $image];
$name = trim((string) ($body['name'] ?? ''));
if ($name !== '') {
// Same character set podman itself enforces (define.NameRegex in
// libpod) — validated here too so a space/invalid character gets
// a clear message instead of podman's raw "running container
// create option: names must match ...: invalid argument" (found
// live: a template-derived container name with a space in it hit
// exactly this).
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) {
podman_json_error(
"Container name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" .
preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.',
400
);
}
$spec['name'] = $name;
}
$env = [];
foreach (($body['env'] ?? []) as $row) {
$key = trim((string) ($row['key'] ?? ''));
if ($key !== '') {
$env[$key] = (string) ($row['value'] ?? '');
}
}
if ($env !== []) {
$spec['env'] = $env;
}
$ports = [];
foreach (($body['ports'] ?? []) as $row) {
$hostPort = (int) ($row['hostPort'] ?? 0);
$containerPort = (int) ($row['containerPort'] ?? 0);
if ($hostPort > 0 && $containerPort > 0) {
$ports[] = [
'host_ip' => '',
'host_port' => $hostPort,
'container_port' => $containerPort,
'protocol' => (string) ($row['protocol'] ?? 'tcp'),
];
}
}
if ($ports !== []) {
$spec['portmappings'] = $ports;
}
$mounts = [];
$volumes = [];
foreach (($body['volumes'] ?? []) as $row) {
$source = trim((string) ($row['source'] ?? ''));
$containerPath = trim((string) ($row['containerPath'] ?? ''));
if ($source === '' || $containerPath === '') {
continue;
}
if (($row['kind'] ?? 'named') === 'path') {
$mounts[] = ['destination' => $containerPath, 'type' => 'bind', 'source' => $source, 'options' => ['rbind']];
} else {
$volumes[] = ['name' => $source, 'dest' => $containerPath];
}
}
if ($mounts !== []) {
$spec['mounts'] = $mounts;
}
if ($volumes !== []) {
$spec['volumes'] = $volumes;
}
// "bridge"/"host"/"none" are podman's own reserved netns modes; any
// other value is an existing custom podman network's name, attached
// via the "networks" field instead (verified live: passing a
// network name through "networks" attaches it without needing an
// explicit netns mode at all).
$networkMode = (string) ($body['networkMode'] ?? 'bridge');
if (in_array($networkMode, ['bridge', 'host', 'none'], true)) {
$spec['netns'] = ['nsmode' => $networkMode];
} elseif ($networkMode !== '') {
// A static IP only makes sense on a custom (typically macvlan)
// network — verified live that "networks":{"<name>":{"static_ips":
// [...]}} assigns it, same as podman itself does for --ip. Basic
// IPv4-shape validation only (not full RFC-correctness) — this
// goes straight into a create request against the local podman
// socket, not anywhere it could reach untrusted input otherwise.
$staticIp = trim((string) ($body['staticIp'] ?? ''));
if ($staticIp !== '') {
if (preg_match('/^(\d{1,3}\.){3}\d{1,3}$/', $staticIp) !== 1) {
podman_json_error("Static IP (\"{$staticIp}\") doesn't look like a valid IPv4 address.", 400);
}
$spec['networks'] = [$networkMode => ['static_ips' => [$staticIp]]];
} else {
$spec['networks'] = [$networkMode => new \stdClass()];
}
}
if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
$spec['restart_policy'] = (string) $body['restartPolicy'];
}
if ($body['privileged'] ?? false) {
$spec['privileged'] = true;
}
$devices = [];
foreach (($body['gpuDevices'] ?? []) as $path) {
// Only ever pass through paths matching the exact shape gpu_list()
// itself reports — the client only ever gets those as checkbox
// values, but this is the boundary where a tampered/malicious
// request body gets rejected rather than handing arbitrary host
// device paths (e.g. "/dev/sda") straight into the container spec.
if (is_string($path) && preg_match('#^/dev/dri/(card|renderD)\d+$#', $path) === 1) {
$devices[] = ['path' => $path];
}
}
if ($devices !== []) {
$spec['devices'] = $devices;
}
$pod = trim((string) ($body['pod'] ?? ''));
if ($pod !== '') {
// "pod" joins an existing pod's shared network namespace — verified
// live that it can be sent alongside "netns" above without
// conflict (podman just defers to the pod's namespace).
$spec['pod'] = $pod;
}
return $spec;
}
/** @param array<string,mixed> $body */
function require_id(array $body): string
{
@@ -96,6 +410,17 @@ function containers_list(PodmanClient $client): array
$out = [];
foreach ($raw as $c) {
// Every pod has a hidden "infra" container managing its shared
// network namespace — not something a user creates or can
// meaningfully stop/remove on its own (found live: it always
// shows "running" with no independent lifecycle, so Containers
// panel gets a permanently un-removable row once any pod exists;
// it already appears as its own row in the Pods panel). See
// ajax/pods.php for actual pod lifecycle management.
if ($c['IsInfra'] ?? false) {
continue;
}
$names = $c['Names'] ?? [];
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
@@ -111,6 +436,24 @@ function containers_list(PodmanClient $client): array
$startedAt = podman_parse_time($c['StartedAt'] ?? null);
$state = strtolower((string) ($c['State'] ?? 'unknown'));
// One extra local-socket round trip per running container (~15ms
// each, verified live — negligible for a home host's container
// count). Best-effort: a container that stops between the list
// call above and this one shouldn't blank out the whole table.
$cpuPercent = null;
$memUsageBytes = null;
$memLimitBytes = null;
if ($state === 'running') {
try {
$stats = $client->containerStats((string) ($c['Id'] ?? ''));
$cpuPercent = isset($stats['cpu_stats']['cpu']) ? round((float) $stats['cpu_stats']['cpu'], 1) : null;
$memUsageBytes = isset($stats['memory_stats']['usage']) ? (int) $stats['memory_stats']['usage'] : null;
$memLimitBytes = isset($stats['memory_stats']['limit']) ? (int) $stats['memory_stats']['limit'] : null;
} catch (PodmanApiException $e) {
// leave stats null
}
}
$out[] = [
'id' => (string) ($c['Id'] ?? ''),
'shortId' => podman_short_id((string) ($c['Id'] ?? '')),
@@ -124,6 +467,9 @@ function containers_list(PodmanClient $client): array
'podName' => $c['PodName'] ?? null,
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
'createdAt' => podman_parse_time($c['Created'] ?? null),
'cpuPercent' => $cpuPercent,
'memUsageBytes' => $memUsageBytes,
'memLimitBytes' => $memLimitBytes,
];
}
+127 -37
View File
@@ -3,35 +3,49 @@
* ajax/exec.php
*
* Backs the Terminal panel — and this is the one panel where "exclusively
* via podman system service, no shell hacks" needs an honest caveat
* spelled out rather than silently glossed over:
* via podman system service, no shell hacks" needs an honest caveat spelled
* out rather than silently glossed over (the same exception ajax/compose.php
* documents for the same underlying reason: some things have no REST
* equivalent).
*
* libpod's real exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) is used here — PodmanClient::execRun() never
* shells out to the `podman` binary. But that API's interactive mode works
* by HTTP connection hijacking: the HTTP connection is upgraded into a raw
* bidirectional byte stream for the lifetime of the shell session. That
* model assumes a long-lived process holding the socket open on both ends
* (an actual terminal emulator, or a WebSocket bridge) — it does not fit
* PHP-FPM's request/response lifecycle, where each AJAX call is a fresh,
* independent, short-lived process with no memory of any previous one.
* POST /exec/{id}/start) works by HTTP connection hijacking: the connection
* is upgraded into a raw bidirectional byte stream for the lifetime of the
* shell session. That model assumes a long-lived process holding the socket
* open on both ends (an actual terminal emulator, or a WebSocket bridge) —
* it does not fit PHP-FPM's request/response lifecycle, where each AJAX call
* is a fresh, independent, short-lived process with no memory of any
* previous one. An earlier version of this file worked around that by
* offering one-shot "run a command, see its output" exec calls — honest
* about not being a real terminal, but not what a user expects when they
* open a "Console" tab (no history, no vim, no persistent `cd`).
*
* Rather than fake interactivity with something that would break on the
* first multi-line prompt, `sudo`, or interactive editor, this endpoint
* offers a deliberately simpler, honest contract: one command in, its
* complete output back, using Tty=true so output reads like a real
* terminal (colors, prompts-in-output, etc. survive) but with no
* persistent shell state (`cd` does not carry over between calls — see
* the "cwd" parameter below, which javascript/terminal.js tracks
* client-side and resends every time instead).
* Unraid's own webGui already solves exactly this problem for its System
* Terminal and for `docker exec` (see
* /usr/local/emhttp/plugins/dynamix/include/OpenTerminal.php's 'docker'
* case, and /etc/nginx/conf.d/locations.conf's "logterminal" location
* block) — by spawning one `ttyd` instance per session, bound to a unix
* socket under /var/tmp, wrapping the real interactive command; nginx then
* proxies /logterminal/<name>/ to that socket with a WebSocket upgrade,
* generically, for ANY name. That proxy rule is already installed and
* already generic — this endpoint reuses it exactly the same way Unraid's
* own docker integration does, just with `podman exec -it` instead of
* `docker exec -it` as the wrapped command. `ttyd-exec` itself is a small
* wrapper script Unraid ships system-wide (sources /etc/default/ttyd for
* common xterm.js options, then execs ttyd in the background) — not
* something this plugin needs to vendor.
*
* A true interactive PTY (arrow-key history, tab completion, vim, ...)
* would need a WebSocket-capable process sitting between the browser and
* podman.sock — out of scope for this PHP/AJAX stack; tracked as a
* follow-up rather than implemented as a shell-out workaround.
* This is the one place in the plugin that shells out to the `podman`
* binary via proc invocation rather than the REST API — container names
* are validated against a fixed safe pattern and passed through
* escapeshellarg(), never concatenated into a shell string.
*
* Actions (?action=...):
* run POST {"id": "...", "cmd": "ls -la", "cwd": "/config"}
* open POST {"name": "...", "shell": "sh"|"bash"} -> {"sockName": "..."}
* Caller then points an iframe/window at /logterminal/<sockName>/.
* close POST {"name": "..."} -> {"status": "closed"}
* Kills the ttyd instance (and, via it, the `podman exec` it
* wraps) for that container, if one is running.
*/
declare(strict_types=1);
@@ -41,27 +55,103 @@ require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'run':
case 'open':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
$commandLine = (string) ($body['cmd'] ?? '');
$cwd = (string) ($body['cwd'] ?? '');
$name = (string) ($body['name'] ?? '');
$shell = (string) ($body['shell'] ?? 'sh');
if ($id === '' || trim($commandLine) === '') {
podman_json_error('Missing id or cmd in request body', 400);
// Same character set libpod itself allows in container names —
// rejecting anything else here (BEFORE it's ever used to build a
// socket path or shell command) is what makes escapeshellarg() on
// top of it a defense in depth rather than the only line of
// defense.
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
if (!in_array($shell, ['sh', 'bash'], true)) {
podman_json_error('Invalid shell', 400);
}
// The command line is run through the container's own shell
// (sh -c) so the user can type ordinary shell syntax (pipes,
// globs, env vars) in the terminal box, exactly like a real
// shell prompt would accept — still one real exec API call, just
// with /bin/sh as the interpreter instead of us parsing shell
// syntax ourselves in PHP.
$output = $client->execRun($id, ['/bin/sh', '-c', $commandLine], $cwd);
podman_json_response(open_terminal($name, $shell));
break;
podman_json_response(['output' => $output]);
case 'close':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
close_terminal($name);
podman_json_response(['status' => 'closed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
function sock_path_for(string $containerName): string
{
// "podman." prefix keeps this plugin's per-container sockets under
// /var/tmp from ever colliding with Unraid's own docker-exec sockets
// (/var/tmp/<name>.sock), which are named after the same container
// names a user might also give their podman containers.
return '/var/tmp/podman.' . $containerName . '.sock';
}
/**
* @return array<string,mixed>
*/
function open_terminal(string $containerName, string $shell): array
{
// Close out any previous session for this container first — sockets
// are named deterministically per-container (not per-open-call), so
// without this, re-opening the same container's terminal (or switching
// shells) would try to bind a second ttyd to the same path and leave
// the first one orphaned, still running, holding /dev resources for a
// client that will never come.
close_terminal($containerName);
$sockPath = sock_path_for($containerName);
// -s9: send SIGKILL to the wrapped command when the client disconnects
// (no orphaned `podman exec` process lingering after the window is
// closed). -o -m1: accept exactly one client, then exit instead of
// staying resident waiting for a next one — matching exactly the
// options Unraid's own OpenTerminal.php uses for `docker exec` (see
// that file's 'docker' case).
$cmd = sprintf(
'ttyd-exec -s9 -o -m1 -i %s podman exec -it %s %s',
escapeshellarg($sockPath),
escapeshellarg($containerName),
escapeshellarg($shell)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
podman_json_error('Could not start terminal session', 500);
}
return ['sockName' => 'podman.' . $containerName];
}
/**
* Kills the ttyd instance (if any) bound to this container's socket, and
* removes the socket file. Matched via `pgrep -f` against the socket path
* embedded in ttyd's own argv (the -i flag passed in open_terminal()) —
* that's a stable, unique needle since it includes the "podman." prefix
* and the validated container name. Killing ttyd itself (rather than
* just closing a client connection nothing is holding) tears down the
* `podman exec` child with it, same as closing a real terminal window
* would once a client was attached.
*/
function close_terminal(string $containerName): void
{
$sockPath = sock_path_for($containerName);
exec('pgrep -f ' . escapeshellarg($sockPath) . ' 2>/dev/null', $pids);
foreach ($pids as $pid) {
if (ctype_digit($pid)) {
exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null');
}
}
@unlink($sockPath);
}
+23
View File
@@ -8,6 +8,8 @@
* list GET -> normalized image list
* pull POST {"reference": "docker.io/library/postgres:16"}
* remove POST {"id": "...", "force": false}
* prune POST {} -> removes every image not used by any container
* tag POST {"id": "...", "repo": "...", "tag": "latest"}
*/
declare(strict_types=1);
@@ -40,6 +42,27 @@ switch ($action) {
podman_json_response(['status' => 'removed']);
break;
case 'prune':
$removed = $client->pruneImages();
$reclaimed = 0;
foreach ($removed as $r) {
$reclaimed += (int) ($r['Size'] ?? 0);
}
podman_json_response(['removedCount' => count($removed), 'reclaimedBytes' => $reclaimed]);
break;
case 'tag':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
$repo = trim((string) ($body['repo'] ?? ''));
$tag = trim((string) ($body['tag'] ?? '')) ?: 'latest';
if ($id === '' || $repo === '') {
podman_json_error('Missing id or repo in request body', 400);
}
$client->tagImage($id, $repo, $tag);
podman_json_response(['status' => 'tagged']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
+83 -3
View File
@@ -8,7 +8,9 @@
*
* Actions (?action=...):
* list GET -> normalized network list with subnet/gateway/usage
* create POST {"name": "...", "driver": "bridge", "subnet": "...", "gateway": "..."}
* list_parent_interfaces GET -> host bridge/VLAN interfaces available as a macvlan parent
* create POST {"name": "...", "driver": "bridge"|"macvlan", "subnet": "...",
* "gateway": "...", "parentInterface": "br0"}
* remove POST {"name": "...", "force": false}
*/
@@ -23,17 +25,40 @@ switch ($action) {
podman_json_response(networks_list($client));
break;
case 'list_parent_interfaces':
podman_json_response(macvlan_parent_interfaces());
break;
case 'create':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
$driver = (string) ($body['driver'] ?? 'bridge');
$parentInterface = null;
if ($driver === 'macvlan') {
$parentInterface = (string) ($body['parentInterface'] ?? '');
// Only ever accept an interface this same host reported via
// macvlan_parent_interfaces() — the boundary preventing a
// tampered request from asking podman to attach to an
// arbitrary/unexpected interface name.
$known = array_column(macvlan_parent_interfaces(), 'interface');
if (!in_array($parentInterface, $known, true)) {
podman_json_error('Unknown parent interface — refresh the page and try again.', 400);
}
if (!isset($body['subnet']) || (string) $body['subnet'] === '') {
podman_json_error('Subnet is required for a macvlan network.', 400);
}
}
podman_json_response($client->createNetwork(
$name,
(string) ($body['driver'] ?? 'bridge'),
$driver,
isset($body['subnet']) ? (string) $body['subnet'] : null,
isset($body['gateway']) ? (string) $body['gateway'] : null
isset($body['gateway']) ? (string) $body['gateway'] : null,
$parentInterface
));
break;
@@ -54,6 +79,61 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Reads Unraid's own /boot/config/network.cfg (BRNAME[i]/VLANID[i,j]/
* DESCRIPTION[i,j]) to list the same host bridge + VLAN interfaces
* Unraid's own Docker Manager offers as "Custom: br0" / "Custom: br0.3
* (VPN)" network types — reusing Unraid's own config instead of guessing
* from raw `ip link` output, so the list always matches what Docker
* Manager shows for the same host. Verified live: this host's
* network.cfg has BRNAME[0]="br0" and VLANID[0,1]="3"/DESCRIPTION[0,1]=
* "VPN", producing "br0" and "br0.3 (VPN)" — matching the interface
* names shown in that other plugin's own network-type dropdown exactly.
* Each candidate is confirmed to actually exist in /sys/class/net before
* being offered, in case network.cfg mentions an interface that isn't
* currently up.
*
* @return array<int,array{interface:string,label:string}>
*/
function macvlan_parent_interfaces(): array
{
$cfgFile = '/boot/config/network.cfg';
if (!is_file($cfgFile)) {
return [];
}
$cfg = [];
foreach (file($cfgFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (preg_match('/^([A-Z0-9_]+)\[(\d+)(?:,(\d+))?\]="([^"]*)"$/', $line, $m) !== 1) {
continue;
}
[, $key, $i, $j, $value] = $m + [3 => ''];
$i = (int) $i;
if ($j === '') {
$cfg[$key][$i] = $value;
} else {
$cfg[$key][$i][(int) $j] = $value;
}
}
$out = [];
foreach (($cfg['BRNAME'] ?? []) as $i => $brname) {
if (!is_string($brname) || $brname === '' || !is_dir("/sys/class/net/{$brname}")) {
continue;
}
$out[] = ['interface' => $brname, 'label' => $brname];
foreach (($cfg['VLANID'][$i] ?? []) as $j => $vlanId) {
$iface = "{$brname}.{$vlanId}";
if (!is_dir("/sys/class/net/{$iface}")) {
continue;
}
$desc = $cfg['DESCRIPTION'][$i][$j] ?? '';
$out[] = ['interface' => $iface, 'label' => $iface . ($desc !== '' ? " ({$desc})" : '')];
}
}
return $out;
}
/** @return array<int,array<string,mixed>> */
function networks_list(PodmanClient $client): array
{
+63
View File
@@ -9,8 +9,10 @@
*
* Actions (?action=...):
* list GET -> pods with nested container summaries
* create POST {"name": "...", "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}]}
* start POST {"name": "..."}
* stop POST {"name": "...", "timeout": 10}
* restart POST {"name": "...", "timeout": 10}
* remove POST {"name": "...", "force": false}
*/
@@ -25,6 +27,12 @@ switch ($action) {
podman_json_response(pods_list($client));
break;
case 'create':
$body = podman_read_json_body();
$id = $client->createPod(build_pod_spec($body));
podman_json_response(['id' => $id, 'status' => 'created']);
break;
case 'start':
$body = podman_read_json_body();
$client->startPod(require_name($body));
@@ -37,6 +45,12 @@ switch ($action) {
podman_json_response(['status' => 'stopped']);
break;
case 'restart':
$body = podman_read_json_body();
$client->restartPod(require_name($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
podman_json_response(['status' => 'restarted']);
break;
case 'remove':
$body = podman_read_json_body();
$client->removePod(require_name($body), (bool) ($body['force'] ?? false));
@@ -47,6 +61,55 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Builds a libpod pod-create body from the "New Pod" form fields. Verified
* live against a real podman system service — {"name": "...",
* "portmappings": [...]} creates a pod with a shared infra container whose
* port bindings apply to every member container.
*
* @param array<string,mixed> $body
* @return array<string,mixed>
*/
function build_pod_spec(array $body): array
{
$name = trim((string) ($body['name'] ?? ''));
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
// Same character set podman enforces for container names (define.NameRegex
// in libpod applies to pods too) — validated here for the same reason
// ajax/containers.php validates it: a clear message instead of podman's
// raw "names must match ...: invalid argument".
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) {
podman_json_error(
"Pod name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" .
preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.',
400
);
}
$spec = ['name' => $name];
$ports = [];
foreach (($body['ports'] ?? []) as $row) {
$hostPort = (int) ($row['hostPort'] ?? 0);
$containerPort = (int) ($row['containerPort'] ?? 0);
if ($hostPort > 0 && $containerPort > 0) {
$ports[] = [
'host_ip' => '',
'host_port' => $hostPort,
'container_port' => $containerPort,
'protocol' => (string) ($row['protocol'] ?? 'tcp'),
];
}
}
if ($ports !== []) {
$spec['portmappings'] = $ports;
}
return $spec;
}
/** @param array<string,mixed> $body */
function require_name(array $body): string
{
+4 -1
View File
@@ -59,7 +59,10 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
return [
'reachable' => true,
'socketPath' => $config->socketPath,
'podmanVersion' => $info['Version']['Version'] ?? null,
// libpod's /info nests the version block under lowercase "version"
// (unlike most other libpod endpoints, which are PascalCase
// throughout) — verified live against a real podman system service.
'podmanVersion' => $info['version']['Version'] ?? null,
'containers' => [
'total' => count($containers),
'running' => $running,
+385
View File
@@ -0,0 +1,385 @@
<?php
/**
* ajax/templates.php
*
* Backs the Templates panel — reusable container configs, saved and
* loaded as XML in the same schema Unraid's own Docker Manager uses for
* its Community Applications templates (<Container version="2"> with
* <Config Type="Port"|"Path"|"Variable"> entries). Deliberately the SAME
* schema, not a podman-specific one of our own: it's the one users and
* template authors already know, and it means a template exported here
* carries over the fields (image, ports, paths, variables, icon,
* category, overview) a Docker template would too, even though the two
* ecosystems' XML isn't fully interchangeable (this plugin's Config
* entries don't have every attribute dockerMan's does, e.g. no GPU/USB/
* device passthrough yet — see docs/ARCHITECTURE.md section 18).
*
* Stored at $bootDir/templates/<name>.xml — same boot-persistence
* reasoning as compose/autostart/networks (see ajax/compose.php).
*
* Actions (?action=...):
* list GET -> [{name, image, icon, category, overview}, ...]
* get GET (&name=...) -> full parsed config, for prefilling the
* Create Container form ("Use template")
* export GET (&name=...) -> {xml: "<raw XML text>"} for download
* save POST {"name": "...", "image": "...", "icon": "...", "category": "...",
* "overview": "...", "networkMode": "...", "privileged": false,
* "restartPolicy": "...", "ports": [...], "volumes": [...], "env": [...]}
* import POST {"xml": "<raw XML text>"} -> parses + saves as a new template
* list_local GET -> [{file, name, image, icon}, ...] from Unraid's own
* dockerMan template directories (real existing Docker
* templates the user already has — see
* local_dockerman_templates_list()), for a "browse local
* templates" picker instead of copy-pasting XML by hand
* import_local POST {"file": "gitea.xml"} -> imports one by filename
* (validated against the same directories list_local
* scanned, never an arbitrary path from the client)
* remove POST {"name": "..."}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$templatesDir = $podmanConfig->bootDir . '/templates';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(templates_list($templatesDir));
break;
case 'get':
podman_json_response(template_read(require_template_path($templatesDir, (string) ($_GET['name'] ?? ''))));
break;
case 'export':
$path = require_template_path($templatesDir, (string) ($_GET['name'] ?? ''));
podman_json_response(['xml' => file_get_contents($path)]);
break;
case 'save':
$body = podman_read_json_body();
$name = trim((string) ($body['name'] ?? ''));
if (!is_valid_template_name($name)) {
podman_json_error('Template name must be non-empty and contain only letters, digits, "-", "_".', 400);
}
template_write($templatesDir, $name, $body);
podman_json_response(['status' => 'saved', 'name' => $name]);
break;
case 'import':
$body = podman_read_json_body();
$xml = (string) ($body['xml'] ?? '');
if (trim($xml) === '') {
podman_json_error('Missing xml in request body', 400);
}
$name = template_import($templatesDir, $xml);
podman_json_response(['status' => 'imported', 'name' => $name]);
break;
case 'list_local':
podman_json_response(local_dockerman_templates_list());
break;
case 'import_local':
$body = podman_read_json_body();
$file = (string) ($body['file'] ?? '');
$name = template_import($templatesDir, local_dockerman_template_read($file));
podman_json_response(['status' => 'imported', 'name' => $name]);
break;
case 'remove':
$body = podman_read_json_body();
$path = require_template_path($templatesDir, (string) ($body['name'] ?? ''));
unlink($path);
podman_json_response(['status' => 'removed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Template names become filenames — restricted to a fixed safe character
* set BEFORE ever being used to build a filesystem path, the same
* pattern ajax/compose.php's is_valid_project_name() uses for the same
* reason.
*/
function is_valid_template_name(string $name): bool
{
return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
}
function require_template_path(string $templatesDir, string $name): string
{
if (!is_valid_template_name($name)) {
podman_json_error('Invalid template name', 400);
}
$path = $templatesDir . '/' . $name . '.xml';
if (!is_file($path)) {
podman_json_error("Template '{$name}' not found", 404);
}
return $path;
}
/** @return array<int,array<string,mixed>> */
function templates_list(string $templatesDir): array
{
if (!is_dir($templatesDir)) {
return [];
}
$out = [];
foreach (scandir($templatesDir) ?: [] as $entry) {
if (!str_ends_with($entry, '.xml')) {
continue;
}
$name = substr($entry, 0, -4);
if (!is_valid_template_name($name)) {
continue;
}
try {
$parsed = template_read($templatesDir . '/' . $entry);
} catch (\Throwable $e) {
continue; // a hand-edited/corrupt file shouldn't break the whole list
}
$out[] = [
'name' => $name,
'image' => $parsed['image'],
'icon' => $parsed['icon'],
'category' => $parsed['category'],
'overview' => $parsed['overview'],
];
}
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
/**
* Parses one template XML file into the same shape
* ajax/containers.php's build_container_spec() (Create Container form)
* consumes, so "Use template" can feed straight into that form/action.
*
* @return array<string,mixed>
*/
function template_read(string $path): array
{
$xml = @simplexml_load_file($path);
if ($xml === false) {
podman_json_error("Could not parse template XML: {$path}", 500);
}
$ports = [];
$volumes = [];
$env = [];
foreach ($xml->Config as $cfg) {
$attrs = $cfg->attributes();
$type = (string) ($attrs['Type'] ?? '');
$value = trim((string) $cfg);
$target = (string) ($attrs['Target'] ?? '');
if ($value === '') {
continue;
}
switch ($type) {
case 'Port':
$ports[] = [
'hostPort' => (int) $value,
'containerPort' => (int) ($target !== '' ? $target : $value),
'protocol' => (string) ($attrs['Mode'] ?? 'tcp') ?: 'tcp',
];
break;
case 'Path':
$volumes[] = [
'kind' => 'path',
'source' => $value,
'containerPath' => $target !== '' ? $target : $value,
];
break;
case 'Variable':
$env[] = ['key' => $target !== '' ? $target : (string) ($attrs['Name'] ?? ''), 'value' => $value];
break;
}
}
return [
'image' => (string) $xml->Repository,
'networkMode' => (string) ($xml->Network ?: 'bridge'),
'privileged' => strtolower((string) $xml->Privileged) === 'true',
'icon' => (string) $xml->Icon,
'category' => (string) $xml->Category,
'overview' => (string) $xml->Overview,
'ports' => $ports,
'volumes' => $volumes,
'env' => $env,
];
}
/**
* Writes a template XML file from the Create Container form's field
* shapes (same as build_container_spec() in ajax/containers.php takes)
* plus template-only metadata (icon/category/overview). Uses DOMDocument
* rather than string concatenation so every value is properly escaped —
* no risk of a "<" or "&" in an image name/description breaking the XML.
*
* @param array<string,mixed> $body
*/
function template_write(string $templatesDir, string $name, array $body): void
{
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
podman_json_error("Could not create {$templatesDir}", 500);
}
$doc = new \DOMDocument('1.0');
$doc->formatOutput = true;
$root = $doc->createElement('Container');
$root->setAttribute('version', '2');
$doc->appendChild($root);
$append = static function (string $tag, string $value) use ($doc, $root): void {
$root->appendChild($doc->createElement($tag, $value));
};
$append('Name', $name);
$append('Repository', trim((string) ($body['image'] ?? '')));
$append('Network', (string) ($body['networkMode'] ?? 'bridge'));
$append('Privileged', ($body['privileged'] ?? false) ? 'true' : 'false');
$append('Overview', (string) ($body['overview'] ?? ''));
$append('Category', (string) ($body['category'] ?? ''));
$append('Icon', (string) ($body['icon'] ?? ''));
foreach (($body['ports'] ?? []) as $row) {
$hostPort = (string) ($row['hostPort'] ?? '');
$containerPort = (string) ($row['containerPort'] ?? '');
if ($hostPort === '' || $containerPort === '') {
continue;
}
$cfg = $doc->createElement('Config', $hostPort);
$cfg->setAttribute('Name', 'Port ' . $containerPort);
$cfg->setAttribute('Target', $containerPort);
$cfg->setAttribute('Mode', (string) ($row['protocol'] ?? 'tcp'));
$cfg->setAttribute('Type', 'Port');
$root->appendChild($cfg);
}
foreach (($body['volumes'] ?? []) as $row) {
$source = (string) ($row['source'] ?? '');
$containerPath = (string) ($row['containerPath'] ?? '');
if ($source === '' || $containerPath === '' || ($row['kind'] ?? 'named') !== 'path') {
continue; // named (podman-managed) volumes aren't portable across hosts, so templates only capture host-path binds
}
$cfg = $doc->createElement('Config', $source);
$cfg->setAttribute('Name', basename($containerPath));
$cfg->setAttribute('Target', $containerPath);
$cfg->setAttribute('Mode', 'rw');
$cfg->setAttribute('Type', 'Path');
$root->appendChild($cfg);
}
foreach (($body['env'] ?? []) as $row) {
$key = (string) ($row['key'] ?? '');
if ($key === '') {
continue;
}
$cfg = $doc->createElement('Config', (string) ($row['value'] ?? ''));
$cfg->setAttribute('Name', $key);
$cfg->setAttribute('Target', $key);
$cfg->setAttribute('Type', 'Variable');
$root->appendChild($cfg);
}
if ($doc->save($templatesDir . '/' . $name . '.xml') === false) {
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
}
}
/**
* Imports a pasted/uploaded template XML (this plugin's own schema, or
* a plain Unraid dockerMan template — both use the same <Container>/
* <Config> shape). Validates it parses and has a usable <Name> before
* writing it under our own templates dir with a sanitized filename.
*/
function template_import(string $templatesDir, string $xmlText): string
{
$xml = @simplexml_load_string($xmlText);
if ($xml === false || $xml->getName() !== 'Container') {
podman_json_error('Not a valid template XML (expected a <Container> root element)', 400);
}
$rawName = trim((string) $xml->Name);
$name = preg_replace('/[^a-zA-Z0-9_-]/', '-', $rawName);
if (!is_valid_template_name((string) $name)) {
podman_json_error('Template XML has no usable <Name>', 400);
}
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
podman_json_error("Could not create {$templatesDir}", 500);
}
if (file_put_contents($templatesDir . '/' . $name . '.xml', $xmlText, LOCK_EX) === false) {
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
}
return (string) $name;
}
/**
* Unraid's own Docker Manager plugin stores every template a user has
* ever saved/customized under templates-user/, plus a local cache of
* Community Applications' own catalog under templates-community/ (may
* be empty/absent if CA was never installed) — both in the same
* <Container>/<Config> XML shape this plugin reads. Read-only: this
* plugin never writes into dockerMan's own directories.
*
* @return array<int,string>
*/
function dockerman_template_dirs(): array
{
return array_values(array_filter([
'/boot/config/plugins/dockerMan/templates-user',
'/boot/config/plugins/dockerMan/templates-community',
], 'is_dir'));
}
/** @return array<int,array<string,string>> */
function local_dockerman_templates_list(): array
{
$out = [];
foreach (dockerman_template_dirs() as $dir) {
foreach (scandir($dir) ?: [] as $entry) {
if (!str_ends_with($entry, '.xml')) {
continue;
}
$xml = @simplexml_load_file($dir . '/' . $entry);
if ($xml === false) {
continue; // skip anything that doesn't parse rather than failing the whole list
}
$out[] = [
'file' => $entry,
'name' => (string) $xml->Name,
'image' => (string) $xml->Repository,
'icon' => (string) $xml->Icon,
];
}
}
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
/**
* Reads one local dockerMan template's raw XML by filename. $file comes
* straight from client input — reduced to its basename and required to
* actually exist in one of dockerman_template_dirs() (the same list
* list_local scanned), never treated as an arbitrary path.
*/
function local_dockerman_template_read(string $file): string
{
$file = basename($file);
if (!str_ends_with($file, '.xml')) {
podman_json_error('Invalid template file', 400);
}
foreach (dockerman_template_dirs() as $dir) {
$path = $dir . '/' . $file;
if (is_file($path)) {
$content = file_get_contents($path);
if ($content !== false) {
return $content;
}
}
}
podman_json_error("Local template '{$file}' not found", 404);
}
+15 -2
View File
@@ -9,7 +9,7 @@
*
* Actions (?action=...):
* list GET -> normalized volume list, with usedBy counts
* create POST {"name": "...", "driver": "local"}
* create POST {"name": "...", "driver": "local", "path": "/mnt/cache/..." (optional)}
* remove POST {"name": "...", "force": false}
*/
@@ -30,7 +30,11 @@ switch ($action) {
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local')));
$path = trim((string) ($body['path'] ?? ''));
if ($path !== '' && !str_starts_with($path, '/')) {
podman_json_error("Host path ({$path}) must be an absolute path.", 400);
}
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local'), $path !== '' ? $path : null));
break;
case 'remove':
@@ -65,10 +69,19 @@ function volumes_list(PodmanClient $client): array
$out = [];
foreach ($raw as $v) {
$name = (string) ($v['Name'] ?? '');
$options = $v['Options'] ?? [];
// A volume created with our "Host path" field carries
// type=none,o=bind,device=<path> (see PodmanClient::createVolume)
// — surfaced separately from 'mountpoint' (podman's own internal
// storage path, which stays populated even for bind-backed
// volumes) so the UI can show users the host path they actually
// asked for.
$hostPath = (is_array($options) && ($options['o'] ?? '') === 'bind') ? (string) ($options['device'] ?? '') : null;
$out[] = [
'name' => $name,
'driver' => (string) ($v['Driver'] ?? 'local'),
'mountpoint' => (string) ($v['Mountpoint'] ?? ''),
'hostPath' => $hostPath,
'createdAt' => podman_parse_time($v['CreatedAt'] ?? null),
'usedBy' => $usageCounts[$name] ?? 0,
];
+174 -44
View File
@@ -101,6 +101,31 @@ final class PodmanClient
return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']);
}
/**
* POST /containers/create — takes a libpod SpecGenerator body. Field
* names/shapes below (image, name, command, env, portmappings,
* netns, networks, mounts, volumes, restart_policy, privileged) were
* verified live against a real podman system service, not assumed
* from docs — see ajax/containers.php's create action, which builds
* this array from the WebUI's Create Container form.
*
* @param array<string,mixed> $spec
* @return string the new container's ID
*/
public function createContainer(array $spec): string
{
// Longer than this client's normal 15s operation timeout as cheap
// insurance: creating a container involves setting up its mounts
// (often onto Unraid array/spinning-disk shares, not the cache
// pool) and network namespace, which can occasionally run past 15s
// even with the image already pulled — found live via a real
// "Operation timed out after 15001 milliseconds" error creating a
// container. Same 600s ceiling as pullImage(), safely under
// nginx's 640s fastcgi_read_timeout.
$result = $this->request('POST', '/containers/create', [], false, $spec, 600);
return (string) ($result['Id'] ?? '');
}
public function startContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
@@ -121,6 +146,27 @@ final class PodmanClient
$this->request('DELETE', '/containers/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
public function pauseContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/pause', [], true);
}
public function unpauseContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/unpause', [], true);
}
/** $signal accepts both a name ("SIGKILL") and a bare number, matching libpod's own `kill?signal=` parsing. */
public function killContainer(string $id, string $signal = 'SIGKILL'): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/kill', ['signal' => $signal], true);
}
public function renameContainer(string $id, string $newName): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/rename', ['name' => $newName], true);
}
/**
* GET /containers/{id}/logs — returns the raw (already de-multiplexed
* where possible) log text. Podman's non-TTY log stream uses the same
@@ -147,41 +193,6 @@ final class PodmanClient
// have — see that file's header comment for the full explanation).
// -------------------------------------------------------------------
/**
* Creates and immediately runs one command inside a container via the
* real libpod exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) and returns its combined stdout+stderr output.
* Tty=true is used deliberately so the response is a plain byte stream
* with no frame-header demultiplexing needed (see containerLogs() for
* the non-TTY case, which does need it).
*/
public function execRun(string $containerId, array $cmd, string $workingDir = ''): string
{
$createBody = [
'AttachStdin' => false,
'AttachStdout' => true,
'AttachStderr' => true,
'Tty' => true,
'Cmd' => $cmd,
];
if ($workingDir !== '') {
$createBody['WorkingDir'] = $workingDir;
}
$created = $this->request('POST', '/containers/' . rawurlencode($containerId) . '/exec', [], false, $createBody);
$execId = $created['Id'] ?? null;
if (!is_string($execId) || $execId === '') {
throw new PodmanApiException('exec create response did not include an Id');
}
$output = $this->requestRaw('POST', '/exec/' . rawurlencode($execId) . '/start', [], [
'Detach' => false,
'Tty' => true,
]);
return $output;
}
// -------------------------------------------------------------------
// Pods
// -------------------------------------------------------------------
@@ -196,6 +207,22 @@ final class PodmanClient
return $this->request('GET', '/pods/' . rawurlencode($name) . '/json');
}
/**
* POST /pods/create — takes a body of {name, portmappings, ...}.
* Verified live against a real podman system service: {"name":"...",
* "portmappings":[{"host_port":...,"container_port":...,"protocol":...}]}
* creates a pod with a shared infra container whose port bindings apply
* to every member container — see ajax/pods.php's build_pod_spec().
*
* @param array<string,mixed> $spec
* @return string the new pod's ID
*/
public function createPod(array $spec): string
{
$result = $this->request('POST', '/pods/create', [], false, $spec);
return (string) ($result['Id'] ?? '');
}
public function startPod(string $name): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true);
@@ -206,6 +233,11 @@ final class PodmanClient
$this->request('POST', '/pods/' . rawurlencode($name) . '/stop', ['t' => (string) $timeoutSeconds], true);
}
public function restartPod(string $name, int $timeoutSeconds = 10): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/restart', ['t' => (string) $timeoutSeconds], true);
}
public function removePod(string $name, bool $force = false): void
{
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
@@ -220,10 +252,61 @@ final class PodmanClient
return $this->request('GET', '/images/json');
}
/** POST /images/pull — pulls (or updates) an image by reference, e.g. "docker.io/library/postgres:16". */
/**
* POST /images/pull — pulls (or updates) an image by reference, e.g.
* "docker.io/library/postgres:16".
*
* Unlike virtually every other libpod endpoint, a successful pull's
* response body is NOT one JSON document — it's newline-delimited
* JSON, one progress object per line (verified live:
* `{"status":"pulling","stream":"..."}` repeated, then a final
* `{"status":"success","images":[...],"id":"..."}` line). Feeding
* that whole blob through the normal single-document request() here
* made json_decode() fail on every successful pull with "Expected a
* JSON object/array response from /images/pull" — found by
* live-testing a real pull through the WebUI's Images panel, not
* from reading libpod's docs. An error that happens before any
* image data is found (e.g. unknown reference) is unaffected: libpod
* sends that as a normal single-JSON-object 4xx response, which
* requestRaw()/request()'s existing status>=400 handling already
* covers correctly.
*/
public function pullImage(string $reference): array
{
return $this->request('POST', '/images/pull', ['reference' => $reference]);
// A real image (e.g. a Plex/media-server image, easily several
// hundred MB) routinely takes far longer than this client's normal
// 15s operation timeout to download — found live: a pull aborted
// mid-stream with "Operation timed out after 15001 milliseconds"
// after only ~1.4KB of progress data. nginx's own fastcgi_read_timeout
// (640s, see /etc/nginx/nginx.conf) already anticipates long-running
// plugin requests, so 600s here stays safely under that.
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference], null, 600);
$last = null;
foreach (explode("\n", trim($raw)) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (!is_array($decoded)) {
continue;
}
// A mid-stream error (pull started, then failed — e.g. the
// connection dropped partway through a layer) is reported as
// an {"error": "..."} line rather than an HTTP error status,
// since headers/status are already committed by the time
// libpod knows the pull failed.
if (isset($decoded['error'])) {
throw new PodmanApiException((string) $decoded['error'], 502);
}
$last = $decoded;
}
if ($last === null) {
throw new PodmanApiException('Expected a JSON object/array response from /images/pull');
}
return $last;
}
public function removeImage(string $id, bool $force = false): void
@@ -231,6 +314,28 @@ final class PodmanClient
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
/**
* POST /images/prune?all=true — removes every image with zero containers
* (running or stopped) referencing it, matching this app's own "Used By"
* column — not just dangling/untagged images. Verified live: a tagged
* but unused image IS removed with all=true (found the hard way: it
* also removed every image on a host with no containers at all, which
* is correct behavior, just aggressive — see ajax/images.php's prune
* action for the confirmation-copy this justifies).
*
* @return array<int,array{Id:string,Size:int}> one entry per removed image
*/
public function pruneImages(): array
{
return $this->request('POST', '/images/prune', ['all' => 'true']);
}
/** POST /images/{id}/tag?repo=...&tag=... — adds a new repo:tag pointing at an existing image. */
public function tagImage(string $id, string $repo, string $tag): void
{
$this->request('POST', '/images/' . rawurlencode($id) . '/tag', ['repo' => $repo, 'tag' => $tag], true);
}
// -------------------------------------------------------------------
// Volumes
// -------------------------------------------------------------------
@@ -240,9 +345,21 @@ final class PodmanClient
return $this->request('GET', '/volumes/json');
}
public function createVolume(string $name, string $driver = 'local'): array
/**
* $hostPath, if given, binds the volume directly to an existing host
* directory instead of a podman-managed one — the local driver's
* `type=none,o=bind,device=<path>` option trio (same mechanism
* `podman volume create --opt type=none --opt o=bind --opt device=...`
* uses on the CLI). Verified live: a container mounting such a volume
* reads/writes the host path directly, not an internal copy.
*/
public function createVolume(string $name, string $driver = 'local', ?string $hostPath = null): array
{
return $this->request('POST', '/volumes/create', [], false, ['Name' => $name, 'Driver' => $driver]);
$body = ['Name' => $name, 'Driver' => $driver];
if ($hostPath !== null && $hostPath !== '') {
$body['Options'] = ['type' => 'none', 'device' => $hostPath, 'o' => 'bind'];
}
return $this->request('POST', '/volumes/create', [], false, $body);
}
public function removeVolume(string $name, bool $force = false): void
@@ -259,12 +376,25 @@ final class PodmanClient
return $this->request('GET', '/networks/json');
}
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null): array
/**
* $parentInterface (only meaningful for driver="macvlan") attaches the
* network directly to an existing host bridge/VLAN interface (e.g.
* Unraid's own "br0" or a VLAN sub-interface like "br0.3") via
* libpod's "network_interface" field — verified live: containers on
* such a network get a real address on that LAN/VLAN's own subnet,
* not a NATed one, matching Unraid Docker Manager's "Custom: br0"
* network type. See ajax/networks.php's macvlan_parent_interfaces()
* for where the interface list itself comes from.
*/
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null, ?string $parentInterface = null): array
{
$body = ['name' => $name, 'driver' => $driver];
if ($subnet !== null) {
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
}
if ($parentInterface !== null && $parentInterface !== '') {
$body['network_interface'] = $parentInterface;
}
return $this->request('POST', '/networks/create', [], false, $body);
}
@@ -285,9 +415,9 @@ final class PodmanClient
* @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
* @return array<mixed>
*/
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null): array
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null, ?int $timeoutSeconds = null): array
{
$raw = $this->requestRaw($method, $path, $query, $jsonBody);
$raw = $this->requestRaw($method, $path, $query, $jsonBody, $timeoutSeconds);
if ($expectEmptyBody || trim($raw) === '') {
return [];
}
@@ -306,7 +436,7 @@ final class PodmanClient
* @param array<string,string> $query
* @param array<mixed>|null $jsonBody
*/
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null): string
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null, ?int $timeoutSeconds = null): string
{
$url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
if (!empty($query)) {
@@ -319,7 +449,7 @@ final class PodmanClient
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_TIMEOUT => $timeoutSeconds ?? $this->timeoutSeconds,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
@@ -0,0 +1,172 @@
<?php
/**
* RegistryClient.php
*
* "Is a newer image available?" — deliberately NOT a podman/libpod feature
* (verified live: no libpod endpoint exists for this; every tool that
* offers it, Watchtower/Diun/Unraid's own Docker Manager included,
* re-implements the same registry-side check). This talks directly to the
* target image's own registry using the standard Docker Registry HTTP API
* V2: a GET on the manifest returns a "Docker-Content-Digest" header
* without downloading any image layers, which is compared against the
* digest of the image already pulled locally (PodmanClient::listImages()'s
* own "Digest" field) — no local image ever needs pulling just to check.
*
* The auth flow is the generic Bearer-challenge dance every compliant
* registry follows (RFC-ish, not just a Docker Hub thing): an
* unauthenticated request gets a 401 with a WWW-Authenticate header naming
* a token realm/service/scope, a token is fetched from that realm, and the
* manifest request is retried with it. Verified live against three
* different registries with three different auth setups — Docker Hub,
* ghcr.io, and a self-hosted Gitea registry — using this exact same code
* path for all three, not registry-specific special-casing.
*/
declare(strict_types=1);
final class RegistryClient
{
/**
* @return array{updateAvailable?:bool,remoteDigest?:string,error?:string}
*/
public static function checkForUpdate(string $reference, string $localDigest): array
{
[$registry, $repo, $tag] = self::parseReference($reference);
$manifestUrl = "https://{$registry}/v2/{$repo}/manifests/{$tag}";
$accept = 'application/vnd.docker.distribution.manifest.v2+json, ' .
'application/vnd.docker.distribution.manifest.list.v2+json, ' .
'application/vnd.oci.image.manifest.v1+json, ' .
'application/vnd.oci.image.index.v1+json';
[$status, $headers] = self::httpRequest($manifestUrl, $accept, null);
if ($status === 401) {
$challenge = self::parseAuthChallenge($headers['www-authenticate'] ?? '');
if ($challenge === null) {
return ['error' => 'Registry requires authentication this app cannot satisfy.'];
}
$token = self::fetchToken($challenge);
if ($token === null) {
return ['error' => 'Could not authenticate with the registry.'];
}
[$status, $headers] = self::httpRequest($manifestUrl, $accept, $token);
}
if ($status !== 200) {
return ['error' => "Registry returned HTTP {$status}."];
}
$remoteDigest = $headers['docker-content-digest'] ?? null;
if ($remoteDigest === null) {
return ['error' => 'Registry response did not include a digest.'];
}
return ['remoteDigest' => $remoteDigest, 'updateAvailable' => $remoteDigest !== $localDigest];
}
/**
* Splits "docker.io/library/nginx:alpine" (or shorthand forms like
* "nginx:alpine" or "someuser/repo:tag") into [registryHost, repoPath,
* tag] — same reference-parsing convention every registry client
* (including podman/Docker themselves) uses: the first path segment is
* a registry host only if it contains a "." or ":" or is "localhost";
* otherwise the whole reference is a Docker Hub repo, implicitly under
* "library/" if it has no namespace of its own. docker.io's actual API
* host is registry-1.docker.io, not docker.io itself — a Docker-Hub-
* specific quirk, not something inferred from the general rule above.
*
* @return array{0:string,1:string,2:string}
*/
private static function parseReference(string $reference): array
{
$reference = explode('@', $reference, 2)[0]; // strip any @sha256:... suffix
$tag = 'latest';
$lastSlash = strrpos($reference, '/');
$lastColon = strrpos($reference, ':');
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
$tag = substr($reference, $lastColon + 1);
$reference = substr($reference, 0, $lastColon);
}
$parts = explode('/', $reference);
$first = $parts[0];
$looksLikeHost = str_contains($first, '.') || str_contains($first, ':') || $first === 'localhost';
if ($looksLikeHost) {
$registry = $first;
$repo = implode('/', array_slice($parts, 1));
} else {
$registry = 'docker.io';
$repo = str_contains($reference, '/') ? $reference : "library/{$reference}";
}
if ($registry === 'docker.io') {
$registry = 'registry-1.docker.io';
}
return [$registry, $repo, $tag];
}
/** @return array{realm:string,service:string,scope:string}|null */
private static function parseAuthChallenge(string $header): ?array
{
if (preg_match('/realm="([^"]+)"/', $header, $m) !== 1) {
return null;
}
$service = preg_match('/service="([^"]+)"/', $header, $sm) === 1 ? $sm[1] : '';
$scope = preg_match('/scope="([^"]+)"/', $header, $om) === 1 ? $om[1] : '';
return ['realm' => $m[1], 'service' => $service, 'scope' => $scope];
}
/** @param array{realm:string,service:string,scope:string} $challenge */
private static function fetchToken(array $challenge): ?string
{
$params = array_filter(['service' => $challenge['service'], 'scope' => $challenge['scope']]);
$url = $challenge['realm'] . '?' . http_build_query($params);
[$status, , $body] = self::httpRequest($url, 'application/json', null, true);
if ($status !== 200 || $body === null) {
return null;
}
$decoded = json_decode($body, true);
// The spec allows either key; registries are inconsistent about
// which one they actually send.
return is_array($decoded) ? (string) ($decoded['token'] ?? $decoded['access_token'] ?? '') ?: null : null;
}
/**
* @return array{0:int,1:array<string,string>,2:?string} [status, lowercased response headers, body (only when $withBody)]
*/
private static function httpRequest(string $url, string $accept, ?string $token, bool $withBody = false): array
{
$ch = curl_init($url);
$headers = ['Accept: ' . $accept];
if ($token !== null) {
$headers[] = "Authorization: Bearer {$token}";
}
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($raw === false) {
return [0, [], null];
}
$parsedHeaders = [];
foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$parsedHeaders[strtolower(trim($k))] = trim($v);
}
}
$body = $withBody ? substr($raw, $headerSize) : null;
return [$status, $parsedHeaders, $body];
}
}
@@ -20,6 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/PodmanClient.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/RegistryClient.php';
set_exception_handler(static function (\Throwable $e): void {
if ($e instanceof PodmanApiException) {
+16 -4
View File
@@ -42,13 +42,25 @@ function podman_format_duration(int $seconds): string
return "{$minutes}m";
}
/** Converts a libpod RFC3339 timestamp (as found in inspect output) to a Unix timestamp, or null if unparsable. */
function podman_parse_time(?string $rfc3339): ?int
/**
* Converts a libpod timestamp to a Unix timestamp, or null if unparsable.
* Accepts both the RFC3339 strings `inspect`-style endpoints return and
* the raw Unix-epoch integers `list`-style endpoints return for the same
* logical field (e.g. containers/json's StartedAt/Created vs. inspect's)
* — verified live against a real podman system service, not just docs.
*/
function podman_parse_time(string|int|null $value): ?int
{
if ($rfc3339 === null || $rfc3339 === '' || str_starts_with($rfc3339, '0001-01-01')) {
if ($value === null || $value === '') {
return null;
}
$ts = strtotime($rfc3339);
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (str_starts_with($value, '0001-01-01')) {
return null;
}
$ts = strtotime($value);
return $ts === false ? null : $ts;
}
+237
View File
@@ -36,6 +36,17 @@ window.Podman = (function () {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
// Unraid's own webGui/include/local_prepend.php (auto_prepend_file on
// every PHP request, not something this plugin controls) kills any
// POST request with no output at all unless it carries the page's
// CSRF token — either as a "csrf_token" POST field or this header.
// `csrf_token` itself is a global var HeadInlineJS.php sets on every
// Unraid page before plugin JS loads (verified live: without this
// header, every mutating action failed with "JSON.parse: unexpected
// end of data", i.e. an empty response body from csrf_terminate()).
if (method === 'POST' && typeof window.csrf_token === 'string') {
opts.headers['X-CSRF-Token'] = window.csrf_token;
}
return fetch(url, opts)
.then(function (res) {
@@ -115,6 +126,229 @@ window.Podman = (function () {
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
}
// --- Modal form dialog -----------------------------------------------------
/**
* Shows a small form modal in place of browser-native prompt()/confirm()
* — needed for any action that takes more than one related value (e.g.
* "New Volume" wants a name AND an optional host path together; chaining
* prompt() calls for that is both bad UX and can't show both fields at
* once, or offer a hint under the path field explaining what it does).
*
* @param {object} opts
* @param {string} opts.title
* @param {Array<{name:string, label:string, placeholder?:string, hint?:string, required?:boolean}>} opts.fields
* @param {string} [opts.submitLabel]
* @param {(values: Object<string,string>) => Promise<any>} opts.onSubmit
* Called with {fieldName: value}. Rejecting keeps the modal open and
* shows the error inline; resolving closes it.
*/
function openFormModal(opts) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
const fieldsHtml = opts.fields.map(function (f) {
return '' +
'<div class="podman-modal-field">' +
'<label for="podman-modal-' + f.name + '">' + escapeHtml(f.label) + '</label>' +
'<input type="text" id="podman-modal-' + f.name + '" name="' + f.name + '"' +
(f.placeholder ? ' placeholder="' + escapeHtml(f.placeholder) + '"' : '') + '>' +
(f.hint ? '<div class="hint">' + escapeHtml(f.hint) + '</div>' : '') +
'</div>';
}).join('');
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title) + '</h3></div>' +
'<form class="podman-modal-body">' + fieldsHtml + '</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' +
escapeHtml(opts.submitLabel || 'Create') + '</button>' +
'</div></div>';
// Appended inside .podman-plugin, not document.body: the --surface/
// --border/etc. custom properties this modal's CSS relies on are
// scoped to .podman-plugin (see podman.css's token strategy comment),
// so a modal appended to body would resolve none of them — verified
// live: the backdrop dimming and card background were both missing,
// only the (inherited-from-body) text was visible. position:fixed
// still overlays the full viewport regardless of this nesting.
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const firstInput = backdrop.querySelector('input');
if (firstInput) firstInput.focus();
function close() {
backdrop.remove();
}
function submit() {
const values = {};
opts.fields.forEach(function (f) {
values[f.name] = backdrop.querySelector('#podman-modal-' + f.name).value.trim();
});
for (const f of opts.fields) {
if (f.required && !values[f.name]) {
showError('"' + f.label + '" is required.');
return;
}
}
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
Promise.resolve(opts.onSubmit(values)).then(close).catch(function (err) {
submitBtn.disabled = false;
showError(err.message || String(err));
});
}
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) {
e.preventDefault();
submit();
});
backdrop.addEventListener('click', function (e) {
if (e.target === backdrop) close();
});
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') {
close();
document.removeEventListener('keydown', onKey);
}
});
}
/**
* Small modal with a scrolling monospace log pane — for actions that run
* several steps in sequence (checking/updating containers) where a plain
* confirm()/alert() at the very end leaves the user with no feedback
* that anything is happening while it runs. Returns {log, done} rather
* than closing itself, since the caller knows when the whole sequence
* (not just one call) has actually finished.
*
* @param {string} title
* @returns {{log: (line: string) => void, done: (closeLabel?: string) => void}}
*/
function openLogModal(title) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + escapeHtml(title) + '</h3></div>' +
'<div class="podman-modal-body"><div class="podman-log-pane" id="podman-log-modal-pane"></div></div>' +
'<div class="podman-modal-actions"><button type="button" class="podman-btn podman-btn-primary" data-role="close" disabled>Working…</button></div>' +
'</div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const pane = backdrop.querySelector('#podman-log-modal-pane');
const closeBtn = backdrop.querySelector('[data-role="close"]');
function close() { backdrop.remove(); }
closeBtn.addEventListener('click', close);
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape' && !closeBtn.disabled) { close(); document.removeEventListener('keydown', onKey); }
});
function log(line) {
const row = document.createElement('div');
row.textContent = line;
pane.appendChild(row);
pane.scrollTop = pane.scrollHeight;
}
function done(closeLabel) {
closeBtn.disabled = false;
closeBtn.textContent = closeLabel || 'Close';
}
return { log: log, done: done };
}
/**
* Small anchored dropdown menu — used for secondary per-row actions
* (pause/kill/rename/...) that would otherwise clutter a table row with
* one icon button each. Only one menu is ever open at a time.
*
* @param {HTMLElement} anchorEl button the menu opens from/closes on
* @param {Array<{label:string, danger?:boolean, disabled?:boolean, onClick?:Function}|'separator'>} items
*/
let openMenuCloser = null;
function openContextMenu(anchorEl, items) {
if (openMenuCloser) {
openMenuCloser();
return;
}
const menu = document.createElement('div');
menu.className = 'podman-context-menu';
menu.innerHTML = items.map(function (item) {
if (item === 'separator') return '<div class="podman-context-menu-sep"></div>';
return '<button type="button" class="' + (item.danger ? 'danger' : '') + '"' +
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
}).join('');
// Viewport-relative (see the "position: fixed" comment on
// .podman-context-menu in podman.css) — no scrollY/scrollX added.
const rect = anchorEl.getBoundingClientRect();
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.left = (rect.right - 180) + 'px';
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
// menu.children includes the separator <div>s too, so indexing into it
// directly (by a counter that only advances for real items) drifts by
// one after every separator — e.g. "Remove" (after a separator) ended
// up wired to the separator <div> instead of its own <button>, so
// clicking it did nothing. querySelectorAll('button') only ever
// returns the actual buttons, in the same order as the non-separator
// items, so indexing into that stays aligned regardless of separators.
const buttons = menu.querySelectorAll('button');
let buttonIndex = 0;
items.forEach(function (item) {
if (item === 'separator') return;
const btn = buttons[buttonIndex];
buttonIndex++;
if (item.disabled) return;
btn.addEventListener('click', function (e) {
e.stopPropagation();
close();
if (item.onClick) item.onClick();
});
});
function close() {
menu.remove();
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onKey);
openMenuCloser = null;
}
function onOutsideClick(e) {
if (!menu.contains(e.target)) close();
}
function onKey(e) {
if (e.key === 'Escape') close();
}
openMenuCloser = close;
// Deferred so the click that opened the menu doesn't immediately
// trigger onOutsideClick via event bubbling.
setTimeout(function () {
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onKey);
}, 0);
}
// --- Panel router ----------------------------------------------------------
const panelModules = {};
@@ -180,6 +414,9 @@ window.Podman = (function () {
stateChipClass: stateChipClass,
loadingRow: loadingRow,
errorRow: errorRow,
openFormModal: openFormModal,
openLogModal: openLogModal,
openContextMenu: openContextMenu,
registerPanel: registerPanel,
activatePanel: activatePanel,
};
+87 -9
View File
@@ -1,10 +1,10 @@
/**
* javascript/compose.js
*
* Compose panel: project list + read-only YAML view + up/down/pull,
* backed by ajax/compose.php. See that file's header comment — this is
* the one panel whose backend shells out to the `podman compose` CLI,
* because no REST equivalent for Compose exists in libpod.
* Compose panel: project list + an editable YAML view + save/up/down/pull/
* delete, backed by ajax/compose.php. See that file's header comment —
* this is the one panel whose backend shells out to the `podman compose`
* CLI, because no REST equivalent for Compose exists in libpod.
*/
(function () {
'use strict';
@@ -12,6 +12,13 @@
let projects = [];
let selected = null;
const STARTER_YAML =
'services:\n' +
' app:\n' +
' image: docker.io/library/nginx:alpine\n' +
' ports:\n' +
' - "8080:80"\n';
function statusChip(status) {
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn');
return '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
@@ -23,21 +30,32 @@
'<div class="name" style="display:flex; justify-content:space-between; gap:8px;">' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '</div>' +
'<div class="path">' + P.escapeHtml(p.path) + '</div>' +
'</div>';
}).join('') || '<div class="podman-empty-note">No compose projects under /boot/config/plugins/podman/compose/</div>';
}).join('') || '<div class="podman-empty-note">No compose projects yet — click "+ New Project".</div>';
}
// Up/Down/Pull/Save/Delete all need an actual selected project to act on
// — disabled (rather than left clickable and erroring) whenever nothing
// is selected, e.g. right after deleting the last project.
function setToolbarEnabled(enabled) {
['compose-action-up', 'compose-action-down', 'compose-action-pull', 'compose-action-save', 'compose-action-delete'].forEach(function (id) {
P.el(id).disabled = !enabled;
});
P.el('compose-yaml').disabled = !enabled;
}
function loadYaml(name) {
P.el('compose-title').textContent = name + ' / compose.yaml';
P.el('compose-yaml').textContent = 'Loading…';
P.el('compose-yaml').value = 'Loading…';
return P.get('compose', 'get', { project: name }).then(function (data) {
P.el('compose-yaml').textContent = data.yaml;
P.el('compose-yaml').value = data.yaml;
}).catch(function (err) {
P.el('compose-yaml').textContent = 'Error: ' + err.message;
P.el('compose-yaml').value = 'Error: ' + err.message;
});
}
function selectProject(name) {
selected = name;
setToolbarEnabled(true);
renderSidebar();
loadYaml(name);
}
@@ -45,9 +63,19 @@
function loadProjects() {
return P.get('compose', 'list').then(function (data) {
projects = data;
if (selected && !projects.some(function (p) { return p.name === selected; })) {
selected = null;
}
if (!selected && projects.length > 0) selected = projects[0].name;
renderSidebar();
if (selected) loadYaml(selected);
if (selected) {
setToolbarEnabled(true);
loadYaml(selected);
} else {
setToolbarEnabled(false);
P.el('compose-title').textContent = '—';
P.el('compose-yaml').value = '';
}
}).catch(function (err) {
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
});
@@ -67,15 +95,65 @@
});
}
function saveYaml() {
if (!selected) return;
const btn = P.el('compose-action-save');
btn.disabled = true;
P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () {
return loadProjects();
}).catch(function (err) {
alert('Save failed: ' + err.message);
}).finally(function () {
btn.disabled = false;
});
}
function deleteProject() {
if (!selected) 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');
btn.disabled = true;
P.post('compose', 'remove', { project: selected }).then(function () {
selected = null;
return loadProjects();
}).catch(function (err) {
alert('Delete failed: ' + err.message);
btn.disabled = false;
});
}
function openNewProjectModal() {
P.openFormModal({
title: 'New Compose Project',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Project name', required: true, placeholder: 'my-stack', hint: 'Letters, digits, "_", "-" only — no spaces.' },
],
onSubmit: function (values) {
if (!/^[a-zA-Z0-9_-]+$/.test(values.name)) {
return Promise.reject(new Error('Project name can only contain letters, digits, "_", "-" — no spaces.'));
}
return P.post('compose', 'save', { project: values.name, yaml: STARTER_YAML }).then(function () {
selected = values.name;
return loadProjects();
});
},
});
}
function init() {
P.el('compose-sidebar').addEventListener('click', function (e) {
const item = e.target.closest('.podman-compose-proj[data-name]');
if (item) selectProject(item.dataset.name);
});
P.el('compose-new-btn').addEventListener('click', openNewProjectModal);
P.el('compose-action-up').addEventListener('click', function () { runAction('up'); });
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); });
P.el('compose-action-save').addEventListener('click', saveYaml);
P.el('compose-action-delete').addEventListener('click', deleteProject);
setToolbarEnabled(false);
return loadProjects();
}
+802 -11
View File
@@ -10,38 +10,429 @@
let allContainers = [];
let filter = 'all';
let searchTerm = '';
// Keyed by image reference (not container id) — several containers
// commonly share the same image, and ajax/containers.php's
// check_updates action itself already dedupes registry requests the
// same way. Persists across load()/renderTable() refreshes so the
// badge doesn't disappear on the next auto-refresh; only re-running
// "Check for Updates" replaces it.
let imageUpdateStatus = {};
function iconLabel(name) {
return P.escapeHtml(name.slice(0, 2).toUpperCase());
}
function hasUpdate(c) {
const status = imageUpdateStatus[c.image];
return !!(status && status.updateAvailable);
}
function rowHtml(c) {
const cpuMem = c.state === 'running'
? '<span class="podman-row-sub">running</span>'
const cpuMem = c.state === 'running' && c.cpuPercent != null
? '<span class="tnum">' + c.cpuPercent.toFixed(1) + '%</span> <span class="podman-row-sub">/ ' + P.formatBytes(c.memUsageBytes) + '</span>'
: '<span class="podman-row-sub">&mdash;</span>';
const updateBadge = hasUpdate(c)
? ' <span class="podman-badge-update" title="A newer image is available">&#8593; Update</span>'
: '';
return '' +
'<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><div class="podman-row-name"><span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</div></td>' +
'<td><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button>' + updateBadge + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
'<td>' + cpuMem + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' +
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</td>' +
'<td class="podman-actions">' + actionButtons(c) + '</td>' +
'<td class="podman-actions"><div class="podman-actions-row">' + actionButtons(c) + '</div></td>' +
'</tr>';
}
function actionButtons(c) {
const updateBtn = hasUpdate(c)
? '<button class="podman-btn podman-btn-icon" data-action="update" title="Update to the newer image">&#8593;</button>'
: '';
if (c.state === 'running') {
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">&#8635;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">&#9632;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove" disabled>&#128465;</button>';
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
return '' +
if (c.state === 'paused') {
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove">&#128465;</button>';
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
function openRowMenu(c, anchorBtn) {
const items = [];
if (c.state === 'running') {
items.push({ label: 'Pause', onClick: function () { handleAction(c.id, 'pause'); } });
items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
}
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } });
items.push('separator');
items.push({
label: 'Remove',
danger: true,
disabled: c.state === 'running',
onClick: function () { handleAction(c.id, 'remove'); },
});
P.openContextMenu(anchorBtn, items);
}
function openRenameModal(c) {
P.openFormModal({
title: 'Rename Container',
submitLabel: 'Rename',
fields: [{ name: 'name', label: 'New name', required: true, placeholder: c.name }],
onSubmit: function (values) {
return P.post('containers', 'rename', { id: c.id, name: values.name }).then(load);
},
});
}
// --- Edit (recreate) --------------------------------------------------------
//
// Podman/Docker have no "modify a running container" API for most of
// this (image, ports, volumes, env, ...) — the only real way to "edit"
// is to stop the old one, remove it (this does NOT touch named volumes,
// only the container itself), and create a new one under the same name
// with the changed settings. Same pattern Unraid's own Docker Manager
// and every other Docker/Podman WebUI uses. Reuses the existing
// "inspect" action (already fetched for the detail modal) rather than
// adding a new endpoint — envToPrefill()/etc. below just reshape that
// same raw libpod inspect JSON into openCreateContainerModal's prefill
// shape.
// Auto-injected by the container runtime itself, not something a user
// set through this form — dropped so the edit form isn't full of noise
// that didn't come from the original Create Container submission.
const AUTO_ENV_KEYS = ['PATH', 'HOSTNAME', 'HOME', 'container', 'TERM'];
function inspectToPrefill(c, d) {
const cfg = d.Config || {};
const hostCfg = d.HostConfig || {};
const ports = [];
Object.keys((hostCfg.PortBindings) || {}).forEach(function (key) {
const [containerPort, protocol] = key.split('/');
((hostCfg.PortBindings[key]) || []).forEach(function (binding) {
ports.push({ hostPort: binding.HostPort, containerPort: containerPort, protocol: protocol || 'tcp' });
});
});
const volumes = (d.Mounts || []).reduce(function (list, m) {
if (m.Type === 'bind') {
list.push({ kind: 'path', source: m.Source, containerPath: m.Destination });
} else if (m.Type === 'volume') {
list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination });
}
return list;
}, []);
const env = (cfg.Env || []).reduce(function (list, line) {
const idx = line.indexOf('=');
const key = idx === -1 ? line : line.slice(0, idx);
if (AUTO_ENV_KEYS.indexOf(key) === -1) {
list.push({ key: key, value: idx === -1 ? '' : line.slice(idx + 1) });
}
return list;
}, []);
// Only the /dev/dri paths our own GPU passthrough checkbox could have
// added — same host-path pattern ajax/containers.php's build_container_
// spec() validates against, so a container with some unrelated device
// mapping (added outside this UI) doesn't get misread as a GPU pick.
const gpuDevices = (hostCfg.Devices || [])
.map(function (dev) { return dev.PathOnHost; })
.filter(function (path) { return /^\/dev\/dri\/(card|renderD)\d+$/.test(path); });
// Only meaningful on a macvlan network (see updateNetworkFieldsVisibility()
// in openCreateContainerModal) — the container's actual address on
// that network, so editing one doesn't blank out an IP it was
// deliberately given.
const netName = hostCfg.NetworkMode;
const netInfo = d.NetworkSettings && d.NetworkSettings.Networks && d.NetworkSettings.Networks[netName];
const staticIp = netInfo && netInfo.IPAddress ? netInfo.IPAddress : '';
return {
name: (d.Name || c.name || '').replace(/^\//, ''),
image: cfg.Image || c.image,
networkMode: hostCfg.NetworkMode || 'bridge',
staticIp: staticIp,
pod: c.podName || '',
privileged: !!hostCfg.Privileged,
restartPolicy: (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || 'no',
ports: ports,
volumes: volumes,
env: env,
gpuDevices: gpuDevices,
};
}
function openEditContainerModal(c) {
P.get('containers', 'inspect', { id: c.id }).then(function (d) {
openCreateContainerModal(inspectToPrefill(c, d), { id: c.id });
}).catch(function (err) {
alert('Could not load container config: ' + err.message);
});
}
// --- Update (pull + recreate, unchanged settings) ---------------------------
//
// "Update" is the same stop/remove/recreate as Edit — see that comment
// above — except nothing in the config changes and an image pull happens
// first. Reuses inspectToPrefill() so both features read a container's
// current settings the exact same way.
//
// Both this and checkForUpdates()/updateAll() below take a `log`
// callback and write one line per step to it — a plain confirm()/alert()
// at the very end left no visible sign anything was happening while a
// check or a several-container update ran (found live: clicking "Check
// for Updates" against two already-current images looked completely
// inert). See app.js's openLogModal() for the small scrolling log window
// these lines end up in.
function updateContainer(c, log) {
return P.get('containers', 'inspect', { id: c.id }).then(function (d) {
const prefill = inspectToPrefill(c, d);
log('Pulling ' + prefill.image + '…');
return P.post('images', 'pull', { reference: prefill.image })
.then(function () {
log('Stopping ' + c.name + '…');
return P.post('containers', 'stop', { id: c.id }).catch(function () { /* already stopped is fine */ });
})
.then(function () {
log('Removing old container…');
return P.post('containers', 'remove', { id: c.id, force: true });
})
.then(function () {
log('Creating new container…');
return P.post('containers', 'create', {
image: prefill.image,
name: prefill.name,
networkMode: prefill.networkMode,
staticIp: prefill.staticIp,
pod: prefill.pod,
ports: prefill.ports,
volumes: prefill.volumes,
env: prefill.env,
restartPolicy: prefill.restartPolicy,
gpuDevices: prefill.gpuDevices,
privileged: prefill.privileged,
startAfterCreate: true,
});
}).then(function () {
// The image just pulled is now current — clear the stale flag
// for it specifically rather than wiping every row's status,
// since other images may still be genuinely outdated.
delete imageUpdateStatus[prefill.image];
log('Done: ' + c.name + ' is up to date.');
});
});
}
function checkForUpdates() {
const modal = P.openLogModal('Check for Updates');
modal.log('Checking every image currently in use…');
return P.get('containers', 'check_updates').then(function (results) {
imageUpdateStatus = results;
let updatable = 0;
Object.keys(results).forEach(function (ref) {
const r = results[ref];
if (r.error) {
modal.log('! ' + ref + ' — ' + r.error);
} else if (r.updateAvailable) {
updatable++;
modal.log('↑ ' + ref + ' — update available');
} else {
modal.log('✓ ' + ref + ' — up to date');
}
});
modal.log('');
modal.log(updatable ? updatable + ' image(s) have an update available.' : 'Everything is up to date.');
modal.done();
renderTable();
}).catch(function (err) {
modal.log('Check failed: ' + err.message);
modal.done();
});
}
function updateAll() {
const btn = P.el('containers-update-all-btn');
btn.disabled = true;
const modal = P.openLogModal('Update All');
modal.log('Checking every image currently in use…');
P.get('containers', 'check_updates').then(function (results) {
imageUpdateStatus = results;
renderTable();
const targets = allContainers.filter(hasUpdate);
if (!targets.length) {
modal.log('Everything is already up to date.');
modal.done();
btn.disabled = false;
return;
}
modal.log(targets.length + ' container(s) to update: ' + targets.map(function (c) { return c.name; }).join(', '));
modal.log('');
// Sequential, not parallel — several containers stopping/recreating
// at once is harder to reason about if one of them fails partway,
// and avoids hammering the same registry with simultaneous pulls.
const failures = [];
targets.reduce(function (chain, c) {
return chain.then(function () {
return updateContainer(c, modal.log).catch(function (err) {
modal.log('Failed: ' + c.name + ' — ' + err.message);
failures.push(c.name);
});
});
}, Promise.resolve()).then(function () {
modal.log('');
modal.log(failures.length
? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.'
: 'All ' + targets.length + ' updated.');
modal.done();
btn.disabled = false;
return load();
});
}).catch(function (err) {
modal.log('Check failed: ' + err.message);
modal.done();
btn.disabled = false;
});
}
// --- Detail view -----------------------------------------------------------
//
// Fed entirely by the existing inspect action (raw libpod inspect JSON) —
// no new backend endpoint needed, just slicing that one payload into
// tabs. Field names below (Config.Env, Config.Labels, Mounts,
// NetworkSettings.Networks, HostConfig.RestartPolicy, ...) were checked
// live against a real inspect response, not assumed from docs.
function kvTable(rows) {
if (rows.length === 0) return '<div class="podman-detail-empty">None.</div>';
return '<table class="podman-detail-table">' + rows.map(function (r) {
return '<tr><td>' + P.escapeHtml(r[0]) + '</td><td class="mono">' + P.escapeHtml(r[1]) + '</td></tr>';
}).join('') + '</table>';
}
function renderOverviewTab(d) {
const cfg = d.Config || {};
const hostCfg = d.HostConfig || {};
return kvTable([
['Name', (d.Name || '').replace(/^\//, '')],
['ID', d.Id || ''],
['Image', cfg.Image || d.Image || ''],
['Created', d.Created || ''],
['Command', (cfg.Cmd || []).join(' ') || (cfg.Entrypoint || []).join(' ') || '—'],
['State', (d.State && d.State.Status) || '—'],
['Restart policy', (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || '—'],
['Restart count', String(d.RestartCount || 0)],
['Privileged', hostCfg.Privileged ? 'yes' : 'no'],
['Working dir', cfg.WorkingDir || '—'],
]);
}
function renderEnvTab(d) {
const env = (d.Config && d.Config.Env) || [];
return kvTable(env.map(function (line) {
const idx = line.indexOf('=');
return idx === -1 ? [line, ''] : [line.slice(0, idx), line.slice(idx + 1)];
}));
}
function renderLabelsTab(d) {
const labels = (d.Config && d.Config.Labels) || {};
return kvTable(Object.keys(labels).map(function (k) { return [k, labels[k]]; }));
}
function renderMountsTab(d) {
const mounts = d.Mounts || [];
if (mounts.length === 0) return '<div class="podman-detail-empty">No mounts.</div>';
return '<table class="podman-detail-table">' +
'<tr><td>Type</td><td>Source &rarr; Destination</td></tr>' +
mounts.map(function (m) {
const mode = m.RW ? 'rw' : 'ro';
return '<tr><td>' + P.escapeHtml(m.Type || '') + '</td><td class="mono">' +
P.escapeHtml(m.Source || '') + ' &rarr; ' + P.escapeHtml(m.Destination || '') +
' <span class="podman-row-sub">(' + mode + ')</span></td></tr>';
}).join('') + '</table>';
}
function renderNetworksTab(d) {
const networks = (d.NetworkSettings && d.NetworkSettings.Networks) || {};
const names = Object.keys(networks);
if (names.length === 0) return '<div class="podman-detail-empty">No networks (host or none mode).</div>';
return names.map(function (name) {
const n = networks[name];
return '<div style="margin-bottom:14px;"><div style="font-weight:700; font-size:12.5px; margin-bottom:6px;">' +
P.escapeHtml(name) + '</div>' + kvTable([
['IP address', n.IPAddress || '—'],
['Gateway', n.Gateway || '—'],
['MAC address', n.MacAddress || '—'],
['Aliases', (n.Aliases || []).join(', ') || '—'],
]) + '</div>';
}).join('');
}
function renderInspectTab(d) {
return '<div class="podman-detail-json">' + P.escapeHtml(JSON.stringify(d, null, 2)) + '</div>';
}
const DETAIL_TABS = [
{ id: 'overview', label: 'Overview', render: renderOverviewTab },
{ id: 'env', label: 'Environment', render: renderEnvTab },
{ id: 'labels', label: 'Labels', render: renderLabelsTab },
{ id: 'mounts', label: 'Mounts', render: renderMountsTab },
{ id: 'networks', label: 'Networks', render: renderNetworksTab },
{ id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab },
];
function openDetailModal(c) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-xwide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + P.escapeHtml(c.name) + '</h3></div>' +
'<div class="podman-detail-tabs">' + DETAIL_TABS.map(function (t, i) {
return '<button type="button" data-tab="' + t.id + '"' + (i === 0 ? ' class="active"' : '') + '>' + t.label + '</button>';
}).join('') + '</div>' +
'<div class="podman-detail-body"><div class="podman-loading">Loading…</div></div>' +
'<div class="podman-modal-actions"><button type="button" class="podman-btn" data-role="cancel">Close</button></div>' +
'</div>';
(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(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { backdrop.remove(); document.removeEventListener('keydown', onKey); }
});
const body = backdrop.querySelector('.podman-detail-body');
P.get('containers', 'inspect', { id: c.id }).then(function (data) {
function showTab(tabId) {
const tab = DETAIL_TABS.find(function (t) { return t.id === tabId; });
body.innerHTML = tab.render(data);
}
backdrop.querySelectorAll('[data-tab]').forEach(function (btn) {
btn.addEventListener('click', function () {
backdrop.querySelectorAll('[data-tab]').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
showTab(btn.dataset.tab);
});
});
showTab('overview');
}).catch(function (err) {
body.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function applyFilters() {
@@ -80,23 +471,392 @@
});
}
// --- Create Container -----------------------------------------------------
//
// Purpose-built modal (not app.js's generic openFormModal, which only
// supports flat text fields) — port/volume/env rows are dynamic
// add/remove groups, and network needs a <select> populated from the
// real network list, none of which fits the generic helper. Reuses its
// .podman-modal-* CSS classes for visual consistency.
function portRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
'<span>&rarr;</span>' +
'<input type="text" class="mono podman-input-narrow" data-field="containerPort" placeholder="Container port">' +
'<select data-field="protocol"><option value="tcp">TCP</option><option value="udp">UDP</option></select>' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function volumeRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<select data-field="kind"><option value="named">Volume</option><option value="path">Host path</option></select>' +
'<input type="text" class="mono" data-field="source" placeholder="my-volume or /mnt/cache/...">' +
'<span>&rarr;</span>' +
'<input type="text" class="mono" data-field="containerPath" placeholder="/data">' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function envRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<input type="text" class="mono" data-field="key" placeholder="KEY">' +
'<span>=</span>' +
'<input type="text" class="mono" data-field="value" placeholder="value">' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function addRow(groupEl, rowHtmlFn, values) {
const div = document.createElement('div');
div.innerHTML = rowHtmlFn();
const row = div.firstElementChild;
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
if (values) {
row.querySelectorAll('[data-field]').forEach(function (input) {
if (values[input.dataset.field] !== undefined) input.value = values[input.dataset.field];
});
}
groupEl.appendChild(row);
}
function readRows(groupEl) {
return Array.from(groupEl.children).map(function (row) {
const values = {};
row.querySelectorAll('[data-field]').forEach(function (input) {
values[input.dataset.field] = input.value.trim();
});
return values;
});
}
/**
* @param {object|null} prefill Optional template data (same shape
* ajax/templates.php's "get" action returns, plus "name"/"pod" which
* only inspectToPrefill() sets) to seed the form with — used by
* templates.js's "Use template" action and openEditContainerModal()
* below. null/omitted opens a blank form, same as the toolbar's
* "+ New Container" button.
* @param {{id:string}|null} editing When set, this is an edit of an
* existing container rather than a fresh create: submitting stops and
* removes container `editing.id` first, then creates a new one under
* whatever name/settings are in the form (see the Podman/Docker have
* no in-place "modify" API comment on openEditContainerModal above).
*/
function openCreateContainerModal(prefill, editing) {
prefill = prefill || {};
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + (editing ? 'Edit Container' : 'New Container') + '</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Image</label>' +
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' +
'<div class="podman-modal-field"><label>Name (optional)</label>' +
'<input type="text" id="cc-name" placeholder="my-container">' +
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
'<div class="podman-modal-field"><label>Network</label>' +
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
'<option value="host">Host</option><option value="none">None</option></select></div>' +
'<div class="podman-modal-field" id="cc-static-ip-field" style="display:none;"><label>Static IP (optional)</label>' +
'<input type="text" class="mono" id="cc-static-ip" placeholder="10.1.1.222">' +
'<div class="hint">Leave blank to let the network assign one automatically.</div></div>' +
'<div class="podman-modal-field"><label>Pod (optional)</label>' +
'<select id="cc-pod"><option value="">None</option></select>' +
'<div class="hint">Joins the pod\'s shared network namespace instead of the setting above.</div></div>' +
'<div class="podman-modal-field" id="cc-ports-field"><label>Port mappings</label>' +
'<div class="podman-row-group" id="cc-ports"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
'<div class="hint" id="cc-ports-macvlan-hint" style="display:none;">Not needed on a macvlan network — the container gets its own address on the LAN.</div></div>' +
'<div class="podman-modal-field"><label>Volumes</label>' +
'<div class="podman-row-group" id="cc-volumes"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="volume">+ Add volume</button></div>' +
'<div class="podman-modal-field"><label>Environment variables</label>' +
'<div class="podman-row-group" id="cc-env"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="env">+ Add variable</button></div>' +
'<div class="podman-modal-field"><label>Restart policy</label>' +
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
'<div class="podman-modal-field" id="cc-gpu-field" style="display:none;"><label>GPU passthrough</label>' +
'<select id="cc-gpu-select"><option value="">None</option></select></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<input type="checkbox" id="cc-start" checked> Start after create</label></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<input type="checkbox" id="cc-save-template"> Save as template</label></div>' +
'<div class="podman-modal-field" id="cc-template-fields" style="display:none;">' +
'<label>Template name</label><input type="text" id="cc-template-name" placeholder="my-template">' +
'<label style="margin-top:10px;">Icon URL (optional)</label><input type="text" id="cc-template-icon" placeholder="https://...">' +
'<label style="margin-top:10px;">Category (optional)</label><input type="text" id="cc-template-category" placeholder="Databases:">' +
'<label style="margin-top:10px;">Description (optional)</label><input type="text" id="cc-template-overview" placeholder="What this template runs">' +
'</div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' + (editing ? 'Save &amp; Recreate' : 'Create') + '</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name;
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
if (prefill.staticIp) backdrop.querySelector('#cc-static-ip').value = prefill.staticIp;
// Macvlan containers get their own address directly on the LAN (see
// the ajax/networks.php macvlan work) — port mappings are meaningless
// for them (there's no host-side NAT to map through) and a static IP
// becomes a relevant option instead of a Bridge/Host/None-only
// concept. Toggled on network-select change and once up front below,
// driven by each <option>'s data-driver (set when the real network
// list loads — the three built-ins are never macvlan).
function updateNetworkFieldsVisibility() {
const select = backdrop.querySelector('#cc-network');
const selectedOption = select.options[select.selectedIndex];
const isMacvlan = !!(selectedOption && selectedOption.dataset.driver === 'macvlan');
backdrop.querySelector('#cc-static-ip-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cc-ports').style.display = isMacvlan ? 'none' : '';
backdrop.querySelector('[data-add="port"]').style.display = isMacvlan ? 'none' : '';
backdrop.querySelector('#cc-ports-macvlan-hint').style.display = isMacvlan ? '' : 'none';
}
backdrop.querySelector('#cc-network').addEventListener('change', updateNetworkFieldsVisibility);
const portsGroup = backdrop.querySelector('#cc-ports');
const volumesGroup = backdrop.querySelector('#cc-volumes');
const envGroup = backdrop.querySelector('#cc-env');
// A template may carry zero, one, or several rows of each kind — always
// leave at least one (blank) row so the user has somewhere to type,
// matching the blank-form behavior.
(prefill.ports && prefill.ports.length ? prefill.ports : [{}]).forEach(function (row) { addRow(portsGroup, portRowHtml, row); });
(prefill.volumes && prefill.volumes.length ? prefill.volumes : [{}]).forEach(function (row) { addRow(volumesGroup, volumeRowHtml, row); });
(prefill.env && prefill.env.length ? prefill.env : [{}]).forEach(function (row) { addRow(envGroup, envRowHtml, row); });
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup, portRowHtml); });
backdrop.querySelector('[data-add="volume"]').addEventListener('click', function () { addRow(volumesGroup, volumeRowHtml); });
backdrop.querySelector('[data-add="env"]').addEventListener('click', function () { addRow(envGroup, envRowHtml); });
// Populate the network dropdown with any existing custom (non-default)
// podman networks, in addition to the built-in bridge/host/none modes
// — best-effort: if the list call fails, the three built-ins still work.
P.get('networks', 'list').then(function (networks) {
const select = backdrop.querySelector('#cc-network');
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
const opt = document.createElement('option');
opt.value = n.name;
opt.textContent = n.name + (n.driver === 'macvlan' ? ' (macvlan)' : '');
opt.dataset.driver = n.driver;
select.appendChild(opt);
});
// Re-applied here (not just at load time above) because a custom
// network's <option> doesn't exist yet until this list comes back —
// setting .value to it any earlier would silently no-op and leave
// the select on its default "bridge" option instead. Matters for
// openEditContainerModal(): a container already on a custom network
// needs that option to exist before it can be selected.
if (prefill.networkMode) select.value = prefill.networkMode;
updateNetworkFieldsVisibility();
}).catch(function () { /* built-in modes still usable */ });
P.get('pods', 'list').then(function (pods) {
const select = backdrop.querySelector('#cc-pod');
pods.forEach(function (p) {
const opt = document.createElement('option');
opt.value = p.name;
opt.textContent = p.name;
select.appendChild(opt);
});
if (prefill.pod) select.value = prefill.pod;
}).catch(function () { /* pod selection stays optional */ });
// Only shown when the host actually has a passthrough-capable GPU
// (AMD/Intel via /dev/dri — see ajax/containers.php's gpu_list(); NVIDIA
// is deliberately excluded there since it needs a different runtime) —
// best-effort, same as networks/pods above.
P.get('containers', 'list_gpus').then(function (gpus) {
if (!gpus.length) return;
const field = backdrop.querySelector('#cc-gpu-field');
const select = backdrop.querySelector('#cc-gpu-select');
field.style.display = '';
gpus.forEach(function (gpu, i) {
const devices = [gpu.render, gpu.card].filter(Boolean).join(', ');
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = gpu.vendor + ' GPU (' + devices + ')';
select.appendChild(opt);
});
select.dataset.gpus = JSON.stringify(gpus);
// Pre-select whichever detected GPU the container being edited is
// already using (matched by device path, not index — gpu_list()'s
// order isn't guaranteed stable across requests).
if (prefill.gpuDevices && prefill.gpuDevices.length) {
const matchIndex = gpus.findIndex(function (gpu) {
return prefill.gpuDevices.indexOf(gpu.render) !== -1 || prefill.gpuDevices.indexOf(gpu.card) !== -1;
});
if (matchIndex !== -1) select.value = String(matchIndex);
}
}).catch(function () { /* GPU passthrough stays unavailable */ });
backdrop.querySelector('#cc-image').focus();
backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) {
backdrop.querySelector('#cc-template-fields').style.display = e.target.checked ? '' : 'none';
});
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
if (editing && !confirm(
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?'
)) {
return;
}
const image = backdrop.querySelector('#cc-image').value.trim();
if (!image) {
showError('"Image" is required.');
return;
}
const name = backdrop.querySelector('#cc-name').value.trim();
// Same character set podman itself enforces — checked here too so
// a typo (most commonly a space, e.g. copying a template's display
// name straight in) gets caught before a round trip to the server.
if (name && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
return;
}
const networkSelect = backdrop.querySelector('#cc-network');
const selectedNetworkOption = networkSelect.options[networkSelect.selectedIndex];
const isMacvlan = !!(selectedNetworkOption && selectedNetworkOption.dataset.driver === 'macvlan');
// Port mappings map a host port to a container port through NAT —
// meaningless on a macvlan network, where the container already has
// its own real address on the LAN (see updateNetworkFieldsVisibility()
// above, which also hides the UI for this) — so none are sent even
// if some were left over from switching the network dropdown after
// adding a few.
const ports = isMacvlan ? [] : readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const staticIp = isMacvlan ? backdrop.querySelector('#cc-static-ip').value.trim() : '';
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
const env = readRows(envGroup).filter(function (r) { return r.key; });
const saveAsTemplate = backdrop.querySelector('#cc-save-template').checked;
const templateName = backdrop.querySelector('#cc-template-name').value.trim();
if (saveAsTemplate && !templateName) {
showError('"Template name" is required when "Save as template" is checked.');
return;
}
const networkMode = backdrop.querySelector('#cc-network').value;
const privileged = backdrop.querySelector('#cc-privileged').checked;
const gpuSelect = backdrop.querySelector('#cc-gpu-select');
const gpus = gpuSelect.dataset.gpus ? JSON.parse(gpuSelect.dataset.gpus) : [];
const selectedGpu = gpuSelect.value !== '' ? gpus[Number(gpuSelect.value)] : null;
const gpuDevices = selectedGpu ? [selectedGpu.render, selectedGpu.card].filter(Boolean) : [];
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
// Editing an existing container: no in-place "modify" API exists
// (see the comment on openEditContainerModal above), so this stops
// and removes the old one first — best-effort stop (it may already
// be stopped) followed by a forced remove — before creating the
// replacement under whatever name is in the form now.
const removeOld = editing
? P.post('containers', 'stop', { id: editing.id }).catch(function () { /* already stopped is fine */ })
.then(function () { return P.post('containers', 'remove', { id: editing.id, force: true }); })
: Promise.resolve();
removeOld.then(function () {
return P.post('containers', 'create', {
image: image,
name: backdrop.querySelector('#cc-name').value.trim(),
networkMode: networkMode,
staticIp: staticIp,
pod: backdrop.querySelector('#cc-pod').value,
ports: ports,
volumes: volumes,
env: env,
restartPolicy: backdrop.querySelector('#cc-restart').value,
gpuDevices: gpuDevices,
privileged: privileged,
startAfterCreate: backdrop.querySelector('#cc-start').checked,
});
}).then(function () {
// Best-effort: a template-save failure shouldn't undo or block
// the container that was just successfully created.
if (!saveAsTemplate) return null;
return P.post('templates', 'save', {
name: templateName,
image: image,
networkMode: networkMode,
privileged: privileged,
ports: ports,
volumes: volumes,
env: env,
icon: backdrop.querySelector('#cc-template-icon').value.trim(),
category: backdrop.querySelector('#cc-template-category').value.trim(),
overview: backdrop.querySelector('#cc-template-overview').value.trim(),
}).catch(function (err) {
alert('Container created, but saving the template failed: ' + err.message);
});
}).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError((editing ? 'The old container may already be removed. ' : '') + err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function handleAction(id, action, btn) {
const doIt = function (extra) {
btn.disabled = true;
if (btn) btn.disabled = true;
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
alert('Action failed: ' + err.message);
btn.disabled = false;
if (btn) btn.disabled = false;
});
};
if (action === 'remove') {
if (!confirm('Remove this container? This does not remove its volumes.')) return;
doIt({ force: true });
} else if (action === 'kill') {
if (!confirm('Send SIGKILL to this container? Unlike Stop, this does not let it shut down gracefully.')) return;
doIt({});
} else {
doIt({});
}
}
function init() {
P.el('containers-create-btn').addEventListener('click', openCreateContainerModal);
P.el('containers-search').addEventListener('input', function (e) {
searchTerm = e.target.value.trim().toLowerCase();
renderTable();
@@ -115,11 +875,42 @@
const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const row = btn.closest('tr');
handleAction(row.dataset.id, btn.dataset.action, btn);
const id = row.dataset.id;
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') {
const c = allContainers.find(function (x) { return x.id === id; });
if (!c) return;
if (btn.dataset.action === 'menu') {
openRowMenu(c, btn);
} else if (btn.dataset.action === 'update') {
if (!confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.')) return;
btn.disabled = true;
const modal = P.openLogModal('Updating ' + c.name);
updateContainer(c, modal.log).then(function () {
modal.done();
return load();
}).catch(function (err) {
modal.log('Failed: ' + err.message);
modal.done();
btn.disabled = false;
});
} else {
openDetailModal(c);
}
return;
}
handleAction(id, btn.dataset.action, btn);
});
P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates);
P.el('containers-update-all-btn').addEventListener('click', updateAll);
return load();
}
// Exposed for templates.js's "Use template" action, which needs to open
// this same modal pre-filled — templates.js loads after containers.js
// (see Podman.page's script list), so this is already set by then.
P.openCreateContainerModal = openCreateContainerModal;
P.registerPanel('containers', { init: init, refresh: load });
})();
+64 -7
View File
@@ -18,8 +18,10 @@
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
'<td class="tnum">' + created + '</td>' +
'<td class="tnum">' + img.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row">' +
'<button class="podman-btn podman-btn-icon" data-action="tag" title="Add tag">&#127991;</button>' +
'<button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>';
}
@@ -43,23 +45,78 @@
function init() {
P.el('images-pull-btn').addEventListener('click', function () {
const reference = prompt('Image to pull (e.g. docker.io/library/postgres:16):');
if (!reference) return;
P.post('images', 'pull', { reference: reference }).then(load).catch(function (err) {
alert('Pull failed: ' + err.message);
P.openFormModal({
title: 'Pull Image',
submitLabel: 'Pull',
fields: [
{ name: 'reference', label: 'Image reference', required: true, placeholder: 'docker.io/library/postgres:16' },
],
onSubmit: function (values) {
return P.post('images', 'pull', { reference: values.reference }).then(load);
},
});
});
P.el('images-prune-btn').addEventListener('click', function () {
// Computed client-side from the list already on screen — no extra
// round trip needed, and it lets the confirm() be specific instead
// of a generic warning. "Unused" here matches libpod's own
// definition (zero containers, running or stopped, referencing the
// image) — the same "Used By" count already shown in the table, not
// just dangling/untagged images. Found live that this can be far
// more aggressive than expected: with no containers at all, it
// removes every image on the host.
const unused = images.filter(function (img) { return img.usedBy === 0; });
if (!unused.length) {
alert('No unused images to remove — every image is referenced by at least one container.');
return;
}
const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
if (!confirm(
'Remove ' + unused.length + ' image(s) not used by any container (' + P.formatBytes(totalBytes) + ')?\n\n' +
'This removes any tagged image with zero containers using it, not just dangling ones.'
)) return;
const btn = this;
btn.disabled = true;
P.post('images', 'prune').then(function (result) {
btn.disabled = false;
alert('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.');
return load();
}).catch(function (err) {
btn.disabled = false;
alert('Prune failed: ' + err.message);
});
});
P.el('images-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const id = btn.closest('tr').dataset.id;
if (btn.dataset.action === 'tag') {
P.openFormModal({
title: 'Add Tag',
submitLabel: 'Add tag',
fields: [
{ name: 'repo', label: 'Repository', required: true, placeholder: 'my-registry.local/my-image' },
{ name: 'tag', label: 'Tag', placeholder: 'latest' },
],
onSubmit: function (values) {
return P.post('images', 'tag', { id: id, repo: values.repo, tag: values.tag || 'latest' }).then(load);
},
});
return;
}
if (btn.dataset.action === 'remove') {
if (!confirm('Remove this image?')) return;
btn.disabled = true;
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
btn.disabled = false;
});
}
});
return load();
+114 -10
View File
@@ -20,8 +20,8 @@
'<td class="mono">' + P.escapeHtml(n.subnet || '&mdash;') + '</td>' +
'<td class="mono">' + P.escapeHtml(n.gateway || '&mdash;') + '</td>' +
'<td class="tnum">' + n.containers + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></div></td>' +
'</tr>';
}
@@ -43,15 +43,119 @@
});
}
// Purpose-built modal (not app.js's generic openFormModal, which only
// supports flat always-visible text fields) — the parent-interface
// dropdown and gateway field only make sense for "macvlan" and need to
// show/hide based on the driver choice.
function openCreateNetworkModal() {
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>New Network</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Network name</label>' +
'<input type="text" id="cn-name" placeholder="my-network"></div>' +
'<div class="podman-modal-field"><label>Type</label>' +
'<select id="cn-driver">' +
'<option value="bridge">Bridge (isolated, NAT — default)</option>' +
'<option value="macvlan">Macvlan (containers get a real IP on your LAN)</option>' +
'</select></div>' +
'<div class="podman-modal-field" id="cn-parent-field" style="display:none;">' +
'<label>Parent interface</label><select id="cn-parent"></select>' +
'<div class="hint">Same interface Docker Manager\'s "Custom: br0"-style networks use.</div></div>' +
'<div class="podman-modal-field"><label id="cn-subnet-label">Subnet (optional)</label>' +
'<input type="text" class="mono" id="cn-subnet" placeholder="10.89.2.0/24"></div>' +
'<div class="podman-modal-field" id="cn-gateway-field" style="display:none;">' +
'<label>Gateway</label><input type="text" class="mono" id="cn-gateway" placeholder="10.1.1.1"></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
let parentInterfaces = [];
P.get('networks', 'list_parent_interfaces').then(function (interfaces) {
parentInterfaces = interfaces;
const select = backdrop.querySelector('#cn-parent');
select.innerHTML = interfaces.map(function (i) {
return '<option value="' + P.escapeHtml(i.interface) + '">' + P.escapeHtml(i.label) + '</option>';
}).join('');
}).catch(function () { /* macvlan option just won't have anything to pick if this fails */ });
backdrop.querySelector('#cn-driver').addEventListener('change', function (e) {
const isMacvlan = e.target.value === 'macvlan';
backdrop.querySelector('#cn-parent-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cn-gateway-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cn-subnet-label').textContent = isMacvlan ? 'Subnet' : 'Subnet (optional)';
});
backdrop.querySelector('#cn-name').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const name = backdrop.querySelector('#cn-name').value.trim();
if (!name) {
showError('"Network name" is required.');
return;
}
const driver = backdrop.querySelector('#cn-driver').value;
const subnet = backdrop.querySelector('#cn-subnet').value.trim();
const gateway = backdrop.querySelector('#cn-gateway').value.trim();
const parentInterface = backdrop.querySelector('#cn-parent').value;
if (driver === 'macvlan') {
if (!subnet) {
showError('"Subnet" is required for a macvlan network.');
return;
}
if (!parentInterfaces.length) {
showError('No host bridge/VLAN interface available to attach to.');
return;
}
}
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('networks', 'create', {
name: name,
driver: driver,
subnet: subnet || undefined,
gateway: gateway || undefined,
parentInterface: driver === 'macvlan' ? parentInterface : undefined,
}).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function init() {
P.el('networks-create-btn').addEventListener('click', function () {
const name = prompt('New network name:');
if (!name) return;
const subnet = prompt('Subnet (optional, e.g. 10.89.2.0/24):') || undefined;
P.post('networks', 'create', { name: name, driver: 'bridge', subnet: subnet }).then(load).catch(function (err) {
alert('Create failed: ' + err.message);
});
});
P.el('networks-create-btn').addEventListener('click', openCreateNetworkModal);
P.el('networks-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
+157 -6
View File
@@ -8,6 +8,107 @@
(function () {
'use strict';
const P = window.Podman;
let allPods = [];
function portRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
'<span>&rarr;</span>' +
'<input type="text" class="mono podman-input-narrow" data-field="containerPort" placeholder="Container port">' +
'<select data-field="protocol"><option value="tcp">TCP</option><option value="udp">UDP</option></select>' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function addRow(groupEl) {
const div = document.createElement('div');
div.innerHTML = portRowHtml();
const row = div.firstElementChild;
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
groupEl.appendChild(row);
}
function readRows(groupEl) {
return Array.from(groupEl.children).map(function (row) {
const values = {};
row.querySelectorAll('[data-field]').forEach(function (input) {
values[input.dataset.field] = input.value.trim();
});
return values;
});
}
function openCreatePodModal() {
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>New Pod</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Name</label>' +
'<input type="text" id="cp-name" placeholder="my-pod">' +
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
'<div class="podman-modal-field"><label>Port mappings</label>' +
'<div class="podman-row-group" id="cp-ports"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
'<div class="hint">Shared by every container later added to this pod.</div></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const portsGroup = backdrop.querySelector('#cp-ports');
addRow(portsGroup);
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup); });
backdrop.querySelector('#cp-name').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const name = backdrop.querySelector('#cp-name').value.trim();
if (!name) {
showError('"Name" is required.');
return;
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
return;
}
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('pods', 'create', { name: name, ports: ports }).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function memberRow(m) {
return '' +
@@ -24,27 +125,77 @@
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
return '' +
'<div class="podman-pod-card">' +
'<div class="podman-pod-card" data-name="' + P.escapeHtml(pod.name) + '">' +
'<div class="podman-pod-head">' +
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
'<button type="button" class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>' +
'</div>' +
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
'<tbody>' + members + '</tbody></table></div>' +
'</div>';
}
function render() {
const grid = P.el('pods-grid');
grid.innerHTML = allPods.length
? allPods.map(podCard).join('')
: '<div class="podman-empty-note">No pods yet — create one, or run a container with a "pod" set from the Create Container form.</div>';
}
function load() {
const container = P.el('podman-panel-pods');
return P.get('pods', 'list').then(function (pods) {
container.innerHTML = pods.length
? pods.map(podCard).join('')
: '<div class="podman-card"><div class="podman-empty-note">No pods yet.</div></div>';
if (!P.el('pods-grid')) {
container.innerHTML = '' +
'<div class="podman-card">' +
'<div class="podman-toolbar">' +
'<strong style="flex:1;">Group containers sharing network/storage namespaces</strong>' +
'<button class="podman-btn podman-btn-primary" id="pods-create-btn">+ New Pod</button>' +
'</div>' +
'<div id="pods-grid"></div>' +
'</div>';
P.el('pods-create-btn').addEventListener('click', openCreatePodModal);
P.el('pods-grid').addEventListener('click', handleCardClick);
}
return P.get('pods', 'list').then(function (data) {
allPods = data;
render();
}).catch(function (err) {
container.innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
P.el('pods-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function handleAction(name, action, extra) {
return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) {
alert('Action failed: ' + err.message);
});
}
function handleCardClick(e) {
const btn = e.target.closest('button[data-action="menu"]');
if (!btn) return;
const pod = allPods.find(function (p) { return p.name === btn.closest('.podman-pod-card').dataset.name; });
if (!pod) return;
const items = [];
if (pod.status === 'running') {
items.push({ label: 'Stop', onClick: function () { handleAction(pod.name, 'stop', { timeout: 10 }); } });
items.push({ label: 'Restart', onClick: function () { handleAction(pod.name, 'restart', { timeout: 10 }); } });
} else {
items.push({ label: 'Start', onClick: function () { handleAction(pod.name, 'start'); } });
}
items.push('separator');
items.push({
label: 'Remove',
danger: true,
onClick: function () {
if (!confirm('Remove pod "' + pod.name + '" and all its member containers?')) return;
handleAction(pod.name, 'remove', { force: true });
},
});
P.openContextMenu(btn, items);
}
P.registerPanel('pods', { init: load, refresh: load });
})();
+8 -5
View File
@@ -17,11 +17,11 @@
return '<tr data-index="' + i + '">' +
'<td class="tnum">' + (i + 1) + '</td>' +
'<td>' + P.escapeHtml(name) + '</td>' +
'<td class="podman-actions">' +
'<td class="podman-actions"><div class="podman-actions-row">' +
'<button class="podman-btn podman-btn-icon" data-action="up"' + (i === 0 ? ' disabled' : '') + ' title="Move up">&#8593;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">&#8595;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">&#128465;</button>' +
'</td></tr>';
'</div></td></tr>';
}).join('')
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
}
@@ -43,9 +43,12 @@
const versions = settings.packageVersions || {};
const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS'];
P.el('settings-package-versions').textContent = order
.map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); })
.join(' · ');
const chipsHtml = order.map(function (k) {
const name = k.toLowerCase().replace(/_/g, '-');
const version = versions[k + '_INSTALLED_VERSION'];
return '<span class="podman-version-chip">' + P.escapeHtml(name) + ' <b>' + P.escapeHtml(version || '?') + '</b></span>';
}).join('');
P.el('settings-package-versions').innerHTML = chipsHtml || '<span class="podman-empty-note">No version manifest found.</span>';
}
function load() {
@@ -0,0 +1,218 @@
/**
* javascript/templates.js
*
* Templates panel: reusable container configs saved as XML (Unraid
* Docker-template-compatible schema — see ajax/templates.php's header
* comment for why). "Use template" hands off to containers.js's Create
* Container modal, pre-filled; templates are themselves created from
* that same modal's "Save as template" checkbox, not from here.
*/
(function () {
'use strict';
const P = window.Podman;
let allTemplates = [];
function iconHtml(t) {
if (t.icon) {
return '<img class="podman-template-icon" src="' + P.escapeHtml(t.icon) + '" alt="" loading="lazy" ' +
'onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'podman-template-icon podman-template-icon-fallback\',textContent:\'' +
P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '\'}))">';
}
return '<div class="podman-template-icon podman-template-icon-fallback">' + P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '</div>';
}
function cardHtml(t) {
const overview = t.overview && t.overview.length > 110 ? t.overview.slice(0, 107) + '…' : (t.overview || '');
return '' +
'<div class="podman-template-card" data-name="' + P.escapeHtml(t.name) + '">' +
iconHtml(t) +
'<div class="podman-template-body">' +
'<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' +
'<div class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</div>' +
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
'</div>' +
'<div class="podman-template-actions">' +
'<button class="podman-btn podman-btn-primary" data-action="use">Use</button>' +
'<button class="podman-btn podman-btn-ghost" data-action="export">Export</button>' +
'<button class="podman-btn podman-btn-ghost podman-btn-danger" data-action="delete">Delete</button>' +
'</div></div>';
}
function render() {
const grid = P.el('templates-grid');
grid.innerHTML = allTemplates.length
? allTemplates.map(cardHtml).join('')
: '<div class="podman-empty-note">No templates yet — save one from the "New Container" form, or import an XML template.</div>';
}
function load() {
const container = P.el('podman-panel-templates');
if (!P.el('templates-grid')) {
container.innerHTML = '' +
'<div class="podman-card">' +
'<div class="podman-toolbar">' +
'<strong style="flex:1;">Reusable container configs</strong>' +
'<button class="podman-btn podman-btn-ghost" id="templates-import-btn">&#11014; Import Template</button>' +
'</div>' +
'<div class="podman-template-grid" id="templates-grid"></div>' +
'</div>';
P.el('templates-import-btn').addEventListener('click', openImportModal);
P.el('templates-grid').addEventListener('click', handleCardClick);
}
return P.get('templates', 'list').then(function (data) {
allTemplates = data;
render();
}).catch(function (err) {
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function handleCardClick(e) {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const name = btn.closest('.podman-template-card').dataset.name;
if (btn.dataset.action === 'use') {
btn.disabled = true;
P.get('templates', 'get', { name: name }).then(function (config) {
btn.disabled = false;
P.openCreateContainerModal(config);
}).catch(function (err) {
btn.disabled = false;
alert('Could not load template: ' + err.message);
});
return;
}
if (btn.dataset.action === 'export') {
btn.disabled = true;
P.get('templates', 'export', { name: name }).then(function (data) {
btn.disabled = false;
const blob = new Blob([data.xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name + '.xml';
a.click();
URL.revokeObjectURL(url);
}).catch(function (err) {
btn.disabled = false;
alert('Export failed: ' + err.message);
});
return;
}
if (btn.dataset.action === 'delete') {
if (!confirm('Delete template "' + name + '"? This does not affect any running containers.')) return;
btn.disabled = true;
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
btn.disabled = false;
alert('Delete failed: ' + err.message);
});
}
}
function openImportModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>Import Template</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Your existing Docker templates</label>' +
'<input type="text" id="ti-local-search" placeholder="Search by name…">' +
'<div class="podman-local-template-list" id="ti-local-list"><div class="podman-empty-note">Loading…</div></div>' +
'</div>' +
'<div class="podman-modal-field"><label>Or paste XML directly</label>' +
'<textarea id="ti-xml" rows="8" class="mono" placeholder="This plugin\'s own export format, or an Unraid/Community Applications Docker template." style="width:100%; resize:vertical;"></textarea></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Import pasted XML</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
let localTemplates = [];
function renderLocalList(filter) {
const list = backdrop.querySelector('#ti-local-list');
const visible = filter
? localTemplates.filter(function (t) { return t.name.toLowerCase().indexOf(filter) !== -1; })
: localTemplates;
if (!visible.length) {
list.innerHTML = '<div class="podman-empty-note">' + (localTemplates.length ? 'No match.' : 'None found.') + '</div>';
return;
}
list.innerHTML = visible.map(function (t) {
return '<div class="podman-local-template-item" data-file="' + P.escapeHtml(t.file) + '">' +
'<span class="podman-local-template-name">' + P.escapeHtml(t.name) + '</span>' +
'<span class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</span>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-action="import-local">Import</button>' +
'</div>';
}).join('');
}
P.get('templates', 'list_local').then(function (data) {
localTemplates = data;
renderLocalList('');
}).catch(function () {
backdrop.querySelector('#ti-local-list').innerHTML = '<div class="podman-empty-note">Could not read local templates.</div>';
});
backdrop.querySelector('#ti-local-search').addEventListener('input', function (e) {
renderLocalList(e.target.value.trim().toLowerCase());
});
backdrop.querySelector('#ti-local-list').addEventListener('click', function (e) {
const btn = e.target.closest('[data-action="import-local"]');
if (!btn) return;
const file = btn.closest('.podman-local-template-item').dataset.file;
btn.disabled = true;
P.post('templates', 'import_local', { file: file }).then(function () {
close();
return load();
}).catch(function (err) {
btn.disabled = false;
showError(err.message);
});
});
backdrop.querySelector('#ti-xml').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const xml = backdrop.querySelector('#ti-xml').value.trim();
if (!xml) {
showError('Paste a template XML first.');
return;
}
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('templates', 'import', { xml: xml }).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
P.registerPanel('templates', { init: load, refresh: load });
})();
+72 -64
View File
@@ -1,89 +1,97 @@
/**
* javascript/terminal.js
*
* Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that
* file's header comment for the full, honest explanation of why this is
* "type a command, see its output" rather than a true interactive PTY
* the short version is that libpod's interactive exec needs a persistent
* bidirectional connection this PHP/AJAX stack doesn't have, and faking
* interactivity on top of that would break the moment a user ran anything
* that expects a real terminal (vim, an interactive prompt, etc).
* Terminal panel: opens a real, fully interactive terminal inline (as an
* <iframe>, not a popup window) the same mechanism Unraid's own webGui
* uses for its System Terminal and for `docker exec` (see ajax/exec.php's
* header comment for the full explanation). This module's own job is just:
*
* `cd` is handled client-side: this module tracks a per-session `cwd` and
* passes it as the exec's working directory on every call, so at least
* directory navigation feels persistent even though nothing else is.
* 1. Ask ajax/exec.php to spawn a ttyd instance wrapping
* `podman exec -it <container> <shell>`, bound to a unix socket.
* 2. Point an <iframe> at /logterminal/<sockName>/ nginx's own
* "logterminal" location block (already installed system-wide by
* Unraid, not something this plugin configures) proxies that,
* WebSocket upgrade included, straight to ttyd's socket.
* 3. Track which container's session (if any) is currently open, so
* "Disconnect" or opening a different container/shell can kill
* the right ttyd process server-side instead of just discarding the
* iframe and leaving it running.
*/
(function () {
'use strict';
const P = window.Podman;
let cwd = '/';
let containerId = null;
let openName = null;
function appendLine(html) {
const out = P.el('term-output');
const div = document.createElement('div');
div.innerHTML = html;
out.appendChild(div);
out.scrollTop = out.scrollHeight;
function populateContainerSelect(list) {
const select = P.el('term-container-select');
if (!select) return;
const running = list.filter(function (c) { return c.state === 'running'; });
select.innerHTML = running
.map(function (c) { return '<option value="' + P.escapeHtml(c.name) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('') || '<option value="">No running containers</option>';
}
function promptHtml() {
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$';
function loadContainers() {
return P.get('containers', 'list').then(populateContainerSelect);
}
function runCommand(cmd) {
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd));
// `cd <dir>` is intercepted client-side (see file header) rather than
// sent as a real command, since a one-shot exec has no way to report
// "the working directory changed" back to us otherwise.
const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/);
if (cdMatch) {
cwd = cdMatch[1].startsWith('/') ? cdMatch[1] : (cwd.replace(/\/$/, '') + '/' + cdMatch[1]);
return Promise.resolve();
function resetFrame(message) {
P.el('term-frame-wrap').innerHTML = '<p class="podman-empty-note">' + message + '</p>';
P.el('term-disconnect-btn').disabled = true;
openName = null;
}
return P.post('exec', 'run', { id: containerId, cmd: cmd, cwd: cwd }).then(function (data) {
if (data.output) appendLine('<span class="mono">' + P.escapeHtml(data.output).replace(/\n/g, '<br>') + '</span>');
/** Best-effort: tells the backend to kill the ttyd/podman-exec session, if any is open. Never rejects. */
function closeCurrent() {
if (!openName) return Promise.resolve();
const name = openName;
return P.post('exec', 'close', { name: name }).catch(function () {});
}
function openLiveTerminal() {
const name = P.el('term-container-select').value;
if (!name) return;
const shell = P.el('term-shell-select').value;
const wrap = P.el('term-frame-wrap');
const btn = P.el('term-open-btn');
wrap.innerHTML = '<p class="podman-empty-note">Opening terminal…</p>';
btn.disabled = true;
closeCurrent().then(function () {
return P.post('exec', 'open', { name: name, shell: shell });
}).then(function (data) {
openName = name;
P.el('term-disconnect-btn').disabled = false;
// Matches the ~200ms delay Unraid's own openTerminal() uses between
// asking the backend to spawn ttyd and navigating to its socket —
// ttyd needs a brief moment to bind 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) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
resetFrame('Could not open terminal: ' + P.escapeHtml(err.message));
}).finally(function () {
btn.disabled = false;
});
}
function populateContainerSelect(containers) {
const select = P.el('term-container-select');
select.innerHTML = containers
.filter(function (c) { return c.state === 'running'; })
.map(function (c) { return '<option value="' + P.escapeHtml(c.id) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('');
containerId = select.value || null;
function disconnect() {
if (!openName) return;
const btn = P.el('term-disconnect-btn');
btn.disabled = true;
closeCurrent().finally(function () {
resetFrame('Disconnected. Pick a container and click "Open Terminal" to start a new session.');
});
}
function init() {
const input = P.el('term-input');
P.el('term-container-select').addEventListener('change', function (e) {
containerId = e.target.value;
cwd = '/';
P.el('term-output').innerHTML = '';
});
input.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
const cmd = input.value;
input.value = '';
if (!containerId) {
appendLine('<span style="color:#ef6470;">No running container selected.</span>');
return;
}
if (cmd.trim() === '') return;
runCommand(cmd);
});
return P.get('containers', 'list').then(populateContainerSelect).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
});
P.el('term-open-btn').addEventListener('click', openLiveTerminal);
P.el('term-disconnect-btn').addEventListener('click', disconnect);
return loadContainers();
}
P.registerPanel('terminal', { init: init });
// refresh() only repopulates the container select — it must never touch
// #term-frame-wrap, or an already-open terminal would be torn down out
// from under the user just by switching tabs and back.
P.registerPanel('terminal', { init: init, refresh: loadContainers });
})();
+26 -9
View File
@@ -2,8 +2,12 @@
* javascript/volumes.js
*
* Volumes panel: named-volume table + create/remove, backed by
* ajax/volumes.php. Bind mounts are deliberately not shown here see
* that file's header comment.
* ajax/volumes.php. Container-level bind mounts (e.g. appdata under
* /mnt/user/appdata/...) are deliberately not shown here, since they
* aren't a libpod-managed resource at all — see that file's header
* comment. A named volume created here WITH a host path (v.hostPath) IS
* still a real, listed podman volume, just backed by that path instead
* of podman's own internal storage see PodmanClient::createVolume().
*/
(function () {
'use strict';
@@ -11,14 +15,17 @@
let volumes = [];
function rowHtml(v) {
const pathCell = v.hostPath
? P.escapeHtml(v.hostPath) + ' <span class="podman-chip podman-chip-neutral" title="Bind-mounted to this host path">bind</span>'
: P.escapeHtml(v.mountpoint);
return '' +
'<tr data-name="' + P.escapeHtml(v.name) + '">' +
'<td>' + P.escapeHtml(v.name) + '</td>' +
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(v.mountpoint) + '</td>' +
'<td class="mono podman-row-sub">' + pathCell + '</td>' +
'<td class="tnum">' + v.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>';
}
@@ -42,10 +49,20 @@
function init() {
P.el('volumes-create-btn').addEventListener('click', function () {
const name = prompt('New volume name:');
if (!name) return;
P.post('volumes', 'create', { name: name }).then(load).catch(function (err) {
alert('Create failed: ' + err.message);
P.openFormModal({
title: 'New Volume',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Volume name', required: true, placeholder: 'my-volume' },
{
name: 'path', label: 'Host path (optional)', placeholder: '/mnt/cache/appdata/my-volume',
hint: 'Leave empty for a podman-managed volume. Set this to bind the volume ' +
'directly to an existing directory on disk (e.g. a cache pool path) instead.',
},
],
onSubmit: function (values) {
return P.post('volumes', 'create', { name: values.name, path: values.path || undefined }).then(load);
},
});
});
+366 -17
View File
@@ -20,7 +20,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20, 22, 26, .06), 0 4px 12px rgba(20, 22, 26, .05);
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace;
@@ -35,7 +35,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
}
@@ -44,7 +44,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
:root[data-theme="light"] .podman-plugin {
@@ -52,7 +52,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05);
}
@@ -76,19 +76,87 @@
.podman-pagehead .meta .dot-good { color: var(--good); }
.podman-pagehead .meta .dot-bad { color: var(--bad); }
/*
* margin: 0 Unraid's own webGui theme applies a 10px top/bottom margin
* to plain <button> elements site-wide. Without resetting it, every
* .podman-btn carries an invisible 10px gap above and below its own box,
* which silently breaks flex cross-axis alignment anywhere a button sits
* next to a non-button sibling (e.g. align-items: flex-end next to a
* <select> verified live: the button's margin, not its content, was
* what left it floating 10px above the dropdown it should line up with).
*/
.podman-btn {
appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text);
padding: 8px 14px; border-radius: 7px; font-size: 13px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; transition: border-color .12s, background .12s;
font-family: var(--font-ui);
font-family: var(--font-ui); margin: 0;
}
.podman-btn:hover { border-color: var(--text-faint); }
.podman-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); }
.podman-btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
/*
* !important here for the same reason as .podman-btn-ghost below: Unraid's
* own webGUI theme applies a default border/hover treatment to every
* <button> that otherwise silently wins over this rule at rest verified
* live via a screen recording: without !important, "Create" only ever
* looked filled while under the mouse (Unraid's generic hover glow, applied
* to literally any button), never at rest, making it indistinguishable
* from Cancel except by coincidence of cursor position.
*/
.podman-btn-primary {
background: var(--accent) !important; border-color: var(--accent) !important; color: var(--accent-contrast) !important;
}
.podman-btn-primary:hover {
background: var(--accent-strong) !important; border-color: var(--accent-strong) !important;
}
.podman-btn-danger { color: var(--bad); }
.podman-btn-danger:hover { border-color: var(--bad); }
.podman-btn-icon { padding: 6px 8px; }
.podman-btn-icon { padding: 6px 8px; min-width: 32px; min-height: 32px; justify-content: center; font-size: 15px; line-height: 1; }
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
/*
* Secondary action (Cancel, "+ Add row") every button previously shared
* the same bordered/accent-colored treatment as Create, so nothing in a
* form stood out as THE primary action (found from a live screenshot AND
* a screen recording: Cancel/Create/+Add/× all read as equally weighted,
* with the exact same hover glow even). Unraid's own webGUI applies a
* site-wide default border+hover-gradient to every <button>, at higher
* effective priority than a plain single-class selector here verified
* live: a bare `.podman-btn-ghost { border-color: transparent }` was
* silently losing to it, on both the rest AND hover state. !important is
* the only reliable way to guarantee this specific, deliberate style
* wins regardless of what Unraid's base theme does elsewhere.
*/
.podman-btn-ghost {
background: transparent !important; border-color: transparent !important;
color: var(--text-dim) !important; box-shadow: none !important;
}
.podman-btn-ghost:hover {
background: var(--surface-2) !important; border-color: transparent !important;
color: var(--text) !important; box-shadow: none !important;
}
/*
* A ghost button can still carry danger intent (Disconnect, Delete,
* template "Delete") needs its own !important since .podman-btn-ghost's
* color/background/border would otherwise win by rule order. Solid fill
* at rest (not just a tint, and not just on hover) so it reads with the
* same weight as .podman-btn-primary, just in red instead of accent
* a merely tinted/outlined button still read as "just another secondary
* action" per live feedback.
*/
.podman-btn-ghost.podman-btn-danger {
color: var(--bad-contrast) !important; background: var(--bad) !important; border-color: var(--bad) !important;
}
.podman-btn-ghost.podman-btn-danger:hover {
background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; color: var(--bad-contrast) !important;
}
.podman-btn-ghost.podman-btn-danger[disabled] {
color: var(--text-faint) !important; background: transparent !important; border-color: var(--border) !important;
}
/* Icon-only danger buttons (row "remove" trash icons) carry an emoji
glyph, not text .podman-btn-danger's `color` alone doesn't recolor an
emoji, so these get the same solid red fill instead, at rest not just
on hover, so "destructive" reads at a glance across a whole table. */
.podman-btn-icon.podman-btn-danger { border-color: var(--bad) !important; background: var(--bad) !important; }
.podman-btn-icon.podman-btn-danger:hover { background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; }
.podman-btn-icon.podman-btn-danger[disabled] { border-color: var(--border) !important; background: transparent !important; }
.podman-subnav {
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
@@ -145,12 +213,54 @@
.podman-plugin tbody tr:hover { background: var(--surface-2); }
.podman-table-wrap { overflow-x: auto; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
.podman-row-name-btn {
/* !important for the same reason as .podman-btn-ghost/-primary Unraid's
own site-wide button theme otherwise still shows its default border
at rest (only losing to plain rules on hover), so a name link one
click away from every table row still looked like a bordered button
forever, not a plain label. */
appearance: none; border: none !important; background: none !important; padding: 0; cursor: pointer;
color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left;
}
.podman-row-name-btn:hover { color: var(--accent-strong); }
.podman-row-name-btn:hover .ico { border-color: var(--accent); }
.podman-row-name .ico {
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);
}
.podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; }
.podman-actions { display: flex; gap: 4px; justify-content: flex-end; }
/*
* The actions <td> itself stays a plain table-cell (default display) so
* every row's column width is computed the same way by the table's layout
* algorithm putting "display: flex" directly on the <td> used to take it
* out of that algorithm, so browsers could size/position it slightly
* differently row to row (found live: the trash-can button in Images drifted
* a few pixels between rows instead of lining up in one column). The actual
* flex/gap/alignment lives on this inner wrapper instead.
*/
.podman-actions { text-align: right; white-space: nowrap; }
.podman-actions-row { display: inline-flex; gap: 4px; justify-content: flex-end; }
/*
* Segmented toggle (Containers' All/Running/Stopped filter, Logs' Follow/
* Paused) previously just an inline-styled wrapper <div> around plain
* <button>s with no CSS of their own at all, so every option (not just the
* active one) showed Unraid's own default button border permanently,
* all three chips looking identically "selected". !important for the same
* site-wide-theme-override reason as .podman-btn-ghost/-primary.
*/
.podman-segmented { display: flex; gap: 2px; background: var(--surface-3); border: 1px solid var(--text-faint); padding: 3px; border-radius: 8px; }
.podman-segmented button {
appearance: none; border: none !important; background: transparent !important; color: var(--text-dim) !important;
padding: 6px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer;
font-family: var(--font-ui); transition: background .12s, color .12s;
}
.podman-segmented button:hover { color: var(--text) !important; }
/* Filled with the accent color (not just a slightly different neutral
shade) the previous var(--surface) vs. var(--surface-2) contrast
between active/inactive was too close in the dark theme to notice at a
glance (found live). */
.podman-segmented button.active { background: var(--accent) !important; color: var(--accent-contrast) !important; box-shadow: var(--shadow); }
.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
@@ -158,7 +268,20 @@
.podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; }
.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.podman-search { flex: 1; min-width: 180px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 11px; font-size: 13px; color: var(--text); font-family: var(--font-ui); }
/*
* !important throughout: Unraid's own webGui/styles/default-base.css
* targets input[type="text"] with an attribute selector (higher
* specificity than our single .podman-search class, :where() around it
* notwithstanding) forcing border-width:0 / border-bottom-width:1px /
* background:transparent an underline-only text field, not a boxed one.
* Found live: our border/background were being silently dropped even
* though this rule appears later in the stylesheet.
*/
.podman-search {
flex: 1; min-width: 180px; max-width: 320px; font-size: 13px; color: var(--text); font-family: var(--font-ui);
background: var(--surface-3) !important; border: 1px solid var(--text-faint) !important;
border-radius: 7px !important; padding: 7px 11px !important;
}
.podman-search::placeholder { color: var(--text-faint); }
.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; }
@@ -175,7 +298,44 @@
.podman-pod-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 14px; background: var(--surface); box-shadow: var(--shadow); }
.podman-pod-head { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); margin-right: auto; }
.podman-badge { display: inline-block; font-size: 10.5px; font-weight: 700; color: var(--text-dim); background: var(--surface-3); padding: 2px 8px; border-radius: 100px; margin-top: 6px; }
.podman-template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; padding: 18px; }
.podman-template-grid .podman-empty-note { grid-column: 1 / -1; }
.podman-template-card {
border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--surface);
display: flex; flex-direction: column; gap: 10px;
}
.podman-template-icon { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--surface-2); }
.podman-template-icon-fallback {
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 16px;
color: var(--accent); border: 1px solid var(--border);
}
.podman-template-name { font-weight: 700; font-size: 13.5px; }
.podman-template-overview { font-size: 12px; color: var(--text-dim); line-height: 1.4; }
.podman-template-actions { display: flex; gap: 6px; margin-top: auto; padding-top: 4px; }
/*
* min-width: 0 overrides the flex-item default of min-width: auto, which
* otherwise refuses to shrink a button below its own label's intrinsic
* width without it, "Delete" (the widest label, and uppercased by
* Unraid's own site-wide button theme) pushed past the card's right edge
* instead of actually sharing the row evenly with Use/Export (found live).
*/
.podman-template-actions .podman-btn {
flex: 1; min-width: 0; justify-content: center; padding: 6px 8px; font-size: 11.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.podman-local-template-list { max-height: 220px; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; margin-top: 8px; }
.podman-local-template-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid var(--border);
}
.podman-local-template-item:last-child { border-bottom: none; }
.podman-local-template-name { font-weight: 600; font-size: 12.5px; white-space: nowrap; }
.podman-local-template-item .podman-row-sub { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.podman-local-template-item .podman-btn { flex: none; padding: 5px 10px; font-size: 11.5px; }
.podman-logs-layout { display: grid; grid-template-columns: 200px 1fr; min-height: 460px; }
@media (max-width: 760px) { .podman-logs-layout { grid-template-columns: 1fr; } }
@@ -191,13 +351,23 @@
.podman-log-pane .lvl-warn { color: #e0b23d; }
.podman-log-pane .lvl-error { color: #ef6470; }
.podman-term { background: #0f1114; color: #d7dbe0; font-family: var(--font-mono); font-size: 12.6px; border-radius: 8px; padding: 14px 16px; height: 380px; overflow-y: auto; line-height: 1.7; }
.podman-term .prompt { color: #4cc785; }
.podman-term .path { color: #6fb2f5; }
.podman-term-input {
width: 100%; margin-top: 10px; background: #0f1114; color: #d7dbe0; border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; font-family: var(--font-mono); font-size: 12.6px;
.podman-term-launcher {
display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
padding: 12px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
}
.podman-term-launcher label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--text-dim); }
/*
* !important here for the same reason as .podman-search's: Unraid's own
* webGui/styles/default-base.css has `select:where(:not(.unapi *))` rules
* for background/border/padding that would otherwise still show through
* around this class's box-model properties.
*/
.podman-term-select {
min-width: 180px !important; padding: 7px 12px !important; font-size: 13px !important;
font-family: var(--font-mono) !important; color: var(--text) !important;
background: var(--surface-3) !important; border: 1px solid var(--accent) !important; border-radius: 6px !important;
}
.podman-term-frame { display: block; width: 100%; height: 480px; border: 1px solid var(--border); border-radius: 8px; background: #0f1114; }
.podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; }
@media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } }
@@ -206,6 +376,19 @@
.podman-compose-proj.active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); }
.podman-compose-proj .path { font-size: 11px; color: var(--text-faint); margin-top: 2px; font-family: var(--font-mono); }
.podman-yaml { background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.4px; padding: 16px 18px; height: 420px; overflow: auto; line-height: 1.7; white-space: pre-wrap; }
/*
* !important: this is now a real <textarea>, not a read-only <pre>
* Unraid's own webGui/styles/default-base.css targets textarea the same
* way it targets input[type="text"] (see .podman-search's comment for
* the exact rule), forcing border-width:0/border-bottom-width:1px/
* background:transparent/border-radius:0, which would otherwise make the
* whole editor look like a barely-visible underline instead of an actual
* text area.
*/
.podman-yaml-editor {
display: block; width: 100%; box-sizing: border-box; resize: vertical;
border: none !important; border-radius: 0 !important; outline: none;
}
.podman-field-row { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); align-items: start; }
.podman-field-row:last-child { border-bottom: none; }
@@ -216,10 +399,176 @@
font-size: 13px; color: var(--text); width: 100%; max-width: 340px; font-family: var(--font-ui);
}
.podman-danger-card { border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
/* Settings panel: a shared save action above all cards (Storage's and
Autostart & Lifecycle's fields save together in one call see
settings.js's save() so one button belongs above both, not buried in
either card, and definitely not in its own row with an empty label).
Framed as its own small bar (background/border), not bare text+button
floating at the top of the page. */
.podman-settings-actions {
display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px;
padding: 12px 16px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px;
}
.podman-settings-actions .hint { margin: 0; max-width: 52ch; color: var(--text-dim); }
.podman-card-head .sub { margin-top: 3px; }
/* Number field + unit label (GB, seconds) the input itself stays compact
instead of stretching to .podman-field-row's normal 340px text-field width. */
.podman-input-suffix { display: flex; align-items: center; gap: 8px; }
.podman-input-suffix input[type="number"] { max-width: 100px; width: auto; }
.podman-input-suffix span { font-size: 12px; color: var(--text-dim); }
/*
* Toggle switch a plain checkbox reads as a leftover form control next
* to everything else in this panel getting a designed treatment; this
* hides the native checkbox (still the real, accessible input driving
* state) and draws a track+thumb off its :checked state instead. Sized in
* em off the track's own font-size so it scales if that ever changes.
*/
.podman-switch { position: relative; display: inline-flex; align-items: center; cursor: pointer; font-size: 22px; }
.podman-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.podman-switch-track {
display: inline-block; width: 1.9em; height: 1.05em; border-radius: 999px;
background: var(--surface-3); border: 1px solid var(--border); transition: background .15s, border-color .15s;
}
.podman-switch-thumb {
display: block; width: 0.75em; height: 0.75em; margin: 0.13em; border-radius: 50%;
background: var(--text-faint); transition: transform .15s, background .15s;
}
.podman-switch input:checked + .podman-switch-track { background: var(--accent); border-color: var(--accent); }
.podman-switch input:checked + .podman-switch-track .podman-switch-thumb { background: var(--accent-contrast); transform: translateX(0.85em); }
.podman-switch input:focus-visible + .podman-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.podman-version-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.podman-version-chip {
font-size: 11.5px; font-family: var(--font-mono); background: var(--surface-3); color: var(--text-dim);
border: 1px solid var(--border); padding: 5px 11px; border-radius: 100px;
}
.podman-version-chip b { color: var(--text); font-weight: 600; margin-left: 5px; }
.podman-danger-card .podman-card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); }
.podman-danger-card .podman-card-head h2 { color: var(--bad); }
.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-loading, .podman-error { padding: 32px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
/**
* Modal form dialog replaces browser-native prompt()/confirm() for any
* action that needs more than a single yes/no (e.g. "New Volume" needs a
* name AND an optional host path together, which prompt() can't express
* as one coherent form). See app.js's openFormModal().
*/
.podman-modal-backdrop {
position: fixed; inset: 0; background: rgba(15, 17, 20, .55); z-index: 1000;
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.podman-modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
box-shadow: var(--shadow); width: 100%; max-width: 420px; max-height: calc(100vh - 40px);
overflow-y: auto; color: var(--text); font-family: var(--font-ui);
}
.podman-modal-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
.podman-modal-head h3 { font-size: 15px; }
.podman-modal-body { padding: 16px 20px; display: grid; gap: 14px; }
.podman-modal-field label { display: block; font-weight: 600; font-size: 12.5px; margin-bottom: 6px; }
.podman-modal-field input[type="text"] {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
font-size: 13px; color: var(--text); width: 100%; font-family: var(--font-ui);
}
.podman-modal-field .hint { font-size: 11.5px; color: var(--text-faint); margin-top: 4px; }
.podman-modal-field select {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
font-size: 13px; color: var(--text); font-family: var(--font-ui);
}
.podman-modal-checkbox label { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 12.5px; margin-bottom: 0; }
.podman-modal-error { font-size: 12.5px; color: var(--bad); background: var(--bad-bg); border-radius: 7px; padding: 8px 10px; }
.podman-modal-actions { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
.podman-error { color: var(--bad); }
/* Wider variant + repeatable row groups, for forms with more than 1-2 fields (e.g. Create Container). */
.podman-modal-wide { max-width: 640px; }
.podman-row-group { display: grid; gap: 8px; margin-bottom: 8px; }
.podman-row-group-item {
display: flex; align-items: center; gap: 8px;
}
.podman-row-group-item input[type="text"] {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px;
font-size: 12.5px; color: var(--text); font-family: var(--font-mono); flex: 1; min-width: 0;
}
.podman-row-group-item select {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px;
font-size: 12.5px; color: var(--text); font-family: var(--font-ui);
/* flex:none alone wasn't enough its auto flex-basis still let the
select stretch to fill the row (verified live: "TCP"/"Volume"
dropdowns spanned almost the entire row width). An explicit width
pins it to content-appropriate size regardless. */
flex: none; width: 110px;
}
.podman-row-group-item span { color: var(--text-faint); font-size: 12px; flex: none; }
/*
* Port number fields share the row with a select + remove button, unlike
* the wide source/path/key/value fields elsewhere in these row groups
* left on flex:1 like everything else, both port inputs fought the fixed-
* width select/button for space and got squeezed down to a few pixels
* (found live: they rendered as near-invisible slivers). Fixed width,
* not flex-grown.
*/
.podman-row-group-item input.podman-input-narrow { flex: none; width: 90px; }
/* Row-remove (x) reads as a normal button like everything else at full
.podman-btn weight muted/borderless by default, only turning
"danger" red on hover, so it registers as a quiet per-row affordance
rather than competing with the form's actual actions. */
.podman-row-group-item .podman-row-remove-btn {
background: transparent; border-color: transparent; color: var(--text-faint); flex: none;
}
.podman-row-group-item .podman-row-remove-btn:hover { background: var(--bad-bg); border-color: transparent; color: var(--bad); }
/* Anchored dropdown context menu — see app.js openContextMenu(). */
.podman-context-menu {
/*
* "fixed", not "absolute": this menu is appended to .podman-plugin, not
* document.body, and Unraid's own page wrapper around .podman-plugin
* turned out to have its own positioned ancestor with "absolute" the
* menu was positioning itself relative to THAT ancestor's box while the
* JS math (getBoundingClientRect + scrollY/X) assumed the viewport,
* so it rendered far from the button that opened it (found live: it
* appeared well below and to the side of the anchor). "fixed" is always
* viewport-relative regardless of any ancestor, which is what the JS
* math actually assumes.
*/
position: fixed; width: 180px; background: var(--surface); border: 1px solid var(--border);
border-radius: 9px; box-shadow: var(--shadow); z-index: 1001; padding: 4px; display: grid; gap: 1px;
}
.podman-context-menu button {
appearance: none; border: none; background: none; text-align: left; padding: 8px 10px;
font-size: 12.5px; font-weight: 600; color: var(--text); border-radius: 6px; cursor: pointer;
font-family: var(--font-ui); width: 100%;
}
.podman-context-menu button:hover { background: var(--surface-2); }
.podman-context-menu button.danger { color: var(--bad); background: var(--bad-bg); }
.podman-context-menu button.danger:hover { background: var(--bad); color: var(--bad-contrast); }
.podman-context-menu button[disabled] { opacity: .4; cursor: not-allowed; }
.podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }
/* Container detail modal — see containers.js openDetailModal(). */
.podman-modal-xwide { max-width: 760px; }
.podman-detail-tabs {
display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--border); overflow-x: auto;
}
.podman-detail-tabs button {
appearance: none; background: none; border: none; border-bottom: 2px solid transparent;
color: var(--text-dim); padding: 10px 12px; font-size: 12.5px; font-weight: 600; cursor: pointer;
white-space: nowrap; font-family: var(--font-ui);
}
.podman-detail-tabs button:hover { color: var(--text); }
.podman-detail-tabs button.active { color: var(--accent-strong); border-bottom-color: var(--accent); }
.podman-detail-body { padding: 16px 20px; max-height: 50vh; overflow-y: auto; }
.podman-detail-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.podman-detail-table td { padding: 6px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
.podman-detail-table td:first-child { color: var(--text-faint); font-weight: 600; width: 160px; padding-right: 12px; }
.podman-detail-table td.mono { font-family: var(--font-mono); word-break: break-all; }
.podman-detail-json {
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 11.8px;
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; }