Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e27f8575 | ||
|
|
151d93c12d | ||
|
|
993e187f59 | ||
|
|
5b47b4cc0a |
@@ -1,9 +1,10 @@
|
|||||||
name: Build Packages
|
name: Build Packages
|
||||||
|
|
||||||
# Builds the seven Slackware .txz packages defined under packages/
|
# Builds the eleven Slackware .txz packages defined under packages/
|
||||||
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs)
|
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
|
||||||
# inside a Slackware container, verifies + consolidates their checksums, and
|
# catatonit, nftables, docker-compose, unraid-podman) inside a Slackware
|
||||||
# uploads the result as a workflow artifact.
|
# container, verifies + consolidates their checksums, and uploads the
|
||||||
|
# result as a workflow artifact.
|
||||||
#
|
#
|
||||||
# Intentionally does NOT commit any built binary back to the repository —
|
# Intentionally does NOT commit any built binary back to the repository —
|
||||||
# packages/**, *.txz, dist/ are all git-ignored (see .gitignore). Artifacts
|
# packages/**, *.txz, dist/ are all git-ignored (see .gitignore). Artifacts
|
||||||
@@ -31,7 +32,7 @@ on:
|
|||||||
inputs:
|
inputs:
|
||||||
packages:
|
packages:
|
||||||
description: >
|
description: >
|
||||||
Space-separated package names to build (default: all seven).
|
Space-separated package names to build (default: all eleven).
|
||||||
Example: "podman conmon"
|
Example: "podman conmon"
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
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 |
|
| `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) |
|
| `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) | |
|
| `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
|
### 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
|
# packages/unraid-podman
|
||||||
|
|
||||||
Packaging recipe for the plugin's own scaffolding — **not** an upstream
|
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.
|
`unraid-podman.SlackBuild`'s header comment for the full rationale.
|
||||||
|
|
||||||
Bundles:
|
Bundles:
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# packages/unraid-podman/unraid-podman.SlackBuild
|
# packages/unraid-podman/unraid-podman.SlackBuild
|
||||||
#
|
#
|
||||||
# Unlike the other seven packages, this one does not compile anything from
|
# Unlike the other ten packages, this one does not fetch anything from
|
||||||
# an external upstream source — it packages THIS repository's own plugin
|
# an external upstream source at all — it packages THIS repository's own
|
||||||
# scaffolding (rc.podman, the sbin/ helper scripts, the official Unraid
|
# plugin scaffolding (rc.podman, the sbin/ helper scripts, the official
|
||||||
# event/ hooks, and the default config templates) into a single .txz,
|
# Unraid event/ hooks, and the default config templates) into a single
|
||||||
# exactly matching how real-world Unraid plugins bundle their own files
|
# .txz, exactly matching how real-world Unraid plugins bundle their own
|
||||||
# (verified against the actual unassigned.devices.plg / package layout —
|
# files (verified against the actual unassigned.devices.plg / package
|
||||||
# see docs/ARCHITECTURE.md section 3.2 for the reference check that led to
|
# layout — see docs/ARCHITECTURE.md section 3.2 for the reference check
|
||||||
# this design). plugin/podman.plg installs this alongside the seven
|
# that led to this design). plugin/podman.plg installs this alongside the
|
||||||
# compiled component packages, all via the same
|
# other ten packages, all via the same
|
||||||
# `upgradepkg --install-new --reinstall` mechanism.
|
# `upgradepkg --install-new --reinstall` mechanism.
|
||||||
#
|
#
|
||||||
# Version: taken directly from plugin/podman.plg's own <!ENTITY version>,
|
# Version: taken directly from plugin/podman.plg's own <!ENTITY version>,
|
||||||
|
|||||||
+66
-8
@@ -32,14 +32,16 @@
|
|||||||
|
|
||||||
Structure of this file:
|
Structure of this file:
|
||||||
1. DOCTYPE entity block — plugin metadata + one version/file/md5 triple
|
1. DOCTYPE entity block — plugin metadata + one version/file/md5 triple
|
||||||
per package (the seven upstream components plus this project's own
|
per package (the seven upstream components, catatonit/nftables/
|
||||||
"unraid-podman" scaffolding package, see packages/unraid-podman/).
|
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
|
Entities are rewritten automatically by scripts/release.sh; never
|
||||||
hand-edit a *_txz_version/_file/_md5 entity — see that script.
|
hand-edit a *_txz_version/_file/_md5 entity — see that script.
|
||||||
2. <PLUGIN> body:
|
2. <PLUGIN> body:
|
||||||
a. <CHANGES> — kept in sync with CHANGELOG.md by hand for now.
|
a. <CHANGES> — kept in sync with CHANGELOG.md by hand for now.
|
||||||
b. Pre-install architecture sanity check.
|
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,
|
d. Postinstall <FILE Run="/bin/bash"> — directory/config seeding,
|
||||||
install-manifest generation, first start.
|
install-manifest generation, first start.
|
||||||
e. <FILE Run="/bin/bash" Method="remove"> — uninstall.
|
e. <FILE Run="/bin/bash" Method="remove"> — uninstall.
|
||||||
@@ -65,8 +67,8 @@
|
|||||||
|
|
||||||
<!-- Slackware package naming components — must match versions.env's
|
<!-- Slackware package naming components — must match versions.env's
|
||||||
PKG_ARCH/PKG_BUILD/PKG_TAG (see that file). Kept as entities here so
|
PKG_ARCH/PKG_BUILD/PKG_TAG (see that file). Kept as entities here so
|
||||||
the eight removepkg calls in the Method="remove" block don't have to
|
the eleven removepkg calls in the Method="remove" block don't have to
|
||||||
repeat "x86_64-1_unraidpodman" eight times by hand. -->
|
repeat "x86_64-1_unraidpodman" eleven times by hand. -->
|
||||||
<!ENTITY pkgArch "x86_64">
|
<!ENTITY pkgArch "x86_64">
|
||||||
<!ENTITY pkgBuild "1">
|
<!ENTITY pkgBuild "1">
|
||||||
<!ENTITY pkgTag "_unraidpodman">
|
<!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_file "fuse-overlayfs-&fuse_overlayfs_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
|
||||||
<!ENTITY fuse_overlayfs_txz_md5 "00000000000000000000000000000000">
|
<!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,
|
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
|
||||||
sbin/ scripts, event/ hooks, config templates — see
|
sbin/ scripts, event/ hooks, config templates — see
|
||||||
packages/unraid-podman/README.md), not an upstream component. Its
|
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
|
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
|
(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">
|
<FILE Run="/bin/bash">
|
||||||
<INLINE>
|
<INLINE>
|
||||||
@@ -157,7 +180,9 @@ fi
|
|||||||
</FILE>
|
</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;/
|
its backup slot under /boot/config/plugins/&name;/backup/packages/&version;/
|
||||||
(grouped by PLUGIN version, not each component's own version — a rollback
|
(grouped by PLUGIN version, not each component's own version — a rollback
|
||||||
targets "this plugin release" as one unit, see
|
targets "this plugin release" as one unit, see
|
||||||
@@ -232,6 +257,33 @@ fi
|
|||||||
</MD5>
|
</MD5>
|
||||||
</FILE>
|
</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 plugin's own scaffolding package — rc.podman, sbin/ helper scripts,
|
||||||
the event/ hooks below, and default config templates. See
|
the event/ hooks below, and default config templates. See
|
||||||
@@ -247,7 +299,7 @@ fi
|
|||||||
</FILE>
|
</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
|
are on disk, but isn't itself package content — directory/config
|
||||||
seeding, the install-manifest that plugin/sbin/podman-verify-packages.sh
|
seeding, the install-manifest that plugin/sbin/podman-verify-packages.sh
|
||||||
and podman-update-packages.sh read, and the first start. Runs on both
|
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 "AARDVARK_DNS_INSTALLED_VERSION=\"&aardvark_dns_txz_version;\"" >> "$MANIFEST"
|
||||||
echo "PASST_INSTALLED_VERSION=\"&passt_txz_version;\"" >> "$MANIFEST"
|
echo "PASST_INSTALLED_VERSION=\"&passt_txz_version;\"" >> "$MANIFEST"
|
||||||
echo "FUSE_OVERLAYFS_INSTALLED_VERSION=\"&fuse_overlayfs_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 "UNRAID_PODMAN_INSTALLED_VERSION=\"&unraid_podman_txz_version;\"" >> "$MANIFEST"
|
||||||
|
|
||||||
echo "Seeding /boot/config/plugins/podman/ configuration (existing files left untouched)..."
|
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 &aardvark_dns_txz_file;
|
||||||
removepkg &passt_txz_file;
|
removepkg &passt_txz_file;
|
||||||
removepkg &fuse_overlayfs_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;
|
removepkg &unraid_podman_txz_file;
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
# needed — see docs/ARCHITECTURE.md section 13.1.
|
# needed — see docs/ARCHITECTURE.md section 13.1.
|
||||||
#
|
#
|
||||||
# Usage:
|
# 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
|
# 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"
|
. "$SCRIPT_DIR/podman-common.sh"
|
||||||
|
|
||||||
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
|
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
|
if [ ! -f "$INSTALLED_VERSIONS_FILE" ]; then
|
||||||
podman_log_error "update-packages: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found"
|
podman_log_error "update-packages: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# plugin/sbin/podman-verify-packages.sh
|
# 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
|
# 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.
|
# 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"
|
. "$SCRIPT_DIR/podman-common.sh"
|
||||||
|
|
||||||
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
|
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
|
QUIET=0
|
||||||
[ "${1:-}" = "--quiet" ] && QUIET=1
|
[ "${1:-}" = "--quiet" ] && QUIET=1
|
||||||
@@ -95,7 +95,7 @@ for name in $PACKAGES; do
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
# --- Check 3: backup artifact integrity, if present --------------------
|
# --- 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
|
# are grouped under a single PLUGIN_VERSION directory (not per-component
|
||||||
# version) — a rollback targets "go back to plugin release X", matching
|
# version) — a rollback targets "go back to plugin release X", matching
|
||||||
# podman-backup.sh's restore-packages <plugin-version>.
|
# 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
|
# scripts/build-packages.sh
|
||||||
#
|
#
|
||||||
# Orchestrates building all seven Slackware .txz packages this plugin ships
|
# Orchestrates building all Slackware .txz packages this plugin ships
|
||||||
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs), by
|
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
|
||||||
# running each package's <name>.SlackBuild in turn. See
|
# catatonit, nftables, docker-compose), by running each package's
|
||||||
# docs/ARCHITECTURE.md section 5.2 (Build-Strategie).
|
# <name>.SlackBuild in turn. See docs/ARCHITECTURE.md section 5.2
|
||||||
|
# (Build-Strategie).
|
||||||
#
|
#
|
||||||
# This script itself does not containerize anything — it assumes it is
|
# This script itself does not containerize anything — it assumes it is
|
||||||
# already running inside a Slackware-compatible build environment (see
|
# 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
|
# 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
|
# fastest and has nothing useful to report on failure that earlier package
|
||||||
# failures wouldn't already explain.
|
# 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
|
# catatonit, nftables, and docker-compose are listed first since they're
|
||||||
# components it is the slowest build and the one most likely to fail on a
|
# all a plain fetch-and-repackage of an already-built upstream artifact
|
||||||
# dependency/tag mistake, so faster packages surface problems first during
|
# (see their own README.md for why) — no compiler, fastest possible
|
||||||
# local iteration. unraid-podman is genuinely last since it packages this
|
# signal if their pinned URL/checksum in versions.env ever goes stale.
|
||||||
# repo's own files and has no compile step at all.
|
# 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=("$@")
|
requested=("$@")
|
||||||
if [ "${#requested[@]}" -eq 0 ]; then
|
if [ "${#requested[@]}" -eq 0 ]; then
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# scripts/ci/setup-slackware-buildenv.sh
|
# scripts/ci/setup-slackware-buildenv.sh
|
||||||
#
|
#
|
||||||
# Prepares a Slackware container (see .github/workflows/build-packages.yml)
|
# 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
|
# 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,
|
# 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
|
# Centralizing this logic means each individual SlackBuild only has to
|
||||||
# describe *how to compile* its component — fetching, checksum verification,
|
# describe *how to compile* its component — fetching, checksum verification,
|
||||||
# and final .txz packaging are implemented once, here, and used the same way
|
# 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
|
# by all ten packages (unraid-podman's own SlackBuild has no upstream
|
||||||
# consistent and short instead of each reinventing (and potentially
|
# 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.
|
# forgetting) checksum verification or Slackware package metadata.
|
||||||
#
|
#
|
||||||
# Every SlackBuild is expected to:
|
# Every SlackBuild is expected to:
|
||||||
|
|||||||
+7
-5
@@ -4,7 +4,7 @@
|
|||||||
#
|
#
|
||||||
# Cuts a release of the plugin itself:
|
# Cuts a release of the plugin itself:
|
||||||
# 1. Bumps the &version; entity in plugin/podman.plg to <new-version>.
|
# 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
|
# 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).
|
# and only wants this script to do the .plg/CHANGELOG bookkeeping).
|
||||||
# 3. Verifies + consolidates checksums (scripts/checksums.sh).
|
# 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
|
# Component name -> the entity name prefix used in podman.plg. Must match
|
||||||
# plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly.
|
# plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly.
|
||||||
# unraid-podman is the plugin's own scaffolding package (see
|
# catatonit, nftables, and docker-compose are vendored runtime
|
||||||
# packages/unraid-podman/README.md), not an upstream component, but it's
|
# dependencies (not built from source, see their own packages/*/README.md)
|
||||||
# released and entity-updated exactly like the other seven.
|
# and unraid-podman is the plugin's own scaffolding package (see
|
||||||
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman)
|
# 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)"
|
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_URL="https://passt.top/passt/snapshot/passt-${PASST_COMMIT}.tar.gz"
|
||||||
PASST_SRC_SHA256="4c58a77504a77d613464dddf22ae69d749a5ba64cb87e44c3b8c252333e209fc"
|
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
|
# Slackware package BUILD number (not upstream version). Bump this if a
|
||||||
# package must be rebuilt without an upstream version change (e.g. 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
|
`/usr/local/emhttp/plugins/<name>/`. `webui/plugins/podman/` is staged to
|
||||||
that path by the `unraid-podman` scaffolding package (see
|
that path by the `unraid-podman` scaffolding package (see
|
||||||
`packages/unraid-podman/unraid-podman.SlackBuild`), which podman.plg installs
|
`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
|
**Status: implemented**, covering all ten sections from
|
||||||
[docs/ARCHITECTURE.md, section 18](../docs/ARCHITECTURE.md#18-zukünftige-webui):
|
[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
|
* page's markup mirrors (same structure, same CSS classes, real data
|
||||||
* instead of static samples).
|
* 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">
|
<div class="podman-plugin">
|
||||||
|
|
||||||
@@ -39,6 +54,7 @@ Icon="podman"
|
|||||||
<nav class="podman-subnav">
|
<nav class="podman-subnav">
|
||||||
<button class="active" data-panel="dashboard">Dashboard</button>
|
<button class="active" data-panel="dashboard">Dashboard</button>
|
||||||
<button data-panel="containers">Containers</button>
|
<button data-panel="containers">Containers</button>
|
||||||
|
<button data-panel="templates">Templates</button>
|
||||||
<button data-panel="pods">Pods</button>
|
<button data-panel="pods">Pods</button>
|
||||||
<button data-panel="images">Images</button>
|
<button data-panel="images">Images</button>
|
||||||
<button data-panel="volumes">Volumes</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="running" id="containers-count-running">Running</button>
|
||||||
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
|
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="podman-table-wrap">
|
<div class="podman-table-wrap">
|
||||||
<table>
|
<table>
|
||||||
@@ -83,6 +100,9 @@ Icon="podman"
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ============================= TEMPLATES ============================= -->
|
||||||
|
<section class="podman-panel" id="podman-panel-templates"></section>
|
||||||
|
|
||||||
<!-- ============================= PODS ============================= -->
|
<!-- ============================= PODS ============================= -->
|
||||||
<section class="podman-panel" id="podman-panel-pods"></section>
|
<section class="podman-panel" id="podman-panel-pods"></section>
|
||||||
|
|
||||||
@@ -243,14 +263,12 @@ Icon="podman"
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/plugins/podman/javascript/app.js"></script>
|
<?php
|
||||||
<script src="/plugins/podman/javascript/dashboard.js"></script>
|
foreach ([
|
||||||
<script src="/plugins/podman/javascript/containers.js"></script>
|
'app', 'dashboard', 'containers', 'templates', 'pods', 'images', 'volumes',
|
||||||
<script src="/plugins/podman/javascript/pods.js"></script>
|
'networks', 'logs', 'terminal', 'compose', 'settings',
|
||||||
<script src="/plugins/podman/javascript/images.js"></script>
|
] as $podmanJsModule) {
|
||||||
<script src="/plugins/podman/javascript/volumes.js"></script>
|
$podmanJsPath = "/javascript/{$podmanJsModule}.js";
|
||||||
<script src="/plugins/podman/javascript/networks.js"></script>
|
echo '<script src="/plugins/podman' . $podmanJsPath . '?v=' . podman_asset_version($podmanJsPath) . '"></script>' . "\n";
|
||||||
<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>
|
|
||||||
|
|||||||
@@ -121,8 +121,19 @@ function compose_status(string $composeDir, string $project): string
|
|||||||
if ($result['exitCode'] !== 0) {
|
if ($result['exitCode'] !== 0) {
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
}
|
}
|
||||||
$decoded = json_decode($result['output'], true);
|
// `podman compose ps --format json` emits one JSON object PER LINE
|
||||||
return (is_array($decoded) && count($decoded) > 0) ? 'up' : 'down';
|
// (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
|
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).
|
* surface even though $project has already been validated above too).
|
||||||
*
|
*
|
||||||
* @param array<int,string> $subcommand
|
* @param array<int,string> $subcommand
|
||||||
* @return array{exitCode:int,output:string}
|
* @return array{exitCode:int,stdout:string,output:string}
|
||||||
*/
|
*/
|
||||||
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
|
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
|
||||||
{
|
{
|
||||||
@@ -165,7 +176,7 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
|
|||||||
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||||
$process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project);
|
$process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project);
|
||||||
if (!is_resource($process)) {
|
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);
|
stream_set_timeout($pipes[1], $timeoutSeconds);
|
||||||
@@ -175,5 +186,9 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
|
|||||||
fclose($pipes[2]);
|
fclose($pipes[2]);
|
||||||
$exitCode = proc_close($process);
|
$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}
|
* stop POST {"id": "...", "timeout": 10}
|
||||||
* restart POST {"id": "...", "timeout": 10}
|
* restart POST {"id": "...", "timeout": 10}
|
||||||
* remove POST {"id": "...", "force": false}
|
* 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
|
* 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);
|
declare(strict_types=1);
|
||||||
@@ -68,10 +77,170 @@ switch ($action) {
|
|||||||
podman_json_response(['status' => 'removed']);
|
podman_json_response(['status' => 'removed']);
|
||||||
break;
|
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:
|
default:
|
||||||
podman_json_error("Unknown action '{$action}'", 400);
|
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 */
|
/** @param array<string,mixed> $body */
|
||||||
function require_id(array $body): string
|
function require_id(array $body): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
|
|||||||
return [
|
return [
|
||||||
'reachable' => true,
|
'reachable' => true,
|
||||||
'socketPath' => $config->socketPath,
|
'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' => [
|
'containers' => [
|
||||||
'total' => count($containers),
|
'total' => count($containers),
|
||||||
'running' => $running,
|
'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=...):
|
* Actions (?action=...):
|
||||||
* list GET -> normalized volume list, with usedBy counts
|
* 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}
|
* remove POST {"name": "...", "force": false}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -30,7 +30,11 @@ switch ($action) {
|
|||||||
if ($name === '') {
|
if ($name === '') {
|
||||||
podman_json_error('Missing name in request body', 400);
|
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;
|
break;
|
||||||
|
|
||||||
case 'remove':
|
case 'remove':
|
||||||
@@ -65,10 +69,19 @@ function volumes_list(PodmanClient $client): array
|
|||||||
$out = [];
|
$out = [];
|
||||||
foreach ($raw as $v) {
|
foreach ($raw as $v) {
|
||||||
$name = (string) ($v['Name'] ?? '');
|
$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[] = [
|
$out[] = [
|
||||||
'name' => $name,
|
'name' => $name,
|
||||||
'driver' => (string) ($v['Driver'] ?? 'local'),
|
'driver' => (string) ($v['Driver'] ?? 'local'),
|
||||||
'mountpoint' => (string) ($v['Mountpoint'] ?? ''),
|
'mountpoint' => (string) ($v['Mountpoint'] ?? ''),
|
||||||
|
'hostPath' => $hostPath,
|
||||||
'createdAt' => podman_parse_time($v['CreatedAt'] ?? null),
|
'createdAt' => podman_parse_time($v['CreatedAt'] ?? null),
|
||||||
'usedBy' => $usageCounts[$name] ?? 0,
|
'usedBy' => $usageCounts[$name] ?? 0,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ final class PodmanClient
|
|||||||
return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']);
|
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
|
public function startContainer(string $id): void
|
||||||
{
|
{
|
||||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
|
$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);
|
$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
|
* GET /containers/{id}/logs — returns the raw (already de-multiplexed
|
||||||
* where possible) log text. Podman's non-TTY log stream uses the same
|
* 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');
|
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
|
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
|
public function removeImage(string $id, bool $force = false): void
|
||||||
@@ -240,9 +322,21 @@ final class PodmanClient
|
|||||||
return $this->request('GET', '/volumes/json');
|
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
|
public function removeVolume(string $name, bool $force = false): void
|
||||||
|
|||||||
@@ -36,6 +36,17 @@ window.Podman = (function () {
|
|||||||
opts.headers['Content-Type'] = 'application/json';
|
opts.headers['Content-Type'] = 'application/json';
|
||||||
opts.body = JSON.stringify(body);
|
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)
|
return fetch(url, opts)
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
@@ -115,6 +126,172 @@ window.Podman = (function () {
|
|||||||
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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 ----------------------------------------------------------
|
// --- Panel router ----------------------------------------------------------
|
||||||
|
|
||||||
const panelModules = {};
|
const panelModules = {};
|
||||||
@@ -180,6 +357,8 @@ window.Podman = (function () {
|
|||||||
stateChipClass: stateChipClass,
|
stateChipClass: stateChipClass,
|
||||||
loadingRow: loadingRow,
|
loadingRow: loadingRow,
|
||||||
errorRow: errorRow,
|
errorRow: errorRow,
|
||||||
|
openFormModal: openFormModal,
|
||||||
|
openContextMenu: openContextMenu,
|
||||||
registerPanel: registerPanel,
|
registerPanel: registerPanel,
|
||||||
activatePanel: activatePanel,
|
activatePanel: activatePanel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
return '' +
|
return '' +
|
||||||
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
|
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
|
||||||
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
|
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
|
||||||
'<td><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 class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
|
||||||
'<td>' + cpuMem + '</td>' +
|
'<td>' + cpuMem + '</td>' +
|
||||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '—') + '</td>' +
|
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '—') + '</td>' +
|
||||||
@@ -37,11 +38,170 @@
|
|||||||
return '' +
|
return '' +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">↻</button>' +
|
'<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="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 '' +
|
return '' +
|
||||||
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
'<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() {
|
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) {
|
function handleAction(id, action, btn) {
|
||||||
const doIt = function (extra) {
|
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) {
|
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
|
||||||
alert('Action failed: ' + err.message);
|
alert('Action failed: ' + err.message);
|
||||||
btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (action === 'remove') {
|
if (action === 'remove') {
|
||||||
if (!confirm('Remove this container? This does not remove its volumes.')) return;
|
if (!confirm('Remove this container? This does not remove its volumes.')) return;
|
||||||
doIt({ force: true });
|
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 {
|
} else {
|
||||||
doIt({});
|
doIt({});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
|
P.el('containers-create-btn').addEventListener('click', openCreateContainerModal);
|
||||||
|
|
||||||
P.el('containers-search').addEventListener('input', function (e) {
|
P.el('containers-search').addEventListener('input', function (e) {
|
||||||
searchTerm = e.target.value.trim().toLowerCase();
|
searchTerm = e.target.value.trim().toLowerCase();
|
||||||
renderTable();
|
renderTable();
|
||||||
@@ -115,11 +522,27 @@
|
|||||||
const btn = e.target.closest('button[data-action]');
|
const btn = e.target.closest('button[data-action]');
|
||||||
if (!btn || btn.disabled) return;
|
if (!btn || btn.disabled) return;
|
||||||
const row = btn.closest('tr');
|
const row = btn.closest('tr');
|
||||||
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();
|
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 });
|
P.registerPanel('containers', { init: init, refresh: load });
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -43,10 +43,15 @@
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
P.el('images-pull-btn').addEventListener('click', function () {
|
P.el('images-pull-btn').addEventListener('click', function () {
|
||||||
const reference = prompt('Image to pull (e.g. docker.io/library/postgres:16):');
|
P.openFormModal({
|
||||||
if (!reference) return;
|
title: 'Pull Image',
|
||||||
P.post('images', 'pull', { reference: reference }).then(load).catch(function (err) {
|
submitLabel: 'Pull',
|
||||||
alert('Pull failed: ' + err.message);
|
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() {
|
function init() {
|
||||||
P.el('networks-create-btn').addEventListener('click', function () {
|
P.el('networks-create-btn').addEventListener('click', function () {
|
||||||
const name = prompt('New network name:');
|
P.openFormModal({
|
||||||
if (!name) return;
|
title: 'New Network',
|
||||||
const subnet = prompt('Subnet (optional, e.g. 10.89.2.0/24):') || undefined;
|
submitLabel: 'Create',
|
||||||
P.post('networks', 'create', { name: name, driver: 'bridge', subnet: subnet }).then(load).catch(function (err) {
|
fields: [
|
||||||
alert('Create failed: ' + err.message);
|
{ 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
|
* javascript/volumes.js
|
||||||
*
|
*
|
||||||
* Volumes panel: named-volume table + create/remove, backed by
|
* Volumes panel: named-volume table + create/remove, backed by
|
||||||
* ajax/volumes.php. Bind mounts are deliberately not shown here — see
|
* ajax/volumes.php. Container-level bind mounts (e.g. appdata under
|
||||||
* that file's header comment.
|
* /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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -11,11 +15,14 @@
|
|||||||
let volumes = [];
|
let volumes = [];
|
||||||
|
|
||||||
function rowHtml(v) {
|
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 '' +
|
return '' +
|
||||||
'<tr data-name="' + P.escapeHtml(v.name) + '">' +
|
'<tr data-name="' + P.escapeHtml(v.name) + '">' +
|
||||||
'<td>' + P.escapeHtml(v.name) + '</td>' +
|
'<td>' + P.escapeHtml(v.name) + '</td>' +
|
||||||
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></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="tnum">' + v.usedBy + '</td>' +
|
||||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
'<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>' +
|
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></td>' +
|
||||||
@@ -42,10 +49,20 @@
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
P.el('volumes-create-btn').addEventListener('click', function () {
|
P.el('volumes-create-btn').addEventListener('click', function () {
|
||||||
const name = prompt('New volume name:');
|
P.openFormModal({
|
||||||
if (!name) return;
|
title: 'New Volume',
|
||||||
P.post('volumes', 'create', { name: name }).then(load).catch(function (err) {
|
submitLabel: 'Create',
|
||||||
alert('Create failed: ' + err.message);
|
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);
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
.podman-btn:hover { border-color: var(--text-faint); }
|
.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 { color: var(--bad); }
|
||||||
.podman-btn-danger:hover { border-color: var(--bad); }
|
.podman-btn-danger:hover { border-color: var(--bad); }
|
||||||
.podman-btn-icon { padding: 6px 8px; }
|
.podman-btn-icon { padding: 6px 8px; }
|
||||||
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
|
.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 {
|
.podman-subnav {
|
||||||
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
|
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-plugin tbody tr:hover { background: var(--surface-2); }
|
||||||
.podman-table-wrap { overflow-x: auto; }
|
.podman-table-wrap { overflow-x: auto; }
|
||||||
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
.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 {
|
.podman-row-name .ico {
|
||||||
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
|
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
|
||||||
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
|
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
|
||||||
@@ -177,6 +222,33 @@
|
|||||||
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
|
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
|
||||||
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
|
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
|
||||||
|
|
||||||
|
.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; }
|
.podman-logs-layout { display: grid; grid-template-columns: 200px 1fr; min-height: 460px; }
|
||||||
@media (max-width: 760px) { .podman-logs-layout { grid-template-columns: 1fr; } }
|
@media (max-width: 760px) { .podman-logs-layout { grid-template-columns: 1fr; } }
|
||||||
.podman-logs-side { border-right: 1px solid var(--border); }
|
.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-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; }
|
.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); }
|
.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