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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 16:26:49 +00:00
35 changed files with 2237 additions and 440 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ name: Build Packages
# Builds the eleven Slackware .txz packages defined under packages/
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
# catatonit, nftables, docker-compose, unraid-podman) inside a Slackware
# catatonit, nftables, podman-compose, unraid-podman) inside a Slackware
# container, verifies + consolidates their checksums, and uploads the
# result as a workflow artifact.
#
+1 -1
View File
@@ -233,7 +233,7 @@ wählen können — mit deutlicher GUI-Warnung bzgl. Performance und Spin-up-Ver
| `passt`/`pasta` | Rootless-Networking | Nachfolger von slirp4netns, Phase 2, aber Paket schon mitbauen (geringe Kosten) |
| `catatonit` oder `tini` | Init-Prozess in Containern (optional, falls von Templates genutzt) | |
| `nftables` | Firewall-Backend für `netavark` | Pflicht seit netavark 2.0 (iptables-Treiber entfernt); Unraid liefert kein `nft` mit — als offizielles Slackware-Paket vendored, nicht selbst gebaut |
| `docker-compose` (CLI-Plugin) | External-Compose-Provider für `podman compose` | `podman compose` hat keine eigene Compose-Implementierung, sondern sucht ein `docker-compose`-Binary in festen CLI-Plugin-Pfaden; ohne dieses Paket schlägt jede Compose-Panel-Aktion auf einem frischen Unraid-Install fehl |
| `podman-compose` | External-Compose-Provider für `podman compose` | `podman compose` hat keine eigene Compose-Implementierung, sondern sucht ein Kommando namens `podman-compose` auf `$PATH` (live verifiziert — anders als das ältere, ebenfalls unterstützte `docker-compose`, das stattdessen in festen CLI-Plugin-Pfaden gesucht wird); ohne dieses Paket schlägt jede Compose-Panel-Aktion auf einem frischen Unraid-Install fehl. Python-Skript, vendored zusammen mit PyYAML/python-dotenv als reines Python-Source (kein C-Build) |
### 5.2 Build-Strategie
-31
View File
@@ -1,31 +0,0 @@
# 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).
@@ -1,60 +0,0 @@
#!/bin/bash
# =============================================================================
# packages/docker-compose/docker-compose.SlackBuild
#
# Packages the official prebuilt docker/compose v2 release binary — not
# built from source, see versions.env's DOCKER_COMPOSE_* block for why.
# `podman compose` (backing webui/plugins/podman/ajax/compose.php) has no
# compose implementation of its own; it shells out to an "external compose
# provider" it discovers by searching a fixed list of CLI-plugin
# directories for a binary literally named `docker-compose` (paths
# extracted from the pinned podman binary itself:
# `strings /usr/bin/podman | grep cli-plugins`). Without one present,
# every Compose panel action fails outright (found by live-testing against
# a real Unraid install — it only worked there because that host happened
# to already have Docker's own compose plugin from an unrelated setup).
#
# Installed to /usr/local/lib/docker/cli-plugins/docker-compose — one of
# podman's search paths, deliberately the /usr/local/ one rather than
# /usr/lib/docker/cli-plugins so this package never collides with (or
# gets silently shadowed by) a genuine Docker installation's own compose
# plugin on hosts that also run Unraid's built-in Docker support.
# =============================================================================
set -eu
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION="$DOCKER_COMPOSE_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "docker-compose"
binary=$(sb_fetch_and_verify "$DOCKER_COMPOSE_SRC_URL" "$DOCKER_COMPOSE_SRC_SHA256" "docker-compose-$VERSION")
install -D -m 0755 "$binary" "$PKG/usr/local/lib/docker/cli-plugins/docker-compose"
# The release only ships the raw binary (+ checksum/signature files, no
# LICENSE/README asset) — write minimal doc metadata by hand instead of
# using sb_install_docs, which expects real files to copy from disk.
docdir="$PKG/usr/doc/docker-compose-$VERSION"
mkdir -p "$docdir"
{
echo "docker-compose (docker/compose v2 CLI plugin) $VERSION"
echo "https://github.com/docker/compose"
echo "Prebuilt static binary, packaged as-is by unraid-podman — see"
echo "versions.env for the pinned release URL and SHA256."
} > "$docdir/README"
{
echo "Built by unraid-podman from upstream's prebuilt release binary."
echo "Package: docker-compose $VERSION"
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$docdir/unraid-podman.build-info"
sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG"
-19
View File
@@ -1,19 +0,0 @@
# 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:
+34
View File
@@ -0,0 +1,34 @@
# packages/podman-compose/
Pinned versions: see `PODMAN_COMPOSE_VERSION`/`PYYAML_VERSION`/
`PYTHON_DOTENV_VERSION` in [versions.env](../../versions.env).
`podman compose` (backing `webui/plugins/podman/ajax/compose.php`, the
WebUI's Compose panel) has no compose implementation of its own — it
needs an external "compose provider" command. This project previously
vendored `docker/compose` (the Go CLI-plugin binary) for that role;
this package replaces it with `podman-compose` instead.
The two aren't discovered the same way — verified live against a real
podman install (placing a fake executable and reading podman's own
provider-search error output): `docker-compose` is searched for by exact
path across a fixed list of CLI-plugin directories, while `podman-compose`
is looked up as a plain command on `$PATH`. That's why this package
installs to `/usr/local/bin/podman-compose` rather than under any
`cli-plugins/` directory.
Unlike `docker-compose`, `podman-compose` is a single Python script, not a
compiled binary. Unraid ships Python3 itself but neither of its two
runtime dependencies, so this package also vendors:
- `PyYAML` — only the pure-Python `yaml/` package, not the `_yaml` C
extension (which would need libyaml plus a compiler). `yaml/__init__.py`
falls back gracefully when the C accelerator isn't importable, so the
pure-Python source is sufficient for what podman-compose needs from it.
- `python-dotenv` — pure Python throughout, no C extensions at all.
Verified end-to-end on a real Unraid host: the vendored bundle correctly
runs `podman compose up`/`ps`/`down` against a real compose project
(with the pre-existing `docker-compose` binary temporarily moved aside
to confirm `podman-compose` was the one actually being invoked, not a
leftover), including a live HTTP check against the started service.
@@ -0,0 +1,83 @@
#!/bin/bash
# =============================================================================
# packages/podman-compose/podman-compose.SlackBuild
#
# Packages podman-compose (github.com/containers/podman-compose) — the
# external "compose provider" `podman compose` shells out to (see
# versions.env's PODMAN_COMPOSE_* block for the full story, including why
# this replaces the project's earlier vendored docker-compose). Verified
# live against a real podman install that podman-compose is looked up as a
# plain $PATH command, unlike docker-compose's fixed CLI-plugin-directory
# search — so this installs a wrapper at /usr/local/bin/podman-compose.
#
# podman-compose itself is a single Python script (not a compiled binary),
# with two runtime dependencies — PyYAML and python-dotenv — vendored here
# as plain pure-Python source (no C extension build) since Unraid ships
# Python3 but neither of those modules.
# =============================================================================
set -eu
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION="$PODMAN_COMPOSE_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "podman-compose"
script=$(sb_fetch_and_verify "$PODMAN_COMPOSE_SRC_URL" "$PODMAN_COMPOSE_SRC_SHA256" "podman_compose-$VERSION.py")
pyyaml_tarball=$(sb_fetch_and_verify "$PYYAML_SRC_URL" "$PYYAML_SRC_SHA256" "pyyaml-$PYYAML_VERSION.tar.gz")
dotenv_tarball=$(sb_fetch_and_verify "$PYTHON_DOTENV_SRC_URL" "$PYTHON_DOTENV_SRC_SHA256" "python-dotenv-$PYTHON_DOTENV_VERSION.tar.gz")
libdir="$PKG/usr/local/lib/podman-compose"
mkdir -p "$libdir"
install -m 0644 "$script" "$libdir/podman_compose.py"
# Only the pure-Python "yaml" package, not the "_yaml" C extension (which
# would need libyaml plus a compiler toolchain this build doesn't otherwise
# require) — see the header comment on why the pure-Python fallback is
# sufficient for what podman-compose actually needs from it.
tar -xzf "$pyyaml_tarball" -C "$TMP" "pyyaml-$PYYAML_VERSION/lib/yaml"
cp -r "$TMP/pyyaml-$PYYAML_VERSION/lib/yaml" "$libdir/yaml"
tar -xzf "$dotenv_tarball" -C "$TMP" "python_dotenv-$PYTHON_DOTENV_VERSION/src/dotenv"
cp -r "$TMP/python_dotenv-$PYTHON_DOTENV_VERSION/src/dotenv" "$libdir/dotenv"
# A thin wrapper, not a symlink or bare shebang: podman_compose.py's own
# shebang (whatever upstream wrote, a plain "#!/usr/bin/env python3") has
# no idea the vendored yaml/dotenv sit right next to it, so PYTHONPATH has
# to be set by whatever actually invokes the script.
install -d "$PKG/usr/local/bin"
cat > "$PKG/usr/local/bin/podman-compose" <<'WRAPPER'
#!/bin/sh
exec env PYTHONPATH="/usr/local/lib/podman-compose${PYTHONPATH:+:$PYTHONPATH}" \
/usr/bin/python3 /usr/local/lib/podman-compose/podman_compose.py "$@"
WRAPPER
chmod 0755 "$PKG/usr/local/bin/podman-compose"
docdir="$PKG/usr/doc/podman-compose-$VERSION"
mkdir -p "$docdir"
{
echo "podman-compose $VERSION"
echo "https://github.com/containers/podman-compose"
echo
echo "Vendored alongside its two runtime dependencies (bundled as plain"
echo "pure-Python source, no C extensions built):"
echo " PyYAML $PYYAML_VERSION - https://pypi.org/project/PyYAML/"
echo " python-dotenv $PYTHON_DOTENV_VERSION - https://pypi.org/project/python-dotenv/"
echo
echo "See versions.env for pinned source URLs and SHA256 checksums."
} > "$docdir/README"
{
echo "Built by unraid-podman from upstream source."
echo "Package: podman-compose $VERSION"
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$docdir/unraid-podman.build-info"
sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG"
+19
View File
@@ -0,0 +1,19 @@
# HOW TO EDIT THIS FILE:
# The "handy ruler" below makes it easier to edit a package description.
# Line up the first '|' above the ':' following the base package name, and
# the '|' on the right side marks the last column you can put a character in.
# You must make exactly 11 lines for the formatting to be correct. It's also
# customary to leave one space after the ':' except on otherwise blank lines.
|-----handy-ruler------------------------------------------------|
podman-compose: podman-compose (external Compose provider for podman compose)
podman-compose:
podman-compose: A Python script that implements Docker Compose file support
podman-compose: on top of podman, installed to /usr/local/bin so
podman-compose: `podman compose` finds it as its external provider.
podman-compose: Required by the WebUI's Compose panel. Bundled with its
podman-compose: two runtime dependencies (PyYAML, python-dotenv) as plain
podman-compose: pure-Python source.
podman-compose:
podman-compose: Homepage: https://github.com/containers/podman-compose
podman-compose:
+12 -12
View File
@@ -33,7 +33,7 @@
Structure of this file:
1. DOCTYPE entity block — plugin metadata + one version/file/md5 triple
per package (the seven upstream components, catatonit/nftables/
docker-compose as vendored runtime dependencies, plus this
podman-compose as vendored runtime dependencies, plus this
project's own "unraid-podman" scaffolding package, see
packages/unraid-podman/).
Entities are rewritten automatically by scripts/release.sh; never
@@ -122,12 +122,12 @@
<!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
<!-- podman-compose is the external Compose provider `podman compose`
shells out to (see packages/podman-compose/README.md) — without it
every Compose panel action fails outright on a clean install. -->
<!ENTITY 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">
<!ENTITY podman_compose_txz_version "0.0.0">
<!ENTITY podman_compose_txz_file "podman-compose-&podman_compose_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY podman_compose_txz_md5 "00000000000000000000000000000000">
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
sbin/ scripts, event/ hooks, config templates — see
@@ -181,7 +181,7 @@ fi
<!--
The seven upstream component packages, plus catatonit, nftables, and
docker-compose (runtime dependencies vendored as-is — see the entity
podman-compose (runtime dependencies vendored as-is — see the entity
block above for why). Each is downloaded straight into
its backup slot under /boot/config/plugins/&name;/backup/packages/&version;/
(grouped by PLUGIN version, not each component's own version — a rollback
@@ -275,12 +275,12 @@ fi
</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&docker_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&docker_compose_txz_file;
&baseURL;/&podman_compose_txz_file;
</URL>
<MD5>
&docker_compose_txz_md5;
&podman_compose_txz_md5;
</MD5>
</FILE>
@@ -345,7 +345,7 @@ echo "PASST_INSTALLED_VERSION=\"&passt_txz_version;\"" >> "$MANIFEST"
echo "FUSE_OVERLAYFS_INSTALLED_VERSION=\"&fuse_overlayfs_txz_version;\"" >> "$MANIFEST"
echo "CATATONIT_INSTALLED_VERSION=\"&catatonit_txz_version;\"" >> "$MANIFEST"
echo "NFTABLES_INSTALLED_VERSION=\"&nftables_txz_version;\"" >> "$MANIFEST"
echo "DOCKER_COMPOSE_INSTALLED_VERSION=\"&docker_compose_txz_version;\"" >> "$MANIFEST"
echo "PODMAN_COMPOSE_INSTALLED_VERSION=\"&podman_compose_txz_version;\"" >> "$MANIFEST"
echo "UNRAID_PODMAN_INSTALLED_VERSION=\"&unraid_podman_txz_version;\"" >> "$MANIFEST"
echo "Seeding /boot/config/plugins/podman/ configuration (existing files left untouched)..."
@@ -408,7 +408,7 @@ removepkg &passt_txz_file;
removepkg &fuse_overlayfs_txz_file;
removepkg &catatonit_txz_file;
removepkg &nftables_txz_file;
removepkg &docker_compose_txz_file;
removepkg &podman_compose_txz_file;
removepkg &unraid_podman_txz_file;
echo ""
+1 -1
View File
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables docker-compose unraid-podman"
ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
if [ ! -f "$INSTALLED_VERSIONS_FILE" ]; then
podman_log_error "update-packages: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found"
+1 -1
View File
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables docker-compose unraid-podman"
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
QUIET=0
[ "${1:-}" = "--quiet" ] && QUIET=1
+8 -6
View File
@@ -4,7 +4,7 @@
#
# Orchestrates building all Slackware .txz packages this plugin ships
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
# catatonit, nftables, docker-compose), by running each package's
# catatonit, nftables, podman-compose), by running each package's
# <name>.SlackBuild in turn. See docs/ARCHITECTURE.md section 5.2
# (Build-Strategie).
#
@@ -38,12 +38,14 @@ DIST_DIR="$REPO_ROOT/dist"
# see packages/unraid-podman/README.md) is built last since it's by far the
# fastest and has nothing useful to report on failure that earlier package
# failures wouldn't already explain.
ALL_PACKAGES=(catatonit nftables docker-compose conmon crun netavark aardvark-dns passt fuse-overlayfs podman unraid-podman)
ALL_PACKAGES=(catatonit nftables podman-compose conmon crun netavark aardvark-dns passt fuse-overlayfs podman unraid-podman)
# catatonit, nftables, and docker-compose are listed first since they're
# all a plain fetch-and-repackage of an already-built upstream artifact
# (see their own README.md for why) — no compiler, fastest possible
# signal if their pinned URL/checksum in versions.env ever goes stale.
# catatonit, nftables, and podman-compose are listed first since none of
# them involve a compiler — catatonit/nftables are a plain
# fetch-and-repackage of an already-built upstream artifact, and
# podman-compose is vendored pure-Python source with nothing to compile
# (see their own README.md/SlackBuild for why) — fastest possible signal
# if a pinned URL/checksum in versions.env ever goes stale.
# Podman is listed second-to-last on purpose: among the compiled
# components it is the slowest build and the one most likely to fail on
# a dependency/tag mistake, so faster packages surface problems first
+2 -2
View File
@@ -54,12 +54,12 @@ RELEASE_BASE_URL="https://github.com/$REPO_SLUG/releases/download/$RELEASE_TAG"
# Component name -> the entity name prefix used in podman.plg. Must match
# plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly.
# catatonit, nftables, and docker-compose are vendored runtime
# catatonit, nftables, and podman-compose are vendored runtime
# dependencies (not built from source, see their own packages/*/README.md)
# and unraid-podman is the plugin's own scaffolding package (see
# packages/unraid-podman/README.md), not an upstream component, but all
# four are released and entity-updated exactly like the seven upstream ones.
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables docker-compose unraid-podman)
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman)
echo "==> Releasing unraid-podman plugin v$NEW_VERSION (packages tag: $RELEASE_TAG)"
+29 -15
View File
@@ -120,22 +120,36 @@ 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
# --- podman-compose -----------------------------------------------------------
# https://github.com/containers/podman-compose — `podman compose` (backing
# webui/plugins/podman/ajax/compose.php, the WebUI's Compose panel) has no
# compose implementation of its own it 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"
# compose implementation of its own; it needs an external "compose
# provider" command. This project previously vendored docker/compose (the
# Go CLI-plugin binary) for that role, found by podman searching a fixed
# set of CLI-plugin directories for a binary named exactly
# "docker-compose". podman-compose is looked up differently — verified
# live (placing a fake executable and watching podman's own error output
# list its search order) that it's found as a plain command on $PATH,
# not from those same CLI-plugin directories — so it's installed as
# /usr/local/bin/podman-compose, not under any cli-plugins/ path.
#
# Unlike docker-compose, podman-compose is a single Python script, not a
# compiled binary — Unraid ships Python3 itself, but not either of its two
# runtime dependencies (PyYAML, python-dotenv), so those are vendored
# alongside it as plain pure-Python source (no C extension build; PyYAML's
# own __init__.py falls back gracefully when its optional C accelerator
# isn't importable — verified by reading it, not assumed).
PODMAN_COMPOSE_VERSION="1.6.0"
PODMAN_COMPOSE_SRC_URL="https://raw.githubusercontent.com/containers/podman-compose/v${PODMAN_COMPOSE_VERSION}/podman_compose.py"
PODMAN_COMPOSE_SRC_SHA256="10df1662477a673dc803c03e89c1bc1fba6c8c091e716fb6c7dd09c0081e1255"
PYYAML_VERSION="6.0.3"
PYYAML_SRC_URL="https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-${PYYAML_VERSION}.tar.gz"
PYYAML_SRC_SHA256="d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"
PYTHON_DOTENV_VERSION="1.2.2"
PYTHON_DOTENV_SRC_URL="https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-${PYTHON_DOTENV_VERSION}.tar.gz"
PYTHON_DOTENV_SRC_SHA256="2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
# =============================================================================
# Slackware package BUILD number (not upstream version). Bump this if a
+61 -21
View File
@@ -1,4 +1,6 @@
Menu="Podman"
Menu="Tasks:66"
Type="xmenu"
Tabs="false"
Title="Podman"
Icon="podman"
---
@@ -84,11 +86,13 @@ function podman_asset_version(string $relPath): string
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" id="containers-search" type="text" placeholder="Search containers by name or image…">
<div class="filterset" id="containers-filterset" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<div class="podman-segmented" id="containers-filterset">
<button class="active" data-filter="all" id="containers-count-all">All</button>
<button data-filter="running" id="containers-count-running">Running</button>
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
</div>
<button class="podman-btn podman-btn-primary" id="containers-check-updates-btn" style="margin-left:auto;">Check for Updates</button>
<button class="podman-btn podman-btn-primary" id="containers-update-all-btn">Update All</button>
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
</div>
<div class="podman-table-wrap">
@@ -111,6 +115,7 @@ function podman_asset_version(string $relPath): string
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" type="text" placeholder="Search images…" disabled title="Client-side filtering not yet wired up for Images">
<button class="podman-btn" id="images-prune-btn" style="margin-left:auto;">Prune unused</button>
<button class="podman-btn" id="images-pull-btn">&#11015; Pull Image</button>
</div>
<div class="podman-table-wrap">
@@ -165,7 +170,7 @@ function podman_asset_version(string $relPath): string
<div>
<div class="podman-toolbar">
<input class="podman-search" id="logs-filter" type="text" placeholder="Filter log output…" style="max-width:280px;">
<span id="logs-follow-toggle" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<span class="podman-segmented" id="logs-follow-toggle">
<button class="active" data-follow="true">Follow</button>
<button data-follow="false">Paused</button>
</span>
@@ -179,12 +184,22 @@ function podman_asset_version(string $relPath): string
<!-- ============================= TERMINAL ============================= -->
<section class="podman-panel" id="podman-panel-terminal">
<div class="podman-card">
<div class="podman-card-head"><h2>Live Terminal</h2></div>
<div class="podman-card-pad">
<div style="display:flex; gap:8px; align-items:center; margin-bottom:12px; font-size:12.5px; color:var(--text-dim);">
Exec into: <select id="term-container-select"></select>
<div class="podman-term-launcher">
<label>Container <select class="podman-term-select" id="term-container-select"></select></label>
<label>Shell
<select class="podman-term-select" id="term-shell-select">
<option value="bash" selected>bash</option>
<option value="sh">sh</option>
</select>
</label>
<button class="podman-btn podman-btn-primary" id="term-open-btn">&#9654; Open Terminal</button>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="term-disconnect-btn" disabled>&#9632; Disconnect</button>
</div>
<div id="term-frame-wrap">
<p class="podman-empty-note">Pick a running container and click "Open Terminal" — the same live, fully interactive terminal Unraid's own Docker "Console" button opens (arrow-key history, tab completion, vim, etc. all work).</p>
</div>
<div class="podman-term" id="term-output"></div>
<input class="podman-term-input" id="term-input" type="text" placeholder="Type a command and press Enter… (one-shot exec — see Compose panel note on API scope)" autocomplete="off">
</div>
</div>
</section>
@@ -193,15 +208,22 @@ function podman_asset_version(string $relPath): string
<section class="podman-panel" id="podman-panel-compose">
<div class="podman-card">
<div class="podman-compose-layout">
<div class="podman-compose-side" id="compose-sidebar"></div>
<div class="podman-compose-side">
<div class="podman-toolbar" style="border-bottom:1px solid var(--border); padding:10px;">
<button class="podman-btn podman-btn-primary" id="compose-new-btn" style="width:100%; justify-content:center;">+ New Project</button>
</div>
<div id="compose-sidebar"></div>
</div>
<div>
<div class="podman-toolbar">
<strong id="compose-title" style="flex:1;">—</strong>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="compose-action-delete">Delete</button>
<button class="podman-btn" id="compose-action-pull">&#11015; Pull</button>
<button class="podman-btn" id="compose-action-down">&#9632; Down</button>
<button class="podman-btn podman-btn-primary" id="compose-action-up">&#9654; Up</button>
<button class="podman-btn podman-btn-primary" id="compose-action-save">Save</button>
</div>
<pre class="podman-yaml" id="compose-yaml"></pre>
<textarea class="podman-yaml podman-yaml-editor mono" id="compose-yaml" spellcheck="false"></textarea>
</div>
</div>
</div>
@@ -209,9 +231,15 @@ function podman_asset_version(string $relPath): string
<!-- ============================= SETTINGS ============================= -->
<section class="podman-panel" id="podman-panel-settings">
<div class="podman-settings-actions">
<span class="hint" id="settings-save-hint">Changes to storage/enabled/timeout need <span class="mono">rc.podman restart</span> to take effect.</span>
<button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button>
</div>
<div class="podman-grid">
<div class="podman-card">
<div class="podman-card-head"><h2>Storage</h2></div>
<div class="podman-card-head">
<div><h2>Storage</h2><div class="sub">Where podman keeps images, containers and volumes on disk.</div></div>
</div>
<div class="podman-field-row">
<label for="settings-storage-path">Storage path</label>
<div>
@@ -221,40 +249,52 @@ function podman_asset_version(string $relPath): string
</div>
<div class="podman-field-row">
<label for="settings-storage-size">podman.img size</label>
<div><input type="number" id="settings-storage-size" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">GB</span></div>
<div>
<div class="podman-input-suffix"><input type="number" id="settings-storage-size" min="1"> <span>GB</span></div>
<div class="hint">Overlay filesystem image size. Only applies the first time podman initializes storage at this path.</div>
</div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Autostart &amp; Lifecycle</h2></div>
<div class="podman-card-head">
<div><h2>Autostart &amp; Lifecycle</h2><div class="sub">What runs when the array starts, and how containers shut down.</div></div>
</div>
<div class="podman-field-row">
<label for="settings-enabled">Start podman on array start</label>
<div><input type="checkbox" id="settings-enabled"></div>
<div>
<label class="podman-switch">
<input type="checkbox" id="settings-enabled"><span class="podman-switch-track"><span class="podman-switch-thumb"></span></span>
</label>
</div>
</div>
<div class="podman-field-row">
<label for="settings-stop-timeout">Container stop timeout</label>
<div><input type="number" id="settings-stop-timeout" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">seconds</span></div>
<div>
<div class="podman-input-suffix"><input type="number" id="settings-stop-timeout" min="0"> <span>seconds</span></div>
<div class="hint">Grace period before a stop/restart escalates to SIGKILL.</div>
</div>
</div>
<div class="podman-field-row">
<label>Autostart order</label>
<div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
<tbody id="autostart-tbody"></tbody>
</table>
</div>
<div class="hint">Saved immediately on reorder/remove — no separate save step.</div>
</div>
<div class="podman-field-row">
<label></label>
<div><button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button></div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Installed Packages</h2></div>
<div class="podman-field-row">
<label>Versions</label>
<div class="hint mono" id="settings-package-versions" style="max-width:none;">—</div>
<div class="podman-card-head">
<div><h2>Installed Packages</h2><div class="sub">Versions currently installed on this system.</div></div>
</div>
<div class="podman-card-pad">
<div class="podman-version-chips" id="settings-package-versions">—</div>
</div>
</div>
</div>
+94 -6
View File
@@ -27,6 +27,8 @@
* Actions (?action=...):
* list GET -> known projects with up/down status
* get GET (&project=...) -> raw compose.yaml content
* save POST {"project": "...", "yaml": "..."} -> creates or overwrites a project's compose.yaml
* remove POST {"project": "..."} -> `down` (best-effort) then deletes the project's directory
* up POST {"project": "..."}
* down POST {"project": "..."}
* pull POST {"project": "..."}
@@ -49,6 +51,15 @@ switch ($action) {
podman_json_response(['yaml' => compose_read($composeDir, $project)]);
break;
case 'save':
$body = podman_read_json_body();
podman_json_response(compose_save($composeDir, require_project($body), (string) ($body['yaml'] ?? '')));
break;
case 'remove':
podman_json_response(compose_remove($composeDir, require_project(podman_read_json_body())));
break;
case 'up':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d']));
break;
@@ -136,6 +147,79 @@ function compose_status(string $composeDir, string $project): string
return $running > 0 ? 'up' : 'down';
}
/**
* Creates a new project (directory doesn't exist yet) or overwrites an
* existing one's compose.yaml. Validated via the real tool — `podman
* compose ... config` parses and resolves the file, exiting non-zero with
* a specific line/column message on invalid YAML/schema (verified live)
* — rather than a hand-rolled YAML parser, since PHP has no YAML
* extension available here to begin with. Written to a *.new sibling
* file first and only renamed into place once validation passes, so a
* bad edit never corrupts a previously-working compose.yaml.
*
* @return array<string,mixed>
*/
function compose_save(string $composeDir, string $project, string $yaml): array
{
if (trim($yaml) === '') {
podman_json_error('compose.yaml content cannot be empty', 400);
}
$projectDir = $composeDir . '/' . $project;
if (!is_dir($projectDir) && !mkdir($projectDir, 0755, true) && !is_dir($projectDir)) {
podman_json_error("Could not create project directory for '{$project}'", 500);
}
$yamlPath = $projectDir . '/compose.yaml';
$tmpName = 'compose.yaml.new';
if (file_put_contents($projectDir . '/' . $tmpName, $yaml) === false) {
podman_json_error('Could not write compose.yaml', 500);
}
$result = run_compose_command($composeDir, $project, ['config'], 30, $tmpName);
if ($result['exitCode'] !== 0) {
@unlink($projectDir . '/' . $tmpName);
podman_json_error("Invalid compose file:\n" . trim($result['output']), 400);
}
if (!rename($projectDir . '/' . $tmpName, $yamlPath)) {
podman_json_error('Could not save compose.yaml', 500);
}
return ['status' => 'saved'];
}
/**
* Best-effort `down` (ignored if it fails — e.g. already down, or the
* file was mid-edit and invalid) so deleting a running project's files
* doesn't leave orphaned containers/networks behind, then deletes just
* that one project's own directory. $project is validated by
* require_project() before this is ever called, so $projectDir can't
* escape $composeDir.
*
* @return array<string,mixed>
*/
function compose_remove(string $composeDir, string $project): array
{
$projectDir = $composeDir . '/' . $project;
if (!is_dir($projectDir)) {
podman_json_error("Project '{$project}' not found", 404);
}
run_compose_command($composeDir, $project, ['down'], 60);
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($projectDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($it as $file) {
$file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname());
}
rmdir($projectDir);
return ['status' => 'removed'];
}
function compose_read(string $composeDir, string $project): string
{
if (!is_valid_project_name($project)) {
@@ -168,9 +252,9 @@ function compose_run(string $composeDir, string $project, array $subcommand): ar
* @param array<int,string> $subcommand
* @return array{exitCode:int,stdout:string,output:string}
*/
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds, string $yamlFile = 'compose.yaml'): array
{
$yamlPath = $composeDir . '/' . $project . '/compose.yaml';
$yamlPath = $composeDir . '/' . $project . '/' . $yamlFile;
$argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
@@ -187,8 +271,12 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
$exitCode = proc_close($process);
// '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)];
// output (e.g. compose_status()'s JSON); 'output' (combined, trimmed,
// ANSI-stripped) for human-facing success/error messages, where seeing
// podman's own stderr banner/warnings is actually useful context
// just not the raw \x1b[4m/\x1b[0m escape codes wrapping it (found
// live: they showed up as literal garbage characters in the WebUI's
// error alerts).
$combined = preg_replace('/\x1b\[[0-9;]*m/', '', $stdout . $stderr) ?? ($stdout . $stderr);
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($combined)];
}
+178 -1
View File
@@ -18,10 +18,14 @@
* kill POST {"id": "...", "signal": "SIGKILL"}
* rename POST {"id": "...", "name": "..."}
* logs GET (&id=...&tail=200) -> plain text
* list_gpus GET -> detected AMD/Intel GPUs (/dev/dri), for the Create Container form's optional passthrough toggle
* check_updates GET -> {"<image ref>": {"updateAvailable": bool, "error": "..."?}} for every image currently in use
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
* "staticIp": "10.1.1.222" (only meaningful with a custom/macvlan networkMode),
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no",
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"],
* "privileged": false, "startAfterCreate": true}
*/
@@ -105,6 +109,14 @@ switch ($action) {
podman_json_response(['status' => 'renamed']);
break;
case 'list_gpus':
podman_json_response(gpu_list());
break;
case 'check_updates':
podman_json_response(check_image_updates($client));
break;
case 'create':
$body = podman_read_json_body();
$image = trim((string) ($body['image'] ?? ''));
@@ -138,6 +150,102 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Checks every image currently backing a (non-infra) container against its
* origin registry — see RegistryClient for how, and why this isn't a
* podman/libpod feature at all. Deduplicated per unique image reference
* first (several containers commonly share the same image), so a host
* with e.g. five containers all on the same base image only makes one
* real registry request for it, not five.
*
* @return array<string,array<string,mixed>> keyed by image reference
*/
function check_image_updates(PodmanClient $client): array
{
$digestByImageId = [];
foreach ($client->listImages() as $img) {
$digestByImageId[(string) ($img['Id'] ?? '')] = (string) ($img['Digest'] ?? '');
}
$localDigestByRef = [];
foreach ($client->listContainers(true) as $c) {
if ($c['IsInfra'] ?? false) {
continue;
}
$ref = (string) ($c['Image'] ?? '');
$imageId = (string) ($c['ImageID'] ?? '');
if ($ref === '' || !isset($digestByImageId[$imageId])) {
continue;
}
$localDigestByRef[$ref] = $digestByImageId[$imageId];
}
$out = [];
foreach ($localDigestByRef as $ref => $localDigest) {
$out[$ref] = $localDigest === ''
? ['error' => 'No local digest recorded for this image.']
: RegistryClient::checkForUpdate($ref, $localDigest);
}
return $out;
}
/**
* Detects AMD/Intel GPUs via /dev/dri + sysfs — NOT via any podman/libpod
* API (libpod has no GPU inventory endpoint; this is plain host hardware
* detection). NVIDIA is deliberately excluded: it needs the separate
* nvidia-container-toolkit runtime, not a plain /dev/dri device passthrough,
* so listing it here would offer a checkbox that doesn't actually work.
* Verified live: card/render pairs from the same GPU share a "device"
* symlink target under /sys/class/drm, which is how they're grouped below;
* vendor 0x1002 = AMD, 0x8086 = Intel (PCI SIG IDs).
*
* @return array<int,array<string,mixed>>
*/
function gpu_list(): array
{
if (!is_dir('/sys/class/drm')) {
return [];
}
$byDevice = [];
foreach (scandir('/sys/class/drm') ?: [] as $entry) {
if (!preg_match('/^(card\d+|renderD\d+)$/', $entry)) {
continue;
}
$devicePath = "/sys/class/drm/{$entry}/device";
$target = @readlink($devicePath);
if ($target === false) {
continue;
}
$vendorFile = "{$devicePath}/vendor";
if (!is_file($vendorFile)) {
continue;
}
$vendorId = trim((string) @file_get_contents($vendorFile));
$byDevice[$target]['vendorId'] ??= $vendorId;
$byDevice[$target][str_starts_with($entry, 'card') ? 'card' : 'render'] = "/dev/dri/{$entry}";
}
$vendorNames = ['0x1002' => 'AMD', '0x8086' => 'Intel', '0x10de' => 'NVIDIA'];
$out = [];
foreach ($byDevice as $group) {
$vendorId = $group['vendorId'] ?? '';
$vendorName = $vendorNames[$vendorId] ?? $vendorId;
// NVIDIA needs the nvidia-container-toolkit runtime, not a plain
// /dev/dri passthrough — excluded so the checkbox we offer always
// actually works (see function comment).
if ($vendorName === 'NVIDIA' || !isset($group['render'])) {
continue;
}
$out[] = [
'vendor' => $vendorName,
'card' => $group['card'] ?? null,
'render' => $group['render'],
];
}
return $out;
}
/**
* Builds a libpod SpecGenerator body (POST /containers/create) from the
* WebUI's Create Container form fields. Field names/shapes here
@@ -228,8 +336,22 @@ function build_container_spec(string $image, array $body): array
if (in_array($networkMode, ['bridge', 'host', 'none'], true)) {
$spec['netns'] = ['nsmode' => $networkMode];
} elseif ($networkMode !== '') {
// A static IP only makes sense on a custom (typically macvlan)
// network — verified live that "networks":{"<name>":{"static_ips":
// [...]}} assigns it, same as podman itself does for --ip. Basic
// IPv4-shape validation only (not full RFC-correctness) — this
// goes straight into a create request against the local podman
// socket, not anywhere it could reach untrusted input otherwise.
$staticIp = trim((string) ($body['staticIp'] ?? ''));
if ($staticIp !== '') {
if (preg_match('/^(\d{1,3}\.){3}\d{1,3}$/', $staticIp) !== 1) {
podman_json_error("Static IP (\"{$staticIp}\") doesn't look like a valid IPv4 address.", 400);
}
$spec['networks'] = [$networkMode => ['static_ips' => [$staticIp]]];
} else {
$spec['networks'] = [$networkMode => new \stdClass()];
}
}
if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
$spec['restart_policy'] = (string) $body['restartPolicy'];
@@ -238,6 +360,29 @@ function build_container_spec(string $image, array $body): array
$spec['privileged'] = true;
}
$devices = [];
foreach (($body['gpuDevices'] ?? []) as $path) {
// Only ever pass through paths matching the exact shape gpu_list()
// itself reports — the client only ever gets those as checkbox
// values, but this is the boundary where a tampered/malicious
// request body gets rejected rather than handing arbitrary host
// device paths (e.g. "/dev/sda") straight into the container spec.
if (is_string($path) && preg_match('#^/dev/dri/(card|renderD)\d+$#', $path) === 1) {
$devices[] = ['path' => $path];
}
}
if ($devices !== []) {
$spec['devices'] = $devices;
}
$pod = trim((string) ($body['pod'] ?? ''));
if ($pod !== '') {
// "pod" joins an existing pod's shared network namespace — verified
// live that it can be sent alongside "netns" above without
// conflict (podman just defers to the pod's namespace).
$spec['pod'] = $pod;
}
return $spec;
}
@@ -265,6 +410,17 @@ function containers_list(PodmanClient $client): array
$out = [];
foreach ($raw as $c) {
// Every pod has a hidden "infra" container managing its shared
// network namespace — not something a user creates or can
// meaningfully stop/remove on its own (found live: it always
// shows "running" with no independent lifecycle, so Containers
// panel gets a permanently un-removable row once any pod exists;
// it already appears as its own row in the Pods panel). See
// ajax/pods.php for actual pod lifecycle management.
if ($c['IsInfra'] ?? false) {
continue;
}
$names = $c['Names'] ?? [];
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
@@ -280,6 +436,24 @@ function containers_list(PodmanClient $client): array
$startedAt = podman_parse_time($c['StartedAt'] ?? null);
$state = strtolower((string) ($c['State'] ?? 'unknown'));
// One extra local-socket round trip per running container (~15ms
// each, verified live — negligible for a home host's container
// count). Best-effort: a container that stops between the list
// call above and this one shouldn't blank out the whole table.
$cpuPercent = null;
$memUsageBytes = null;
$memLimitBytes = null;
if ($state === 'running') {
try {
$stats = $client->containerStats((string) ($c['Id'] ?? ''));
$cpuPercent = isset($stats['cpu_stats']['cpu']) ? round((float) $stats['cpu_stats']['cpu'], 1) : null;
$memUsageBytes = isset($stats['memory_stats']['usage']) ? (int) $stats['memory_stats']['usage'] : null;
$memLimitBytes = isset($stats['memory_stats']['limit']) ? (int) $stats['memory_stats']['limit'] : null;
} catch (PodmanApiException $e) {
// leave stats null
}
}
$out[] = [
'id' => (string) ($c['Id'] ?? ''),
'shortId' => podman_short_id((string) ($c['Id'] ?? '')),
@@ -293,6 +467,9 @@ function containers_list(PodmanClient $client): array
'podName' => $c['PodName'] ?? null,
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
'createdAt' => podman_parse_time($c['Created'] ?? null),
'cpuPercent' => $cpuPercent,
'memUsageBytes' => $memUsageBytes,
'memLimitBytes' => $memLimitBytes,
];
}
+127 -37
View File
@@ -3,35 +3,49 @@
* ajax/exec.php
*
* Backs the Terminal panel — and this is the one panel where "exclusively
* via podman system service, no shell hacks" needs an honest caveat
* spelled out rather than silently glossed over:
* via podman system service, no shell hacks" needs an honest caveat spelled
* out rather than silently glossed over (the same exception ajax/compose.php
* documents for the same underlying reason: some things have no REST
* equivalent).
*
* libpod's real exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) is used here — PodmanClient::execRun() never
* shells out to the `podman` binary. But that API's interactive mode works
* by HTTP connection hijacking: the HTTP connection is upgraded into a raw
* bidirectional byte stream for the lifetime of the shell session. That
* model assumes a long-lived process holding the socket open on both ends
* (an actual terminal emulator, or a WebSocket bridge) — it does not fit
* PHP-FPM's request/response lifecycle, where each AJAX call is a fresh,
* independent, short-lived process with no memory of any previous one.
* POST /exec/{id}/start) works by HTTP connection hijacking: the connection
* is upgraded into a raw bidirectional byte stream for the lifetime of the
* shell session. That model assumes a long-lived process holding the socket
* open on both ends (an actual terminal emulator, or a WebSocket bridge) —
* it does not fit PHP-FPM's request/response lifecycle, where each AJAX call
* is a fresh, independent, short-lived process with no memory of any
* previous one. An earlier version of this file worked around that by
* offering one-shot "run a command, see its output" exec calls — honest
* about not being a real terminal, but not what a user expects when they
* open a "Console" tab (no history, no vim, no persistent `cd`).
*
* Rather than fake interactivity with something that would break on the
* first multi-line prompt, `sudo`, or interactive editor, this endpoint
* offers a deliberately simpler, honest contract: one command in, its
* complete output back, using Tty=true so output reads like a real
* terminal (colors, prompts-in-output, etc. survive) but with no
* persistent shell state (`cd` does not carry over between calls — see
* the "cwd" parameter below, which javascript/terminal.js tracks
* client-side and resends every time instead).
* Unraid's own webGui already solves exactly this problem for its System
* Terminal and for `docker exec` (see
* /usr/local/emhttp/plugins/dynamix/include/OpenTerminal.php's 'docker'
* case, and /etc/nginx/conf.d/locations.conf's "logterminal" location
* block) — by spawning one `ttyd` instance per session, bound to a unix
* socket under /var/tmp, wrapping the real interactive command; nginx then
* proxies /logterminal/<name>/ to that socket with a WebSocket upgrade,
* generically, for ANY name. That proxy rule is already installed and
* already generic — this endpoint reuses it exactly the same way Unraid's
* own docker integration does, just with `podman exec -it` instead of
* `docker exec -it` as the wrapped command. `ttyd-exec` itself is a small
* wrapper script Unraid ships system-wide (sources /etc/default/ttyd for
* common xterm.js options, then execs ttyd in the background) — not
* something this plugin needs to vendor.
*
* A true interactive PTY (arrow-key history, tab completion, vim, ...)
* would need a WebSocket-capable process sitting between the browser and
* podman.sock — out of scope for this PHP/AJAX stack; tracked as a
* follow-up rather than implemented as a shell-out workaround.
* This is the one place in the plugin that shells out to the `podman`
* binary via proc invocation rather than the REST API — container names
* are validated against a fixed safe pattern and passed through
* escapeshellarg(), never concatenated into a shell string.
*
* Actions (?action=...):
* run POST {"id": "...", "cmd": "ls -la", "cwd": "/config"}
* open POST {"name": "...", "shell": "sh"|"bash"} -> {"sockName": "..."}
* Caller then points an iframe/window at /logterminal/<sockName>/.
* close POST {"name": "..."} -> {"status": "closed"}
* Kills the ttyd instance (and, via it, the `podman exec` it
* wraps) for that container, if one is running.
*/
declare(strict_types=1);
@@ -41,27 +55,103 @@ require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'run':
case 'open':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
$commandLine = (string) ($body['cmd'] ?? '');
$cwd = (string) ($body['cwd'] ?? '');
$name = (string) ($body['name'] ?? '');
$shell = (string) ($body['shell'] ?? 'sh');
if ($id === '' || trim($commandLine) === '') {
podman_json_error('Missing id or cmd in request body', 400);
// Same character set libpod itself allows in container names —
// rejecting anything else here (BEFORE it's ever used to build a
// socket path or shell command) is what makes escapeshellarg() on
// top of it a defense in depth rather than the only line of
// defense.
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
if (!in_array($shell, ['sh', 'bash'], true)) {
podman_json_error('Invalid shell', 400);
}
// The command line is run through the container's own shell
// (sh -c) so the user can type ordinary shell syntax (pipes,
// globs, env vars) in the terminal box, exactly like a real
// shell prompt would accept — still one real exec API call, just
// with /bin/sh as the interpreter instead of us parsing shell
// syntax ourselves in PHP.
$output = $client->execRun($id, ['/bin/sh', '-c', $commandLine], $cwd);
podman_json_response(open_terminal($name, $shell));
break;
podman_json_response(['output' => $output]);
case 'close':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
close_terminal($name);
podman_json_response(['status' => 'closed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
function sock_path_for(string $containerName): string
{
// "podman." prefix keeps this plugin's per-container sockets under
// /var/tmp from ever colliding with Unraid's own docker-exec sockets
// (/var/tmp/<name>.sock), which are named after the same container
// names a user might also give their podman containers.
return '/var/tmp/podman.' . $containerName . '.sock';
}
/**
* @return array<string,mixed>
*/
function open_terminal(string $containerName, string $shell): array
{
// Close out any previous session for this container first — sockets
// are named deterministically per-container (not per-open-call), so
// without this, re-opening the same container's terminal (or switching
// shells) would try to bind a second ttyd to the same path and leave
// the first one orphaned, still running, holding /dev resources for a
// client that will never come.
close_terminal($containerName);
$sockPath = sock_path_for($containerName);
// -s9: send SIGKILL to the wrapped command when the client disconnects
// (no orphaned `podman exec` process lingering after the window is
// closed). -o -m1: accept exactly one client, then exit instead of
// staying resident waiting for a next one — matching exactly the
// options Unraid's own OpenTerminal.php uses for `docker exec` (see
// that file's 'docker' case).
$cmd = sprintf(
'ttyd-exec -s9 -o -m1 -i %s podman exec -it %s %s',
escapeshellarg($sockPath),
escapeshellarg($containerName),
escapeshellarg($shell)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
podman_json_error('Could not start terminal session', 500);
}
return ['sockName' => 'podman.' . $containerName];
}
/**
* Kills the ttyd instance (if any) bound to this container's socket, and
* removes the socket file. Matched via `pgrep -f` against the socket path
* embedded in ttyd's own argv (the -i flag passed in open_terminal()) —
* that's a stable, unique needle since it includes the "podman." prefix
* and the validated container name. Killing ttyd itself (rather than
* just closing a client connection nothing is holding) tears down the
* `podman exec` child with it, same as closing a real terminal window
* would once a client was attached.
*/
function close_terminal(string $containerName): void
{
$sockPath = sock_path_for($containerName);
exec('pgrep -f ' . escapeshellarg($sockPath) . ' 2>/dev/null', $pids);
foreach ($pids as $pid) {
if (ctype_digit($pid)) {
exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null');
}
}
@unlink($sockPath);
}
+23
View File
@@ -8,6 +8,8 @@
* list GET -> normalized image list
* pull POST {"reference": "docker.io/library/postgres:16"}
* remove POST {"id": "...", "force": false}
* prune POST {} -> removes every image not used by any container
* tag POST {"id": "...", "repo": "...", "tag": "latest"}
*/
declare(strict_types=1);
@@ -40,6 +42,27 @@ switch ($action) {
podman_json_response(['status' => 'removed']);
break;
case 'prune':
$removed = $client->pruneImages();
$reclaimed = 0;
foreach ($removed as $r) {
$reclaimed += (int) ($r['Size'] ?? 0);
}
podman_json_response(['removedCount' => count($removed), 'reclaimedBytes' => $reclaimed]);
break;
case 'tag':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
$repo = trim((string) ($body['repo'] ?? ''));
$tag = trim((string) ($body['tag'] ?? '')) ?: 'latest';
if ($id === '' || $repo === '') {
podman_json_error('Missing id or repo in request body', 400);
}
$client->tagImage($id, $repo, $tag);
podman_json_response(['status' => 'tagged']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
+83 -3
View File
@@ -8,7 +8,9 @@
*
* Actions (?action=...):
* list GET -> normalized network list with subnet/gateway/usage
* create POST {"name": "...", "driver": "bridge", "subnet": "...", "gateway": "..."}
* list_parent_interfaces GET -> host bridge/VLAN interfaces available as a macvlan parent
* create POST {"name": "...", "driver": "bridge"|"macvlan", "subnet": "...",
* "gateway": "...", "parentInterface": "br0"}
* remove POST {"name": "...", "force": false}
*/
@@ -23,17 +25,40 @@ switch ($action) {
podman_json_response(networks_list($client));
break;
case 'list_parent_interfaces':
podman_json_response(macvlan_parent_interfaces());
break;
case 'create':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
$driver = (string) ($body['driver'] ?? 'bridge');
$parentInterface = null;
if ($driver === 'macvlan') {
$parentInterface = (string) ($body['parentInterface'] ?? '');
// Only ever accept an interface this same host reported via
// macvlan_parent_interfaces() — the boundary preventing a
// tampered request from asking podman to attach to an
// arbitrary/unexpected interface name.
$known = array_column(macvlan_parent_interfaces(), 'interface');
if (!in_array($parentInterface, $known, true)) {
podman_json_error('Unknown parent interface — refresh the page and try again.', 400);
}
if (!isset($body['subnet']) || (string) $body['subnet'] === '') {
podman_json_error('Subnet is required for a macvlan network.', 400);
}
}
podman_json_response($client->createNetwork(
$name,
(string) ($body['driver'] ?? 'bridge'),
$driver,
isset($body['subnet']) ? (string) $body['subnet'] : null,
isset($body['gateway']) ? (string) $body['gateway'] : null
isset($body['gateway']) ? (string) $body['gateway'] : null,
$parentInterface
));
break;
@@ -54,6 +79,61 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Reads Unraid's own /boot/config/network.cfg (BRNAME[i]/VLANID[i,j]/
* DESCRIPTION[i,j]) to list the same host bridge + VLAN interfaces
* Unraid's own Docker Manager offers as "Custom: br0" / "Custom: br0.3
* (VPN)" network types — reusing Unraid's own config instead of guessing
* from raw `ip link` output, so the list always matches what Docker
* Manager shows for the same host. Verified live: this host's
* network.cfg has BRNAME[0]="br0" and VLANID[0,1]="3"/DESCRIPTION[0,1]=
* "VPN", producing "br0" and "br0.3 (VPN)" — matching the interface
* names shown in that other plugin's own network-type dropdown exactly.
* Each candidate is confirmed to actually exist in /sys/class/net before
* being offered, in case network.cfg mentions an interface that isn't
* currently up.
*
* @return array<int,array{interface:string,label:string}>
*/
function macvlan_parent_interfaces(): array
{
$cfgFile = '/boot/config/network.cfg';
if (!is_file($cfgFile)) {
return [];
}
$cfg = [];
foreach (file($cfgFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (preg_match('/^([A-Z0-9_]+)\[(\d+)(?:,(\d+))?\]="([^"]*)"$/', $line, $m) !== 1) {
continue;
}
[, $key, $i, $j, $value] = $m + [3 => ''];
$i = (int) $i;
if ($j === '') {
$cfg[$key][$i] = $value;
} else {
$cfg[$key][$i][(int) $j] = $value;
}
}
$out = [];
foreach (($cfg['BRNAME'] ?? []) as $i => $brname) {
if (!is_string($brname) || $brname === '' || !is_dir("/sys/class/net/{$brname}")) {
continue;
}
$out[] = ['interface' => $brname, 'label' => $brname];
foreach (($cfg['VLANID'][$i] ?? []) as $j => $vlanId) {
$iface = "{$brname}.{$vlanId}";
if (!is_dir("/sys/class/net/{$iface}")) {
continue;
}
$desc = $cfg['DESCRIPTION'][$i][$j] ?? '';
$out[] = ['interface' => $iface, 'label' => $iface . ($desc !== '' ? " ({$desc})" : '')];
}
}
return $out;
}
/** @return array<int,array<string,mixed>> */
function networks_list(PodmanClient $client): array
{
+63
View File
@@ -9,8 +9,10 @@
*
* Actions (?action=...):
* list GET -> pods with nested container summaries
* create POST {"name": "...", "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}]}
* start POST {"name": "..."}
* stop POST {"name": "...", "timeout": 10}
* restart POST {"name": "...", "timeout": 10}
* remove POST {"name": "...", "force": false}
*/
@@ -25,6 +27,12 @@ switch ($action) {
podman_json_response(pods_list($client));
break;
case 'create':
$body = podman_read_json_body();
$id = $client->createPod(build_pod_spec($body));
podman_json_response(['id' => $id, 'status' => 'created']);
break;
case 'start':
$body = podman_read_json_body();
$client->startPod(require_name($body));
@@ -37,6 +45,12 @@ switch ($action) {
podman_json_response(['status' => 'stopped']);
break;
case 'restart':
$body = podman_read_json_body();
$client->restartPod(require_name($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
podman_json_response(['status' => 'restarted']);
break;
case 'remove':
$body = podman_read_json_body();
$client->removePod(require_name($body), (bool) ($body['force'] ?? false));
@@ -47,6 +61,55 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Builds a libpod pod-create body from the "New Pod" form fields. Verified
* live against a real podman system service — {"name": "...",
* "portmappings": [...]} creates a pod with a shared infra container whose
* port bindings apply to every member container.
*
* @param array<string,mixed> $body
* @return array<string,mixed>
*/
function build_pod_spec(array $body): array
{
$name = trim((string) ($body['name'] ?? ''));
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
// Same character set podman enforces for container names (define.NameRegex
// in libpod applies to pods too) — validated here for the same reason
// ajax/containers.php validates it: a clear message instead of podman's
// raw "names must match ...: invalid argument".
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) {
podman_json_error(
"Pod name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" .
preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.',
400
);
}
$spec = ['name' => $name];
$ports = [];
foreach (($body['ports'] ?? []) as $row) {
$hostPort = (int) ($row['hostPort'] ?? 0);
$containerPort = (int) ($row['containerPort'] ?? 0);
if ($hostPort > 0 && $containerPort > 0) {
$ports[] = [
'host_ip' => '',
'host_port' => $hostPort,
'container_port' => $containerPort,
'protocol' => (string) ($row['protocol'] ?? 'tcp'),
];
}
}
if ($ports !== []) {
$spec['portmappings'] = $ports;
}
return $spec;
}
/** @param array<string,mixed> $body */
function require_name(array $body): string
{
+78 -42
View File
@@ -114,7 +114,15 @@ final class PodmanClient
*/
public function createContainer(array $spec): string
{
$result = $this->request('POST', '/containers/create', [], false, $spec);
// Longer than this client's normal 15s operation timeout as cheap
// insurance: creating a container involves setting up its mounts
// (often onto Unraid array/spinning-disk shares, not the cache
// pool) and network namespace, which can occasionally run past 15s
// even with the image already pulled — found live via a real
// "Operation timed out after 15001 milliseconds" error creating a
// container. Same 600s ceiling as pullImage(), safely under
// nginx's 640s fastcgi_read_timeout.
$result = $this->request('POST', '/containers/create', [], false, $spec, 600);
return (string) ($result['Id'] ?? '');
}
@@ -185,41 +193,6 @@ final class PodmanClient
// have — see that file's header comment for the full explanation).
// -------------------------------------------------------------------
/**
* Creates and immediately runs one command inside a container via the
* real libpod exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) and returns its combined stdout+stderr output.
* Tty=true is used deliberately so the response is a plain byte stream
* with no frame-header demultiplexing needed (see containerLogs() for
* the non-TTY case, which does need it).
*/
public function execRun(string $containerId, array $cmd, string $workingDir = ''): string
{
$createBody = [
'AttachStdin' => false,
'AttachStdout' => true,
'AttachStderr' => true,
'Tty' => true,
'Cmd' => $cmd,
];
if ($workingDir !== '') {
$createBody['WorkingDir'] = $workingDir;
}
$created = $this->request('POST', '/containers/' . rawurlencode($containerId) . '/exec', [], false, $createBody);
$execId = $created['Id'] ?? null;
if (!is_string($execId) || $execId === '') {
throw new PodmanApiException('exec create response did not include an Id');
}
$output = $this->requestRaw('POST', '/exec/' . rawurlencode($execId) . '/start', [], [
'Detach' => false,
'Tty' => true,
]);
return $output;
}
// -------------------------------------------------------------------
// Pods
// -------------------------------------------------------------------
@@ -234,6 +207,22 @@ final class PodmanClient
return $this->request('GET', '/pods/' . rawurlencode($name) . '/json');
}
/**
* POST /pods/create — takes a body of {name, portmappings, ...}.
* Verified live against a real podman system service: {"name":"...",
* "portmappings":[{"host_port":...,"container_port":...,"protocol":...}]}
* creates a pod with a shared infra container whose port bindings apply
* to every member container — see ajax/pods.php's build_pod_spec().
*
* @param array<string,mixed> $spec
* @return string the new pod's ID
*/
public function createPod(array $spec): string
{
$result = $this->request('POST', '/pods/create', [], false, $spec);
return (string) ($result['Id'] ?? '');
}
public function startPod(string $name): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true);
@@ -244,6 +233,11 @@ final class PodmanClient
$this->request('POST', '/pods/' . rawurlencode($name) . '/stop', ['t' => (string) $timeoutSeconds], true);
}
public function restartPod(string $name, int $timeoutSeconds = 10): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/restart', ['t' => (string) $timeoutSeconds], true);
}
public function removePod(string $name, bool $force = false): void
{
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
@@ -279,7 +273,14 @@ final class PodmanClient
*/
public function pullImage(string $reference): array
{
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference]);
// A real image (e.g. a Plex/media-server image, easily several
// hundred MB) routinely takes far longer than this client's normal
// 15s operation timeout to download — found live: a pull aborted
// mid-stream with "Operation timed out after 15001 milliseconds"
// after only ~1.4KB of progress data. nginx's own fastcgi_read_timeout
// (640s, see /etc/nginx/nginx.conf) already anticipates long-running
// plugin requests, so 600s here stays safely under that.
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference], null, 600);
$last = null;
foreach (explode("\n", trim($raw)) as $line) {
@@ -313,6 +314,28 @@ final class PodmanClient
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
/**
* POST /images/prune?all=true — removes every image with zero containers
* (running or stopped) referencing it, matching this app's own "Used By"
* column — not just dangling/untagged images. Verified live: a tagged
* but unused image IS removed with all=true (found the hard way: it
* also removed every image on a host with no containers at all, which
* is correct behavior, just aggressive — see ajax/images.php's prune
* action for the confirmation-copy this justifies).
*
* @return array<int,array{Id:string,Size:int}> one entry per removed image
*/
public function pruneImages(): array
{
return $this->request('POST', '/images/prune', ['all' => 'true']);
}
/** POST /images/{id}/tag?repo=...&tag=... — adds a new repo:tag pointing at an existing image. */
public function tagImage(string $id, string $repo, string $tag): void
{
$this->request('POST', '/images/' . rawurlencode($id) . '/tag', ['repo' => $repo, 'tag' => $tag], true);
}
// -------------------------------------------------------------------
// Volumes
// -------------------------------------------------------------------
@@ -353,12 +376,25 @@ final class PodmanClient
return $this->request('GET', '/networks/json');
}
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null): array
/**
* $parentInterface (only meaningful for driver="macvlan") attaches the
* network directly to an existing host bridge/VLAN interface (e.g.
* Unraid's own "br0" or a VLAN sub-interface like "br0.3") via
* libpod's "network_interface" field — verified live: containers on
* such a network get a real address on that LAN/VLAN's own subnet,
* not a NATed one, matching Unraid Docker Manager's "Custom: br0"
* network type. See ajax/networks.php's macvlan_parent_interfaces()
* for where the interface list itself comes from.
*/
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null, ?string $parentInterface = null): array
{
$body = ['name' => $name, 'driver' => $driver];
if ($subnet !== null) {
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
}
if ($parentInterface !== null && $parentInterface !== '') {
$body['network_interface'] = $parentInterface;
}
return $this->request('POST', '/networks/create', [], false, $body);
}
@@ -379,9 +415,9 @@ final class PodmanClient
* @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
* @return array<mixed>
*/
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null): array
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null, ?int $timeoutSeconds = null): array
{
$raw = $this->requestRaw($method, $path, $query, $jsonBody);
$raw = $this->requestRaw($method, $path, $query, $jsonBody, $timeoutSeconds);
if ($expectEmptyBody || trim($raw) === '') {
return [];
}
@@ -400,7 +436,7 @@ final class PodmanClient
* @param array<string,string> $query
* @param array<mixed>|null $jsonBody
*/
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null): string
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null, ?int $timeoutSeconds = null): string
{
$url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
if (!empty($query)) {
@@ -413,7 +449,7 @@ final class PodmanClient
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_TIMEOUT => $timeoutSeconds ?? $this->timeoutSeconds,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
@@ -0,0 +1,172 @@
<?php
/**
* RegistryClient.php
*
* "Is a newer image available?" — deliberately NOT a podman/libpod feature
* (verified live: no libpod endpoint exists for this; every tool that
* offers it, Watchtower/Diun/Unraid's own Docker Manager included,
* re-implements the same registry-side check). This talks directly to the
* target image's own registry using the standard Docker Registry HTTP API
* V2: a GET on the manifest returns a "Docker-Content-Digest" header
* without downloading any image layers, which is compared against the
* digest of the image already pulled locally (PodmanClient::listImages()'s
* own "Digest" field) — no local image ever needs pulling just to check.
*
* The auth flow is the generic Bearer-challenge dance every compliant
* registry follows (RFC-ish, not just a Docker Hub thing): an
* unauthenticated request gets a 401 with a WWW-Authenticate header naming
* a token realm/service/scope, a token is fetched from that realm, and the
* manifest request is retried with it. Verified live against three
* different registries with three different auth setups — Docker Hub,
* ghcr.io, and a self-hosted Gitea registry — using this exact same code
* path for all three, not registry-specific special-casing.
*/
declare(strict_types=1);
final class RegistryClient
{
/**
* @return array{updateAvailable?:bool,remoteDigest?:string,error?:string}
*/
public static function checkForUpdate(string $reference, string $localDigest): array
{
[$registry, $repo, $tag] = self::parseReference($reference);
$manifestUrl = "https://{$registry}/v2/{$repo}/manifests/{$tag}";
$accept = 'application/vnd.docker.distribution.manifest.v2+json, ' .
'application/vnd.docker.distribution.manifest.list.v2+json, ' .
'application/vnd.oci.image.manifest.v1+json, ' .
'application/vnd.oci.image.index.v1+json';
[$status, $headers] = self::httpRequest($manifestUrl, $accept, null);
if ($status === 401) {
$challenge = self::parseAuthChallenge($headers['www-authenticate'] ?? '');
if ($challenge === null) {
return ['error' => 'Registry requires authentication this app cannot satisfy.'];
}
$token = self::fetchToken($challenge);
if ($token === null) {
return ['error' => 'Could not authenticate with the registry.'];
}
[$status, $headers] = self::httpRequest($manifestUrl, $accept, $token);
}
if ($status !== 200) {
return ['error' => "Registry returned HTTP {$status}."];
}
$remoteDigest = $headers['docker-content-digest'] ?? null;
if ($remoteDigest === null) {
return ['error' => 'Registry response did not include a digest.'];
}
return ['remoteDigest' => $remoteDigest, 'updateAvailable' => $remoteDigest !== $localDigest];
}
/**
* Splits "docker.io/library/nginx:alpine" (or shorthand forms like
* "nginx:alpine" or "someuser/repo:tag") into [registryHost, repoPath,
* tag] — same reference-parsing convention every registry client
* (including podman/Docker themselves) uses: the first path segment is
* a registry host only if it contains a "." or ":" or is "localhost";
* otherwise the whole reference is a Docker Hub repo, implicitly under
* "library/" if it has no namespace of its own. docker.io's actual API
* host is registry-1.docker.io, not docker.io itself — a Docker-Hub-
* specific quirk, not something inferred from the general rule above.
*
* @return array{0:string,1:string,2:string}
*/
private static function parseReference(string $reference): array
{
$reference = explode('@', $reference, 2)[0]; // strip any @sha256:... suffix
$tag = 'latest';
$lastSlash = strrpos($reference, '/');
$lastColon = strrpos($reference, ':');
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
$tag = substr($reference, $lastColon + 1);
$reference = substr($reference, 0, $lastColon);
}
$parts = explode('/', $reference);
$first = $parts[0];
$looksLikeHost = str_contains($first, '.') || str_contains($first, ':') || $first === 'localhost';
if ($looksLikeHost) {
$registry = $first;
$repo = implode('/', array_slice($parts, 1));
} else {
$registry = 'docker.io';
$repo = str_contains($reference, '/') ? $reference : "library/{$reference}";
}
if ($registry === 'docker.io') {
$registry = 'registry-1.docker.io';
}
return [$registry, $repo, $tag];
}
/** @return array{realm:string,service:string,scope:string}|null */
private static function parseAuthChallenge(string $header): ?array
{
if (preg_match('/realm="([^"]+)"/', $header, $m) !== 1) {
return null;
}
$service = preg_match('/service="([^"]+)"/', $header, $sm) === 1 ? $sm[1] : '';
$scope = preg_match('/scope="([^"]+)"/', $header, $om) === 1 ? $om[1] : '';
return ['realm' => $m[1], 'service' => $service, 'scope' => $scope];
}
/** @param array{realm:string,service:string,scope:string} $challenge */
private static function fetchToken(array $challenge): ?string
{
$params = array_filter(['service' => $challenge['service'], 'scope' => $challenge['scope']]);
$url = $challenge['realm'] . '?' . http_build_query($params);
[$status, , $body] = self::httpRequest($url, 'application/json', null, true);
if ($status !== 200 || $body === null) {
return null;
}
$decoded = json_decode($body, true);
// The spec allows either key; registries are inconsistent about
// which one they actually send.
return is_array($decoded) ? (string) ($decoded['token'] ?? $decoded['access_token'] ?? '') ?: null : null;
}
/**
* @return array{0:int,1:array<string,string>,2:?string} [status, lowercased response headers, body (only when $withBody)]
*/
private static function httpRequest(string $url, string $accept, ?string $token, bool $withBody = false): array
{
$ch = curl_init($url);
$headers = ['Accept: ' . $accept];
if ($token !== null) {
$headers[] = "Authorization: Bearer {$token}";
}
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($raw === false) {
return [0, [], null];
}
$parsedHeaders = [];
foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$parsedHeaders[strtolower(trim($k))] = trim($v);
}
}
$body = $withBody ? substr($raw, $headerSize) : null;
return [$status, $parsedHeaders, $body];
}
}
@@ -20,6 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/PodmanClient.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/RegistryClient.php';
set_exception_handler(static function (\Throwable $e): void {
if ($e instanceof PodmanApiException) {
+61 -3
View File
@@ -229,6 +229,53 @@ window.Podman = (function () {
});
}
/**
* Small modal with a scrolling monospace log pane — for actions that run
* several steps in sequence (checking/updating containers) where a plain
* confirm()/alert() at the very end leaves the user with no feedback
* that anything is happening while it runs. Returns {log, done} rather
* than closing itself, since the caller knows when the whole sequence
* (not just one call) has actually finished.
*
* @param {string} title
* @returns {{log: (line: string) => void, done: (closeLabel?: string) => void}}
*/
function openLogModal(title) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + escapeHtml(title) + '</h3></div>' +
'<div class="podman-modal-body"><div class="podman-log-pane" id="podman-log-modal-pane"></div></div>' +
'<div class="podman-modal-actions"><button type="button" class="podman-btn podman-btn-primary" data-role="close" disabled>Working…</button></div>' +
'</div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const pane = backdrop.querySelector('#podman-log-modal-pane');
const closeBtn = backdrop.querySelector('[data-role="close"]');
function close() { backdrop.remove(); }
closeBtn.addEventListener('click', close);
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape' && !closeBtn.disabled) { close(); document.removeEventListener('keydown', onKey); }
});
function log(line) {
const row = document.createElement('div');
row.textContent = line;
pane.appendChild(row);
pane.scrollTop = pane.scrollHeight;
}
function done(closeLabel) {
closeBtn.disabled = false;
closeBtn.textContent = closeLabel || 'Close';
}
return { log: log, done: done };
}
/**
* Small anchored dropdown menu — used for secondary per-row actions
* (pause/kill/rename/...) that would otherwise clutter a table row with
@@ -252,15 +299,25 @@ window.Podman = (function () {
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
}).join('');
// Viewport-relative (see the "position: fixed" comment on
// .podman-context-menu in podman.css) — no scrollY/scrollX added.
const rect = anchorEl.getBoundingClientRect();
menu.style.top = (rect.bottom + window.scrollY + 4) + 'px';
menu.style.left = (rect.right + window.scrollX - 180) + 'px';
menu.style.top = (rect.bottom + 4) + 'px';
menu.style.left = (rect.right - 180) + 'px';
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
// menu.children includes the separator <div>s too, so indexing into it
// directly (by a counter that only advances for real items) drifts by
// one after every separator — e.g. "Remove" (after a separator) ended
// up wired to the separator <div> instead of its own <button>, so
// clicking it did nothing. querySelectorAll('button') only ever
// returns the actual buttons, in the same order as the non-separator
// items, so indexing into that stays aligned regardless of separators.
const buttons = menu.querySelectorAll('button');
let buttonIndex = 0;
items.forEach(function (item) {
if (item === 'separator') return;
const btn = menu.children[buttonIndex];
const btn = buttons[buttonIndex];
buttonIndex++;
if (item.disabled) return;
btn.addEventListener('click', function (e) {
@@ -358,6 +415,7 @@ window.Podman = (function () {
loadingRow: loadingRow,
errorRow: errorRow,
openFormModal: openFormModal,
openLogModal: openLogModal,
openContextMenu: openContextMenu,
registerPanel: registerPanel,
activatePanel: activatePanel,
+87 -9
View File
@@ -1,10 +1,10 @@
/**
* javascript/compose.js
*
* Compose panel: project list + read-only YAML view + up/down/pull,
* backed by ajax/compose.php. See that file's header comment — this is
* the one panel whose backend shells out to the `podman compose` CLI,
* because no REST equivalent for Compose exists in libpod.
* Compose panel: project list + an editable YAML view + save/up/down/pull/
* delete, backed by ajax/compose.php. See that file's header comment —
* this is the one panel whose backend shells out to the `podman compose`
* CLI, because no REST equivalent for Compose exists in libpod.
*/
(function () {
'use strict';
@@ -12,6 +12,13 @@
let projects = [];
let selected = null;
const STARTER_YAML =
'services:\n' +
' app:\n' +
' image: docker.io/library/nginx:alpine\n' +
' ports:\n' +
' - "8080:80"\n';
function statusChip(status) {
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn');
return '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
@@ -23,21 +30,32 @@
'<div class="name" style="display:flex; justify-content:space-between; gap:8px;">' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '</div>' +
'<div class="path">' + P.escapeHtml(p.path) + '</div>' +
'</div>';
}).join('') || '<div class="podman-empty-note">No compose projects under /boot/config/plugins/podman/compose/</div>';
}).join('') || '<div class="podman-empty-note">No compose projects yet — click "+ New Project".</div>';
}
// Up/Down/Pull/Save/Delete all need an actual selected project to act on
// — disabled (rather than left clickable and erroring) whenever nothing
// is selected, e.g. right after deleting the last project.
function setToolbarEnabled(enabled) {
['compose-action-up', 'compose-action-down', 'compose-action-pull', 'compose-action-save', 'compose-action-delete'].forEach(function (id) {
P.el(id).disabled = !enabled;
});
P.el('compose-yaml').disabled = !enabled;
}
function loadYaml(name) {
P.el('compose-title').textContent = name + ' / compose.yaml';
P.el('compose-yaml').textContent = 'Loading…';
P.el('compose-yaml').value = 'Loading…';
return P.get('compose', 'get', { project: name }).then(function (data) {
P.el('compose-yaml').textContent = data.yaml;
P.el('compose-yaml').value = data.yaml;
}).catch(function (err) {
P.el('compose-yaml').textContent = 'Error: ' + err.message;
P.el('compose-yaml').value = 'Error: ' + err.message;
});
}
function selectProject(name) {
selected = name;
setToolbarEnabled(true);
renderSidebar();
loadYaml(name);
}
@@ -45,9 +63,19 @@
function loadProjects() {
return P.get('compose', 'list').then(function (data) {
projects = data;
if (selected && !projects.some(function (p) { return p.name === selected; })) {
selected = null;
}
if (!selected && projects.length > 0) selected = projects[0].name;
renderSidebar();
if (selected) loadYaml(selected);
if (selected) {
setToolbarEnabled(true);
loadYaml(selected);
} else {
setToolbarEnabled(false);
P.el('compose-title').textContent = '—';
P.el('compose-yaml').value = '';
}
}).catch(function (err) {
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
});
@@ -67,15 +95,65 @@
});
}
function saveYaml() {
if (!selected) return;
const btn = P.el('compose-action-save');
btn.disabled = true;
P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () {
return loadProjects();
}).catch(function (err) {
alert('Save failed: ' + err.message);
}).finally(function () {
btn.disabled = false;
});
}
function deleteProject() {
if (!selected) return;
if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return;
const btn = P.el('compose-action-delete');
btn.disabled = true;
P.post('compose', 'remove', { project: selected }).then(function () {
selected = null;
return loadProjects();
}).catch(function (err) {
alert('Delete failed: ' + err.message);
btn.disabled = false;
});
}
function openNewProjectModal() {
P.openFormModal({
title: 'New Compose Project',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Project name', required: true, placeholder: 'my-stack', hint: 'Letters, digits, "_", "-" only — no spaces.' },
],
onSubmit: function (values) {
if (!/^[a-zA-Z0-9_-]+$/.test(values.name)) {
return Promise.reject(new Error('Project name can only contain letters, digits, "_", "-" — no spaces.'));
}
return P.post('compose', 'save', { project: values.name, yaml: STARTER_YAML }).then(function () {
selected = values.name;
return loadProjects();
});
},
});
}
function init() {
P.el('compose-sidebar').addEventListener('click', function (e) {
const item = e.target.closest('.podman-compose-proj[data-name]');
if (item) selectProject(item.dataset.name);
});
P.el('compose-new-btn').addEventListener('click', openNewProjectModal);
P.el('compose-action-up').addEventListener('click', function () { runAction('up'); });
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); });
P.el('compose-action-save').addEventListener('click', saveYaml);
P.el('compose-action-delete').addEventListener('click', deleteProject);
setToolbarEnabled(false);
return loadProjects();
}
+388 -20
View File
@@ -10,42 +10,60 @@
let allContainers = [];
let filter = 'all';
let searchTerm = '';
// Keyed by image reference (not container id) — several containers
// commonly share the same image, and ajax/containers.php's
// check_updates action itself already dedupes registry requests the
// same way. Persists across load()/renderTable() refreshes so the
// badge doesn't disappear on the next auto-refresh; only re-running
// "Check for Updates" replaces it.
let imageUpdateStatus = {};
function iconLabel(name) {
return P.escapeHtml(name.slice(0, 2).toUpperCase());
}
function hasUpdate(c) {
const status = imageUpdateStatus[c.image];
return !!(status && status.updateAvailable);
}
function rowHtml(c) {
const cpuMem = c.state === 'running'
? '<span class="podman-row-sub">running</span>'
const cpuMem = c.state === 'running' && c.cpuPercent != null
? '<span class="tnum">' + c.cpuPercent.toFixed(1) + '%</span> <span class="podman-row-sub">/ ' + P.formatBytes(c.memUsageBytes) + '</span>'
: '<span class="podman-row-sub">&mdash;</span>';
const updateBadge = hasUpdate(c)
? ' <span class="podman-badge-update" title="A newer image is available">&#8593; Update</span>'
: '';
return '' +
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
'<td><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>' +
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button>' + updateBadge + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
'<td>' + cpuMem + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' +
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</td>' +
'<td class="podman-actions">' + actionButtons(c) + '</td>' +
'<td class="podman-actions"><div class="podman-actions-row">' + actionButtons(c) + '</div></td>' +
'</tr>';
}
function actionButtons(c) {
const updateBtn = hasUpdate(c)
? '<button class="podman-btn podman-btn-icon" data-action="update" title="Update to the newer image">&#8593;</button>'
: '';
if (c.state === 'running') {
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">&#8635;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">&#9632;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
if (c.state === 'paused') {
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
return '' +
return updateBtn +
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
@@ -57,6 +75,7 @@
items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
}
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } });
items.push('separator');
items.push({
label: 'Remove',
@@ -78,6 +97,218 @@
});
}
// --- Edit (recreate) --------------------------------------------------------
//
// Podman/Docker have no "modify a running container" API for most of
// this (image, ports, volumes, env, ...) — the only real way to "edit"
// is to stop the old one, remove it (this does NOT touch named volumes,
// only the container itself), and create a new one under the same name
// with the changed settings. Same pattern Unraid's own Docker Manager
// and every other Docker/Podman WebUI uses. Reuses the existing
// "inspect" action (already fetched for the detail modal) rather than
// adding a new endpoint — envToPrefill()/etc. below just reshape that
// same raw libpod inspect JSON into openCreateContainerModal's prefill
// shape.
// Auto-injected by the container runtime itself, not something a user
// set through this form — dropped so the edit form isn't full of noise
// that didn't come from the original Create Container submission.
const AUTO_ENV_KEYS = ['PATH', 'HOSTNAME', 'HOME', 'container', 'TERM'];
function inspectToPrefill(c, d) {
const cfg = d.Config || {};
const hostCfg = d.HostConfig || {};
const ports = [];
Object.keys((hostCfg.PortBindings) || {}).forEach(function (key) {
const [containerPort, protocol] = key.split('/');
((hostCfg.PortBindings[key]) || []).forEach(function (binding) {
ports.push({ hostPort: binding.HostPort, containerPort: containerPort, protocol: protocol || 'tcp' });
});
});
const volumes = (d.Mounts || []).reduce(function (list, m) {
if (m.Type === 'bind') {
list.push({ kind: 'path', source: m.Source, containerPath: m.Destination });
} else if (m.Type === 'volume') {
list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination });
}
return list;
}, []);
const env = (cfg.Env || []).reduce(function (list, line) {
const idx = line.indexOf('=');
const key = idx === -1 ? line : line.slice(0, idx);
if (AUTO_ENV_KEYS.indexOf(key) === -1) {
list.push({ key: key, value: idx === -1 ? '' : line.slice(idx + 1) });
}
return list;
}, []);
// Only the /dev/dri paths our own GPU passthrough checkbox could have
// added — same host-path pattern ajax/containers.php's build_container_
// spec() validates against, so a container with some unrelated device
// mapping (added outside this UI) doesn't get misread as a GPU pick.
const gpuDevices = (hostCfg.Devices || [])
.map(function (dev) { return dev.PathOnHost; })
.filter(function (path) { return /^\/dev\/dri\/(card|renderD)\d+$/.test(path); });
// Only meaningful on a macvlan network (see updateNetworkFieldsVisibility()
// in openCreateContainerModal) — the container's actual address on
// that network, so editing one doesn't blank out an IP it was
// deliberately given.
const netName = hostCfg.NetworkMode;
const netInfo = d.NetworkSettings && d.NetworkSettings.Networks && d.NetworkSettings.Networks[netName];
const staticIp = netInfo && netInfo.IPAddress ? netInfo.IPAddress : '';
return {
name: (d.Name || c.name || '').replace(/^\//, ''),
image: cfg.Image || c.image,
networkMode: hostCfg.NetworkMode || 'bridge',
staticIp: staticIp,
pod: c.podName || '',
privileged: !!hostCfg.Privileged,
restartPolicy: (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || 'no',
ports: ports,
volumes: volumes,
env: env,
gpuDevices: gpuDevices,
};
}
function openEditContainerModal(c) {
P.get('containers', 'inspect', { id: c.id }).then(function (d) {
openCreateContainerModal(inspectToPrefill(c, d), { id: c.id });
}).catch(function (err) {
alert('Could not load container config: ' + err.message);
});
}
// --- Update (pull + recreate, unchanged settings) ---------------------------
//
// "Update" is the same stop/remove/recreate as Edit — see that comment
// above — except nothing in the config changes and an image pull happens
// first. Reuses inspectToPrefill() so both features read a container's
// current settings the exact same way.
//
// Both this and checkForUpdates()/updateAll() below take a `log`
// callback and write one line per step to it — a plain confirm()/alert()
// at the very end left no visible sign anything was happening while a
// check or a several-container update ran (found live: clicking "Check
// for Updates" against two already-current images looked completely
// inert). See app.js's openLogModal() for the small scrolling log window
// these lines end up in.
function updateContainer(c, log) {
return P.get('containers', 'inspect', { id: c.id }).then(function (d) {
const prefill = inspectToPrefill(c, d);
log('Pulling ' + prefill.image + '…');
return P.post('images', 'pull', { reference: prefill.image })
.then(function () {
log('Stopping ' + c.name + '…');
return P.post('containers', 'stop', { id: c.id }).catch(function () { /* already stopped is fine */ });
})
.then(function () {
log('Removing old container…');
return P.post('containers', 'remove', { id: c.id, force: true });
})
.then(function () {
log('Creating new container…');
return P.post('containers', 'create', {
image: prefill.image,
name: prefill.name,
networkMode: prefill.networkMode,
staticIp: prefill.staticIp,
pod: prefill.pod,
ports: prefill.ports,
volumes: prefill.volumes,
env: prefill.env,
restartPolicy: prefill.restartPolicy,
gpuDevices: prefill.gpuDevices,
privileged: prefill.privileged,
startAfterCreate: true,
});
}).then(function () {
// The image just pulled is now current — clear the stale flag
// for it specifically rather than wiping every row's status,
// since other images may still be genuinely outdated.
delete imageUpdateStatus[prefill.image];
log('Done: ' + c.name + ' is up to date.');
});
});
}
function checkForUpdates() {
const modal = P.openLogModal('Check for Updates');
modal.log('Checking every image currently in use…');
return P.get('containers', 'check_updates').then(function (results) {
imageUpdateStatus = results;
let updatable = 0;
Object.keys(results).forEach(function (ref) {
const r = results[ref];
if (r.error) {
modal.log('! ' + ref + ' — ' + r.error);
} else if (r.updateAvailable) {
updatable++;
modal.log('↑ ' + ref + ' — update available');
} else {
modal.log('✓ ' + ref + ' — up to date');
}
});
modal.log('');
modal.log(updatable ? updatable + ' image(s) have an update available.' : 'Everything is up to date.');
modal.done();
renderTable();
}).catch(function (err) {
modal.log('Check failed: ' + err.message);
modal.done();
});
}
function updateAll() {
const btn = P.el('containers-update-all-btn');
btn.disabled = true;
const modal = P.openLogModal('Update All');
modal.log('Checking every image currently in use…');
P.get('containers', 'check_updates').then(function (results) {
imageUpdateStatus = results;
renderTable();
const targets = allContainers.filter(hasUpdate);
if (!targets.length) {
modal.log('Everything is already up to date.');
modal.done();
btn.disabled = false;
return;
}
modal.log(targets.length + ' container(s) to update: ' + targets.map(function (c) { return c.name; }).join(', '));
modal.log('');
// Sequential, not parallel — several containers stopping/recreating
// at once is harder to reason about if one of them fails partway,
// and avoids hammering the same registry with simultaneous pulls.
const failures = [];
targets.reduce(function (chain, c) {
return chain.then(function () {
return updateContainer(c, modal.log).catch(function (err) {
modal.log('Failed: ' + c.name + ' — ' + err.message);
failures.push(c.name);
});
});
}, Promise.resolve()).then(function () {
modal.log('');
modal.log(failures.length
? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.'
: 'All ' + targets.length + ' updated.');
modal.done();
btn.disabled = false;
return load();
});
}).catch(function (err) {
modal.log('Check failed: ' + err.message);
modal.done();
btn.disabled = false;
});
}
// --- Detail view -----------------------------------------------------------
//
// Fed entirely by the existing inspect action (raw libpod inspect JSON) —
@@ -305,17 +536,24 @@
/**
* @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.
* ajax/templates.php's "get" action returns, plus "name"/"pod" which
* only inspectToPrefill() sets) to seed the form with — used by
* templates.js's "Use template" action and openEditContainerModal()
* below. null/omitted opens a blank form, same as the toolbar's
* "+ New Container" button.
* @param {{id:string}|null} editing When set, this is an edit of an
* existing container rather than a fresh create: submitting stops and
* removes container `editing.id` first, then creates a new one under
* whatever name/settings are in the form (see the Podman/Docker have
* no in-place "modify" API comment on openEditContainerModal above).
*/
function openCreateContainerModal(prefill) {
function openCreateContainerModal(prefill, editing) {
prefill = prefill || {};
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Container</h3></div>' +
'<div class="podman-modal-head"><h3>' + (editing ? 'Edit Container' : 'New Container') + '</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Image</label>' +
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' +
@@ -325,9 +563,16 @@
'<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-modal-field" id="cc-static-ip-field" style="display:none;"><label>Static IP (optional)</label>' +
'<input type="text" class="mono" id="cc-static-ip" placeholder="10.1.1.222">' +
'<div class="hint">Leave blank to let the network assign one automatically.</div></div>' +
'<div class="podman-modal-field"><label>Pod (optional)</label>' +
'<select id="cc-pod"><option value="">None</option></select>' +
'<div class="hint">Joins the pod\'s shared network namespace instead of the setting above.</div></div>' +
'<div class="podman-modal-field" id="cc-ports-field"><label>Port mappings</label>' +
'<div class="podman-row-group" id="cc-ports"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
'<div class="hint" id="cc-ports-macvlan-hint" style="display:none;">Not needed on a macvlan network — the container gets its own address on the LAN.</div></div>' +
'<div class="podman-modal-field"><label>Volumes</label>' +
'<div class="podman-row-group" id="cc-volumes"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="volume">+ Add volume</button></div>' +
@@ -337,6 +582,8 @@
'<div class="podman-modal-field"><label>Restart policy</label>' +
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
'<div class="podman-modal-field" id="cc-gpu-field" style="display:none;"><label>GPU passthrough</label>' +
'<select id="cc-gpu-select"><option value="">None</option></select></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
@@ -352,14 +599,35 @@
'</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>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' + (editing ? 'Save &amp; Recreate' : 'Create') + '</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name;
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
if (prefill.staticIp) backdrop.querySelector('#cc-static-ip').value = prefill.staticIp;
// Macvlan containers get their own address directly on the LAN (see
// the ajax/networks.php macvlan work) — port mappings are meaningless
// for them (there's no host-side NAT to map through) and a static IP
// becomes a relevant option instead of a Bridge/Host/None-only
// concept. Toggled on network-select change and once up front below,
// driven by each <option>'s data-driver (set when the real network
// list loads — the three built-ins are never macvlan).
function updateNetworkFieldsVisibility() {
const select = backdrop.querySelector('#cc-network');
const selectedOption = select.options[select.selectedIndex];
const isMacvlan = !!(selectedOption && selectedOption.dataset.driver === 'macvlan');
backdrop.querySelector('#cc-static-ip-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cc-ports').style.display = isMacvlan ? 'none' : '';
backdrop.querySelector('[data-add="port"]').style.display = isMacvlan ? 'none' : '';
backdrop.querySelector('#cc-ports-macvlan-hint').style.display = isMacvlan ? '' : 'none';
}
backdrop.querySelector('#cc-network').addEventListener('change', updateNetworkFieldsVisibility);
const portsGroup = backdrop.querySelector('#cc-ports');
const volumesGroup = backdrop.querySelector('#cc-volumes');
@@ -383,11 +651,59 @@
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
const opt = document.createElement('option');
opt.value = n.name;
opt.textContent = n.name;
opt.textContent = n.name + (n.driver === 'macvlan' ? ' (macvlan)' : '');
opt.dataset.driver = n.driver;
select.appendChild(opt);
});
// Re-applied here (not just at load time above) because a custom
// network's <option> doesn't exist yet until this list comes back —
// setting .value to it any earlier would silently no-op and leave
// the select on its default "bridge" option instead. Matters for
// openEditContainerModal(): a container already on a custom network
// needs that option to exist before it can be selected.
if (prefill.networkMode) select.value = prefill.networkMode;
updateNetworkFieldsVisibility();
}).catch(function () { /* built-in modes still usable */ });
P.get('pods', 'list').then(function (pods) {
const select = backdrop.querySelector('#cc-pod');
pods.forEach(function (p) {
const opt = document.createElement('option');
opt.value = p.name;
opt.textContent = p.name;
select.appendChild(opt);
});
if (prefill.pod) select.value = prefill.pod;
}).catch(function () { /* pod selection stays optional */ });
// Only shown when the host actually has a passthrough-capable GPU
// (AMD/Intel via /dev/dri — see ajax/containers.php's gpu_list(); NVIDIA
// is deliberately excluded there since it needs a different runtime) —
// best-effort, same as networks/pods above.
P.get('containers', 'list_gpus').then(function (gpus) {
if (!gpus.length) return;
const field = backdrop.querySelector('#cc-gpu-field');
const select = backdrop.querySelector('#cc-gpu-select');
field.style.display = '';
gpus.forEach(function (gpu, i) {
const devices = [gpu.render, gpu.card].filter(Boolean).join(', ');
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = gpu.vendor + ' GPU (' + devices + ')';
select.appendChild(opt);
});
select.dataset.gpus = JSON.stringify(gpus);
// Pre-select whichever detected GPU the container being edited is
// already using (matched by device path, not index — gpu_list()'s
// order isn't guaranteed stable across requests).
if (prefill.gpuDevices && prefill.gpuDevices.length) {
const matchIndex = gpus.findIndex(function (gpu) {
return prefill.gpuDevices.indexOf(gpu.render) !== -1 || prefill.gpuDevices.indexOf(gpu.card) !== -1;
});
if (matchIndex !== -1) select.value = String(matchIndex);
}
}).catch(function () { /* GPU passthrough stays unavailable */ });
backdrop.querySelector('#cc-image').focus();
backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) {
@@ -407,6 +723,13 @@
}
function submit() {
if (editing && !confirm(
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?'
)) {
return;
}
const image = backdrop.querySelector('#cc-image').value.trim();
if (!image) {
showError('"Image" is required.');
@@ -420,7 +743,17 @@
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 networkSelect = backdrop.querySelector('#cc-network');
const selectedNetworkOption = networkSelect.options[networkSelect.selectedIndex];
const isMacvlan = !!(selectedNetworkOption && selectedNetworkOption.dataset.driver === 'macvlan');
// Port mappings map a host port to a container port through NAT —
// meaningless on a macvlan network, where the container already has
// its own real address on the LAN (see updateNetworkFieldsVisibility()
// above, which also hides the UI for this) — so none are sent even
// if some were left over from switching the network dropdown after
// adding a few.
const ports = isMacvlan ? [] : readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const staticIp = isMacvlan ? backdrop.querySelector('#cc-static-ip').value.trim() : '';
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
const env = readRows(envGroup).filter(function (r) { return r.key; });
@@ -433,19 +766,39 @@
const networkMode = backdrop.querySelector('#cc-network').value;
const privileged = backdrop.querySelector('#cc-privileged').checked;
const gpuSelect = backdrop.querySelector('#cc-gpu-select');
const gpus = gpuSelect.dataset.gpus ? JSON.parse(gpuSelect.dataset.gpus) : [];
const selectedGpu = gpuSelect.value !== '' ? gpus[Number(gpuSelect.value)] : null;
const gpuDevices = selectedGpu ? [selectedGpu.render, selectedGpu.card].filter(Boolean) : [];
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('containers', 'create', {
// Editing an existing container: no in-place "modify" API exists
// (see the comment on openEditContainerModal above), so this stops
// and removes the old one first — best-effort stop (it may already
// be stopped) followed by a forced remove — before creating the
// replacement under whatever name is in the form now.
const removeOld = editing
? P.post('containers', 'stop', { id: editing.id }).catch(function () { /* already stopped is fine */ })
.then(function () { return P.post('containers', 'remove', { id: editing.id, force: true }); })
: Promise.resolve();
removeOld.then(function () {
return P.post('containers', 'create', {
image: image,
name: backdrop.querySelector('#cc-name').value.trim(),
networkMode: networkMode,
staticIp: staticIp,
pod: backdrop.querySelector('#cc-pod').value,
ports: ports,
volumes: volumes,
env: env,
restartPolicy: backdrop.querySelector('#cc-restart').value,
gpuDevices: gpuDevices,
privileged: privileged,
startAfterCreate: backdrop.querySelector('#cc-start').checked,
});
}).then(function () {
// Best-effort: a template-save failure shouldn't undo or block
// the container that was just successfully created.
@@ -469,7 +822,7 @@
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
showError((editing ? 'The old container may already be removed. ' : '') + err.message);
});
}
@@ -523,11 +876,23 @@
if (!btn || btn.disabled) return;
const row = btn.closest('tr');
const id = row.dataset.id;
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details') {
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') {
const c = allContainers.find(function (x) { return x.id === id; });
if (!c) return;
if (btn.dataset.action === 'menu') {
openRowMenu(c, btn);
} else if (btn.dataset.action === 'update') {
if (!confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.')) return;
btn.disabled = true;
const modal = P.openLogModal('Updating ' + c.name);
updateContainer(c, modal.log).then(function () {
modal.done();
return load();
}).catch(function (err) {
modal.log('Failed: ' + err.message);
modal.done();
btn.disabled = false;
});
} else {
openDetailModal(c);
}
@@ -536,6 +901,9 @@
handleAction(id, btn.dataset.action, btn);
});
P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates);
P.el('containers-update-all-btn').addEventListener('click', updateAll);
return load();
}
+55 -3
View File
@@ -18,8 +18,10 @@
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
'<td class="tnum">' + created + '</td>' +
'<td class="tnum">' + img.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row">' +
'<button class="podman-btn podman-btn-icon" data-action="tag" title="Add tag">&#127991;</button>' +
'<button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>';
}
@@ -55,16 +57,66 @@
});
});
P.el('images-prune-btn').addEventListener('click', function () {
// Computed client-side from the list already on screen — no extra
// round trip needed, and it lets the confirm() be specific instead
// of a generic warning. "Unused" here matches libpod's own
// definition (zero containers, running or stopped, referencing the
// image) — the same "Used By" count already shown in the table, not
// just dangling/untagged images. Found live that this can be far
// more aggressive than expected: with no containers at all, it
// removes every image on the host.
const unused = images.filter(function (img) { return img.usedBy === 0; });
if (!unused.length) {
alert('No unused images to remove — every image is referenced by at least one container.');
return;
}
const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
if (!confirm(
'Remove ' + unused.length + ' image(s) not used by any container (' + P.formatBytes(totalBytes) + ')?\n\n' +
'This removes any tagged image with zero containers using it, not just dangling ones.'
)) return;
const btn = this;
btn.disabled = true;
P.post('images', 'prune').then(function (result) {
btn.disabled = false;
alert('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.');
return load();
}).catch(function (err) {
btn.disabled = false;
alert('Prune failed: ' + err.message);
});
});
P.el('images-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const id = btn.closest('tr').dataset.id;
if (btn.dataset.action === 'tag') {
P.openFormModal({
title: 'Add Tag',
submitLabel: 'Add tag',
fields: [
{ name: 'repo', label: 'Repository', required: true, placeholder: 'my-registry.local/my-image' },
{ name: 'tag', label: 'Tag', placeholder: 'latest' },
],
onSubmit: function (values) {
return P.post('images', 'tag', { id: id, repo: values.repo, tag: values.tag || 'latest' }).then(load);
},
});
return;
}
if (btn.dataset.action === 'remove') {
if (!confirm('Remove this image?')) return;
btn.disabled = true;
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
btn.disabled = false;
});
}
});
return load();
+114 -15
View File
@@ -20,8 +20,8 @@
'<td class="mono">' + P.escapeHtml(n.subnet || '&mdash;') + '</td>' +
'<td class="mono">' + P.escapeHtml(n.gateway || '&mdash;') + '</td>' +
'<td class="tnum">' + n.containers + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></div></td>' +
'</tr>';
}
@@ -43,20 +43,119 @@
});
}
// Purpose-built modal (not app.js's generic openFormModal, which only
// supports flat always-visible text fields) — the parent-interface
// dropdown and gateway field only make sense for "macvlan" and need to
// show/hide based on the driver choice.
function openCreateNetworkModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Network</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Network name</label>' +
'<input type="text" id="cn-name" placeholder="my-network"></div>' +
'<div class="podman-modal-field"><label>Type</label>' +
'<select id="cn-driver">' +
'<option value="bridge">Bridge (isolated, NAT — default)</option>' +
'<option value="macvlan">Macvlan (containers get a real IP on your LAN)</option>' +
'</select></div>' +
'<div class="podman-modal-field" id="cn-parent-field" style="display:none;">' +
'<label>Parent interface</label><select id="cn-parent"></select>' +
'<div class="hint">Same interface Docker Manager\'s "Custom: br0"-style networks use.</div></div>' +
'<div class="podman-modal-field"><label id="cn-subnet-label">Subnet (optional)</label>' +
'<input type="text" class="mono" id="cn-subnet" placeholder="10.89.2.0/24"></div>' +
'<div class="podman-modal-field" id="cn-gateway-field" style="display:none;">' +
'<label>Gateway</label><input type="text" class="mono" id="cn-gateway" placeholder="10.1.1.1"></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
let parentInterfaces = [];
P.get('networks', 'list_parent_interfaces').then(function (interfaces) {
parentInterfaces = interfaces;
const select = backdrop.querySelector('#cn-parent');
select.innerHTML = interfaces.map(function (i) {
return '<option value="' + P.escapeHtml(i.interface) + '">' + P.escapeHtml(i.label) + '</option>';
}).join('');
}).catch(function () { /* macvlan option just won't have anything to pick if this fails */ });
backdrop.querySelector('#cn-driver').addEventListener('change', function (e) {
const isMacvlan = e.target.value === 'macvlan';
backdrop.querySelector('#cn-parent-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cn-gateway-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cn-subnet-label').textContent = isMacvlan ? 'Subnet' : 'Subnet (optional)';
});
backdrop.querySelector('#cn-name').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const name = backdrop.querySelector('#cn-name').value.trim();
if (!name) {
showError('"Network name" is required.');
return;
}
const driver = backdrop.querySelector('#cn-driver').value;
const subnet = backdrop.querySelector('#cn-subnet').value.trim();
const gateway = backdrop.querySelector('#cn-gateway').value.trim();
const parentInterface = backdrop.querySelector('#cn-parent').value;
if (driver === 'macvlan') {
if (!subnet) {
showError('"Subnet" is required for a macvlan network.');
return;
}
if (!parentInterfaces.length) {
showError('No host bridge/VLAN interface available to attach to.');
return;
}
}
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('networks', 'create', {
name: name,
driver: driver,
subnet: subnet || undefined,
gateway: gateway || undefined,
parentInterface: driver === 'macvlan' ? parentInterface : undefined,
}).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function init() {
P.el('networks-create-btn').addEventListener('click', function () {
P.openFormModal({
title: 'New Network',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Network name', required: true, placeholder: 'my-network' },
{ name: 'subnet', label: 'Subnet (optional)', placeholder: '10.89.2.0/24' },
],
onSubmit: function (values) {
return P.post('networks', 'create', { name: values.name, driver: 'bridge', subnet: values.subnet || undefined }).then(load);
},
});
});
P.el('networks-create-btn').addEventListener('click', openCreateNetworkModal);
P.el('networks-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
+157 -6
View File
@@ -8,6 +8,107 @@
(function () {
'use strict';
const P = window.Podman;
let allPods = [];
function portRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
'<span>&rarr;</span>' +
'<input type="text" class="mono podman-input-narrow" data-field="containerPort" placeholder="Container port">' +
'<select data-field="protocol"><option value="tcp">TCP</option><option value="udp">UDP</option></select>' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function addRow(groupEl) {
const div = document.createElement('div');
div.innerHTML = portRowHtml();
const row = div.firstElementChild;
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
groupEl.appendChild(row);
}
function readRows(groupEl) {
return Array.from(groupEl.children).map(function (row) {
const values = {};
row.querySelectorAll('[data-field]').forEach(function (input) {
values[input.dataset.field] = input.value.trim();
});
return values;
});
}
function openCreatePodModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Pod</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Name</label>' +
'<input type="text" id="cp-name" placeholder="my-pod">' +
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
'<div class="podman-modal-field"><label>Port mappings</label>' +
'<div class="podman-row-group" id="cp-ports"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
'<div class="hint">Shared by every container later added to this pod.</div></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const portsGroup = backdrop.querySelector('#cp-ports');
addRow(portsGroup);
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup); });
backdrop.querySelector('#cp-name').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const name = backdrop.querySelector('#cp-name').value.trim();
if (!name) {
showError('"Name" is required.');
return;
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
return;
}
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('pods', 'create', { name: name, ports: ports }).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function memberRow(m) {
return '' +
@@ -24,27 +125,77 @@
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
return '' +
'<div class="podman-pod-card">' +
'<div class="podman-pod-card" data-name="' + P.escapeHtml(pod.name) + '">' +
'<div class="podman-pod-head">' +
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
'<button type="button" class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>' +
'</div>' +
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
'<tbody>' + members + '</tbody></table></div>' +
'</div>';
}
function render() {
const grid = P.el('pods-grid');
grid.innerHTML = allPods.length
? allPods.map(podCard).join('')
: '<div class="podman-empty-note">No pods yet — create one, or run a container with a "pod" set from the Create Container form.</div>';
}
function load() {
const container = P.el('podman-panel-pods');
return P.get('pods', 'list').then(function (pods) {
container.innerHTML = pods.length
? pods.map(podCard).join('')
: '<div class="podman-card"><div class="podman-empty-note">No pods yet.</div></div>';
if (!P.el('pods-grid')) {
container.innerHTML = '' +
'<div class="podman-card">' +
'<div class="podman-toolbar">' +
'<strong style="flex:1;">Group containers sharing network/storage namespaces</strong>' +
'<button class="podman-btn podman-btn-primary" id="pods-create-btn">+ New Pod</button>' +
'</div>' +
'<div id="pods-grid"></div>' +
'</div>';
P.el('pods-create-btn').addEventListener('click', openCreatePodModal);
P.el('pods-grid').addEventListener('click', handleCardClick);
}
return P.get('pods', 'list').then(function (data) {
allPods = data;
render();
}).catch(function (err) {
container.innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
P.el('pods-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function handleAction(name, action, extra) {
return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) {
alert('Action failed: ' + err.message);
});
}
function handleCardClick(e) {
const btn = e.target.closest('button[data-action="menu"]');
if (!btn) return;
const pod = allPods.find(function (p) { return p.name === btn.closest('.podman-pod-card').dataset.name; });
if (!pod) return;
const items = [];
if (pod.status === 'running') {
items.push({ label: 'Stop', onClick: function () { handleAction(pod.name, 'stop', { timeout: 10 }); } });
items.push({ label: 'Restart', onClick: function () { handleAction(pod.name, 'restart', { timeout: 10 }); } });
} else {
items.push({ label: 'Start', onClick: function () { handleAction(pod.name, 'start'); } });
}
items.push('separator');
items.push({
label: 'Remove',
danger: true,
onClick: function () {
if (!confirm('Remove pod "' + pod.name + '" and all its member containers?')) return;
handleAction(pod.name, 'remove', { force: true });
},
});
P.openContextMenu(btn, items);
}
P.registerPanel('pods', { init: load, refresh: load });
})();
+8 -5
View File
@@ -17,11 +17,11 @@
return '<tr data-index="' + i + '">' +
'<td class="tnum">' + (i + 1) + '</td>' +
'<td>' + P.escapeHtml(name) + '</td>' +
'<td class="podman-actions">' +
'<td class="podman-actions"><div class="podman-actions-row">' +
'<button class="podman-btn podman-btn-icon" data-action="up"' + (i === 0 ? ' disabled' : '') + ' title="Move up">&#8593;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">&#8595;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">&#128465;</button>' +
'</td></tr>';
'</div></td></tr>';
}).join('')
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
}
@@ -43,9 +43,12 @@
const versions = settings.packageVersions || {};
const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS'];
P.el('settings-package-versions').textContent = order
.map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); })
.join(' · ');
const chipsHtml = order.map(function (k) {
const name = k.toLowerCase().replace(/_/g, '-');
const version = versions[k + '_INSTALLED_VERSION'];
return '<span class="podman-version-chip">' + P.escapeHtml(name) + ' <b>' + P.escapeHtml(version || '?') + '</b></span>';
}).join('');
P.el('settings-package-versions').innerHTML = chipsHtml || '<span class="podman-empty-note">No version manifest found.</span>';
}
function load() {
@@ -29,7 +29,6 @@
'<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">' +
+72 -64
View File
@@ -1,89 +1,97 @@
/**
* javascript/terminal.js
*
* Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that
* file's header comment for the full, honest explanation of why this is
* "type a command, see its output" rather than a true interactive PTY —
* the short version is that libpod's interactive exec needs a persistent
* bidirectional connection this PHP/AJAX stack doesn't have, and faking
* interactivity on top of that would break the moment a user ran anything
* that expects a real terminal (vim, an interactive prompt, etc).
* Terminal panel: opens a real, fully interactive terminal inline (as an
* <iframe>, not a popup window) — the same mechanism Unraid's own webGui
* uses for its System Terminal and for `docker exec` (see ajax/exec.php's
* header comment for the full explanation). This module's own job is just:
*
* `cd` is handled client-side: this module tracks a per-session `cwd` and
* passes it as the exec's working directory on every call, so at least
* directory navigation feels persistent even though nothing else is.
* 1. Ask ajax/exec.php to spawn a ttyd instance wrapping
* `podman exec -it <container> <shell>`, bound to a unix socket.
* 2. Point an <iframe> at /logterminal/<sockName>/ — nginx's own
* "logterminal" location block (already installed system-wide by
* Unraid, not something this plugin configures) proxies that,
* WebSocket upgrade included, straight to ttyd's socket.
* 3. Track which container's session (if any) is currently open, so
* "Disconnect" — or opening a different container/shell — can kill
* the right ttyd process server-side instead of just discarding the
* iframe and leaving it running.
*/
(function () {
'use strict';
const P = window.Podman;
let cwd = '/';
let containerId = null;
let openName = null;
function appendLine(html) {
const out = P.el('term-output');
const div = document.createElement('div');
div.innerHTML = html;
out.appendChild(div);
out.scrollTop = out.scrollHeight;
function populateContainerSelect(list) {
const select = P.el('term-container-select');
if (!select) return;
const running = list.filter(function (c) { return c.state === 'running'; });
select.innerHTML = running
.map(function (c) { return '<option value="' + P.escapeHtml(c.name) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('') || '<option value="">No running containers</option>';
}
function promptHtml() {
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$';
function loadContainers() {
return P.get('containers', 'list').then(populateContainerSelect);
}
function runCommand(cmd) {
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd));
// `cd <dir>` is intercepted client-side (see file header) rather than
// sent as a real command, since a one-shot exec has no way to report
// "the working directory changed" back to us otherwise.
const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/);
if (cdMatch) {
cwd = cdMatch[1].startsWith('/') ? cdMatch[1] : (cwd.replace(/\/$/, '') + '/' + cdMatch[1]);
return Promise.resolve();
function resetFrame(message) {
P.el('term-frame-wrap').innerHTML = '<p class="podman-empty-note">' + message + '</p>';
P.el('term-disconnect-btn').disabled = true;
openName = null;
}
return P.post('exec', 'run', { id: containerId, cmd: cmd, cwd: cwd }).then(function (data) {
if (data.output) appendLine('<span class="mono">' + P.escapeHtml(data.output).replace(/\n/g, '<br>') + '</span>');
/** Best-effort: tells the backend to kill the ttyd/podman-exec session, if any is open. Never rejects. */
function closeCurrent() {
if (!openName) return Promise.resolve();
const name = openName;
return P.post('exec', 'close', { name: name }).catch(function () {});
}
function openLiveTerminal() {
const name = P.el('term-container-select').value;
if (!name) return;
const shell = P.el('term-shell-select').value;
const wrap = P.el('term-frame-wrap');
const btn = P.el('term-open-btn');
wrap.innerHTML = '<p class="podman-empty-note">Opening terminal…</p>';
btn.disabled = true;
closeCurrent().then(function () {
return P.post('exec', 'open', { name: name, shell: shell });
}).then(function (data) {
openName = name;
P.el('term-disconnect-btn').disabled = false;
// Matches the ~200ms delay Unraid's own openTerminal() uses between
// asking the backend to spawn ttyd and navigating to its socket —
// ttyd needs a brief moment to bind before nginx can proxy to it.
setTimeout(function () {
wrap.innerHTML = '<iframe class="podman-term-frame" src="/logterminal/' + encodeURIComponent(data.sockName) + '/"></iframe>';
}, 200);
}).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
resetFrame('Could not open terminal: ' + P.escapeHtml(err.message));
}).finally(function () {
btn.disabled = false;
});
}
function populateContainerSelect(containers) {
const select = P.el('term-container-select');
select.innerHTML = containers
.filter(function (c) { return c.state === 'running'; })
.map(function (c) { return '<option value="' + P.escapeHtml(c.id) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('');
containerId = select.value || null;
function disconnect() {
if (!openName) return;
const btn = P.el('term-disconnect-btn');
btn.disabled = true;
closeCurrent().finally(function () {
resetFrame('Disconnected. Pick a container and click "Open Terminal" to start a new session.');
});
}
function init() {
const input = P.el('term-input');
P.el('term-container-select').addEventListener('change', function (e) {
containerId = e.target.value;
cwd = '/';
P.el('term-output').innerHTML = '';
});
input.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
const cmd = input.value;
input.value = '';
if (!containerId) {
appendLine('<span style="color:#ef6470;">No running container selected.</span>');
return;
}
if (cmd.trim() === '') return;
runCommand(cmd);
});
return P.get('containers', 'list').then(populateContainerSelect).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
});
P.el('term-open-btn').addEventListener('click', openLiveTerminal);
P.el('term-disconnect-btn').addEventListener('click', disconnect);
return loadContainers();
}
P.registerPanel('terminal', { init: init });
// refresh() only repopulates the container select — it must never touch
// #term-frame-wrap, or an already-open terminal would be torn down out
// from under the user just by switching tabs and back.
P.registerPanel('terminal', { init: init, refresh: loadContainers });
})();
+2 -2
View File
@@ -24,8 +24,8 @@
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
'<td class="mono podman-row-sub">' + pathCell + '</td>' +
'<td class="tnum">' + v.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>';
}
+194 -25
View File
@@ -20,7 +20,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20, 22, 26, .06), 0 4px 12px rgba(20, 22, 26, .05);
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace;
@@ -35,7 +35,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
}
@@ -44,7 +44,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
:root[data-theme="light"] .podman-plugin {
@@ -52,7 +52,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05);
}
@@ -76,11 +76,20 @@
.podman-pagehead .meta .dot-good { color: var(--good); }
.podman-pagehead .meta .dot-bad { color: var(--bad); }
/*
* margin: 0 Unraid's own webGui theme applies a 10px top/bottom margin
* to plain <button> elements site-wide. Without resetting it, every
* .podman-btn carries an invisible 10px gap above and below its own box,
* which silently breaks flex cross-axis alignment anywhere a button sits
* next to a non-button sibling (e.g. align-items: flex-end next to a
* <select> verified live: the button's margin, not its content, was
* what left it floating 10px above the dropdown it should line up with).
*/
.podman-btn {
appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text);
padding: 8px 14px; border-radius: 7px; font-size: 13px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; transition: border-color .12s, background .12s;
font-family: var(--font-ui);
font-family: var(--font-ui); margin: 0;
}
.podman-btn:hover { border-color: var(--text-faint); }
/*
@@ -100,7 +109,7 @@
}
.podman-btn-danger { color: var(--bad); }
.podman-btn-danger:hover { border-color: var(--bad); }
.podman-btn-icon { padding: 6px 8px; }
.podman-btn-icon { padding: 6px 8px; min-width: 32px; min-height: 32px; justify-content: center; font-size: 15px; line-height: 1; }
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
/*
* Secondary action (Cancel, "+ Add row") every button previously shared
@@ -123,11 +132,31 @@
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; }
/*
* A ghost button can still carry danger intent (Disconnect, Delete,
* template "Delete") needs its own !important since .podman-btn-ghost's
* color/background/border would otherwise win by rule order. Solid fill
* at rest (not just a tint, and not just on hover) so it reads with the
* same weight as .podman-btn-primary, just in red instead of accent
* a merely tinted/outlined button still read as "just another secondary
* action" per live feedback.
*/
.podman-btn-ghost.podman-btn-danger {
color: var(--bad-contrast) !important; background: var(--bad) !important; border-color: var(--bad) !important;
}
.podman-btn-ghost.podman-btn-danger:hover {
background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; color: var(--bad-contrast) !important;
}
.podman-btn-ghost.podman-btn-danger[disabled] {
color: var(--text-faint) !important; background: transparent !important; border-color: var(--border) !important;
}
/* Icon-only danger buttons (row "remove" trash icons) carry an emoji
glyph, not text .podman-btn-danger's `color` alone doesn't recolor an
emoji, so these get the same solid red fill instead, at rest not just
on hover, so "destructive" reads at a glance across a whole table. */
.podman-btn-icon.podman-btn-danger { border-color: var(--bad) !important; background: var(--bad) !important; }
.podman-btn-icon.podman-btn-danger:hover { background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; }
.podman-btn-icon.podman-btn-danger[disabled] { border-color: var(--border) !important; background: transparent !important; }
.podman-subnav {
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
@@ -185,7 +214,12 @@
.podman-table-wrap { overflow-x: auto; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
.podman-row-name-btn {
appearance: none; border: none; background: none; padding: 0; cursor: pointer;
/* !important for the same reason as .podman-btn-ghost/-primary Unraid's
own site-wide button theme otherwise still shows its default border
at rest (only losing to plain rules on hover), so a name link one
click away from every table row still looked like a bordered button
forever, not a plain label. */
appearance: none; border: none !important; background: none !important; padding: 0; cursor: pointer;
color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left;
}
.podman-row-name-btn:hover { color: var(--accent-strong); }
@@ -195,7 +229,38 @@
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
}
.podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; }
.podman-actions { display: flex; gap: 4px; justify-content: flex-end; }
/*
* The actions <td> itself stays a plain table-cell (default display) so
* every row's column width is computed the same way by the table's layout
* algorithm putting "display: flex" directly on the <td> used to take it
* out of that algorithm, so browsers could size/position it slightly
* differently row to row (found live: the trash-can button in Images drifted
* a few pixels between rows instead of lining up in one column). The actual
* flex/gap/alignment lives on this inner wrapper instead.
*/
.podman-actions { text-align: right; white-space: nowrap; }
.podman-actions-row { display: inline-flex; gap: 4px; justify-content: flex-end; }
/*
* Segmented toggle (Containers' All/Running/Stopped filter, Logs' Follow/
* Paused) previously just an inline-styled wrapper <div> around plain
* <button>s with no CSS of their own at all, so every option (not just the
* active one) showed Unraid's own default button border permanently,
* all three chips looking identically "selected". !important for the same
* site-wide-theme-override reason as .podman-btn-ghost/-primary.
*/
.podman-segmented { display: flex; gap: 2px; background: var(--surface-3); border: 1px solid var(--text-faint); padding: 3px; border-radius: 8px; }
.podman-segmented button {
appearance: none; border: none !important; background: transparent !important; color: var(--text-dim) !important;
padding: 6px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer;
font-family: var(--font-ui); transition: background .12s, color .12s;
}
.podman-segmented button:hover { color: var(--text) !important; }
/* Filled with the accent color (not just a slightly different neutral
shade) the previous var(--surface) vs. var(--surface-2) contrast
between active/inactive was too close in the dark theme to notice at a
glance (found live). */
.podman-segmented button.active { background: var(--accent) !important; color: var(--accent-contrast) !important; box-shadow: var(--shadow); }
.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
@@ -203,7 +268,20 @@
.podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; }
.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.podman-search { flex: 1; min-width: 180px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 11px; font-size: 13px; color: var(--text); font-family: var(--font-ui); }
/*
* !important throughout: Unraid's own webGui/styles/default-base.css
* targets input[type="text"] with an attribute selector (higher
* specificity than our single .podman-search class, :where() around it
* notwithstanding) forcing border-width:0 / border-bottom-width:1px /
* background:transparent an underline-only text field, not a boxed one.
* Found live: our border/background were being silently dropped even
* though this rule appears later in the stylesheet.
*/
.podman-search {
flex: 1; min-width: 180px; max-width: 320px; font-size: 13px; color: var(--text); font-family: var(--font-ui);
background: var(--surface-3) !important; border: 1px solid var(--text-faint) !important;
border-radius: 7px !important; padding: 7px 11px !important;
}
.podman-search::placeholder { color: var(--text-faint); }
.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; }
@@ -220,7 +298,7 @@
.podman-pod-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 14px; background: var(--surface); box-shadow: var(--shadow); }
.podman-pod-head { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); margin-right: auto; }
.podman-badge { display: inline-block; font-size: 10.5px; font-weight: 700; color: var(--text-dim); background: var(--surface-3); padding: 2px 8px; border-radius: 100px; margin-top: 6px; }
@@ -237,8 +315,18 @@
}
.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-template-actions { display: flex; gap: 6px; margin-top: auto; padding-top: 4px; }
/*
* min-width: 0 overrides the flex-item default of min-width: auto, which
* otherwise refuses to shrink a button below its own label's intrinsic
* width without it, "Delete" (the widest label, and uppercased by
* Unraid's own site-wide button theme) pushed past the card's right edge
* instead of actually sharing the row evenly with Use/Export (found live).
*/
.podman-template-actions .podman-btn {
flex: 1; min-width: 0; justify-content: center; padding: 6px 8px; font-size: 11.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.podman-local-template-list { max-height: 220px; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; margin-top: 8px; }
.podman-local-template-item {
@@ -263,13 +351,23 @@
.podman-log-pane .lvl-warn { color: #e0b23d; }
.podman-log-pane .lvl-error { color: #ef6470; }
.podman-term { background: #0f1114; color: #d7dbe0; font-family: var(--font-mono); font-size: 12.6px; border-radius: 8px; padding: 14px 16px; height: 380px; overflow-y: auto; line-height: 1.7; }
.podman-term .prompt { color: #4cc785; }
.podman-term .path { color: #6fb2f5; }
.podman-term-input {
width: 100%; margin-top: 10px; background: #0f1114; color: #d7dbe0; border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; font-family: var(--font-mono); font-size: 12.6px;
.podman-term-launcher {
display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
padding: 12px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
}
.podman-term-launcher label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--text-dim); }
/*
* !important here for the same reason as .podman-search's: Unraid's own
* webGui/styles/default-base.css has `select:where(:not(.unapi *))` rules
* for background/border/padding that would otherwise still show through
* around this class's box-model properties.
*/
.podman-term-select {
min-width: 180px !important; padding: 7px 12px !important; font-size: 13px !important;
font-family: var(--font-mono) !important; color: var(--text) !important;
background: var(--surface-3) !important; border: 1px solid var(--accent) !important; border-radius: 6px !important;
}
.podman-term-frame { display: block; width: 100%; height: 480px; border: 1px solid var(--border); border-radius: 8px; background: #0f1114; }
.podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; }
@media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } }
@@ -278,6 +376,19 @@
.podman-compose-proj.active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); }
.podman-compose-proj .path { font-size: 11px; color: var(--text-faint); margin-top: 2px; font-family: var(--font-mono); }
.podman-yaml { background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.4px; padding: 16px 18px; height: 420px; overflow: auto; line-height: 1.7; white-space: pre-wrap; }
/*
* !important: this is now a real <textarea>, not a read-only <pre>
* Unraid's own webGui/styles/default-base.css targets textarea the same
* way it targets input[type="text"] (see .podman-search's comment for
* the exact rule), forcing border-width:0/border-bottom-width:1px/
* background:transparent/border-radius:0, which would otherwise make the
* whole editor look like a barely-visible underline instead of an actual
* text area.
*/
.podman-yaml-editor {
display: block; width: 100%; box-sizing: border-box; resize: vertical;
border: none !important; border-radius: 0 !important; outline: none;
}
.podman-field-row { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); align-items: start; }
.podman-field-row:last-child { border-bottom: none; }
@@ -288,6 +399,52 @@
font-size: 13px; color: var(--text); width: 100%; max-width: 340px; font-family: var(--font-ui);
}
.podman-danger-card { border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
/* Settings panel: a shared save action above all cards (Storage's and
Autostart & Lifecycle's fields save together in one call see
settings.js's save() so one button belongs above both, not buried in
either card, and definitely not in its own row with an empty label).
Framed as its own small bar (background/border), not bare text+button
floating at the top of the page. */
.podman-settings-actions {
display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px;
padding: 12px 16px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px;
}
.podman-settings-actions .hint { margin: 0; max-width: 52ch; color: var(--text-dim); }
.podman-card-head .sub { margin-top: 3px; }
/* Number field + unit label (GB, seconds) the input itself stays compact
instead of stretching to .podman-field-row's normal 340px text-field width. */
.podman-input-suffix { display: flex; align-items: center; gap: 8px; }
.podman-input-suffix input[type="number"] { max-width: 100px; width: auto; }
.podman-input-suffix span { font-size: 12px; color: var(--text-dim); }
/*
* Toggle switch a plain checkbox reads as a leftover form control next
* to everything else in this panel getting a designed treatment; this
* hides the native checkbox (still the real, accessible input driving
* state) and draws a track+thumb off its :checked state instead. Sized in
* em off the track's own font-size so it scales if that ever changes.
*/
.podman-switch { position: relative; display: inline-flex; align-items: center; cursor: pointer; font-size: 22px; }
.podman-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.podman-switch-track {
display: inline-block; width: 1.9em; height: 1.05em; border-radius: 999px;
background: var(--surface-3); border: 1px solid var(--border); transition: background .15s, border-color .15s;
}
.podman-switch-thumb {
display: block; width: 0.75em; height: 0.75em; margin: 0.13em; border-radius: 50%;
background: var(--text-faint); transition: transform .15s, background .15s;
}
.podman-switch input:checked + .podman-switch-track { background: var(--accent); border-color: var(--accent); }
.podman-switch input:checked + .podman-switch-track .podman-switch-thumb { background: var(--accent-contrast); transform: translateX(0.85em); }
.podman-switch input:focus-visible + .podman-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.podman-version-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.podman-version-chip {
font-size: 11.5px; font-family: var(--font-mono); background: var(--surface-3); color: var(--text-dim);
border: 1px solid var(--border); padding: 5px 11px; border-radius: 100px;
}
.podman-version-chip b { color: var(--text); font-weight: 600; margin-left: 5px; }
.podman-danger-card .podman-card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); }
.podman-danger-card .podman-card-head h2 { color: var(--bad); }
@@ -368,7 +525,18 @@
/* Anchored dropdown context menu — see app.js openContextMenu(). */
.podman-context-menu {
position: absolute; width: 180px; background: var(--surface); border: 1px solid var(--border);
/*
* "fixed", not "absolute": this menu is appended to .podman-plugin, not
* document.body, and Unraid's own page wrapper around .podman-plugin
* turned out to have its own positioned ancestor with "absolute" the
* menu was positioning itself relative to THAT ancestor's box while the
* JS math (getBoundingClientRect + scrollY/X) assumed the viewport,
* so it rendered far from the button that opened it (found live: it
* appeared well below and to the side of the anchor). "fixed" is always
* viewport-relative regardless of any ancestor, which is what the JS
* math actually assumes.
*/
position: fixed; width: 180px; background: var(--surface); border: 1px solid var(--border);
border-radius: 9px; box-shadow: var(--shadow); z-index: 1001; padding: 4px; display: grid; gap: 1px;
}
.podman-context-menu button {
@@ -377,7 +545,8 @@
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.danger { color: var(--bad); background: var(--bad-bg); }
.podman-context-menu button.danger:hover { background: var(--bad); color: var(--bad-contrast); }
.podman-context-menu button[disabled] { opacity: .4; cursor: not-allowed; }
.podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }