4 Commits
Author SHA1 Message Date
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
36 changed files with 2554 additions and 95 deletions
+6 -5
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, docker-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: ""
+2
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 |
| `docker-compose` (CLI-Plugin) | External-Compose-Provider für `podman compose` | `podman compose` hat keine eigene Compose-Implementierung, sondern sucht ein `docker-compose`-Binary in festen CLI-Plugin-Pfaden; ohne dieses Paket schlägt jede Compose-Panel-Aktion auf einem frischen Unraid-Install fehl |
### 5.2 Build-Strategie
+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:
+31
View File
@@ -0,0 +1,31 @@
# packages/docker-compose/
Pinned version: see `DOCKER_COMPOSE_VERSION` in [versions.env](../../versions.env).
Not built from source — `docker-compose.SlackBuild` fetches and repackages
upstream's own prebuilt static x86_64 release binary (`docker/compose`,
the Go-based Compose v2 CLI plugin — a different project from the older
Python `podman-compose`). It's a small, purely static ELF with zero
runtime library dependencies (verified: `ldd` reports "not a dynamic
executable"), so there's nothing meaningful to gain from a from-source
build.
`podman compose` (backing the WebUI's Compose panel, see
`webui/plugins/podman/ajax/compose.php`) has no compose implementation of
its own — it shells out to an "external compose provider" it discovers by
searching a fixed list of CLI-plugin directories for a binary named
`docker-compose`. Without one present, every Compose panel action fails
outright. Installed to `/usr/local/lib/docker/cli-plugins/docker-compose`
— one of podman's own search paths (extracted from the pinned podman
binary: `strings /usr/bin/podman | grep cli-plugins`), chosen specifically
under `/usr/local/` rather than `/usr/lib/docker/...` so this package
never collides with (or gets silently shadowed by) a genuine Docker
installation's own compose plugin on hosts that also run Unraid's
built-in Docker support.
Found by live-testing the Compose panel end-to-end against a real Unraid
install: it happened to work only because that particular host already
had Docker's own `docker-compose` plugin installed from an unrelated,
pre-existing Docker setup — a clean Unraid install has no compose
provider at all without this package. See
[docs/ARCHITECTURE.md, section 5.1](../../docs/ARCHITECTURE.md#51-zu-paketierende-komponenten).
+60
View File
@@ -0,0 +1,60 @@
#!/bin/bash
# =============================================================================
# packages/docker-compose/docker-compose.SlackBuild
#
# Packages the official prebuilt docker/compose v2 release binary — not
# built from source, see versions.env's DOCKER_COMPOSE_* block for why.
# `podman compose` (backing webui/plugins/podman/ajax/compose.php) has no
# compose implementation of its own; it shells out to an "external compose
# provider" it discovers by searching a fixed list of CLI-plugin
# directories for a binary literally named `docker-compose` (paths
# extracted from the pinned podman binary itself:
# `strings /usr/bin/podman | grep cli-plugins`). Without one present,
# every Compose panel action fails outright (found by live-testing against
# a real Unraid install — it only worked there because that host happened
# to already have Docker's own compose plugin from an unrelated setup).
#
# Installed to /usr/local/lib/docker/cli-plugins/docker-compose — one of
# podman's search paths, deliberately the /usr/local/ one rather than
# /usr/lib/docker/cli-plugins so this package never collides with (or
# gets silently shadowed by) a genuine Docker installation's own compose
# plugin on hosts that also run Unraid's built-in Docker support.
# =============================================================================
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="$DOCKER_COMPOSE_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "docker-compose"
binary=$(sb_fetch_and_verify "$DOCKER_COMPOSE_SRC_URL" "$DOCKER_COMPOSE_SRC_SHA256" "docker-compose-$VERSION")
install -D -m 0755 "$binary" "$PKG/usr/local/lib/docker/cli-plugins/docker-compose"
# 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/docker-compose-$VERSION"
mkdir -p "$docdir"
{
echo "docker-compose (docker/compose v2 CLI plugin) $VERSION"
echo "https://github.com/docker/compose"
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: docker-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------------------------------------------------|
docker-compose: docker-compose (external Compose provider for podman compose)
docker-compose:
docker-compose: The docker/compose v2 CLI-plugin binary, installed as a
docker-compose: CLI-plugin so `podman compose` can find it. Required by the
docker-compose: WebUI's Compose panel; packaged from upstream's prebuilt
docker-compose: static binary release, not built from source.
docker-compose:
docker-compose: Homepage: https://github.com/docker/compose
docker-compose:
docker-compose:
docker-compose:
+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"
+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:
@@ -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>,
+66 -8
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/
docker-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.
@@ -65,8 +67,8 @@
<!-- 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">
@@ -106,6 +108,27 @@
<!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-&fuse_overlayfs_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY fuse_overlayfs_txz_md5 "00000000000000000000000000000000">
<!-- 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.0.0">
<!ENTITY catatonit_txz_file "catatonit-&catatonit_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY catatonit_txz_md5 "00000000000000000000000000000000">
<!ENTITY nftables_txz_version "0.0.0">
<!ENTITY nftables_txz_file "nftables-&nftables_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY nftables_txz_md5 "00000000000000000000000000000000">
<!-- docker-compose is the external Compose provider `podman compose`
shells out to (see packages/docker-compose/README.md) — without it
every Compose panel action fails outright on a clean install. -->
<!ENTITY docker_compose_txz_version "0.0.0">
<!ENTITY docker_compose_txz_file "docker-compose-&docker_compose_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY docker_compose_txz_md5 "00000000000000000000000000000000">
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
sbin/ scripts, event/ hooks, config templates — see
packages/unraid-podman/README.md), not an upstream component. Its
@@ -145,7 +168,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 +180,9 @@ fi
</FILE>
<!--
The seven upstream component packages. Each is downloaded straight into
The seven upstream component packages, plus catatonit, nftables, and
docker-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
@@ -232,6 +257,33 @@ fi
</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;/&docker_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&docker_compose_txz_file;
</URL>
<MD5>
&docker_compose_txz_md5;
</MD5>
</FILE>
<!--
The plugin's own scaffolding package — rc.podman, sbin/ helper scripts,
the event/ hooks below, and default config templates. See
@@ -247,7 +299,7 @@ fi
</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
@@ -291,6 +343,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 "DOCKER_COMPOSE_INSTALLED_VERSION=\"&docker_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 +406,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 &docker_compose_txz_file;
removepkg &unraid_podman_txz_file;
echo ""
+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 docker-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 docker-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
---
+15 -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, docker-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,17 @@ 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 docker-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 docker-compose are listed first since they're
# all a plain fetch-and-repackage of an already-built upstream artifact
# (see their own README.md for why) — no compiler, fastest possible
# signal if their 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
+1 -1
View File
@@ -3,7 +3,7 @@
# 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: vbatts/slackware:15.0 (the image build-packages.yml runs this
# in) is a minimal rootfs — it ships none of the 'D' (development) series,
+3 -2
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:
+7 -5
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).
@@ -54,10 +54,12 @@ RELEASE_BASE_URL="https://github.com/$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 docker-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 docker-compose unraid-podman)
echo "==> Releasing unraid-podman plugin v$NEW_VERSION (packages tag: $RELEASE_TAG)"
+44
View File
@@ -93,6 +93,50 @@ 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"
# --- docker-compose ----------------------------------------------------------
# https://github.com/docker/compose — the Compose v2 CLI-plugin binary
# (Go, not the older Python podman-compose). `podman compose` (backing
# webui/plugins/podman/ajax/compose.php, the WebUI's Compose panel) has no
# compose implementation of its own — it searches a fixed set of
# CLI-plugin directories for a binary named exactly "docker-compose" and
# shells out to it. Without one present, every Compose panel action fails
# outright (found by live-testing: it only worked on the test host because
# that host happened to already have Docker's own compose plugin
# installed from an unrelated, pre-existing Docker setup — a clean Unraid
# install has none). Upstream publishes a prebuilt static x86_64 binary
# release asset (verified via `ldd`: "not a dynamic executable") plus a
# matching .sha256 sidecar — no from-source build needed.
DOCKER_COMPOSE_VERSION="5.3.1"
DOCKER_COMPOSE_SRC_URL="https://github.com/docker/compose/releases/download/v${DOCKER_COMPOSE_VERSION}/docker-compose-linux-x86_64"
DOCKER_COMPOSE_SRC_SHA256="f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959"
# =============================================================================
# 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):
+30 -12
View File
@@ -16,8 +16,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 +54,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>
@@ -73,6 +89,7 @@ Icon="podman"
<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-create-btn">+ New Container</button>
</div>
<div class="podman-table-wrap">
<table>
@@ -83,6 +100,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>
@@ -243,14 +263,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";
}
?>
+20 -5
View File
@@ -121,8 +121,19 @@ 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';
}
function compose_read(string $composeDir, string $project): string
@@ -155,7 +166,7 @@ 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
{
@@ -165,7 +176,7 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
$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 +186,9 @@ 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) for human-facing success/error messages, where seeing
// podman's own stderr banner/warnings is actually useful context.
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($stdout . $stderr)];
}
+169
View File
@@ -13,7 +13,16 @@
* 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
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no",
* "privileged": false, "startAfterCreate": true}
*/
declare(strict_types=1);
@@ -68,10 +77,170 @@ 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 '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);
}
/**
* 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 !== '') {
$spec['networks'] = [$networkMode => new \stdClass()];
}
if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
$spec['restart_policy'] = (string) $body['restartPolicy'];
}
if ($body['privileged'] ?? false) {
$spec['privileged'] = true;
}
return $spec;
}
/** @param array<string,mixed> $body */
function require_id(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,
];
+98 -4
View File
@@ -101,6 +101,23 @@ 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
{
$result = $this->request('POST', '/containers/create', [], false, $spec);
return (string) ($result['Id'] ?? '');
}
public function startContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
@@ -121,6 +138,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
@@ -220,10 +258,54 @@ 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]);
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference]);
$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
@@ -240,9 +322,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
+179
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,172 @@ 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 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('');
const rect = anchorEl.getBoundingClientRect();
menu.style.top = (rect.bottom + window.scrollY + 4) + 'px';
menu.style.left = (rect.right + window.scrollX - 180) + 'px';
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
let buttonIndex = 0;
items.forEach(function (item) {
if (item === 'separator') return;
const btn = menu.children[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 +357,8 @@ window.Podman = (function () {
stateChipClass: stateChipClass,
loadingRow: loadingRow,
errorRow: errorRow,
openFormModal: openFormModal,
openContextMenu: openContextMenu,
registerPanel: registerPanel,
activatePanel: activatePanel,
};
+429 -6
View File
@@ -23,7 +23,8 @@
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></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>' +
@@ -37,11 +38,170 @@
return '' +
'<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>';
}
if (c.state === 'paused') {
return '' +
'<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 '' +
'<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('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);
},
});
}
// --- 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 +240,270 @@
});
}
// --- 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) to seed the form with —
* used by templates.js's "Use template" action. null/omitted opens a
* blank form, same as the toolbar's "+ New Container" button.
*/
function openCreateContainerModal(prefill) {
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>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"><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>' +
'<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 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">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
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;
select.appendChild(opt);
});
}).catch(function () { /* built-in modes still usable */ });
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() {
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 ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
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 submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('containers', 'create', {
image: image,
name: backdrop.querySelector('#cc-name').value.trim(),
networkMode: networkMode,
ports: ports,
volumes: volumes,
env: env,
restartPolicy: backdrop.querySelector('#cc-restart').value,
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(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 +522,27 @@
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') {
const c = allContainers.find(function (x) { return x.id === id; });
if (!c) return;
if (btn.dataset.action === 'menu') {
openRowMenu(c, btn);
} else {
openDetailModal(c);
}
return;
}
handleAction(id, btn.dataset.action, btn);
});
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 });
})();
+9 -4
View File
@@ -43,10 +43,15 @@
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);
},
});
});
+10 -5
View File
@@ -45,11 +45,16 @@
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.openFormModal({
title: 'New Network',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Network name', required: true, placeholder: 'my-network' },
{ name: 'subnet', label: 'Subnet (optional)', placeholder: '10.89.2.0/24' },
],
onSubmit: function (values) {
return P.post('networks', 'create', { name: values.name, driver: 'bridge', subnet: values.subnet || undefined }).then(load);
},
});
});
@@ -0,0 +1,219 @@
/**
* 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>' +
(t.category ? '<span class="podman-badge">' + P.escapeHtml(t.category) + '</span>' : '') +
(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 });
})();
+24 -7
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,11 +15,14 @@
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>' +
@@ -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);
},
});
});
+182 -2
View File
@@ -83,12 +83,51 @@
font-family: var(--font-ui);
}
.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[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 (e.g. a template's
"Delete") needs its own !important since .podman-btn-ghost's color
would otherwise win by rule order. */
.podman-btn-ghost.podman-btn-danger { color: var(--bad) !important; }
.podman-btn-ghost.podman-btn-danger:hover { background: var(--bad-bg) !important; color: var(--bad) !important; }
.podman-subnav {
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
@@ -145,6 +184,12 @@
.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 {
appearance: none; border: none; background: none; 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);
@@ -177,6 +222,33 @@
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
.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: 8px; margin-top: auto; padding-top: 4px; }
.podman-template-actions .podman-btn { flex: 1; justify-content: center; padding: 6px 10px; font-size: 12px; }
.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; } }
.podman-logs-side { border-right: 1px solid var(--border); }
@@ -222,4 +294,112 @@
.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 {
position: absolute; 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); }
.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; }