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/ # Builds the eleven Slackware .txz packages defined under packages/
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs, # (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
# 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 # container, verifies + consolidates their checksums, and uploads the
# result as a workflow artifact. # 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) | | `passt`/`pasta` | Rootless-Networking | Nachfolger von slirp4netns, Phase 2, aber Paket schon mitbauen (geringe Kosten) |
| `catatonit` oder `tini` | Init-Prozess in Containern (optional, falls von Templates genutzt) | | | `catatonit` oder `tini` | Init-Prozess in Containern (optional, falls von Templates genutzt) | |
| `nftables` | Firewall-Backend für `netavark` | Pflicht seit netavark 2.0 (iptables-Treiber entfernt); Unraid liefert kein `nft` mit — als offizielles Slackware-Paket vendored, nicht selbst gebaut | | `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 ### 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: Structure of this file:
1. DOCTYPE entity block — plugin metadata + one version/file/md5 triple 1. DOCTYPE entity block — plugin metadata + one version/file/md5 triple
per package (the seven upstream components, catatonit/nftables/ 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 project's own "unraid-podman" scaffolding package, see
packages/unraid-podman/). packages/unraid-podman/).
Entities are rewritten automatically by scripts/release.sh; never 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_file "nftables-&nftables_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY nftables_txz_md5 "00000000000000000000000000000000"> <!ENTITY nftables_txz_md5 "00000000000000000000000000000000">
<!-- docker-compose is the external Compose provider `podman compose` <!-- podman-compose is the external Compose provider `podman compose`
shells out to (see packages/docker-compose/README.md) — without it shells out to (see packages/podman-compose/README.md) — without it
every Compose panel action fails outright on a clean install. --> every Compose panel action fails outright on a clean install. -->
<!ENTITY docker_compose_txz_version "0.0.0"> <!ENTITY podman_compose_txz_version "0.0.0">
<!ENTITY docker_compose_txz_file "docker-compose-&docker_compose_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz"> <!ENTITY podman_compose_txz_file "podman-compose-&podman_compose_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY docker_compose_txz_md5 "00000000000000000000000000000000"> <!ENTITY podman_compose_txz_md5 "00000000000000000000000000000000">
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman, <!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
sbin/ scripts, event/ hooks, config templates — see sbin/ scripts, event/ hooks, config templates — see
@@ -181,7 +181,7 @@ fi
<!-- <!--
The seven upstream component packages, plus catatonit, nftables, and 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 block above for why). Each is downloaded straight into
its backup slot under /boot/config/plugins/&name;/backup/packages/&version;/ its backup slot under /boot/config/plugins/&name;/backup/packages/&version;/
(grouped by PLUGIN version, not each component's own version — a rollback (grouped by PLUGIN version, not each component's own version — a rollback
@@ -275,12 +275,12 @@ fi
</MD5> </MD5>
</FILE> </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> <URL>
&baseURL;/&docker_compose_txz_file; &baseURL;/&podman_compose_txz_file;
</URL> </URL>
<MD5> <MD5>
&docker_compose_txz_md5; &podman_compose_txz_md5;
</MD5> </MD5>
</FILE> </FILE>
@@ -345,7 +345,7 @@ echo "PASST_INSTALLED_VERSION=\"&passt_txz_version;\"" >> "$MANIFEST"
echo "FUSE_OVERLAYFS_INSTALLED_VERSION=\"&fuse_overlayfs_txz_version;\"" >> "$MANIFEST" echo "FUSE_OVERLAYFS_INSTALLED_VERSION=\"&fuse_overlayfs_txz_version;\"" >> "$MANIFEST"
echo "CATATONIT_INSTALLED_VERSION=\"&catatonit_txz_version;\"" >> "$MANIFEST" echo "CATATONIT_INSTALLED_VERSION=\"&catatonit_txz_version;\"" >> "$MANIFEST"
echo "NFTABLES_INSTALLED_VERSION=\"&nftables_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 "UNRAID_PODMAN_INSTALLED_VERSION=\"&unraid_podman_txz_version;\"" >> "$MANIFEST"
echo "Seeding /boot/config/plugins/podman/ configuration (existing files left untouched)..." echo "Seeding /boot/config/plugins/podman/ configuration (existing files left untouched)..."
@@ -408,7 +408,7 @@ removepkg &passt_txz_file;
removepkg &fuse_overlayfs_txz_file; removepkg &fuse_overlayfs_txz_file;
removepkg &catatonit_txz_file; removepkg &catatonit_txz_file;
removepkg &nftables_txz_file; removepkg &nftables_txz_file;
removepkg &docker_compose_txz_file; removepkg &podman_compose_txz_file;
removepkg &unraid_podman_txz_file; removepkg &unraid_podman_txz_file;
echo "" echo ""
+1 -1
View File
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh" . "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env" INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs 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 if [ ! -f "$INSTALLED_VERSIONS_FILE" ]; then
podman_log_error "update-packages: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found" podman_log_error "update-packages: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found"
+1 -1
View File
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh" . "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env" INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables docker-compose unraid-podman" PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
QUIET=0 QUIET=0
[ "${1:-}" = "--quiet" ] && QUIET=1 [ "${1:-}" = "--quiet" ] && QUIET=1
+8 -6
View File
@@ -4,7 +4,7 @@
# #
# Orchestrates building all Slackware .txz packages this plugin ships # Orchestrates building all Slackware .txz packages this plugin ships
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs, # (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 # <name>.SlackBuild in turn. See docs/ARCHITECTURE.md section 5.2
# (Build-Strategie). # (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 # see packages/unraid-podman/README.md) is built last since it's by far the
# fastest and has nothing useful to report on failure that earlier package # fastest and has nothing useful to report on failure that earlier package
# failures wouldn't already explain. # failures wouldn't already explain.
ALL_PACKAGES=(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 # catatonit, nftables, and podman-compose are listed first since none of
# all a plain fetch-and-repackage of an already-built upstream artifact # them involve a compiler — catatonit/nftables are a plain
# (see their own README.md for why) — no compiler, fastest possible # fetch-and-repackage of an already-built upstream artifact, and
# signal if their pinned URL/checksum in versions.env ever goes stale. # 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 # 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 # components it is the slowest build and the one most likely to fail on
# a dependency/tag mistake, so faster packages surface problems first # 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 # Component name -> the entity name prefix used in podman.plg. Must match
# plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly. # plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly.
# 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) # dependencies (not built from source, see their own packages/*/README.md)
# and unraid-podman is the plugin's own scaffolding package (see # and unraid-podman is the plugin's own scaffolding package (see
# packages/unraid-podman/README.md), not an upstream component, but all # packages/unraid-podman/README.md), not an upstream component, but all
# four are released and entity-updated exactly like the seven upstream ones. # 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)" 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_URL="http://slackware.osuosl.org/slackware64-15.0/slackware64/n/nftables-${NFTABLES_VERSION}-x86_64-1.txz"
NFTABLES_SRC_SHA256="239e70d48edd6667ce875ff0d339b6f63c1fc94c472524d58772310b1006d31c" NFTABLES_SRC_SHA256="239e70d48edd6667ce875ff0d339b6f63c1fc94c472524d58772310b1006d31c"
# --- docker-compose ---------------------------------------------------------- # --- podman-compose -----------------------------------------------------------
# https://github.com/docker/compose — the Compose v2 CLI-plugin binary # https://github.com/containers/podman-compose — `podman compose` (backing
# (Go, not the older Python podman-compose). `podman compose` (backing
# webui/plugins/podman/ajax/compose.php, the WebUI's Compose panel) has no # webui/plugins/podman/ajax/compose.php, the WebUI's Compose panel) has no
# compose implementation of its own it searches a fixed set of # compose implementation of its own; it needs an external "compose
# CLI-plugin directories for a binary named exactly "docker-compose" and # provider" command. This project previously vendored docker/compose (the
# shells out to it. Without one present, every Compose panel action fails # Go CLI-plugin binary) for that role, found by podman searching a fixed
# outright (found by live-testing: it only worked on the test host because # set of CLI-plugin directories for a binary named exactly
# that host happened to already have Docker's own compose plugin # "docker-compose". podman-compose is looked up differently — verified
# installed from an unrelated, pre-existing Docker setup — a clean Unraid # live (placing a fake executable and watching podman's own error output
# install has none). Upstream publishes a prebuilt static x86_64 binary # list its search order) that it's found as a plain command on $PATH,
# release asset (verified via `ldd`: "not a dynamic executable") plus a # not from those same CLI-plugin directories — so it's installed as
# matching .sha256 sidecar — no from-source build needed. # /usr/local/bin/podman-compose, not under any cli-plugins/ path.
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" # Unlike docker-compose, podman-compose is a single Python script, not a
DOCKER_COMPOSE_SRC_SHA256="f9ebc6ebdb19d769b793c245a736caaeb198c62587f13b25c660c13b4987f959" # 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 # Slackware package BUILD number (not upstream version). Bump this if a
+71 -31
View File
@@ -1,4 +1,6 @@
Menu="Podman" Menu="Tasks:66"
Type="xmenu"
Tabs="false"
Title="Podman" Title="Podman"
Icon="podman" Icon="podman"
--- ---
@@ -84,11 +86,13 @@ function podman_asset_version(string $relPath): string
<div class="podman-card"> <div class="podman-card">
<div class="podman-toolbar"> <div class="podman-toolbar">
<input class="podman-search" id="containers-search" type="text" placeholder="Search containers by name or image…"> <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 class="active" data-filter="all" id="containers-count-all">All</button>
<button data-filter="running" id="containers-count-running">Running</button> <button data-filter="running" id="containers-count-running">Running</button>
<button data-filter="stopped" id="containers-count-stopped">Stopped</button> <button data-filter="stopped" id="containers-count-stopped">Stopped</button>
</div> </div>
<button class="podman-btn podman-btn-primary" id="containers-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> <button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
</div> </div>
<div class="podman-table-wrap"> <div class="podman-table-wrap">
@@ -111,6 +115,7 @@ function podman_asset_version(string $relPath): string
<div class="podman-card"> <div class="podman-card">
<div class="podman-toolbar"> <div class="podman-toolbar">
<input class="podman-search" type="text" placeholder="Search images…" disabled title="Client-side filtering not yet wired up for Images"> <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> <button class="podman-btn" id="images-pull-btn">&#11015; Pull Image</button>
</div> </div>
<div class="podman-table-wrap"> <div class="podman-table-wrap">
@@ -165,7 +170,7 @@ function podman_asset_version(string $relPath): string
<div> <div>
<div class="podman-toolbar"> <div class="podman-toolbar">
<input class="podman-search" id="logs-filter" type="text" placeholder="Filter log output…" style="max-width:280px;"> <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 class="active" data-follow="true">Follow</button>
<button data-follow="false">Paused</button> <button data-follow="false">Paused</button>
</span> </span>
@@ -179,12 +184,22 @@ function podman_asset_version(string $relPath): string
<!-- ============================= TERMINAL ============================= --> <!-- ============================= TERMINAL ============================= -->
<section class="podman-panel" id="podman-panel-terminal"> <section class="podman-panel" id="podman-panel-terminal">
<div class="podman-card"> <div class="podman-card">
<div class="podman-card-head"><h2>Live Terminal</h2></div>
<div class="podman-card-pad"> <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);"> <div class="podman-term-launcher">
Exec into: <select id="term-container-select"></select> <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>
<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>
</div> </div>
</section> </section>
@@ -193,15 +208,22 @@ function podman_asset_version(string $relPath): string
<section class="podman-panel" id="podman-panel-compose"> <section class="podman-panel" id="podman-panel-compose">
<div class="podman-card"> <div class="podman-card">
<div class="podman-compose-layout"> <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>
<div class="podman-toolbar"> <div class="podman-toolbar">
<strong id="compose-title" style="flex:1;">—</strong> <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-pull">&#11015; Pull</button>
<button class="podman-btn" id="compose-action-down">&#9632; Down</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-up">&#9654; Up</button>
<button class="podman-btn podman-btn-primary" id="compose-action-save">Save</button>
</div> </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> </div>
</div> </div>
@@ -209,9 +231,15 @@ function podman_asset_version(string $relPath): string
<!-- ============================= SETTINGS ============================= --> <!-- ============================= SETTINGS ============================= -->
<section class="podman-panel" id="podman-panel-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-grid">
<div class="podman-card"> <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"> <div class="podman-field-row">
<label for="settings-storage-path">Storage path</label> <label for="settings-storage-path">Storage path</label>
<div> <div>
@@ -221,40 +249,52 @@ function podman_asset_version(string $relPath): string
</div> </div>
<div class="podman-field-row"> <div class="podman-field-row">
<label for="settings-storage-size">podman.img size</label> <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> </div>
<div class="podman-card"> <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"> <div class="podman-field-row">
<label for="settings-enabled">Start podman on array start</label> <label for="settings-enabled">Start podman on array start</label>
<div><input type="checkbox" id="settings-enabled"></div> <div>
</div> <label class="podman-switch">
<div class="podman-field-row"> <input type="checkbox" id="settings-enabled"><span class="podman-switch-track"><span class="podman-switch-thumb"></span></span>
<label for="settings-stop-timeout">Container stop timeout</label> </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-field-row">
<label>Autostart order</label>
<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>
</div> </div>
<div class="podman-field-row"> <div class="podman-field-row">
<label></label> <label for="settings-stop-timeout">Container stop timeout</label>
<div><button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button></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> </div>
</div> </div>
<div class="podman-card"> <div class="podman-card">
<div class="podman-card-head"><h2>Installed Packages</h2></div> <div class="podman-card-head">
<div class="podman-field-row"> <div><h2>Installed Packages</h2><div class="sub">Versions currently installed on this system.</div></div>
<label>Versions</label> </div>
<div class="hint mono" id="settings-package-versions" style="max-width:none;">—</div> <div class="podman-card-pad">
<div class="podman-version-chips" id="settings-package-versions">—</div>
</div> </div>
</div> </div>
</div> </div>
+94 -6
View File
@@ -27,6 +27,8 @@
* Actions (?action=...): * Actions (?action=...):
* list GET -> known projects with up/down status * list GET -> known projects with up/down status
* get GET (&project=...) -> raw compose.yaml content * 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": "..."} * up POST {"project": "..."}
* down POST {"project": "..."} * down POST {"project": "..."}
* pull POST {"project": "..."} * pull POST {"project": "..."}
@@ -49,6 +51,15 @@ switch ($action) {
podman_json_response(['yaml' => compose_read($composeDir, $project)]); podman_json_response(['yaml' => compose_read($composeDir, $project)]);
break; 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': case 'up':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d'])); podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d']));
break; break;
@@ -136,6 +147,79 @@ function compose_status(string $composeDir, string $project): string
return $running > 0 ? 'up' : 'down'; 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 function compose_read(string $composeDir, string $project): string
{ {
if (!is_valid_project_name($project)) { 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 * @param array<int,string> $subcommand
* @return array{exitCode:int,stdout:string,output:string} * @return array{exitCode:int,stdout:string,output:string}
*/ */
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds, string $yamlFile = 'compose.yaml'): array
{ {
$yamlPath = $composeDir . '/' . $project . '/compose.yaml'; $yamlPath = $composeDir . '/' . $project . '/' . $yamlFile;
$argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand); $argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; $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); $exitCode = proc_close($process);
// 'stdout' (raw) for callers that need to parse machine-readable // 'stdout' (raw) for callers that need to parse machine-readable
// output (e.g. compose_status()'s JSON); 'output' (combined, // output (e.g. compose_status()'s JSON); 'output' (combined, trimmed,
// trimmed) for human-facing success/error messages, where seeing // ANSI-stripped) for human-facing success/error messages, where seeing
// podman's own stderr banner/warnings is actually useful context. // podman's own stderr banner/warnings is actually useful context
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($stdout . $stderr)]; // 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)];
} }
+179 -2
View File
@@ -18,10 +18,14 @@
* kill POST {"id": "...", "signal": "SIGKILL"} * kill POST {"id": "...", "signal": "SIGKILL"}
* rename POST {"id": "...", "name": "..."} * rename POST {"id": "...", "name": "..."}
* logs GET (&id=...&tail=200) -> plain text * 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>", * 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"}], * "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}], * "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} * "privileged": false, "startAfterCreate": true}
*/ */
@@ -105,6 +109,14 @@ switch ($action) {
podman_json_response(['status' => 'renamed']); podman_json_response(['status' => 'renamed']);
break; break;
case 'list_gpus':
podman_json_response(gpu_list());
break;
case 'check_updates':
podman_json_response(check_image_updates($client));
break;
case 'create': case 'create':
$body = podman_read_json_body(); $body = podman_read_json_body();
$image = trim((string) ($body['image'] ?? '')); $image = trim((string) ($body['image'] ?? ''));
@@ -138,6 +150,102 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400); 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 * Builds a libpod SpecGenerator body (POST /containers/create) from the
* WebUI's Create Container form fields. Field names/shapes here * WebUI's Create Container form fields. Field names/shapes here
@@ -228,7 +336,21 @@ function build_container_spec(string $image, array $body): array
if (in_array($networkMode, ['bridge', 'host', 'none'], true)) { if (in_array($networkMode, ['bridge', 'host', 'none'], true)) {
$spec['netns'] = ['nsmode' => $networkMode]; $spec['netns'] = ['nsmode' => $networkMode];
} elseif ($networkMode !== '') { } elseif ($networkMode !== '') {
$spec['networks'] = [$networkMode => new \stdClass()]; // 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'] !== '') { if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
@@ -238,6 +360,29 @@ function build_container_spec(string $image, array $body): array
$spec['privileged'] = true; $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; return $spec;
} }
@@ -265,6 +410,17 @@ function containers_list(PodmanClient $client): array
$out = []; $out = [];
foreach ($raw as $c) { 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'] ?? []; $names = $c['Names'] ?? [];
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? '')); $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); $startedAt = podman_parse_time($c['StartedAt'] ?? null);
$state = strtolower((string) ($c['State'] ?? 'unknown')); $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[] = [ $out[] = [
'id' => (string) ($c['Id'] ?? ''), 'id' => (string) ($c['Id'] ?? ''),
'shortId' => podman_short_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, 'podName' => $c['PodName'] ?? null,
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null, 'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
'createdAt' => podman_parse_time($c['Created'] ?? 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 * ajax/exec.php
* *
* Backs the Terminal panel — and this is the one panel where "exclusively * Backs the Terminal panel — and this is the one panel where "exclusively
* via podman system service, no shell hacks" needs an honest caveat * via podman system service, no shell hacks" needs an honest caveat spelled
* spelled out rather than silently glossed over: * 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 * libpod's real exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) is used here — PodmanClient::execRun() never * POST /exec/{id}/start) works by HTTP connection hijacking: the connection
* shells out to the `podman` binary. But that API's interactive mode works * is upgraded into a raw bidirectional byte stream for the lifetime of the
* by HTTP connection hijacking: the HTTP connection is upgraded into a raw * shell session. That model assumes a long-lived process holding the socket
* bidirectional byte stream for the lifetime of the shell session. That * open on both ends (an actual terminal emulator, or a WebSocket bridge) —
* model assumes a long-lived process holding the socket open on both ends * it does not fit PHP-FPM's request/response lifecycle, where each AJAX call
* (an actual terminal emulator, or a WebSocket bridge) — it does not fit * is a fresh, independent, short-lived process with no memory of any
* PHP-FPM's request/response lifecycle, where each AJAX call is a fresh, * previous one. An earlier version of this file worked around that by
* independent, short-lived process with no memory of any previous one. * 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 * Unraid's own webGui already solves exactly this problem for its System
* first multi-line prompt, `sudo`, or interactive editor, this endpoint * Terminal and for `docker exec` (see
* offers a deliberately simpler, honest contract: one command in, its * /usr/local/emhttp/plugins/dynamix/include/OpenTerminal.php's 'docker'
* complete output back, using Tty=true so output reads like a real * case, and /etc/nginx/conf.d/locations.conf's "logterminal" location
* terminal (colors, prompts-in-output, etc. survive) but with no * block) — by spawning one `ttyd` instance per session, bound to a unix
* persistent shell state (`cd` does not carry over between calls — see * socket under /var/tmp, wrapping the real interactive command; nginx then
* the "cwd" parameter below, which javascript/terminal.js tracks * proxies /logterminal/<name>/ to that socket with a WebSocket upgrade,
* client-side and resends every time instead). * 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, ...) * This is the one place in the plugin that shells out to the `podman`
* would need a WebSocket-capable process sitting between the browser and * binary via proc invocation rather than the REST API — container names
* podman.sock — out of scope for this PHP/AJAX stack; tracked as a * are validated against a fixed safe pattern and passed through
* follow-up rather than implemented as a shell-out workaround. * escapeshellarg(), never concatenated into a shell string.
* *
* Actions (?action=...): * 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); declare(strict_types=1);
@@ -41,27 +55,103 @@ require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
switch ($action) { switch ($action) {
case 'run': case 'open':
$body = podman_read_json_body(); $body = podman_read_json_body();
$id = (string) ($body['id'] ?? ''); $name = (string) ($body['name'] ?? '');
$commandLine = (string) ($body['cmd'] ?? ''); $shell = (string) ($body['shell'] ?? 'sh');
$cwd = (string) ($body['cwd'] ?? '');
if ($id === '' || trim($commandLine) === '') { // Same character set libpod itself allows in container names —
podman_json_error('Missing id or cmd in request body', 400); // 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 podman_json_response(open_terminal($name, $shell));
// (sh -c) so the user can type ordinary shell syntax (pipes, break;
// 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(['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; break;
default: default:
podman_json_error("Unknown action '{$action}'", 400); 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 * list GET -> normalized image list
* pull POST {"reference": "docker.io/library/postgres:16"} * pull POST {"reference": "docker.io/library/postgres:16"}
* remove POST {"id": "...", "force": false} * 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); declare(strict_types=1);
@@ -40,6 +42,27 @@ switch ($action) {
podman_json_response(['status' => 'removed']); podman_json_response(['status' => 'removed']);
break; 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: default:
podman_json_error("Unknown action '{$action}'", 400); podman_json_error("Unknown action '{$action}'", 400);
} }
+85 -5
View File
@@ -7,9 +7,11 @@
* from Docker's own docker0/custom-network space. * from Docker's own docker0/custom-network space.
* *
* Actions (?action=...): * Actions (?action=...):
* list GET -> normalized network list with subnet/gateway/usage * 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
* remove POST {"name": "...", "force": false} * create POST {"name": "...", "driver": "bridge"|"macvlan", "subnet": "...",
* "gateway": "...", "parentInterface": "br0"}
* remove POST {"name": "...", "force": false}
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -23,17 +25,40 @@ switch ($action) {
podman_json_response(networks_list($client)); podman_json_response(networks_list($client));
break; break;
case 'list_parent_interfaces':
podman_json_response(macvlan_parent_interfaces());
break;
case 'create': case 'create':
$body = podman_read_json_body(); $body = podman_read_json_body();
$name = (string) ($body['name'] ?? ''); $name = (string) ($body['name'] ?? '');
if ($name === '') { if ($name === '') {
podman_json_error('Missing name in request body', 400); 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( podman_json_response($client->createNetwork(
$name, $name,
(string) ($body['driver'] ?? 'bridge'), $driver,
isset($body['subnet']) ? (string) $body['subnet'] : null, isset($body['subnet']) ? (string) $body['subnet'] : null,
isset($body['gateway']) ? (string) $body['gateway'] : null isset($body['gateway']) ? (string) $body['gateway'] : null,
$parentInterface
)); ));
break; break;
@@ -54,6 +79,61 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400); 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>> */ /** @return array<int,array<string,mixed>> */
function networks_list(PodmanClient $client): array function networks_list(PodmanClient $client): array
{ {
+63
View File
@@ -9,8 +9,10 @@
* *
* Actions (?action=...): * Actions (?action=...):
* list GET -> pods with nested container summaries * list GET -> pods with nested container summaries
* create POST {"name": "...", "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}]}
* start POST {"name": "..."} * start POST {"name": "..."}
* stop POST {"name": "...", "timeout": 10} * stop POST {"name": "...", "timeout": 10}
* restart POST {"name": "...", "timeout": 10}
* remove POST {"name": "...", "force": false} * remove POST {"name": "...", "force": false}
*/ */
@@ -25,6 +27,12 @@ switch ($action) {
podman_json_response(pods_list($client)); podman_json_response(pods_list($client));
break; 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': case 'start':
$body = podman_read_json_body(); $body = podman_read_json_body();
$client->startPod(require_name($body)); $client->startPod(require_name($body));
@@ -37,6 +45,12 @@ switch ($action) {
podman_json_response(['status' => 'stopped']); podman_json_response(['status' => 'stopped']);
break; 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': case 'remove':
$body = podman_read_json_body(); $body = podman_read_json_body();
$client->removePod(require_name($body), (bool) ($body['force'] ?? false)); $client->removePod(require_name($body), (bool) ($body['force'] ?? false));
@@ -47,6 +61,55 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400); 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 */ /** @param array<string,mixed> $body */
function require_name(array $body): string function require_name(array $body): string
{ {
+78 -42
View File
@@ -114,7 +114,15 @@ final class PodmanClient
*/ */
public function createContainer(array $spec): string 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'] ?? ''); return (string) ($result['Id'] ?? '');
} }
@@ -185,41 +193,6 @@ final class PodmanClient
// have — see that file's header comment for the full explanation). // 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 // Pods
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -234,6 +207,22 @@ final class PodmanClient
return $this->request('GET', '/pods/' . rawurlencode($name) . '/json'); 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 public function startPod(string $name): void
{ {
$this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true); $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); $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 public function removePod(string $name, bool $force = false): void
{ {
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true); $this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
@@ -279,7 +273,14 @@ final class PodmanClient
*/ */
public function pullImage(string $reference): array 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; $last = null;
foreach (explode("\n", trim($raw)) as $line) { 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); $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 // Volumes
// ------------------------------------------------------------------- // -------------------------------------------------------------------
@@ -353,12 +376,25 @@ final class PodmanClient
return $this->request('GET', '/networks/json'); 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]; $body = ['name' => $name, 'driver' => $driver];
if ($subnet !== null) { if ($subnet !== null) {
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])]; $body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
} }
if ($parentInterface !== null && $parentInterface !== '') {
$body['network_interface'] = $parentInterface;
}
return $this->request('POST', '/networks/create', [], false, $body); 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 * @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
* @return array<mixed> * @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) === '') { if ($expectEmptyBody || trim($raw) === '') {
return []; return [];
} }
@@ -400,7 +436,7 @@ final class PodmanClient
* @param array<string,string> $query * @param array<string,string> $query
* @param array<mixed>|null $jsonBody * @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; $url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
if (!empty($query)) { if (!empty($query)) {
@@ -413,7 +449,7 @@ final class PodmanClient
CURLOPT_URL => $url, CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method, CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds, CURLOPT_TIMEOUT => $timeoutSeconds ?? $this->timeoutSeconds,
CURLOPT_HTTPHEADER => ['Accept: application/json'], 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__ . '/PodmanClient.php';
require_once __DIR__ . '/Config.php'; require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/helpers.php'; require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/RegistryClient.php';
set_exception_handler(static function (\Throwable $e): void { set_exception_handler(static function (\Throwable $e): void {
if ($e instanceof PodmanApiException) { 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 * Small anchored dropdown menu — used for secondary per-row actions
* (pause/kill/rename/...) that would otherwise clutter a table row with * (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>'; (item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
}).join(''); }).join('');
// Viewport-relative (see the "position: fixed" comment on
// .podman-context-menu in podman.css) — no scrollY/scrollX added.
const rect = anchorEl.getBoundingClientRect(); const rect = anchorEl.getBoundingClientRect();
menu.style.top = (rect.bottom + window.scrollY + 4) + 'px'; menu.style.top = (rect.bottom + 4) + 'px';
menu.style.left = (rect.right + window.scrollX - 180) + 'px'; menu.style.left = (rect.right - 180) + 'px';
(document.querySelector('.podman-plugin') || document.body).appendChild(menu); (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; let buttonIndex = 0;
items.forEach(function (item) { items.forEach(function (item) {
if (item === 'separator') return; if (item === 'separator') return;
const btn = menu.children[buttonIndex]; const btn = buttons[buttonIndex];
buttonIndex++; buttonIndex++;
if (item.disabled) return; if (item.disabled) return;
btn.addEventListener('click', function (e) { btn.addEventListener('click', function (e) {
@@ -358,6 +415,7 @@ window.Podman = (function () {
loadingRow: loadingRow, loadingRow: loadingRow,
errorRow: errorRow, errorRow: errorRow,
openFormModal: openFormModal, openFormModal: openFormModal,
openLogModal: openLogModal,
openContextMenu: openContextMenu, openContextMenu: openContextMenu,
registerPanel: registerPanel, registerPanel: registerPanel,
activatePanel: activatePanel, activatePanel: activatePanel,
+87 -9
View File
@@ -1,10 +1,10 @@
/** /**
* javascript/compose.js * javascript/compose.js
* *
* Compose panel: project list + read-only YAML view + up/down/pull, * Compose panel: project list + an editable YAML view + save/up/down/pull/
* backed by ajax/compose.php. See that file's header comment — this is * delete, backed by ajax/compose.php. See that file's header comment —
* the one panel whose backend shells out to the `podman compose` CLI, * this is the one panel whose backend shells out to the `podman compose`
* because no REST equivalent for Compose exists in libpod. * CLI, because no REST equivalent for Compose exists in libpod.
*/ */
(function () { (function () {
'use strict'; 'use strict';
@@ -12,6 +12,13 @@
let projects = []; let projects = [];
let selected = null; 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) { function statusChip(status) {
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn'); 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>'; 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="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 class="path">' + P.escapeHtml(p.path) + '</div>' +
'</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) { function loadYaml(name) {
P.el('compose-title').textContent = name + ' / compose.yaml'; 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) { 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) { }).catch(function (err) {
P.el('compose-yaml').textContent = 'Error: ' + err.message; P.el('compose-yaml').value = 'Error: ' + err.message;
}); });
} }
function selectProject(name) { function selectProject(name) {
selected = name; selected = name;
setToolbarEnabled(true);
renderSidebar(); renderSidebar();
loadYaml(name); loadYaml(name);
} }
@@ -45,9 +63,19 @@
function loadProjects() { function loadProjects() {
return P.get('compose', 'list').then(function (data) { return P.get('compose', 'list').then(function (data) {
projects = data; projects = data;
if (selected && !projects.some(function (p) { return p.name === selected; })) {
selected = null;
}
if (!selected && projects.length > 0) selected = projects[0].name; if (!selected && projects.length > 0) selected = projects[0].name;
renderSidebar(); 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) { }).catch(function (err) {
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>'; 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() { function init() {
P.el('compose-sidebar').addEventListener('click', function (e) { P.el('compose-sidebar').addEventListener('click', function (e) {
const item = e.target.closest('.podman-compose-proj[data-name]'); const item = e.target.closest('.podman-compose-proj[data-name]');
if (item) selectProject(item.dataset.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-up').addEventListener('click', function () { runAction('up'); });
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); }); P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); }); 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(); return loadProjects();
} }
+397 -29
View File
@@ -10,42 +10,60 @@
let allContainers = []; let allContainers = [];
let filter = 'all'; let filter = 'all';
let searchTerm = ''; 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) { function iconLabel(name) {
return P.escapeHtml(name.slice(0, 2).toUpperCase()); return P.escapeHtml(name.slice(0, 2).toUpperCase());
} }
function hasUpdate(c) {
const status = imageUpdateStatus[c.image];
return !!(status && status.updateAvailable);
}
function rowHtml(c) { function rowHtml(c) {
const cpuMem = c.state === 'running' const cpuMem = c.state === 'running' && c.cpuPercent != null
? '<span class="podman-row-sub">running</span>' ? '<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>'; : '<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 '' + return '' +
'<tr data-id="' + P.escapeHtml(c.id) + '">' + '<tr data-id="' + P.escapeHtml(c.id) + '">' +
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' + '<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
'<td><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' + '<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 class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
'<td>' + cpuMem + '</td>' + '<td>' + cpuMem + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' + '<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' +
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</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>'; '</tr>';
} }
function actionButtons(c) { 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') { 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="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="stop" title="Stop">&#9632;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>'; '<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
} }
if (c.state === 'paused') { 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="unpause" title="Resume">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</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="start" title="Start">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</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: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
} }
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } }); items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } });
items.push('separator'); items.push('separator');
items.push({ items.push({
label: 'Remove', 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 ----------------------------------------------------------- // --- Detail view -----------------------------------------------------------
// //
// Fed entirely by the existing inspect action (raw libpod inspect JSON) — // Fed entirely by the existing inspect action (raw libpod inspect JSON) —
@@ -305,17 +536,24 @@
/** /**
* @param {object|null} prefill Optional template data (same shape * @param {object|null} prefill Optional template data (same shape
* ajax/templates.php's "get" action returns) to seed the form with — * ajax/templates.php's "get" action returns, plus "name"/"pod" which
* used by templates.js's "Use template" action. null/omitted opens a * only inspectToPrefill() sets) to seed the form with — used by
* blank form, same as the toolbar's "+ New Container" button. * 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 || {}; prefill = prefill || {};
const backdrop = document.createElement('div'); const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop'; backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' + backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' + '<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">' + '<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Image</label>' + '<div class="podman-modal-field"><label>Image</label>' +
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' + '<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>' + '<div class="podman-modal-field"><label>Network</label>' +
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' + '<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
'<option value="host">Host</option><option value="none">None</option></select></div>' + '<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>' + '<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-modal-field"><label>Volumes</label>' +
'<div class="podman-row-group" id="cc-volumes"></div>' + '<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>' + '<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>' + '<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>' + '<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>' + '<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>' + '<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' + '<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' + '<div class="podman-modal-field podman-modal-checkbox"><label>' +
@@ -352,14 +599,35 @@
'</form>' + '</form>' +
'<div class="podman-modal-actions">' + '<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-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>'; '</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop); (document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image; 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.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.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 portsGroup = backdrop.querySelector('#cc-ports');
const volumesGroup = backdrop.querySelector('#cc-volumes'); const volumesGroup = backdrop.querySelector('#cc-volumes');
@@ -383,11 +651,59 @@
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) { networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = n.name; opt.value = n.name;
opt.textContent = n.name; opt.textContent = n.name + (n.driver === 'macvlan' ? ' (macvlan)' : '');
opt.dataset.driver = n.driver;
select.appendChild(opt); 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 */ }); }).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-image').focus();
backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) { backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) {
@@ -407,6 +723,13 @@
} }
function submit() { 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(); const image = backdrop.querySelector('#cc-image').value.trim();
if (!image) { if (!image) {
showError('"Image" is required.'); 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.'); showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
return; 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 volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
const env = readRows(envGroup).filter(function (r) { return r.key; }); const env = readRows(envGroup).filter(function (r) { return r.key; });
@@ -433,19 +766,39 @@
const networkMode = backdrop.querySelector('#cc-network').value; const networkMode = backdrop.querySelector('#cc-network').value;
const privileged = backdrop.querySelector('#cc-privileged').checked; 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"]'); const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true; submitBtn.disabled = true;
P.post('containers', 'create', {
image: image, // Editing an existing container: no in-place "modify" API exists
name: backdrop.querySelector('#cc-name').value.trim(), // (see the comment on openEditContainerModal above), so this stops
networkMode: networkMode, // and removes the old one first — best-effort stop (it may already
ports: ports, // be stopped) followed by a forced remove — before creating the
volumes: volumes, // replacement under whatever name is in the form now.
env: env, const removeOld = editing
restartPolicy: backdrop.querySelector('#cc-restart').value, ? P.post('containers', 'stop', { id: editing.id }).catch(function () { /* already stopped is fine */ })
privileged: privileged, .then(function () { return P.post('containers', 'remove', { id: editing.id, force: true }); })
startAfterCreate: backdrop.querySelector('#cc-start').checked, : 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 () { }).then(function () {
// Best-effort: a template-save failure shouldn't undo or block // Best-effort: a template-save failure shouldn't undo or block
// the container that was just successfully created. // the container that was just successfully created.
@@ -469,7 +822,7 @@
return load(); return load();
}).catch(function (err) { }).catch(function (err) {
submitBtn.disabled = false; 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; if (!btn || btn.disabled) return;
const row = btn.closest('tr'); const row = btn.closest('tr');
const id = row.dataset.id; 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; }); const c = allContainers.find(function (x) { return x.id === id; });
if (!c) return; if (!c) return;
if (btn.dataset.action === 'menu') { if (btn.dataset.action === 'menu') {
openRowMenu(c, btn); 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 { } else {
openDetailModal(c); openDetailModal(c);
} }
@@ -536,6 +901,9 @@
handleAction(id, btn.dataset.action, btn); 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(); return load();
} }
+61 -9
View File
@@ -18,8 +18,10 @@
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' + '<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
'<td class="tnum">' + created + '</td>' + '<td class="tnum">' + created + '</td>' +
'<td class="tnum">' + img.usedBy + '</td>' + '<td class="tnum">' + img.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' + '<td class="podman-actions"><div class="podman-actions-row">' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' + '<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>'; '</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) { 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; if (!btn || btn.disabled) return;
const id = btn.closest('tr').dataset.id; const id = btn.closest('tr').dataset.id;
if (!confirm('Remove this image?')) return;
btn.disabled = true; if (btn.dataset.action === 'tag') {
P.post('images', 'remove', { id: id }).then(load).catch(function (err) { P.openFormModal({
alert('Remove failed: ' + err.message); title: 'Add Tag',
btn.disabled = false; 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(); 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.subnet || '&mdash;') + '</td>' +
'<td class="mono">' + P.escapeHtml(n.gateway || '&mdash;') + '</td>' + '<td class="mono">' + P.escapeHtml(n.gateway || '&mdash;') + '</td>' +
'<td class="tnum">' + n.containers + '</td>' + '<td class="tnum">' + n.containers + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' + '<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></td>' + (removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></div></td>' +
'</tr>'; '</tr>';
} }
@@ -43,21 +43,120 @@
}); });
} }
function init() { // Purpose-built modal (not app.js's generic openFormModal, which only
P.el('networks-create-btn').addEventListener('click', function () { // supports flat always-visible text fields) — the parent-interface
P.openFormModal({ // dropdown and gateway field only make sense for "macvlan" and need to
title: 'New Network', // show/hide based on the driver choice.
submitLabel: 'Create', function openCreateNetworkModal() {
fields: [ const backdrop = document.createElement('div');
{ name: 'name', label: 'Network name', required: true, placeholder: 'my-network' }, backdrop.className = 'podman-modal-backdrop';
{ name: 'subnet', label: 'Subnet (optional)', placeholder: '10.89.2.0/24' }, backdrop.innerHTML = '' +
], '<div class="podman-modal" role="dialog" aria-modal="true">' +
onSubmit: function (values) { '<div class="podman-modal-head"><h3>New Network</h3></div>' +
return P.post('networks', 'create', { name: values.name, driver: 'bridge', subnet: values.subnet || undefined }).then(load); '<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', openCreateNetworkModal);
P.el('networks-tbody').addEventListener('click', function (e) { P.el('networks-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]'); const btn = e.target.closest('button[data-action="remove"]');
if (!btn || btn.disabled) return; if (!btn || btn.disabled) return;
+157 -6
View File
@@ -8,6 +8,107 @@
(function () { (function () {
'use strict'; 'use strict';
const P = window.Podman; 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) { function memberRow(m) {
return '' + return '' +
@@ -24,27 +125,77 @@
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>'; : '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
return '' + return '' +
'<div class="podman-pod-card">' + '<div class="podman-pod-card" data-name="' + P.escapeHtml(pod.name) + '">' +
'<div class="podman-pod-head">' + '<div class="podman-pod-head">' +
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' + '<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="name">' + P.escapeHtml(pod.name) + '</span>' +
'<span class="infra">' + pod.containersTotal + ' container(s)</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>' +
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' + '<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
'<tbody>' + members + '</tbody></table></div>' + '<tbody>' + members + '</tbody></table></div>' +
'</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() { function load() {
const container = P.el('podman-panel-pods'); const container = P.el('podman-panel-pods');
return P.get('pods', 'list').then(function (pods) { if (!P.el('pods-grid')) {
container.innerHTML = pods.length container.innerHTML = '' +
? pods.map(podCard).join('') '<div class="podman-card">' +
: '<div class="podman-card"><div class="podman-empty-note">No pods yet.</div></div>'; '<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) { }).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 }); P.registerPanel('pods', { init: load, refresh: load });
})(); })();
+8 -5
View File
@@ -17,11 +17,11 @@
return '<tr data-index="' + i + '">' + return '<tr data-index="' + i + '">' +
'<td class="tnum">' + (i + 1) + '</td>' + '<td class="tnum">' + (i + 1) + '</td>' +
'<td>' + P.escapeHtml(name) + '</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="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="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>' + '<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">&#128465;</button>' +
'</td></tr>'; '</div></td></tr>';
}).join('') }).join('')
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>'; : '<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 versions = settings.packageVersions || {};
const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS']; const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS'];
P.el('settings-package-versions').textContent = order const chipsHtml = order.map(function (k) {
.map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); }) const name = k.toLowerCase().replace(/_/g, '-');
.join(' · '); 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() { function load() {
@@ -29,7 +29,6 @@
'<div class="podman-template-body">' + '<div class="podman-template-body">' +
'<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' + '<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' +
'<div class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</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>' : '') + (overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
'</div>' + '</div>' +
'<div class="podman-template-actions">' + '<div class="podman-template-actions">' +
+72 -64
View File
@@ -1,89 +1,97 @@
/** /**
* javascript/terminal.js * javascript/terminal.js
* *
* Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that * Terminal panel: opens a real, fully interactive terminal inline (as an
* file's header comment for the full, honest explanation of why this is * <iframe>, not a popup window) — the same mechanism Unraid's own webGui
* "type a command, see its output" rather than a true interactive PTY — * uses for its System Terminal and for `docker exec` (see ajax/exec.php's
* the short version is that libpod's interactive exec needs a persistent * header comment for the full explanation). This module's own job is just:
* 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).
* *
* `cd` is handled client-side: this module tracks a per-session `cwd` and * 1. Ask ajax/exec.php to spawn a ttyd instance wrapping
* passes it as the exec's working directory on every call, so at least * `podman exec -it <container> <shell>`, bound to a unix socket.
* directory navigation feels persistent even though nothing else is. * 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 () { (function () {
'use strict'; 'use strict';
const P = window.Podman; const P = window.Podman;
let cwd = '/'; let openName = null;
let containerId = null;
function appendLine(html) { function populateContainerSelect(list) {
const out = P.el('term-output'); const select = P.el('term-container-select');
const div = document.createElement('div'); if (!select) return;
div.innerHTML = html; const running = list.filter(function (c) { return c.state === 'running'; });
out.appendChild(div); select.innerHTML = running
out.scrollTop = out.scrollHeight; .map(function (c) { return '<option value="' + P.escapeHtml(c.name) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('') || '<option value="">No running containers</option>';
} }
function promptHtml() { function loadContainers() {
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$'; return P.get('containers', 'list').then(populateContainerSelect);
} }
function runCommand(cmd) { function resetFrame(message) {
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd)); P.el('term-frame-wrap').innerHTML = '<p class="podman-empty-note">' + message + '</p>';
P.el('term-disconnect-btn').disabled = true;
openName = null;
}
// `cd <dir>` is intercepted client-side (see file header) rather than /** Best-effort: tells the backend to kill the ttyd/podman-exec session, if any is open. Never rejects. */
// sent as a real command, since a one-shot exec has no way to report function closeCurrent() {
// "the working directory changed" back to us otherwise. if (!openName) return Promise.resolve();
const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/); const name = openName;
if (cdMatch) { return P.post('exec', 'close', { name: name }).catch(function () {});
cwd = cdMatch[1].startsWith('/') ? cdMatch[1] : (cwd.replace(/\/$/, '') + '/' + cdMatch[1]); }
return Promise.resolve();
}
return P.post('exec', 'run', { id: containerId, cmd: cmd, cwd: cwd }).then(function (data) { function openLiveTerminal() {
if (data.output) appendLine('<span class="mono">' + P.escapeHtml(data.output).replace(/\n/g, '<br>') + '</span>'); 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) { }).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) { function disconnect() {
const select = P.el('term-container-select'); if (!openName) return;
select.innerHTML = containers const btn = P.el('term-disconnect-btn');
.filter(function (c) { return c.state === 'running'; }) btn.disabled = true;
.map(function (c) { return '<option value="' + P.escapeHtml(c.id) + '">' + P.escapeHtml(c.name) + '</option>'; }) closeCurrent().finally(function () {
.join(''); resetFrame('Disconnected. Pick a container and click "Open Terminal" to start a new session.');
containerId = select.value || null; });
} }
function init() { function init() {
const input = P.el('term-input'); P.el('term-open-btn').addEventListener('click', openLiveTerminal);
P.el('term-disconnect-btn').addEventListener('click', disconnect);
P.el('term-container-select').addEventListener('change', function (e) { return loadContainers();
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.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><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
'<td class="mono podman-row-sub">' + pathCell + '</td>' + '<td class="mono podman-row-sub">' + pathCell + '</td>' +
'<td class="tnum">' + v.usedBy + '</td>' + '<td class="tnum">' + v.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' + '<td class="podman-actions"><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></td>' + (v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>'; '</tr>';
} }
+194 -25
View File
@@ -20,7 +20,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1; --border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3; --accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6; --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); --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-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; --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; --border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002; --accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d; --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); --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; --border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002; --accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d; --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); --shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
} }
:root[data-theme="light"] .podman-plugin { :root[data-theme="light"] .podman-plugin {
@@ -52,7 +52,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1; --border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3; --accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6; --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); --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-good { color: var(--good); }
.podman-pagehead .meta .dot-bad { color: var(--bad); } .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 { .podman-btn {
appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text); 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; 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; display: inline-flex; align-items: center; gap: 6px; transition: border-color .12s, background .12s;
font-family: var(--font-ui); font-family: var(--font-ui); margin: 0;
} }
.podman-btn:hover { border-color: var(--text-faint); } .podman-btn:hover { border-color: var(--text-faint); }
/* /*
@@ -100,7 +109,7 @@
} }
.podman-btn-danger { color: var(--bad); } .podman-btn-danger { color: var(--bad); }
.podman-btn-danger:hover { border-color: var(--bad); } .podman-btn-danger:hover { border-color: var(--bad); }
.podman-btn-icon { padding: 6px 8px; } .podman-btn-icon { padding: 6px 8px; min-width: 32px; min-height: 32px; justify-content: center; font-size: 15px; line-height: 1; }
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; } .podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
/* /*
* Secondary action (Cancel, "+ Add row") — every button previously shared * Secondary action (Cancel, "+ Add row") — every button previously shared
@@ -123,11 +132,31 @@
background: var(--surface-2) !important; border-color: transparent !important; background: var(--surface-2) !important; border-color: transparent !important;
color: var(--text) !important; box-shadow: none !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 * A ghost button can still carry danger intent (Disconnect, Delete,
would otherwise win by rule order. */ * template "Delete") — needs its own !important since .podman-btn-ghost's
.podman-btn-ghost.podman-btn-danger { color: var(--bad) !important; } * color/background/border would otherwise win by rule order. Solid fill
.podman-btn-ghost.podman-btn-danger:hover { background: var(--bad-bg) !important; color: var(--bad) !important; } * 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 { .podman-subnav {
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
@@ -185,7 +214,12 @@
.podman-table-wrap { overflow-x: auto; } .podman-table-wrap { overflow-x: auto; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; } .podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
.podman-row-name-btn { .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; color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left;
} }
.podman-row-name-btn:hover { color: var(--accent-strong); } .podman-row-name-btn:hover { 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); 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-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 { 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; } .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-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-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-search::placeholder { color: var(--text-faint); }
.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; } .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-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 { 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 .name { font-weight: 700; font-size: 13.5px; }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); } .podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); margin-right: auto; }
.podman-badge { display: inline-block; font-size: 10.5px; font-weight: 700; color: var(--text-dim); background: var(--surface-3); padding: 2px 8px; border-radius: 100px; margin-top: 6px; } .podman-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-name { font-weight: 700; font-size: 13.5px; }
.podman-template-overview { font-size: 12px; color: var(--text-dim); line-height: 1.4; } .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 { display: flex; gap: 6px; margin-top: auto; padding-top: 4px; }
.podman-template-actions .podman-btn { flex: 1; justify-content: center; padding: 6px 10px; font-size: 12px; } /*
* 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-list { max-height: 220px; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; margin-top: 8px; }
.podman-local-template-item { .podman-local-template-item {
@@ -263,13 +351,23 @@
.podman-log-pane .lvl-warn { color: #e0b23d; } .podman-log-pane .lvl-warn { color: #e0b23d; }
.podman-log-pane .lvl-error { color: #ef6470; } .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-launcher {
.podman-term .prompt { color: #4cc785; } display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
.podman-term .path { color: #6fb2f5; } padding: 12px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
.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 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; } .podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; }
@media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } } @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.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-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; } .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 { 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; } .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); 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)); } .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 { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); }
.podman-danger-card .podman-card-head h2 { color: var(--bad); } .podman-danger-card .podman-card-head h2 { color: var(--bad); }
@@ -368,7 +525,18 @@
/* Anchored dropdown context menu — see app.js openContextMenu(). */ /* Anchored dropdown context menu — see app.js openContextMenu(). */
.podman-context-menu { .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; border-radius: 9px; box-shadow: var(--shadow); z-index: 1001; padding: 4px; display: grid; gap: 1px;
} }
.podman-context-menu button { .podman-context-menu button {
@@ -377,7 +545,8 @@
font-family: var(--font-ui); width: 100%; font-family: var(--font-ui); width: 100%;
} }
.podman-context-menu button:hover { background: var(--surface-2); } .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 button[disabled] { opacity: .4; cursor: not-allowed; }
.podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; } .podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }