Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e27f8575 | ||
|
|
151d93c12d | ||
|
|
993e187f59 | ||
|
|
5b47b4cc0a |
@@ -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: ""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
Executable
+54
@@ -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"
|
||||
@@ -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:
|
||||
@@ -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
@@ -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"
|
||||
@@ -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:
|
||||
@@ -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).
|
||||
Executable
+45
@@ -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,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
@@ -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 ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(', ') || '—') + '</td>' +
|
||||
@@ -37,11 +38,170 @@
|
||||
return '' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">↻</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">■</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove" disabled>🗑</button>';
|
||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||
}
|
||||
if (c.state === 'paused') {
|
||||
return '' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">▶</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
||||
}
|
||||
return '' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove">🗑</button>';
|
||||
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</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 → 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 || '') + ' → ' + 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>→</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">×</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>→</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">×</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">×</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 });
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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">⬆ 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 });
|
||||
})();
|
||||
@@ -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"') + '>🗑</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);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user