37 Commits
Author SHA1 Message Date
maggesandClaude Sonnet 5 7e2ed451e3 Add Apps/Store tab, template WebUI/URL import, container RO volumes/device passthrough/run-as-user, and in-app confirm dialogs
Lint / ShellCheck (push) Successful in 14s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 6s
Replaces every native confirm() with a shared P.confirm() modal (a hung
native dialog was found live to block the whole tab, including
auto-refresh, and once even double-confirmed an unrelated deletion).
Also fixes Edit Container silently resetting to Bridge/blanking the
Static IP for any container on a custom network, and a context menu
losing its anchor to a mid-read auto-refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:24:32 +00:00
maggesandClaude Sonnet 5 9d46547ef4 Fix package-verify false negatives: symlink traversal + prefix collision
Lint / ShellCheck (push) Successful in 13s
Lint / Validate .plg XML (push) Successful in 13s
Lint / EditorConfig (push) Successful in 5s
podman-verify-packages.sh/podman-update-packages.sh reported every
package as "not installed" right after a genuinely successful install.
Root cause: /var/log/packages is itself a symlink on Unraid (->
../lib/pkgtools/packages), and GNU find's default -P mode doesn't
descend into a symlinked starting path at all without -L — confirmed
live by running the exact same find both with and without -L against a
host where the packages had just installed successfully.

Fixing that alone surfaced a second, previously-masked bug: "podman"'s
own glob also matches podman-compose's file (a literal prefix
collision), and find's unsorted output let podman-compose's record
silently win podman's own check.

Both fixed once via a new shared podman_find_installed_package_record()
helper in podman-common.sh, rather than separately in each caller.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 22:10:44 +00:00
maggesandClaude Sonnet 5 d931d8e9e9 release: v0.1.5
Build Packages / Build .txz packages (push) Successful in 8m36s
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 6s
Release / Build release packages (push) Successful in 7m50s
Release / Publish Gitea Release (push) Failing after 6s
Fixes a real bug in scripts/release.sh's per-package entity matching
found while cutting this release: "podman"'s own glob also matched
podman-compose's file (podman is a literal prefix of podman-compose),
and find | head -n1's unsorted output order let podman-compose's
package silently win the "podman" entity on this run. Now explicitly
skips any match that actually belongs to a different, more specific
component name also in the COMPONENTS list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:53:59 +00:00
maggesandClaude Sonnet 5 23898ff62e Rework Dashboard, add toasts, container folders/icons/WebUI links, detail tabs
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 6s
Dashboard:
- Plain-count stat tiles (Running/Stopped/Pods/Images/Volumes/Networks)
  separated from a single "Resource Usage" card (CPU/Memory/Swap/Storage
  meter rows) instead of forcing both into one tile grid, which produced
  awkward spanning-tile/dead-cell layouts.
- Fixed CPU usage never changing (libpod's own cpuUtilization is computed
  once and never resampled) by computing it from /proc/stat deltas instead.
- Fixed memory usage reading far too high by using /proc/meminfo's
  MemAvailable instead of libpod's raw (non-reclaimable-aware) memFree.
- Added an Autostart Queue table reusing podman-autostart.sh's own
  failure-counter files.
- Dashboard and Containers now auto-refresh every ~2s (paused when the
  tab is hidden or a modal is open).

Toasts:
- Real success/warn/error/info toast notifications replacing every
  alert() used for one-way feedback, across every panel.

Container detail modal:
- 5 new tabs: Resources, Logs, Console, Events, Healthcheck.

Containers panel:
- Folders to group containers (name + icon), stored in the plugin's own
  folders.json — a folder's header always shows an icon+name+status chip
  per member, matching Unraid's own Docker page folders. "Move to
  Folder" becomes "Remove from Folder" once a container is already
  grouped.
- Containers can carry an icon URL and a WebUI URL (small button next to
  the name), both stored as container labels and auto-filled from
  templates where applicable.
- Settings: an "Add container" control for the Autostart order table.

Fixes:
- Context menus now measure their own rendered size and flip above the
  anchor when there isn't room below, instead of running off-screen.
- Containers table now uses table-layout:fixed with explicit column
  widths — auto layout was shifting every column (and the header) on
  every folder expand/collapse, and briefly again when a flex wrapper
  was mistakenly placed directly on a <td>.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 21:32:34 +00:00
maggesandClaude Sonnet 5 1ca78e7115 Stop marking releases as pre-release by default
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 4s
The 0.x-is-always-prerelease default didn't match what this project
actually wants — v0.1.3 was explicitly unmarked as pre-release right
after publishing. Every release is now just a normal release.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:39:17 +00:00
maggesandClaude Sonnet 5 166f0d96d1 release: v0.1.3
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 5s
Release / Build release packages (push) Successful in 7m21s
Release / Publish Gitea Release (push) Failing after 5s
Built and verified via Gitea Actions (build-packages.yml run 124,
commit 9b9d1a0, all 11 packages succeeded). Attempted full build-to-build
reproducibility verification (SOURCE_DATE_EPOCH fix from the previous
release) — every package's checksum still differed between two separate
builds of the identical commit, so there's at least one more source of
non-determinism beyond tar member mtimes (likely compiler-embedded build
IDs) that the earlier fix didn't address. Given that, this release was
cut and published the same way v0.1.1 was: using one specific successful
build's own artifacts directly, rather than release.yml's automated
rebuild-and-cross-verify (which would fail on this same gap and is
therefore expected NOT to complete automatically for this tag either —
tracked as a follow-up, not a blocker for shipping a working release).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:36:13 +00:00
maggesandClaude Sonnet 5 9b9d1a05ac Fix Podman Service log showing rc.podman's output as one run-together line
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
.podman-log-pane's own multi-line white-space handling lived only on its
.l child divs (used by the Logs tab's per-line entries) — Settings sets
.textContent directly on the pane itself for rc.podman's raw output, so
without white-space: pre-wrap on the pane too, every real newline in
`rc.podman status`'s multi-line output collapsed into one paragraph.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:22:21 +00:00
maggesandClaude Sonnet 5 66ef830234 Fix Settings service chip showing "Not running" right after a successful start
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
rc_podman()'s "is it running" check only recognized rc.podman status's
own "service:             running (pid ..., socket ...)" wording — but
start/stop/restart print differently-worded messages of their own
("start: already running (pid ...)", "stop: stopped", ...), which never
matched that same regex. Found live: a successful Start Podman click
(podman already running, correctly a no-op) still turned the chip red
because its own success message didn't say "service:". Now always runs
a fresh `status` check after the requested verb (unless the verb WAS
status) to determine "running", instead of trying to regex-parse each
verb's differently-worded own output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:20:30 +00:00
maggesandClaude Sonnet 5 676fd8bc89 Fix cache-busting entirely: __DIR__ resolves wrong under Unraid's eval()
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 6s
podman_asset_version() has never actually cache-busted anything, all
session — found live: it kept returning '0' for every asset regardless
of a hard browser reload OR a full php-fpm restart (both ruled out
explicitly before looking further), which meant the mechanism itself was
broken, not caching around it.

Root cause: Unraid's PageBuilder runs every .page file's PHP through
eval() (webGui/include/DefaultPageLayout/evalContent.php literally does
`eval($evalContent)`), and __DIR__/__FILE__ inside eval()'d code resolve
to the eval() CALL SITE's own directory, not to Podman.page's real
location — a standard PHP gotcha. So `__DIR__ . $relPath` was always
pointing at a nonexistent path under webGui/include/DefaultPageLayout/,
is_file() always failed, and the function always fell back to '0'.

Every "do a hard refresh" instruction given throughout this session
worked only because Ctrl+Shift+R bypasses the browser's cache directly —
completely unrelated to this (non-functional) query-string mechanism.
Likely also the real explanation for containers getting stopped
unexpectedly during live testing just now: a stale, mismatched cached
JS/HTML combination made a "Start Podman" click actually run as a full
restart cycle (confirmed via plugin.log: a complete, uninterrupted
stop-then-start at that exact timestamp) — restarted manually afterward.

Fixed by hardcoding /usr/local/emhttp/plugins/podman instead of __DIR__ —
consistent with the rest of the codebase already assuming this exact
install path elsewhere (include/Config.php, plugin/podman.plg's event
hook paths).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:17:08 +00:00
maggesandClaude Sonnet 5 20d8686b61 Show Start/Stop/Restart Podman progress in a log modal
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
Same pattern already used for container update checking (P.openLogModal)
— these can take a while (storage checks, container stop grace periods,
...) and are exactly the actions someone reaches for when something's
actually wrong, so a button that just sits there disabled with no
feedback until it's done isn't good enough. "Refresh Status" stays inline
(quick, read-only, no modal needed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:05:04 +00:00
maggesandClaude Sonnet 5 9557f8c8f8 Add Stop Podman to Settings, alongside Start/Restart
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 5s
Rounds out the Podman Service card to the full start/stop/restart trio.
Stop and Restart both get a confirm() first — both actually stop every
running container (with its own configured grace period) before touching
the API service itself, not just the service process; worth surfacing
that explicitly rather than letting it be a surprise.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 23:01:26 +00:00
maggesandClaude Sonnet 5 5fb7376b64 Fix Release workflow: publish to Gitea's own API, not GitHub's
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
softprops/action-gh-release talks to GitHub's REST API — it cannot
publish anywhere else, so this workflow could never have actually
published a release on this Gitea-hosted repo, reproducibility bug aside.
Replaced the publish step with plain curl against Gitea's own
/api/v1/repos/.../releases endpoints, using the repo-scoped token Gitea
Actions already injects as secrets.GITHUB_TOKEN (same env var name as
GitHub Actions, for exactly this kind of drop-in compatibility).
Idempotent: deletes and recreates the release if one already exists for
the tag, so a re-run after a transient failure doesn't just error on a
duplicate tag.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:57:21 +00:00
maggesandClaude Sonnet 5 7f6fcb9166 Fix real STORAGE_PATH bug; add Start Podman + format-disk from the WebUI
Build Packages / Build .txz packages (push) Successful in 9m17s
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 13s
Lint / EditorConfig (push) Successful in 6s
Root cause of a fresh-install "cannot reach the Podman API socket" report
(a friend's Unraid box, cache pool present and mounted): unlike
Docker-for-Unraid's docker.img path, this plugin never auto-created
STORAGE_PATH itself — only podman.img inside it. A perfectly normal,
already-mounted cache pool still failed preflight/storage-create with
"does not exist", just because its own .../system/podman subdirectory
had never been created. Fixed by walking up to the nearest existing
ancestor and checking whether it's on a different device than / (real
mount vs. nothing mounted at all) — see podman-common.sh's new
podman_path_has_real_mount_ancestor(), used by both podman-preflight.sh
and podman-storage.sh.

Settings gets a "Podman Service" card (status chip + Start/Restart,
backed by new ajax/settings.php service_status/start/restart actions
that just shell out to rc.podman) so a fresh install that failed to start
can be diagnosed and retried without SSH/terminal access at all — exactly
what was missing when this was first needed live.

Also adds "Format a Disk for Podman Storage" (new ajax/disks.php) for a
single-disk system with no cache pool at all. Only ever lists disks with
literally no existing partition/filesystem/RAID-or-ZFS-membership
signature and that aren't Unraid's boot flash — found live, twice, during
development: the boot USB (FAT, labeled "UNRAID") passed the initial
mounted-only check because this host's /boot is backed by a ZFS dataset
rather than a direct partition mount, and active RAID-member cache disks
passed a data-vs-blank *warning* rather than a hard exclusion. Both are
now excluded outright, not just flagged — see disks.php's
device_or_children_labeled_unraid() and the hasData exclusion in
list_candidate_disks(). A disk formatted this way is remounted by UUID on
every boot via a new plugin/sbin/podman-mount-managed-disk.sh, called
from plugin/event/disks_mounted before rc.podman start.

Unrelated fix bundled in: scripts/lib/slackbuild-common.sh now sets
SOURCE_DATE_EPOCH (derived from the repo's last commit) before calling
makepkg, so two separate builds of the same commit produce byte-identical
.txz files — makepkg already supports this (`--clamp-mtime` when
$SOURCE_DATE_EPOCH is set, confirmed by reading a real host's
/sbin/makepkg) but nothing was setting the variable, so release.yml's
"rebuild in CI and verify it matches the committed checksums" step was
guaranteed to fail on the first package it checked alphabetically
(observed live: aardvark-dns).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:55:14 +00:00
maggesandClaude Sonnet 5 80e006c73f release: v0.1.1 - fix plugin.plg <URL>/<MD5> formatting bug
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 5s
Release / Build release packages (push) Successful in 7m34s
Release / Publish GitHub Release (push) Failing after 7s
v0.1.0's <URL>/<MD5> entity values were split across their own lines
inside the tags. Unraid's plugin manager (scripts/plugin, see download())
passes that raw text straight into a `wget ... -O $name $url` shell
command without trimming whitespace, so the leading newline split the
command in two: wget got no URL argument ("wget: missing URL") and the
URL text itself ran as a separate, failing shell command
("sh: line 2: https://...: No such file or directory") — which the
installer then reported as "download failure: zero-length file",
looking like a network problem when it was a formatting bug.

Confirmed live: v0.1.0 fails to install on a real Unraid host (reproduced
via the plugin manager's own CLI, scripts/plugin install, not just the
webGUI). Every real Unraid plugin keeps <URL>...</URL> on one line
(verified against unassigned.devices.plg on the same host) — this fix
matches that convention. Package contents are unchanged from v0.1.0; only
podman.plg's XML formatting and the version/baseURL entities are bumped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 22:14:59 +00:00
maggesandClaude Sonnet 5 b91bdb9810 release: v0.1.0
Build Packages / Build .txz packages (push) Successful in 7m29s
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 5s
Release / Build release packages (push) Successful in 7m23s
Release / Publish GitHub Release (push) Failing after 19s
First cut release, built and verified via Gitea Actions (build-packages.yml
run against commit 18b414d, all 11 packages succeeded). Repo lives on
Gitea (git.mp-mueller.de), not GitHub — scripts/release.sh and
plugin/podman.plg's github/gitURL/supportURL/baseURL entities are updated
to point there instead of the GitHub placeholders they had before (this
project has never actually had a GitHub remote).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:59:01 +00:00
maggesandClaude Sonnet 5 6e36ee1aef Fix lint: add missing trailing newline to prompt.md
Lint / ShellCheck (push) Successful in 14s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
editorconfig-checker correctly flagged this in CI (Lint workflow, run
107) — every other tracked file already ends with one per .editorconfig.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:43:04 +00:00
maggesandClaude Sonnet 5 18b414d7b5 Fix build: podman-compose.SlackBuild was missing its executable bit
Build Packages / Build .txz packages (push) Successful in 9m46s
Lint / ShellCheck (push) Successful in 13s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Failing after 5s
Every other packages/*/*.SlackBuild is 755 — this one was created at 644
(likely from how it was originally written to disk), so
scripts/build-packages.sh's own executability check correctly refused to
run it ("No SlackBuild found/executable for 'podman-compose'"), failing
the whole build — confirmed from a real Gitea Actions run's log
(build-packages.yml run 110).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 21:40:58 +00:00
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
maggesandClaude Sonnet 5 45e27f8575 Add Create Container UI and Templates (Unraid XML) feature
Lets users create containers from the WebUI (image/name/ports/volumes/
env/network mode/restart policy/privileged) instead of only managing
existing ones, and adds a Templates panel to save/reuse those configs
as Unraid-Docker-compatible template XML, including browsing and
importing the host's own existing Docker Manager templates directly.

Also fixes bugs found via live testing along the way: container names
with spaces/invalid characters now get a clear client- and server-side
error with a suggested fix instead of podman's raw API error, the New
Container modal's backdrop no longer renders transparent (was being
appended outside the .podman-plugin CSS scope), and modal buttons now
have real visual hierarchy (ghost/primary/danger) after Unraid's own
site-wide button theme was found to override plain single-class rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 13:02:50 +00:00
maggesandClaude Sonnet 5 151d93c12d Add container detail view (Overview/Environment/Labels/Mounts/Networks/Inspect tabs)
Clicking a container's name now opens a tabbed detail modal, fed entirely
by the existing inspect action's raw libpod data — no new backend needed.
Field names (Config.Env, Config.Labels, Mounts[].Source/Destination/RW,
NetworkSettings.Networks{}.IPAddress/Gateway/MacAddress, HostConfig.
RestartPolicy) verified against a real inspect response before building
the tabs around them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 12:02:05 +00:00
maggesandClaude Sonnet 5 993e187f59 Add container pause/resume/kill/rename + reusable context-menu component
First increment of the big WebUI feature-parity spec (see task list) —
row-level actions were about to run out of icon-button space, so this adds
a small anchored dropdown menu (app.js openContextMenu) for secondary
per-container actions instead of cramming more buttons into every row.

All four new actions verified live against the real podman API before
wiring up the UI (same discipline as the CSRF/pull/compose bugs found
earlier — field names and response shapes checked against the running
socket, not assumed from docs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 11:56:46 +00:00
maggesandClaude Sonnet 5 5b47b4cc0a Add catatonit/nftables/docker-compose packages, fix CSRF/streaming/storage bugs found by live testing
- Package #9-11: catatonit (pod infra init), nftables (netavark firewall
  backend), docker-compose (external compose provider for `podman compose`)
  — all vendored prebuilt binaries, versions.env pinned, propagated through
  build-packages.sh/release.sh/podman.plg/verify+update-packages.sh.
- Fix WebUI: every POST action was silently failing (empty response body)
  because Unraid's own CSRF protection was never satisfied — app.js now
  sends the page's csrf_token as X-CSRF-Token.
- Fix WebUI: PodmanClient::pullImage() assumed a single JSON response, but
  /images/pull actually streams newline-delimited JSON — every successful
  pull was throwing "Expected a JSON object/array response".
- Fix WebUI: compose.php's up/down status detection had the same
  single-JSON-vs-NDJSON bug for `podman compose ps`, plus stderr was
  corrupting the parse.
- Add cache-busting (?v=<mtime>) to Podman.page's script/style tags so a
  redeployed JS/CSS fix isn't served stale from browser cache.
- Add a reusable modal dialog (app.js openFormModal) replacing
  prompt()/alert() for New Volume/Network/Pull Image.
- Add host-path (bind-mount) support when creating a named volume.
- Add Create Container (image, name, network mode incl. custom networks,
  ports, volumes, env, restart policy, privileged, start-after-create),
  auto-pulling the image on first use since /containers/create doesn't.

All fixes verified live against a real podman system service and, where
reachable, via the actual WebUI over the real socket — not just unit-level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 11:51:17 +00:00
maggesandClaude Sonnet 5 5944ddf722 Fix WebUI: podman_parse_time() rejected int timestamps from list endpoints
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
Found by exercising the AJAX endpoints directly against a real running
podman service (php -d display_errors=1 -r '...containers.php...') —
containers.php's list action crashed with an uncaught TypeError the
moment a real container existed. podman's libpod API is inconsistent
about container/volume timestamp encoding: inspect-style endpoints
return RFC3339 strings, but list-style endpoints (containers/json,
volumes/json) return raw Unix-epoch integers for the same logical
field. podman_parse_time() only accepted ?string, so any list call
with a real container blew up outright rather than merely
mis-rendering. Widened it to string|int|null and handle both.

Verified: containers.php's list action now returns correct JSON for
real running/exited containers, including their createdAt timestamps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 23:12:04 +00:00
maggesandClaude Sonnet 5 51b7262b72 Fix five real bugs found by actually installing and running the plugin
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 4s
First live end-to-end install on real Unraid hardware (all 8 built
packages installed via upgradepkg, rc.podman started, containers
pulled/run/networked/port-mapped) — surfaced five genuine bugs no
amount of container-based CI testing could have caught, since none of
them exist inside the vbatts/slackware:15.0 build container:

1. rc.podman never created $PODMAN_LOG_DIR before redirecting the
   podman system service's output into it, so the service failed to
   even start ("No such file or directory"). Added it alongside the
   existing PODMAN_RUN_DIR mkdir.

2. config/storage.conf hardcoded a [storage] table, and
   podman-config.sh's `sync` step appended a second one at boot with
   the real graphroot/runroot — TOML forbids defining the same table
   twice. Removed the template's [storage] entirely; sync already
   generates the whole thing.

3. config/policy.json had a "_comment" pseudo-field for
   documentation, but containers/image's policy parser rejects any
   unknown top-level key outright. JSON has no comment syntax; moved
   the rationale into docs/ARCHITECTURE.md instead.

4. netavark >= 2.0 dropped its iptables firewall driver entirely
   (verified: passing "iptables" is flatly rejected) — nftables or
   firewalld are the only remaining options, and firewalld needs
   systemd/dbus, which Unraid has neither of. Set firewall_driver =
   "nftables" explicitly and documented that Unraid OS doesn't ship
   the `nft` binary this needs (a slackware64 nftables package works;
   not yet wired into the build/install pipeline — see follow-up).

5. Every container failed with "crun: pivot_root: Invalid argument".
   Root cause: Unraid's / is permanently the kernel's initial "rootfs"
   pseudo-filesystem (Unraid never pivots to a real one at boot — the
   whole OS runs from RAM), and pivot_root(2) unconditionally rejects
   that as the old root. This is not new: Docker/runc hits the exact
   same kernel restriction on this exact host and silently falls back
   to an MS_MOVE-based chroot; crun has no such fallback, only a
   --no-pivot flag with no config-file equivalent. Added
   plugin/sbin/crun-no-pivot.sh, a thin wrapper that scans crun's full
   argument list (podman puts global flags before the subcommand, so
   the subcommand isn't reliably $1) and injects --no-pivot right
   after create/run, and pointed containers.conf's crun runtime at it.
   Also fixed the podman.plg postinstall's chmod glob
   (`podman-*.sh` -> `*.sh`), which would have skipped this new
   non-podman-prefixed sbin script.

Verified end-to-end on the real host: pull, run, real network
connectivity (wget through the container's bridge), and a published
port actually serving HTTP (curl through -p 8099:80 to nginx) all
work. --no-pivot's security tradeoff (disabling one particular
container-escape mitigation) was explicitly discussed with and
approved by the user before committing, given it must be the default
for any container to start at all on this platform.

Follow-up not yet done: nftables (needed for #4) is not yet a
packages/ component in the reproducible build pipeline — it was only
installed manually on the test host for this verification run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 23:06:53 +00:00
maggesandClaude Sonnet 5 6cd541de23 Fix CI: downgrade upload/download-artifact to v3 for Gitea compatibility
Build Packages / Build .txz packages (push) Successful in 7m28s
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 4s
All 8 packages built and verified successfully in task 96 — the only
remaining failure was the very last step: actions/upload-artifact@v4
errored with GHESNotSupportedError. Gitea Actions' built-in artifact
storage doesn't implement the newer v2 upload/download API that
upload-artifact@v4/download-artifact@v4 require. Downgraded both (they
must match — v3 and v4 artifacts aren't cross-compatible) to v3, which
uses the older protocol Gitea does support.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:26:57 +00:00
maggesandClaude Sonnet 5 c3b8ae8ed9 Fix CI: json-c for crun, and fetch crun's git submodules as pinned tarballs
Build Packages / Build .txz packages (push) Failing after 7m21s
Lint / ShellCheck (push) Successful in 10s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 5s
Verified end-to-end with a full local build of all 8 packages on the
actual runner host (docker exec against vbatts/slackware:15.0, bind-
mounted repo on the cache pool) instead of round-tripping through
Gitea Actions for each fix — much faster for a chain of issues this
deep. All 8 packages now build successfully from a clean checkout.

Three fixes, all in crun (the last package still failing):

1. "Package requirements (json-c >= 0.14) were not met" — added
   json-c to the toolchain bootstrap.

2. crun depends on the libocispec git submodule, which in turn depends
   on the image-spec and runtime-spec git submodules. GitHub's source
   archive tarball never includes submodule content (no .git directory
   for `git submodule update` to work against — the existing `|| true`
   masked this failing silently). Fetched all three as their own
   pinned tarballs instead, at the exact commits crun 1.28 references
   (cross-checked via the GitHub contents API), matching how every
   other dependency in this project is already pinned.

3. crun.c unconditionally #includes git-version.h, which crun's own
   Makefile only generates via `git describe` (again, no .git) or from
   a pre-existing .tarball-git-version.h — the file crun's own `make
   dist` would have written, which we never run. Write that file
   ourselves in the exact format the Makefile already expects; this is
   the documented fallback path bundled release tarballs rely on, not
   a workaround around it.

Also includes the podman go-md2man pre-seed fix and setup-slackware-
buildenv.sh python3 addition from the previous commit's follow-up
testing (both already verified working in this same local build run).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 22:17:02 +00:00
maggesandClaude Sonnet 5 f1962acc8c Fix CI: correct the pinned Go toolchain SHA256
Build Packages / Build .txz packages (push) Failing after 6m3s
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 13s
Lint / EditorConfig (push) Successful in 5s
GO_SRC_SHA256 was wrong since it was first pinned in Task 1 — this
path had never actually been exercised in any prior CI run or local
test because every earlier failure happened before reaching the Go
install step, or (once it did run) nothing had checked the pinned
value against go.dev's own published checksum yet. Task 88's log
caught it: the real go1.26.5 linux-amd64 tarball hashes to
5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053
(cross-checked against https://go.dev/dl/?mode=json directly), not
the previously pinned value. Also independently re-verified
LIBSECCOMP_SRC_SHA256 against the real v2.6.1 tarball while looking at
this — that one was already correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 21:37:48 +00:00
maggesandClaude Sonnet 5 23a1ab22a6 Fix CI: unshadow the pinned Go, and add python3/protoc/go-md2man
Build Packages / Build .txz packages (push) Failing after 2m59s
Lint / ShellCheck (push) Successful in 12s
Lint / Validate .plg XML (push) Successful in 12s
Lint / EditorConfig (push) Successful in 5s
Task 84's log got further than any run so far — 4 of 8 packages
(aardvark-dns, passt, fuse-overlayfs, unraid-podman) built successfully
— and surfaced four distinct, genuine build-time issues for the rest:

1. podman: go.mod parsing failed with "invalid go version '1.25.6':
   must match format 1.23". Root cause: slackpkg's batch-mode
   `install gcc` (verified directly) pulls in every gcc-<lang> sibling
   package Slackware's gcc SlackBuild produces — including gcc-go, an
   ancient bundled go1.16.5. Our Go bootstrap only installed the pinned
   $GO_VERSION when `command -v go` found nothing, so gcc-go's go1.16.5
   silently won. Fixed by always installing/overwriting the pinned Go
   and prepending it to PATH, regardless of what else provides `go`.

2. crun: "no suitable Python interpreter found" — added python3.

3. netavark: build.rs (via prost-build) needs a `protoc` binary;
   Slackware packages no protobuf/protoc at all. Added the official
   prebuilt release binary, pinned + checksummed (no `unzip` on this
   image either, so extracted with `python3 -m zipfile` instead of
   adding yet another package).

4. conmon: `make install`'s docs target needs go-md2man, not packaged
   by Slackware and no prebuilt release exists upstream. `go install`
   it, pinned to a tagged release, now that our own Go is reliably on
   PATH.

All four verified directly against vbatts/slackware:15.0 on the actual
runner host before this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 21:32:27 +00:00
maggesandClaude Sonnet 5 e912180e8d Fix two real bugs in the package build scripts, found via a live CI run
Build Packages / Build .txz packages (push) Failing after 5m5s
Lint / ShellCheck (push) Successful in 13s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 4s
The toolchain bootstrap fixes finally got build-packages.sh far enough
to attempt actual package builds, surfacing two genuine bugs (not
environment/toolchain issues) in task 80's log:

1. sb_fetch_and_verify() echoed its "Fetching..."/"SHA256 verified..."
   progress messages to stdout, same stream its return value (the
   tarball path) is returned on. Every caller captures that return value
   via `tarball=$(sb_fetch_and_verify ...)`, so command substitution
   swallowed the progress lines into $tarball too, and the resulting
   multi-line garbage got handed to `tar -xf` as a single bogus
   filename. Fixed by sending the progress echoes to stderr.

2. unraid-podman.SlackBuild read plugin/podman.plg's version via
   `grep -oP` with a variable-length lookbehind ({1,10} to match
   flexible whitespace). PCRE requires fixed-length lookbehind; this
   works on a PCRE2 grep (e.g. most dev machines) but fails outright
   ("lookbehind assertion is not fixed length") on Slackware's PCRE1
   grep. Replaced with a portable sed capture group.

Both verified directly against vbatts/slackware:15.0 on the runner host
before this commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 15:14:45 +00:00
maggesandClaude Sonnet 5 3133d45d74 Fix CI: add cmake and its transitive runtime deps to the toolchain bootstrap
Build Packages / Build .txz packages (push) Failing after 3m40s
Lint / ShellCheck (push) Successful in 9s
Lint / Validate .plg XML (push) Successful in 9s
Lint / EditorConfig (push) Successful in 10s
yajl (needed by conmon, bootstrapped from source since Slackware ships
no package for it) builds via CMake, not the cmake-free autoconf script
its ./configure wrapper name suggests. Tracing the failure through the
actual vbatts/slackware:15.0 image on the runner host surfaced a chain
of packages slackpkg does not auto-resolve (Slackware packages carry no
dependency metadata at all): cmake needs libarchive, which needs lz4 and
libxml2; the patched make/gmake this mirror serves needs guile, which
needs gc; compiling anything needs kernel-headers for <linux/errno.h>;
and this build's binutils (ar/ranlib) needs flex, while objdump needs
elfutils. All added to the same slackpkg install list as the rest of
the toolchain, keeping everything on one mutually consistent version
set. Verified end-to-end against vbatts/slackware:15.0 on the actual
runner host at each step of this dependency chain.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 15:07:18 +00:00
maggesandClaude Sonnet 5 c6947007aa Fix CI: install Node.js so actions/checkout can run in the container
Build Packages / Build .txz packages (push) Failing after 3m30s
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 5s
The git fix alone got further, but actions/checkout@v4 (and later,
actions/upload-artifact@v4) are Node-based actions — Gitea Actions
execs their JS bundle with the "node" binary from inside the job's
own container rather than injecting a runtime of its own, and
vbatts/slackware:15.0 has no nodejs package anywhere on the official
Slackware mirror. Install a pinned, checksum-verified Node.js release
straight from nodejs.org in the same "Install git" step, matching the
existing Go/Rust bootstrap style in setup-slackware-buildenv.sh.
Verified end-to-end (node --version succeeds after a fresh install) in
vbatts/slackware:15.0 on the actual runner host.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 14:30:12 +00:00
maggesandClaude Sonnet 5 acb9fb704b Fix CI: bootstrap the full Slackware build toolchain via slackpkg
Build Packages / Build .txz packages (push) Failing after 41s
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 5s
The build-packages.yml run was failing for two compounding reasons,
found by testing directly against vbatts/slackware:15.0 on the actual
runner host:

1. The image ships neither git nor its HTTPS runtime libs, so
   actions/checkout failed immediately.
2. It's a minimal rootfs with none of the 'D' (development) series —
   no gcc, make, autoconf, pkg-config, curl, glib2, libcap, or fuse3 —
   contrary to setup-slackware-buildenv.sh's assumption that a "full"
   Slackware install already provides these.

An earlier fix attempt hand-pinned git + its deps (nghttp2, brotli,
cyrus-sasl) by exact file + SHA256 from the base 15.0 release
directory. That drifted out of sync with the newer, patched curl
slackpkg installs later in the same container — same shared library,
two different builds, causing a runtime symbol lookup error. Both
steps now resolve every package through slackpkg's own prioritized
mirror instead, keeping the whole toolchain on one mutually consistent
version set. Verified end-to-end (git ls-remote and curl both succeed
over HTTPS against the real Gitea instance, full toolchain present)
in a fresh vbatts/slackware:15.0 container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 14:11:29 +00:00
maggesandClaude Sonnet 5 f56704a7fb Fix CI: install git in the Slackware build container before checkout
vbatts/slackware:15.0 ships neither git nor its runtime shared libs
(nghttp2, brotli, cyrus-sasl) or ca-certificates, so actions/checkout
was failing immediately with "base image is missing git/tar". Install
git and its dependencies from Slackware's own official mirror, pinned
by exact filename and verified SHA256, and build the CA bundle so git
can trust HTTPS remotes. Verified end-to-end against the real Gitea
instance (git ls-remote succeeds) in a fresh vbatts/slackware:15.0
container.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 13:57:19 +00:00
67 changed files with 8092 additions and 581 deletions
+82 -9
View File
@@ -1,9 +1,10 @@
name: Build Packages
# Builds the seven Slackware .txz packages defined under packages/
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs)
# inside a Slackware container, verifies + consolidates their checksums, and
# uploads the result as a workflow artifact.
# Builds the eleven Slackware .txz packages defined under packages/
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
# catatonit, nftables, podman-compose, unraid-podman) inside a Slackware
# container, verifies + consolidates their checksums, and uploads the
# result as a workflow artifact.
#
# Intentionally does NOT commit any built binary back to the repository —
# packages/**, *.txz, dist/ are all git-ignored (see .gitignore). Artifacts
@@ -31,7 +32,7 @@ on:
inputs:
packages:
description: >
Space-separated package names to build (default: all seven).
Space-separated package names to build (default: all eleven).
Example: "podman conmon"
required: false
default: ""
@@ -46,8 +47,41 @@ on:
# pin by digest) if the project standardizes on a different/self-hosted
# base image. See scripts/ci/setup-slackware-buildenv.sh for how missing
# build dependencies are bootstrapped on top of whatever this image ships.
#
# vbatts/slackware:15.0 ships tar but NOT git (needed by actions/checkout),
# nor any of the shared libraries git's HTTPS transport needs. The
# "Install git" step below uses slackpkg (already present and pre-
# configured with a mirror in this image) rather than hand-picking package
# files: git's HTTPS support pulls in nghttp2/brotli/cyrus-sasl, and
# hand-pinning those separately from the base 15.0 release directory (as
# an earlier version of this step did) silently drifted out of sync with
# the newer, patched curl that scripts/ci/setup-slackware-buildenv.sh
# installs later in the same container — same library, two different
# builds, resulting in a symbol lookup error at runtime. Letting slackpkg
# resolve everything from the same prioritized repo set (patches over
# main, see /etc/slackpkg/slackpkg.conf's PRIORITY) keeps every package on
# this image on a mutually consistent version set.
#
# actions/checkout and actions/upload-artifact are both Node-based actions
# — Gitea Actions execs their JS bundle with the "node" binary found
# inside the job's own container, it does not inject a runtime of its
# own. Slackware has no nodejs package at all (checked: not in any of the
# main/extra/pasture/testing repos), so the "Install git" step also
# installs a pinned, checksum-verified Node.js release directly from
# nodejs.org — the same style already used for Go/Rust in
# scripts/ci/setup-slackware-buildenv.sh.
#
# upload-artifact is pinned to v3, not v4 — Gitea Actions' built-in
# artifact storage does not implement the newer v2 upload/download API
# that actions/upload-artifact@v4 and actions/download-artifact@v4
# require; uploading fails outright with "GHESNotSupportedError". v3 uses
# the older, still-supported protocol. release.yml's download-artifact
# must stay on the matching v3 — the two protocols aren't compatible with
# each other.
env:
SLACKWARE_IMAGE: "vbatts/slackware:15.0"
NODE_VERSION: "20.20.2"
NODE_SHA256: "df770b2a6f130ed8627c9782c988fda9669fa23898329a61a871e32f965e007d"
jobs:
build:
@@ -58,10 +92,49 @@ jobs:
outputs:
artifact-name: ${{ steps.artifact-name.outputs.value }}
steps:
- name: Install git and tar (needed before actions/checkout can run)
- name: Install git and Node.js (needed before actions/checkout can run)
run: |
(command -v git && command -v tar) || \
(echo "!! base image is missing git/tar — see SLACKWARE_IMAGE in this workflow" && exit 1)
set -eu
command -v tar > /dev/null || (echo "!! base image is missing tar — see SLACKWARE_IMAGE in this workflow" && exit 1)
# CHECKGPG is turned off: slackpkg's default GPG-key bootstrap
# fetches Slackware's signing key from www.slackware.com, which
# is not reachable from every CI network (observed to hang on
# this project's self-hosted Gitea Actions runner). CHECKMD5
# (on by default) still verifies every package against the
# mirror's own CHECKSUMS.md5 as a transit-integrity check.
sed -i 's/^CHECKGPG=on/CHECKGPG=off/' /etc/slackpkg/slackpkg.conf
slackpkg -batch=on -default_answer=y update
# git and curl's HTTPS transport need nghttp2/brotli/cyrus-sasl at
# runtime, but slackpkg does not resolve shared-library
# dependencies (Slackware packages carry no such metadata) — list
# them explicitly so they come from the same slackpkg pass (and
# therefore the same mutually-consistent build) as git itself.
slackpkg -batch=on -default_answer=y install \
git ca-certificates nghttp2 brotli cyrus-sasl
# ca-certificates ships individual certs under
# /usr/share/ca-certificates/ — this builds the combined bundle
# git (and later, curl) need to actually trust HTTPS remotes.
update-ca-certificates
echo "GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt" >> "$GITHUB_ENV"
echo "CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt" >> "$GITHUB_ENV"
git --version
# curl isn't installed until scripts/ci/setup-slackware-buildenv.sh
# runs, later — use wget (present in the base image) here instead.
# wget does not consult the system CA bundle on its own (unlike
# curl/git, which respect CURL_CA_BUNDLE/GIT_SSL_CAINFO), so pass
# it explicitly via --ca-certificate.
node_tarball="node-v${NODE_VERSION}-linux-x64.tar.xz"
wget -q --tries=3 --ca-certificate=/etc/ssl/certs/ca-certificates.crt \
-O "/tmp/$node_tarball" "https://nodejs.org/dist/v${NODE_VERSION}/$node_tarball"
echo "${NODE_SHA256} /tmp/$node_tarball" | sha256sum -c -
mkdir -p /usr/local/lib/nodejs
tar -xf "/tmp/$node_tarball" -C /usr/local/lib/nodejs
echo "/usr/local/lib/nodejs/node-v${NODE_VERSION}-linux-x64/bin" >> "$GITHUB_PATH"
"/usr/local/lib/nodejs/node-v${NODE_VERSION}-linux-x64/bin/node" --version
- uses: actions/checkout@v4
@@ -79,7 +152,7 @@ jobs:
run: echo "value=podman-packages-${{ github.sha }}" >> "$GITHUB_OUTPUT"
- name: Upload build artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: ${{ steps.artifact-name.outputs.value }}
path: |
+76 -21
View File
@@ -1,6 +1,10 @@
name: Release
# Publishes a GitHub Release for a version tag (vX.Y.Z).
# Publishes a Gitea Release for a version tag (vX.Y.Z). This project lives
# on a self-hosted Gitea instance (git.mp-mueller.de), not GitHub — despite
# the .github/workflows/ path (kept there because Gitea Actions picks up
# workflows from that path too, verified live: lint.yml/build-packages.yml
# both already run from here without any .gitea/workflows/ copy).
#
# By design, this workflow does NOT bump versions or modify podman.plg
# itself — that happens locally via `scripts/release.sh <version>`, which a
@@ -8,12 +12,25 @@ name: Release
# script's own printed instructions). This workflow's only job is to:
# 1. Rebuild all packages from the tagged commit in a clean Slackware
# container (reproducibility check + provenance — we don't trust
# whatever a maintainer happened to have in their local dist/).
# whatever a maintainer happened to have in their local dist/). This
# only actually verifies anything because
# scripts/lib/slackbuild-common.sh's sb_make_package() pins
# SOURCE_DATE_EPOCH to the tagged commit's timestamp before calling
# makepkg — without that, two separate builds of the identical commit
# produce byte-different .txz files (different file mtimes baked into
# the tar archive) and this whole verify step fails on the first
# package it happens to check, every time (found live: aardvark-dns).
# 2. Verify checksums match what's already committed in plugin/podman.plg
# at this tag (catches a release.sh run that wasn't followed by a
# matching commit — see the "Verify plg matches build" step).
# 3. Create the GitHub Release and attach the .txz packages, checksum
# manifests, and podman.plg.
# 3. Create the Gitea Release and attach the .txz packages, checksum
# manifests, and podman.plg, via Gitea's own REST API — NOT
# softprops/action-gh-release, which talks to GitHub's API and simply
# cannot publish anywhere else. Gitea Actions injects a real,
# repo-scoped API token as secrets.GITHUB_TOKEN (the same env var
# name GitHub Actions uses, for exactly this kind of drop-in
# compatibility) — permissions: contents: write above is what scopes
# that token to allow creating releases.
#
# See docs/ARCHITECTURE.md section 13 (Updates).
@@ -25,20 +42,24 @@ on:
permissions:
contents: write
env:
GITEA_HOST: git.mp-mueller.de
GITEA_REPO: magges/unraid-podman
jobs:
build:
name: Build release packages
uses: ./.github/workflows/build-packages.yml
publish:
name: Publish GitHub Release
name: Publish Gitea Release
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download built packages
uses: actions/download-artifact@v4
uses: actions/download-artifact@v3
with:
name: ${{ needs.build.outputs.artifact-name }}
path: dist
@@ -79,18 +100,52 @@ jobs:
' CHANGELOG.md > /tmp/release-notes.md
echo "path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
name: "unraid-podman v${{ steps.version.outputs.value }}"
body_path: ${{ steps.changelog.outputs.path }}
# v0.x tags are treated as pre-releases until the plugin reaches a
# first stable 1.0.0 — see docs/ROADMAP.md.
prerelease: ${{ startsWith(steps.version.outputs.value, '0.') }}
files: |
dist/*.txz
dist/*.sha256
dist/*.md5
dist/CHECKSUMS.sha256
dist/CHECKSUMS.md5
plugin/podman.plg
- name: Publish Gitea Release
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
set -eu
API="https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}"
# Every release is a normal release, not a "pre-release" — the
# earlier 0.x-is-always-prerelease default didn't match what
# this project actually wants published (v0.1.3 was explicitly
# corrected off "pre-release" after the fact).
prerelease=false
# Idempotent: if a release for this tag already exists (e.g. a
# re-run after a transient failure), delete it first rather than
# erroring on Gitea's own duplicate-tag conflict.
existing_id=$(curl -s -H "Authorization: token ${GITEA_TOKEN}" "${API}/releases/tags/${TAG}" | jq -r '.id // empty')
if [ -n "$existing_id" ]; then
echo "==> Deleting existing release id=$existing_id for tag $TAG"
curl -s -X DELETE -H "Authorization: token ${GITEA_TOKEN}" "${API}/releases/${existing_id}"
fi
body_json=$(jq -Rs '.' < "${{ steps.changelog.outputs.path }}")
payload=$(jq -n --arg tag "$TAG" --arg name "unraid-podman ${TAG}" --argjson body "$body_json" --argjson prerelease "$prerelease" \
'{tag_name: $tag, name: $name, body: $body, draft: false, prerelease: $prerelease}')
release_id=$(curl -s -X POST -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" \
-d "$payload" "${API}/releases" | jq -r '.id')
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
echo "!! Could not create release (no id in response)" >&2
exit 1
fi
echo "==> Created release id=$release_id, uploading assets..."
for f in dist/*.txz dist/*.sha256 dist/*.md5 dist/CHECKSUMS.sha256 dist/CHECKSUMS.md5 plugin/podman.plg; do
name=$(basename "$f")
code=$(curl -s -o /tmp/upload_resp.json -w '%{http_code}' -X POST \
-H "Authorization: token ${GITEA_TOKEN}" \
-F "attachment=@${f};filename=${name}" \
"${API}/releases/${release_id}/assets?name=${name}")
if [ "$code" != "201" ]; then
echo "!! Upload failed ($code) for $name:" >&2
cat /tmp/upload_resp.json >&2
exit 1
fi
echo " uploaded: $name"
done
echo "==> Published: https://${GITEA_HOST}/${GITEA_REPO}/releases/tag/${TAG}"
+263
View File
@@ -9,6 +9,269 @@ see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#52-build-strategie)).
## [Unreleased]
### Fixed
- Edit Container always reset the Network dropdown to "Bridge" and blanked
the Static IP field, even for a container actually on a custom/macvlan
network with a real IP — `HostConfig.NetworkMode` turns out to just say
"bridge" regardless of what a container is actually attached to via a
custom network (verified live: a running container on "Lan" reported
NetworkMode "bridge" while `NetworkSettings.Networks` only had a "Lan"
entry, no "bridge" one at all). The real network name now comes from
that one `NetworkSettings.Networks` key instead, except when it's
podman's own literal default bridge network (named "podman", not
"bridge") — found while investigating why Sonarr's real static IP never
showed up in its own Edit form.
- A row's context menu (opened from a container's name or its "⋮") kept
the ~2s auto-refresh running underneath it — found live: leaving the
menu open longer than that (reading it, or opening "Move to Folder"
after a pause) let a refresh replace the whole table's rows in the
background, so the menu's anchor button was no longer the one actually
on screen, and a submenu opened from it then positioned itself
wherever that stale anchor now was instead of anywhere sensible.
Auto-refresh now also pauses while any context menu is open, the same
way it already paused for an open modal.
### Added
- Create/Edit Container: "Run as user (optional)" overrides the image's
own default user (e.g. `99:100`) — found live migrating a real
container (Seerr) that Docker had run as `--user 99:100` to match its
bind-mounted appdata's ownership; without this field, podman fell back
to the image's own `USER node` (UID 1000), which couldn't write to
files/directories owned by `nobody:users`. Pre-fills from an existing
container's own `Config.User` when editing.
- Clicking a container's name in the Containers table now opens its row
menu (Details/Pause/Kill/Rename/Edit/Remove), matching how a folder's
member chips already worked — Details becomes just the first menu item
again, consistent everywhere a container is represented, rather than
only inside a folder.
- Create/Edit Container: volumes can now be marked read-only (a "RO"
checkbox per row), and a new "Device passthrough" field passes an
arbitrary host device (e.g. a USB serial adapter like `/dev/ttyACM0`)
through at the same path inside the container — the existing GPU
passthrough field is unchanged and stays the right choice for
`/dev/dri/*`. Both were verified live against podman's own API before
wiring them up (`RW:false` on the resulting mount, and the device
showing up as `PathOnHost`/`PathInContainer`). Device paths are
restricted to `/dev/...` (no `..`) — this goes straight into a podman
create request, not anywhere it could reach untrusted input otherwise.
Read-only also round-trips through templates now (dockerMan's own
`Mode="ro"` convention on a `Path` Config — found in the wild on a real
template that mounts `/mnt/user` read-only for a storage-stats
sidecar). Generic device passthrough is a container-only field for now,
not yet part of the template schema.
- Templates now carry a container's WebUI URL through save/export/import
too (`<WebUI>`, the same tag Unraid's own Docker templates already use
for this) — "Use template" now pre-fills the WebUI URL field, and this
applies to existing Community Applications/dockerMan templates on
import too, not just ones authored by this plugin.
- Templates: "Import from a URL" (e.g. a raw GitHub link to a Community
Applications template), fetched server-side rather than requiring
copy-paste. The fetch only allows plain http(s) to a hostname that
resolves exclusively to public addresses (checked before the request,
then pinned via curl's `CURLOPT_RESOLVE` so a DNS answer can't change
between that check and the actual connection), doesn't follow
redirects, and caps the response size — see
`template_fetch_url()` in `ajax/templates.php`.
- The Templates tab is now "Apps", with a Store/My Templates toggle.
Store browses/searches Community Applications' own public app feed
directly (the same catalog CA's own plugin is built on — see
`ca_feed_search()` in `ajax/templates.php`), paginated (24/page, with
Prev/Next) rather than a single capped-length list. Browsing (no search
term) defaults to Newest-first (by the feed's own FirstSeen timestamp —
when CA's feed first picked the template up), with a toggle to
alphabetical; an actual search is always alphabetical regardless of that
toggle — the only other candidate signal, the feed's own "downloads"
figure, turns out to just be the underlying Docker image's Docker Hub
pull count (found live: dozens of unrelated templates that all happen to
wrap the official nginx/postgres/redis images share the exact same,
enormous number), which would make search results look ranked by
relevance while really just favoring whichever match wraps the
most-pulled base image. "Install" fetches + imports an app the same way
pasting its template URL always did, then opens it straight in the
Create Container form, pre-filled — the same experience "Use" already
gives a saved template, since installing one also saves it as one. My
Templates is this plugin's own saved-template grid, unchanged, with
"Import Template" (paste XML / a URL / one of Unraid's own existing
local Docker templates) still a modal off of it.
- A shared in-app confirm dialog (`P.confirm()` in `app.js`) replaces
every browser-native `confirm()` across the whole plugin (containers,
templates, compose, images, volumes, networks, pods, settings). A
native `confirm()` blocks the entire tab until dismissed — including
this plugin's own auto-refresh — and found live to be an actual
liability: a hung dialog blocked further interaction outright, and in
one case a stray keypress meant to dismiss it ended up confirming a
second, unrelated deletion too.
### Fixed
- `podman-verify-packages.sh`/`podman-update-packages.sh` reported every
single package as "not installed" right after a genuinely successful
install (confirmed live on a real v0.1.5 install: `/var/log/packages/`
had the correct records the whole time). Root cause:
`/var/log/packages` is itself a symlink on Unraid
(`-> ../lib/pkgtools/packages`), and GNU `find`'s default `-P` mode
doesn't descend into a symlinked starting path at all without `-L`
it just returns the symlink itself and nothing below it. Fixed via a
new shared `podman_find_installed_package_record()` helper in
`podman-common.sh`, which also fixes a second bug found while testing
the first fix: "podman"'s own glob also matched podman-compose's file
(a literal prefix collision), and `find`'s unsorted output let
podman-compose's record silently win podman's own check on the same
real host.
## [0.1.5] - 2026-07-13
### Added
- Containers panel: folders to group containers (name + optional icon
URL), purely cosmetic organizational metadata stored in the plugin's
own `folders.json` — podman itself has no such concept, same as
Unraid's own Docker page's folders. "+ New Folder", a "Move to Folder"
submenu on each container's row menu, and per-folder rename/delete
(deleting a folder only ungroups its containers, never touches them).
A folder with nothing assigned yet still shows up (so there's
somewhere to move a container into); one that's merely hidden by the
current search/filter does not. A folder's header always shows an
icon+name+status chip per member — collapsed or expanded, matching how
Unraid's own Docker page folders behave — rather than hiding everything
behind a bare count; clicking a chip opens the same row menu the "⋮"
button does (Details is now a menu item there too, alongside
Pause/Kill/Rename/Edit/Remove) rather than jumping straight to the
detail modal. "Move to Folder" only shows for a container that isn't
grouped yet — once it's in one, that item becomes a direct "Remove
from Folder" instead, since "move to a folder" reads as "add" and is
ambiguous/redundant once it's already in one. Containers can also
carry an icon URL (settable in the create/edit form, auto-filled when
creating from a template) shown in the table instead of the 2-letter
initials avatar.
- Containers can now carry a WebUI URL too (create/edit form, preserved
through Update/Update All the same way the icon URL is), shown as a
small open-in-new-tab button next to the name in the Containers table
when set. Both this and the icon are stored as the plugin's own
container labels (`podman-webui.weburl`/`.icon`), read straight off
the already-fetched container list — no extra per-container calls.
- Container detail modal: 5 new tabs (Resources, Logs, Console, Events,
Healthcheck), rounding it out from 6 to 11 of prompt.md's 12 requested
tabs (Volumes was left merged into the existing Mounts tab — same
underlying source/destination data, a separate tab would just repeat
it). Resources shows live CPU/memory alongside configured limits
(memory/swap/CPU/PIDs/block-I/O — a `0` in podman's own HostConfig
means "unlimited", not zero). Logs reuses the existing per-container
logs endpoint. Console opens a real ttyd/podman-exec session scoped to
the modal, cleaned up (killing the ttyd process server-side) on every
way of leaving — switching tabs, Close, backdrop click, or Escape —
not just on an explicit Disconnect, so peeking at a console doesn't
leak an orphaned ttyd process. Events and Healthcheck are both one-shot
historical queries (libpod's `/events?stream=false` for the last 7
days, and inspect's own `State.Health.Log`) rather than a live stream,
which stays out of scope (see dashboard.js's own note on why).
- A real toast notification system (success/warning/error/info, auto-
dismissing, stacked bottom-right, dismissible early) replacing every
`alert()` used for one-way feedback across Containers, Images, Volumes,
Networks, Pods, Templates, Compose, and Settings — confirmations stay
native `confirm()` (a decision, not a notice), and long command output
(`podman compose up/down`, previously also an `alert()`) now goes to
the existing scrolling log-modal instead, which fits it better than a
toast ever could.
- Dashboard reworked: a clean 6-tile row of plain counts (Running,
Stopped, Pods, Images, Volumes, Networks — colored green/red on
Running/Stopped so fleet health reads at a glance), a single "Resource
Usage" card with aligned CPU/Memory/Swap/Storage meter rows (bars shift
to warn/bad colors above 75%/90% instead of staying accent-colored at
any value), and an "Autostart Queue" table (reusing
`podman-autostart.sh`'s own per-container failure counters, so it shows
exactly what that script would decide, not a second tracked history).
Bar-and-percentage metrics were kept out of the plain-count tile grid
entirely after an earlier attempt at combining them (a tile spanning
multiple grid columns) left dead, empty cells in the layout.
- Dashboard and Containers now auto-refresh every ~2s (paused while the
tab is hidden or a modal is open) instead of requiring a manual
Refresh click to see current state.
- Settings: an "Add container" dropdown + button above the Autostart
order table — previously that table could only reorder/remove
containers already in the chain, with no way to add one in the first
place.
- Containers: "Update All" now also removes the old, now-unused image
version each updated container leaves behind (only when at least one
container actually updated), instead of leaving stale images to pile up
on disk after every update run.
### Fixed
- Dashboard memory usage was calculated from libpod's raw `memFree` (which
excludes reclaimable buffers/cache), showing usage far higher than
reality — e.g. 67% "used" where `free -h` reported 19%. Now reads
`/proc/meminfo`'s `MemAvailable` directly, matching what `free -h` and
most monitoring tools show.
- Dashboard CPU usage never changed — libpod's own `/info` computes
`cpuUtilization` once and never resamples it (confirmed live: three
calls seconds apart returned byte-identical numbers). Now computed from
`/proc/stat` deltas between successive requests, the same technique
`top`/`htop` use, which auto-refresh's ~2s cadence fits naturally.
- Context menus (a container's "⋮", a folder's "⋮") always dropped down
from the anchor at a fixed position — opened from a row near the
bottom of the viewport, the menu ran off-screen with its last items
unreachable. Now measures its own actual rendered size (which varies
with item count) after appending and flips above the anchor instead
when there isn't enough room below, clamped horizontally too.
- Expanding/collapsing a folder shifted every column (including the
header text) in the Containers table — its default `table-layout:
auto` sizes each column from the widest content among only the
currently-visible rows, so adding/removing a folder's rows changed
what counted as "widest" on every toggle. Columns now use explicit
fixed widths that don't depend on row content at all.
- The Name column's content (icon + name + WebUI button) was
misaligned with its own header, drifting further off with every row —
putting `display:flex` directly on the `<td>` (to keep the WebUI
button on the same line as the name) pulls that cell out of
table-cell layout entirely, so with `table-layout:fixed` it stopped
respecting the column width its `<th>` assigns. The flex layout now
lives on a `<div>` inside the `<td>` instead, so the `<td>` itself
stays a normal, fixed-width table cell.
## [0.1.3] - 2026-07-12
### Added
- Settings: a "Podman Service" card with live status plus Start/Stop/Restart,
each shown in a progress log modal — diagnosing and recovering a podman
that failed to start no longer needs SSH/terminal access at all.
- Settings: "Format a Disk for Podman Storage", for a single-disk system
with no cache pool. Only ever lists disks with no existing partition,
filesystem, or RAID/ZFS membership signature, and never the Unraid boot
flash — both excluded by multiple independent checks, not just one.
Formatted disks are remounted by UUID on every boot.
### Fixed
- `STORAGE_PATH` was never auto-created, even when its parent (a real,
already-mounted cache pool) existed — only failed if genuinely nothing
was mounted at all. Root cause of a real "cannot reach the Podman API
socket" report on a fresh install with a perfectly normal cache pool.
- Cache-busting for the WebUI's own JS/CSS never actually worked, at all,
the entire time — Unraid runs `.page` PHP through `eval()`, and
`__DIR__` inside `eval()`'d code resolves to the *eval() call site's*
directory, not the plugin's. Every "hard refresh" this project's own
docs/commits ever recommended only worked because Ctrl+Shift+R bypasses
the browser cache directly, independent of this (broken) mechanism.
- Settings' service status chip showed "Not running" immediately after a
successful Start, because the "is it running" check only recognized
`rc.podman status`'s own wording, not `start`/`stop`/`restart`'s.
- The Podman Service log showed `rc.podman`'s multi-line output as one
run-together paragraph (missing `white-space: pre-wrap` on the pane
itself, not just its per-line children).
## [0.1.1] - 2026-07-12
### Fixed
- `plugin/podman.plg`'s `<URL>`/`<MD5>` entity values were split across their
own lines (`<URL>\n&baseURL;/...\n</URL>`) — Unraid's plugin manager passes
that text straight into a `wget ... -O <name> <url>` shell command without
trimming it, so the leading newline broke the command in two: `wget` saw no
URL argument at all, and the URL text ran on the next line as its own
(failing) shell command. Every real Unraid plugin (verified against
`unassigned.devices.plg` on a live host) keeps `<URL>...</URL>` on one
line — found by running the plugin installer's own CLI (`scripts/plugin
install`) directly on a real Unraid host and reading its raw output,
rather than trusting the webGUI's summarized install log.
## [0.1.0] - 2026-07-12
### Added
- Initial repository scaffolding: directory structure, documentation skeleton,
CI workflow stubs, and community health files.
+26 -2
View File
@@ -18,6 +18,30 @@
# TODO: static_dir / volume_path / runroot overrides pointing at the cache-pool
# backed storage location instead of RAM-root defaults.
[engine.runtimes]
# Unraid's / is the kernel's initial 'rootfs' pseudo-filesystem — Unraid
# never pivots to a real one during boot, the whole OS runs from RAM — and
# pivot_root(2) unconditionally rejects that as the "old root" (EINVAL).
# runc (what Docker uses) silently falls back to an MS_MOVE-based chroot
# in that situation; crun has no such fallback, only a --no-pivot flag on
# `create`/`run` with no config-file equivalent — so podman is pointed at
# a thin wrapper (installed by the unraid-podman package, see
# plugin/sbin/crun-no-pivot.sh) that injects it, instead of crun directly.
# Verified live: without this, every container fails with
# "crun: pivot_root: Invalid argument: OCI runtime error".
crun = ["/usr/local/sbin/crun-no-pivot.sh"]
[network]
# TODO: default network backend (netavark), default subnet range distinct from
# Docker's docker0 range — see docs/ARCHITECTURE.md section 8.
# TODO: default subnet range distinct from Docker's docker0 range — see
# docs/ARCHITECTURE.md section 8.
#
# netavark >= 2.0 dropped its iptables firewall driver entirely — only
# nftables and firewalld remain (verified against the actual netavark
# binary; "iptables" is rejected with "Must provide a valid firewall
# backend"). firewalld needs systemd/dbus, which Unraid has neither of, so
# nftables (netavark's own default — explicit here so that stays true even
# if netavark's default ever changes) is the only viable driver. Unraid OS
# does not ship the `nft` binary this needs — see docs/ARCHITECTURE.md
# section 8 for how it's provisioned.
network_backend = "netavark"
firewall_driver = "nftables"
-1
View File
@@ -1,5 +1,4 @@
{
"_comment": "Default container image signature verification policy. Placeholder: accepts all images without signature verification, matching Docker's default trust model on Unraid today. See docs/ARCHITECTURE.md section 10 (Images). Revisit before a 1.0 release if signed-image verification becomes a goal.",
"default": [
{ "type": "insecureAcceptAnything" }
],
+7 -8
View File
@@ -7,14 +7,13 @@
#
# Full reference: https://github.com/containers/storage/blob/main/docs/containers-storage.conf.5.md
[storage]
driver = "overlay"
# runroot and graphroot are set at runtime by rc.podman based on
# /boot/config/plugins/podman/podman.cfg (configurable storage location),
# not hardcoded here. TODO: document the exact substitution mechanism once
# rc.podman is implemented.
# graphroot = "/var/lib/containers/storage"
# runroot = "/var/run/containers/storage"
# The [storage] table itself (driver, graphroot, runroot) is intentionally
# NOT defined here — podman-config.sh's `sync` command appends it in full
# at sync time, since graphroot depends on STORAGE_PATH from
# /boot/config/plugins/podman/podman.cfg (configurable storage location) and
# can't be known statically. A second [storage] table here would be a TOML
# duplicate-key error once sync appends its own — see podman-config.sh's
# cmd_sync for the generated content.
[storage.options]
# TODO: overlay-specific mount options once the loopback filesystem
+14
View File
@@ -232,6 +232,8 @@ wählen können — mit deutlicher GUI-Warnung bzgl. Performance und Spin-up-Ver
| `fuse-overlayfs` | Fallback-Storage-Driver | Für Rootless-Phase 2 vorbereitet, in Phase 1 optional |
| `passt`/`pasta` | Rootless-Networking | Nachfolger von slirp4netns, Phase 2, aber Paket schon mitbauen (geringe Kosten) |
| `catatonit` oder `tini` | Init-Prozess in Containern (optional, falls von Templates genutzt) | |
| `nftables` | Firewall-Backend für `netavark` | Pflicht seit netavark 2.0 (iptables-Treiber entfernt); Unraid liefert kein `nft` mit — als offizielles Slackware-Paket vendored, nicht selbst gebaut |
| `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
@@ -339,6 +341,12 @@ Unraid-Plugins.
## 8. Netzwerke
- **Backend**: `netavark` + `aardvark-dns` (Podman-Default seit 4.x), kein CNI in Phase 1.
- **Firewall-Treiber**: `netavark` >= 2.0 hat seinen `iptables`-Treiber ersatzlos entfernt —
nur noch `nftables` oder `firewalld` (Letzteres braucht systemd/dbus, hat Unraid nicht).
`nftables` ist also zwingend, aber Unraid OS bringt das `nft`-Binary selbst nicht mit
(nur das ältere `iptables`/`iptables-nft`) — muss vom Plugin bereitgestellt werden
(verifiziert per Live-Test: `podman run` mit Port-Publishing scheitert ohne `nft`
mit „Must provide a valid firewall backend“).
- **Default-Bridge**: eigene Bridge `podman0` (nicht `docker0`), eigener privater
Adressraum (konfigurierbar, Default-Vorschlag außerhalb von Dockers Default-Range,
um Kollisionen bei Parallelbetrieb zu vermeiden).
@@ -397,6 +405,12 @@ Unraid-Plugins.
- **Community-Applications-Kompatibilität**: kein automatischer Import von
Docker-Templates in Phase 1 (eigenes, separates Vorhaben); Podman-Images werden
zunächst über CLI/eigene, minimale Template-Definition verwaltet.
- **Signatur-Policy** (`config/policy.json`): `insecureAcceptAnything` als Default —
akzeptiert Images ohne Signaturprüfung, entspricht Dockers heutigem
Standard-Vertrauensmodell auf Unraid. Vor einem 1.0-Release erneut bewerten, falls
signierte Images ein Ziel werden. Die Datei selbst darf keine Kommentarfelder
enthalten (`containers/image`s Policy-Parser lehnt unbekannte Top-Level-Keys wie
`_comment` strikt ab) — Begründung lebt deshalb hier, nicht in der Datei.
---
+20
View File
@@ -0,0 +1,20 @@
# packages/catatonit/
Pinned version: see `CATATONIT_VERSION` in [versions.env](../../versions.env).
Not built from source — `catatonit.SlackBuild` fetches and repackages
upstream's own prebuilt static x86_64 release binary. catatonit ships no
GitHub release asset for source-tarball builds that's meaningfully
different from just using the binary directly, and it's a small, purely
static ELF with zero runtime library dependencies (verified: `ldd` reports
"not a dynamic executable").
Required by `podman pod create` — without it, pod creation fails with
`finding catatonit binary: exec: catatonit: executable file not found in
$PATH`. Installed to `/usr/libexec/podman/catatonit`, alongside
netavark/aardvark-dns, which podman's `helper_binaries_dir` search already
covers.
Found by live-testing this plugin end-to-end against a real Unraid
install, not from reading podman's docs — see
[docs/ARCHITECTURE.md, section 8](../../docs/ARCHITECTURE.md#8-netzwerke).
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# =============================================================================
# packages/catatonit/catatonit.SlackBuild
#
# Packages the official prebuilt catatonit release binary — not built from
# source, see versions.env's CATATONIT_* block for why. catatonit is the
# init process podman runs inside every pod's infra container to reap
# zombies; without it, `podman pod create` fails outright with "finding
# catatonit binary: exec: catatonit: executable file not found in $PATH"
# (found by live-testing pod creation against a real Unraid install).
#
# Installed alongside netavark/aardvark-dns under /usr/libexec/podman/ —
# podman's helper_binaries_dir search path already covers that directory,
# matching where the other two helper binaries this project ships already
# live (see packages/netavark, packages/aardvark-dns).
# =============================================================================
set -eu
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION="$CATATONIT_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "catatonit"
binary=$(sb_fetch_and_verify "$CATATONIT_SRC_URL" "$CATATONIT_SRC_SHA256" "catatonit-$VERSION")
install -D -m 0755 "$binary" "$PKG/usr/libexec/podman/catatonit"
# The release only ships the raw binary (+ checksum/signature files, no
# LICENSE/README asset) — write minimal doc metadata by hand instead of
# using sb_install_docs, which expects real files to copy from disk.
docdir="$PKG/usr/doc/catatonit-$VERSION"
mkdir -p "$docdir"
{
echo "catatonit $VERSION"
echo "https://github.com/openSUSE/catatonit"
echo "Prebuilt static binary, packaged as-is by unraid-podman — see"
echo "versions.env for the pinned release URL and SHA256."
} > "$docdir/README"
{
echo "Built by unraid-podman from upstream's prebuilt release binary."
echo "Package: catatonit $VERSION"
echo "Built: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
} > "$docdir/unraid-podman.build-info"
sb_make_package "$VERSION" "$ARCH" "$BUILD" "$TAG"
+19
View File
@@ -0,0 +1,19 @@
# HOW TO EDIT THIS FILE:
# The "handy ruler" below makes it easier to edit a package description.
# Line up the first '|' above the ':' following the base package name, and
# the '|' on the right side marks the last column you can put a character in.
# You must make exactly 11 lines for the formatting to be correct. It's also
# customary to leave one space after the ':' except on otherwise blank lines.
|-----handy-ruler------------------------------------------------|
catatonit: catatonit (init process for OCI pod infra containers)
catatonit:
catatonit: A minimal init that reaps zombie processes inside a pod's infra
catatonit: container. Required by `podman pod create` — packaged here from
catatonit: upstream's prebuilt static binary release for the unraid-podman
catatonit: plugin, not built from source (see versions.env).
catatonit:
catatonit: Homepage: https://github.com/openSUSE/catatonit
catatonit:
catatonit:
catatonit:
+35 -7
View File
@@ -37,13 +37,41 @@ done
cd "$srcdir"
# GitHub's source archive does not include vendored git submodules
# (libocispec) that crun's release tarballs normally bundle; init/update
# them explicitly.
if [ -f .gitmodules ]; then
echo "==> [crun] fetching submodules"
git -c protocol.file.allow=always submodule update --init --recursive || true
fi
# GitHub's source archive has no .git directory, so `git submodule
# update` can never work against it (it needs an actual git repo to
# resolve against) — it silently fails and leaves libocispec/ empty,
# which only surfaces much later as a confusing "No rule to make target
# 'all'" in that subdirectory. Fetch libocispec as its own pinned source
# instead, matching every other dependency in this project, and unpack it
# directly over the empty submodule directory.
echo "==> [crun] fetching libocispec (git submodule, pinned separately — see versions.env)"
libocispec_tarball=$(sb_fetch_and_verify "$LIBOCISPEC_SRC_URL" "$LIBOCISPEC_SRC_SHA256" "libocispec-$LIBOCISPEC_COMMIT.tar.gz")
rm -rf "$srcdir/libocispec"
mkdir -p "$srcdir/libocispec"
tar -xf "$libocispec_tarball" -C "$srcdir/libocispec" --strip-components=1
# libocispec has its own two git submodules (image-spec, runtime-spec) —
# same problem, one level deeper. Same fix, same reasoning.
echo "==> [crun] fetching libocispec's image-spec/runtime-spec submodules"
imagespec_tarball=$(sb_fetch_and_verify "$IMAGE_SPEC_SRC_URL" "$IMAGE_SPEC_SRC_SHA256" "image-spec-$IMAGE_SPEC_COMMIT.tar.gz")
rm -rf "$srcdir/libocispec/image-spec"
mkdir -p "$srcdir/libocispec/image-spec"
tar -xf "$imagespec_tarball" -C "$srcdir/libocispec/image-spec" --strip-components=1
runtimespec_tarball=$(sb_fetch_and_verify "$RUNTIME_SPEC_SRC_URL" "$RUNTIME_SPEC_SRC_SHA256" "runtime-spec-$RUNTIME_SPEC_COMMIT.tar.gz")
rm -rf "$srcdir/libocispec/runtime-spec"
mkdir -p "$srcdir/libocispec/runtime-spec"
tar -xf "$runtimespec_tarball" -C "$srcdir/libocispec/runtime-spec" --strip-components=1
# crun.c unconditionally #includes git-version.h. Its own Makefile only
# generates that file from `git describe` (needs .git, which a plain
# tarball checkout never has) or, failing that, from a pre-existing
# .tarball-git-version.h — the file crun's own `make dist` would have
# written. We're not running `make dist`, so write it ourselves in the
# exact format that Makefile target expects; this is the documented
# fallback path, not a workaround around it.
printf '/* autogenerated. */\n#ifndef GIT_VERSION\n# define GIT_VERSION "%s"\n#endif\n' "$VERSION" \
> .tarball-git-version.h
echo "==> [crun] autogen + configure"
./autogen.sh
+19
View File
@@ -0,0 +1,19 @@
# packages/nftables/
Pinned version: see `NFTABLES_VERSION` in [versions.env](../../versions.env).
Not built from source, and no `slack-desc` here (unlike this project's
other packages) — `nftables.SlackBuild` fetches Slackware's own official
`nftables` package and re-hosts it as-is under this project's naming and
checksum convention. It's already a correctly-built Slackware package
(built by the Slackware team for exactly this OS/glibc/arch); the
slack-desc bundled inside it travels along unchanged.
netavark >= 2.0 dropped its iptables firewall driver entirely — nftables
(via the `nft` binary this package provides) is the only firewall backend
that works on Unraid (firewalld needs systemd/dbus, which Unraid has
neither of). Unraid OS itself ships no `nft` binary.
Found by live-testing this plugin end-to-end against a real Unraid
install, not from reading netavark's docs — see
[docs/ARCHITECTURE.md, section 8](../../docs/ARCHITECTURE.md#8-netzwerke).
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# =============================================================================
# packages/nftables/nftables.SlackBuild
#
# Vendors Slackware's own official nftables package as-is — not rebuilt
# from source, see versions.env's NFTABLES_* block for why. Unlike every
# other package here, there is no compile step: the fetched .txz is
# already a correctly-built Slackware package (built by the Slackware
# team for exactly this OS/glibc/arch), so it is re-hosted under this
# project's naming/checksum convention rather than unpacked and restaged
# through makepkg, which would add risk (differing compression/metadata)
# for no benefit.
#
# netavark >= 2.0 requires the nftables firewall driver (its iptables
# driver was removed entirely) but Unraid OS ships no `nft` binary — see
# config/containers.conf and docs/ARCHITECTURE.md section 8. Without this
# package, every `podman run`/`podman pod create` that touches networking
# fails with "netavark: Must provide a valid firewall backend" (found by
# live-testing against a real Unraid install).
# =============================================================================
set -eu
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/scripts/lib/slackbuild-common.sh"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION="$NFTABLES_VERSION"
ARCH="$PKG_ARCH"
BUILD="$PKG_BUILD"
TAG="$PKG_TAG"
sb_init "nftables"
official_pkg=$(sb_fetch_and_verify "$NFTABLES_SRC_URL" "$NFTABLES_SRC_SHA256" "nftables-$VERSION-official.txz")
pkg_file="nftables-$VERSION-$ARCH-$BUILD$TAG.txz"
cp "$official_pkg" "$OUTPUT/$pkg_file"
( cd "$OUTPUT" && sha256sum "$pkg_file" > "$pkg_file.sha256" )
( cd "$OUTPUT" && md5sum "$pkg_file" > "$pkg_file.md5" )
echo "==> [nftables] vendored official Slackware package as $OUTPUT/$pkg_file"
+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.
+83
View File
@@ -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
View File
@@ -50,6 +50,18 @@ export BUILDTAGS="seccomp exclude_graphdriver_btrfs exclude_graphdriver_devicema
export CGO_ENABLED=1
export GOFLAGS="${GOFLAGS:--mod=mod}"
# podman's own Makefile wants to build ITS bundled copy of go-md2man from
# test/tools/vendor/ for doc generation, but that vendor tree isn't
# consistent enough to build standalone outside podman's own module scope
# ("without -mod=vendor, directory ... has no package path"). Its Makefile
# only attempts that build if test/tools/build/go-md2man doesn't already
# exist — pre-seed it with our own system go-md2man (installed in
# scripts/ci/setup-slackware-buildenv.sh) to skip the broken vendor build
# entirely; it's the same tool doing the same job.
mkdir -p test/tools/build
cp "$(command -v go-md2man)" test/tools/build/go-md2man
chmod +x test/tools/build/go-md2man
echo "==> [podman] make (BUILDTAGS=$BUILDTAGS)"
make BUILDTAGS="$BUILDTAGS" GO_BUILD_FLAGS="-ldflags -s"
+1 -1
View File
@@ -1,7 +1,7 @@
# packages/unraid-podman
Packaging recipe for the plugin's own scaffolding — **not** an upstream
component like the other seven package directories. See
component or vendored dependency like the other ten package directories. See
`unraid-podman.SlackBuild`'s header comment for the full rationale.
Bundles:
+15 -10
View File
@@ -2,15 +2,15 @@
# =============================================================================
# packages/unraid-podman/unraid-podman.SlackBuild
#
# Unlike the other seven packages, this one does not compile anything from
# an external upstream source — it packages THIS repository's own plugin
# scaffolding (rc.podman, the sbin/ helper scripts, the official Unraid
# event/ hooks, and the default config templates) into a single .txz,
# exactly matching how real-world Unraid plugins bundle their own files
# (verified against the actual unassigned.devices.plg / package layout —
# see docs/ARCHITECTURE.md section 3.2 for the reference check that led to
# this design). plugin/podman.plg installs this alongside the seven
# compiled component packages, all via the same
# Unlike the other ten packages, this one does not fetch anything from
# an external upstream source at all — it packages THIS repository's own
# plugin scaffolding (rc.podman, the sbin/ helper scripts, the official
# Unraid event/ hooks, and the default config templates) into a single
# .txz, exactly matching how real-world Unraid plugins bundle their own
# files (verified against the actual unassigned.devices.plg / package
# layout — see docs/ARCHITECTURE.md section 3.2 for the reference check
# that led to this design). plugin/podman.plg installs this alongside the
# other ten packages, all via the same
# `upgradepkg --install-new --reinstall` mechanism.
#
# Version: taken directly from plugin/podman.plg's own <!ENTITY version>,
@@ -28,7 +28,12 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
# shellcheck source=/dev/null
. "$REPO_ROOT/versions.env"
VERSION=$(grep -oP '(?<=<!ENTITY version[[:space:]]{1,10}")[^"]+' "$REPO_ROOT/plugin/podman.plg" | head -n1)
# sed, not `grep -oP` with a lookbehind: the lookbehind here needed a
# variable-length quantifier ({1,10}) to match the entity's flexible
# whitespace, but that requires PCRE2 — the PCRE1 grep ships on Slackware
# (and many other distros) rejects it outright ("lookbehind assertion is
# not fixed length"). sed's basic regex has no such restriction.
VERSION=$(sed -n 's/.*<!ENTITY version[[:space:]]\+"\([^"]*\)".*/\1/p' "$REPO_ROOT/plugin/podman.plg" | head -n1)
if [ -z "$VERSION" ]; then
echo "!! Could not read <!ENTITY version> from $REPO_ROOT/plugin/podman.plg" >&2
exit 1
+7 -1
View File
@@ -20,6 +20,12 @@
# blocked on podman's full startup sequence (preflight, storage mount,
# service start, autostart chain) — mirrors how unassigned.devices
# backgrounds its own longer-running "started" hook.
#
# podman-mount-managed-disk.sh runs first, still within the same
# backgrounded subshell: it's a no-op unless the WebUI's "Format a Disk
# for Podman Storage" flow (ajax/disks.php) was ever used, and rc.podman
# start's own storage step needs that disk already mounted at
# $STORAGE_PATH to succeed — see that script's own header comment.
# =============================================================================
/etc/rc.d/rc.podman start > /dev/null 2>&1 & disown
(/usr/local/sbin/podman-mount-managed-disk.sh; /etc/rc.d/rc.podman start) > /dev/null 2>&1 & disown
+106 -86
View File
@@ -32,14 +32,16 @@
Structure of this file:
1. DOCTYPE entity block — plugin metadata + one version/file/md5 triple
per package (the seven upstream components plus this project's own
"unraid-podman" scaffolding package, see packages/unraid-podman/).
per package (the seven upstream components, catatonit/nftables/
podman-compose as vendored runtime dependencies, plus this
project's own "unraid-podman" scaffolding package, see
packages/unraid-podman/).
Entities are rewritten automatically by scripts/release.sh; never
hand-edit a *_txz_version/_file/_md5 entity — see that script.
2. <PLUGIN> body:
a. <CHANGES> — kept in sync with CHANGELOG.md by hand for now.
b. Pre-install architecture sanity check.
c. Eight <FILE> package install/update blocks.
c. Eleven <FILE> package install/update blocks.
d. Postinstall <FILE Run="/bin/bash"> — directory/config seeding,
install-manifest generation, first start.
e. <FILE Run="/bin/bash" Method="remove"> — uninstall.
@@ -48,25 +50,31 @@
<!DOCTYPE PLUGIN [
<!ENTITY name "podman">
<!ENTITY author "unraid-podman contributors">
<!ENTITY version "0.0.0">
<!ENTITY version "0.1.5">
<!-- "Podman" (no parent) — Podman.page declares Menu="Podman", making it
its own top-level nav tab next to Docker/VMs, not nested under
Settings — see webui/plugins/podman/Podman.page. -->
<!ENTITY launch "Podman">
<!ENTITY github "OWNER/unraid-podman">
<!ENTITY gitURL "https://raw.githubusercontent.com/&github;/main">
<!-- This project is hosted on a self-hosted Gitea instance, not GitHub —
&github; is kept as the entity name (widely referenced below) but
holds the Gitea owner/repo slug; gitURL/supportURL/baseURL all point
at git.mp-mueller.de using Gitea's own raw-file and release-asset URL
conventions (structurally the same shape as GitHub's, different host
and raw-file path segment: /raw/branch/<ref>/ instead of /<ref>/). -->
<!ENTITY github "magges/unraid-podman">
<!ENTITY gitURL "https://git.mp-mueller.de/&github;/raw/branch/main">
<!ENTITY pluginURL "&gitURL;/plugin/podman.plg">
<!ENTITY supportURL "https://github.com/&github;/discussions">
<!ENTITY supportURL "https://git.mp-mueller.de/&github;/issues">
<!-- Release asset base — matches scripts/release.sh's RELEASE_BASE_URL
exactly; both must agree since release.sh is what publishes the
packages this URL is expected to find. -->
<!ENTITY baseURL "https://github.com/&github;/releases/download/v&version;">
<!ENTITY baseURL "https://git.mp-mueller.de/magges/unraid-podman/releases/download/v0.1.5">
<!-- Slackware package naming components — must match versions.env's
PKG_ARCH/PKG_BUILD/PKG_TAG (see that file). Kept as entities here so
the eight removepkg calls in the Method="remove" block don't have to
repeat "x86_64-1_unraidpodman" eight times by hand. -->
the eleven removepkg calls in the Method="remove" block don't have to
repeat "x86_64-1_unraidpodman" eleven times by hand. -->
<!ENTITY pkgArch "x86_64">
<!ENTITY pkgBuild "1">
<!ENTITY pkgTag "_unraidpodman">
@@ -78,33 +86,54 @@
fail to download anything — that is intentional; there is nothing to
install before the first tagged release. -->
<!ENTITY podman_txz_version "0.0.0">
<!ENTITY podman_txz_file "podman-&podman_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY podman_txz_md5 "00000000000000000000000000000000">
<!ENTITY podman_txz_version "6.0.1">
<!ENTITY podman_txz_file "podman-6.0.1-x86_64-1_unraidpodman.txz">
<!ENTITY podman_txz_md5 "692b8df0a1ac25544748dea4ff518705">
<!ENTITY conmon_txz_version "0.0.0">
<!ENTITY conmon_txz_file "conmon-&conmon_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY conmon_txz_md5 "00000000000000000000000000000000">
<!ENTITY conmon_txz_version "2.2.1">
<!ENTITY conmon_txz_file "conmon-2.2.1-x86_64-1_unraidpodman.txz">
<!ENTITY conmon_txz_md5 "fc0af377c1beeee452040e8991c4aafa">
<!ENTITY crun_txz_version "0.0.0">
<!ENTITY crun_txz_file "crun-&crun_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY crun_txz_md5 "00000000000000000000000000000000">
<!ENTITY crun_txz_version "1.28">
<!ENTITY crun_txz_file "crun-1.28-x86_64-1_unraidpodman.txz">
<!ENTITY crun_txz_md5 "2e787b0f6826fc61b7a76ca42573d6c4">
<!ENTITY netavark_txz_version "0.0.0">
<!ENTITY netavark_txz_file "netavark-&netavark_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY netavark_txz_md5 "00000000000000000000000000000000">
<!ENTITY netavark_txz_version "2.0.0">
<!ENTITY netavark_txz_file "netavark-2.0.0-x86_64-1_unraidpodman.txz">
<!ENTITY netavark_txz_md5 "27a2128b2fcbeb590773c5dd004d911e">
<!ENTITY aardvark_dns_txz_version "0.0.0">
<!ENTITY aardvark_dns_txz_file "aardvark-dns-&aardvark_dns_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY aardvark_dns_txz_md5 "00000000000000000000000000000000">
<!ENTITY aardvark_dns_txz_version "2.0.0">
<!ENTITY aardvark_dns_txz_file "aardvark-dns-2.0.0-x86_64-1_unraidpodman.txz">
<!ENTITY aardvark_dns_txz_md5 "f775f20903c2301736594fdde0081c6d">
<!ENTITY passt_txz_version "0.0.0">
<!ENTITY passt_txz_file "passt-&passt_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY passt_txz_md5 "00000000000000000000000000000000">
<!ENTITY passt_txz_version "git6ef3d1c">
<!ENTITY passt_txz_file "passt-git6ef3d1c-x86_64-1_unraidpodman.txz">
<!ENTITY passt_txz_md5 "9257eb90fb218b047e9f099e9b2f0418">
<!ENTITY fuse_overlayfs_txz_version "0.0.0">
<!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-&fuse_overlayfs_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY fuse_overlayfs_txz_md5 "00000000000000000000000000000000">
<!ENTITY fuse_overlayfs_txz_version "1.17">
<!ENTITY fuse_overlayfs_txz_file "fuse-overlayfs-1.17-x86_64-1_unraidpodman.txz">
<!ENTITY fuse_overlayfs_txz_md5 "431b1d3ab36d05f817379ef36ba70ae2">
<!-- catatonit and nftables are runtime dependencies this plugin ships,
not upstream podman-ecosystem components — see packages/catatonit/
and packages/nftables/ READMEs for why each is needed (pod infra
container init; netavark's only viable firewall driver, since it
dropped iptables in 2.0 and Unraid has neither `nft` nor
systemd/dbus for firewalld) and why neither is built from source. -->
<!ENTITY catatonit_txz_version "0.2.1">
<!ENTITY catatonit_txz_file "catatonit-0.2.1-x86_64-1_unraidpodman.txz">
<!ENTITY catatonit_txz_md5 "a335c4924f244a41662f891d79b1251d">
<!ENTITY nftables_txz_version "1.0.1">
<!ENTITY nftables_txz_file "nftables-1.0.1-x86_64-1_unraidpodman.txz">
<!ENTITY nftables_txz_md5 "d9bb93b0bdc061681ffbd42a243bb5fc">
<!-- podman-compose is the external Compose provider `podman compose`
shells out to (see packages/podman-compose/README.md) — without it
every Compose panel action fails outright on a clean install. -->
<!ENTITY podman_compose_txz_version "1.6.0">
<!ENTITY podman_compose_txz_file "podman-compose-1.6.0-x86_64-1_unraidpodman.txz">
<!ENTITY podman_compose_txz_md5 "f4344ec947cab1a73c50f9afa0b24bee">
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
sbin/ scripts, event/ hooks, config templates — see
@@ -112,9 +141,9 @@
version always equals the plugin's own &version; — see
packages/unraid-podman/unraid-podman.SlackBuild, which reads it
straight out of this very file rather than tracking it twice. -->
<!ENTITY unraid_podman_txz_version "&version;">
<!ENTITY unraid_podman_txz_file "unraid-podman-&unraid_podman_txz_version;-&pkgArch;-&pkgBuild;&pkgTag;.txz">
<!ENTITY unraid_podman_txz_md5 "00000000000000000000000000000000">
<!ENTITY unraid_podman_txz_version "0.1.5">
<!ENTITY unraid_podman_txz_file "unraid-podman-0.1.5-x86_64-1_unraidpodman.txz">
<!ENTITY unraid_podman_txz_md5 "750fc748b054865b41297a658864d261">
]>
<PLUGIN name="&name;"
@@ -145,7 +174,7 @@
<!--
Pre-install sanity check: this project only builds/ships x86_64 packages
(see versions.env's PKG_ARCH) — fail with a clear message on any other
architecture rather than letting eight package downloads 404 one by one.
architecture rather than letting eleven package downloads 404 one by one.
-->
<FILE Run="/bin/bash">
<INLINE>
@@ -157,7 +186,9 @@ fi
</FILE>
<!--
The seven upstream component packages. Each is downloaded straight into
The seven upstream component packages, plus catatonit, nftables, and
podman-compose (runtime dependencies vendored as-is — see the entity
block above for why). Each is downloaded straight into
its backup slot under /boot/config/plugins/&name;/backup/packages/&version;/
(grouped by PLUGIN version, not each component's own version — a rollback
targets "this plugin release" as one unit, see
@@ -170,66 +201,53 @@ fi
-->
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&podman_txz_file;
</URL>
<MD5>
&podman_txz_md5;
</MD5>
<URL>&baseURL;/&podman_txz_file;</URL>
<MD5>&podman_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&conmon_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&conmon_txz_file;
</URL>
<MD5>
&conmon_txz_md5;
</MD5>
<URL>&baseURL;/&conmon_txz_file;</URL>
<MD5>&conmon_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&crun_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&crun_txz_file;
</URL>
<MD5>
&crun_txz_md5;
</MD5>
<URL>&baseURL;/&crun_txz_file;</URL>
<MD5>&crun_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&netavark_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&netavark_txz_file;
</URL>
<MD5>
&netavark_txz_md5;
</MD5>
<URL>&baseURL;/&netavark_txz_file;</URL>
<MD5>&netavark_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&aardvark_dns_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&aardvark_dns_txz_file;
</URL>
<MD5>
&aardvark_dns_txz_md5;
</MD5>
<URL>&baseURL;/&aardvark_dns_txz_file;</URL>
<MD5>&aardvark_dns_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&passt_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&passt_txz_file;
</URL>
<MD5>
&passt_txz_md5;
</MD5>
<URL>&baseURL;/&passt_txz_file;</URL>
<MD5>&passt_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&fuse_overlayfs_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&fuse_overlayfs_txz_file;
</URL>
<MD5>
&fuse_overlayfs_txz_md5;
</MD5>
<URL>&baseURL;/&fuse_overlayfs_txz_file;</URL>
<MD5>&fuse_overlayfs_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&catatonit_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>&baseURL;/&catatonit_txz_file;</URL>
<MD5>&catatonit_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&nftables_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>&baseURL;/&nftables_txz_file;</URL>
<MD5>&nftables_txz_md5;</MD5>
</FILE>
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>&baseURL;/&podman_compose_txz_file;</URL>
<MD5>&podman_compose_txz_md5;</MD5>
</FILE>
<!--
@@ -238,16 +256,12 @@ fi
packages/unraid-podman/README.md.
-->
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&unraid_podman_txz_file;" Run="upgradepkg --install-new --reinstall">
<URL>
&baseURL;/&unraid_podman_txz_file;
</URL>
<MD5>
&unraid_podman_txz_md5;
</MD5>
<URL>&baseURL;/&unraid_podman_txz_file;</URL>
<MD5>&unraid_podman_txz_md5;</MD5>
</FILE>
<!--
Postinstall: everything that has to happen AFTER the eight packages above
Postinstall: everything that has to happen AFTER the eleven packages above
are on disk, but isn't itself package content — directory/config
seeding, the install-manifest that plugin/sbin/podman-verify-packages.sh
and podman-update-packages.sh read, and the first start. Runs on both
@@ -274,7 +288,7 @@ set -u
echo "Setting permissions..."
chmod 0755 /etc/rc.d/rc.podman
chmod 0755 /usr/local/sbin/podman-*.sh
chmod 0755 /usr/local/sbin/*.sh
chmod 0755 /usr/local/emhttp/plugins/podman/event/disks_mounted
chmod 0755 /usr/local/emhttp/plugins/podman/event/stopping
@@ -291,6 +305,9 @@ echo "NETAVARK_INSTALLED_VERSION=\"&netavark_txz_version;\"" >> "$MANIFEST"
echo "AARDVARK_DNS_INSTALLED_VERSION=\"&aardvark_dns_txz_version;\"" >> "$MANIFEST"
echo "PASST_INSTALLED_VERSION=\"&passt_txz_version;\"" >> "$MANIFEST"
echo "FUSE_OVERLAYFS_INSTALLED_VERSION=\"&fuse_overlayfs_txz_version;\"" >> "$MANIFEST"
echo "CATATONIT_INSTALLED_VERSION=\"&catatonit_txz_version;\"" >> "$MANIFEST"
echo "NFTABLES_INSTALLED_VERSION=\"&nftables_txz_version;\"" >> "$MANIFEST"
echo "PODMAN_COMPOSE_INSTALLED_VERSION=\"&podman_compose_txz_version;\"" >> "$MANIFEST"
echo "UNRAID_PODMAN_INSTALLED_VERSION=\"&unraid_podman_txz_version;\"" >> "$MANIFEST"
echo "Seeding /boot/config/plugins/podman/ configuration (existing files left untouched)..."
@@ -351,6 +368,9 @@ removepkg &netavark_txz_file;
removepkg &aardvark_dns_txz_file;
removepkg &passt_txz_file;
removepkg &fuse_overlayfs_txz_file;
removepkg &catatonit_txz_file;
removepkg &nftables_txz_file;
removepkg &podman_compose_txz_file;
removepkg &unraid_podman_txz_file;
echo ""
+1 -1
View File
@@ -98,7 +98,7 @@ podman_start() {
# a shared process gives consistent state and event streaming.
# --time=0 disables the idle-shutdown timeout (this is a long-running
# daemon under our process management, not an on-demand activation).
mkdir -p "$PODMAN_RUN_DIR"
mkdir -p "$PODMAN_RUN_DIR" "$PODMAN_LOG_DIR"
podman_log "start: starting podman system service on unix://$PODMAN_SOCKET"
nohup podman system service --time=0 "unix://$PODMAN_SOCKET" \
> "$PODMAN_LOG_DIR/podman-service.log" 2>&1 &
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# =============================================================================
# plugin/sbin/crun-no-pivot.sh
#
# Thin OCI-runtime wrapper around crun, injecting --no-pivot on `create`/
# `run`. Unraid's / is the kernel's initial "rootfs" pseudo-filesystem —
# Unraid never pivots to a real one during boot, the whole OS runs from
# RAM — and pivot_root(2) unconditionally rejects that as the "old root"
# (EINVAL). runc (what Docker uses) silently falls back to an MS_MOVE-based
# chroot in that situation; crun has no such fallback and no config-file
# equivalent, only this per-invocation flag — so config/containers.conf
# points podman's crun runtime at this wrapper instead of crun directly.
# See docs/ARCHITECTURE.md section 8.
#
# Verified live: without this, every container fails with
# "crun: pivot_root: Invalid argument: OCI runtime error".
#
# --no-pivot is only a valid flag on crun's create/run subcommands (see
# `crun run --help`) — every other subcommand (delete, exec, kill, list,
# ...) is passed straight through unmodified. podman invokes crun with its
# own global flags BEFORE the subcommand (e.g.
# `crun --log-format=json --log <path> create --bundle <path> ...`), so
# the subcommand is not reliably $1 — scan every argument instead of only
# checking the first one, and insert --no-pivot immediately after
# create/run wherever it actually appears.
# =============================================================================
args=()
found=0
for arg in "$@"; do
args+=("$arg")
if [ "$found" -eq 0 ] && { [ "$arg" = create ] || [ "$arg" = run ]; }; then
args+=("--no-pivot")
found=1
fi
done
exec /usr/bin/crun "${args[@]}"
+76
View File
@@ -168,6 +168,82 @@ podman_storage_path_is_safe() {
return 0
}
# -----------------------------------------------------------------------------
# podman_path_has_real_mount_ancestor <path>
#
# True if <path> itself, or its nearest EXISTING ancestor directory, lives
# on a different filesystem than / (root) — i.e. something is genuinely
# mounted along this path (a cache pool, a dedicated disk, ...), even if
# the exact leaf directory doesn't exist yet. False only when nothing real
# is mounted anywhere along the path (root/RAM all the way up), which is
# the one case that's actually unsafe to silently `mkdir -p` into.
#
# This exists because this project never auto-created $STORAGE_PATH
# itself (only podman.img inside it) — found live: a perfectly normal,
# already-mounted cache pool still failed preflight/storage-create with
# "does not exist", because the pool's own .../system/podman subdirectory
# had simply never been created. "Does the exact leaf directory exist" was
# always the wrong question; "is a real filesystem mounted somewhere along
# this path" is the one that actually matters.
# -----------------------------------------------------------------------------
podman_path_has_real_mount_ancestor() {
local path="$1"
local parent="$path"
while [ ! -d "$parent" ] && [ "$parent" != "/" ]; do
parent="$(dirname "$parent")"
done
[ "$parent" != "/" ] && [ "$(stat -c %d "$parent")" != "$(stat -c %d /)" ]
}
# -----------------------------------------------------------------------------
# podman_find_installed_package_record <name> <all-package-names...>
#
# Prints the /var/log/packages/<name>-... record for <name> (empty/failure
# if not installed). Two real bugs, both found live on an actual host,
# fixed here once instead of separately in every script that needs this:
#
# -L: /var/log/packages is itself a symlink on Unraid (->
# ../lib/pkgtools/packages) — without it, GNU find's default -P mode
# doesn't descend into it AT ALL (it returns just the symlink itself and
# nothing below it), so every package was reported "not installed"
# regardless of what had actually just been installed.
#
# Prefix collision: a plain `-name "$name-*"` glob for "podman" also
# matches podman-compose's file ("podman" is a literal prefix of
# "podman-compose"), and find's output order isn't sorted, so whichever
# one happened to come back first silently won — on a real host, that
# was podman-compose's record, reported as if it were podman's own.
# Explicitly skip any match that actually belongs to a different, more
# specific name also in the given package list.
# -----------------------------------------------------------------------------
podman_find_installed_package_record() {
local name="$1"
shift
local all_names=("$@")
local candidate other belongs_to_other base
while IFS= read -r candidate; do
[ -n "$candidate" ] || continue
base=$(basename "$candidate")
belongs_to_other=0
for other in "${all_names[@]}"; do
# Only a LONGER (more specific) other name can steal a match —
# "podman" is itself a prefix of "podman-compose", so without the
# length check, searching for "podman-compose" would wrongly
# exclude its own, genuinely correct record too (found live: this
# exact over-correction on the very first fix attempt).
if [ "$other" != "$name" ] && [ "${#other}" -gt "${#name}" ] && [ "${base#"$other"-}" != "$base" ]; then
belongs_to_other=1
break
fi
done
if [ "$belongs_to_other" -eq 0 ]; then
echo "$candidate"
return 0
fi
done < <(find -L /var/log/packages -maxdepth 1 -name "${name}-*" -print 2> /dev/null)
return 1
}
# -----------------------------------------------------------------------------
# podman_require_command <binary>
#
+51
View File
@@ -0,0 +1,51 @@
#!/bin/bash
# =============================================================================
# plugin/sbin/podman-mount-managed-disk.sh
#
# Remounts, on every boot, a disk the WebUI's "Format a Disk for Podman
# Storage" flow (webui/plugins/podman/ajax/disks.php's "format" action)
# formatted and mounted for a single-disk system with no cache pool —
# that disk is deliberately outside Unraid's own array/cache pool
# management (it's just a plain XFS filesystem on an otherwise-unassigned
# disk), so nothing else on the system would remount it after a reboot.
#
# Called from plugin/event/disks_mounted, BEFORE rc.podman start, so
# $STORAGE_PATH (pointed at this disk's mountpoint via Settings) is a real
# mounted filesystem by the time podman-storage.sh's `create`/`mount`
# steps run — see that script's "does not exist or is not mounted" check.
#
# Does nothing (exit 0) if the plugin was never used to format a disk —
# /boot/config/plugins/podman/managed-disk.cfg only exists after that flow
# has actually run at least once.
# =============================================================================
set -eu
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./podman-common.sh
. "$SCRIPT_DIR/podman-common.sh"
MANAGED_DISK_CFG="$PODMAN_BOOT_DIR/managed-disk.cfg"
[ -f "$MANAGED_DISK_CFG" ] || exit 0
UUID=""
MOUNTPOINT=""
# shellcheck source=/dev/null
. "$MANAGED_DISK_CFG"
if [ -z "$UUID" ] || [ -z "$MOUNTPOINT" ]; then
podman_log_error "mount-managed-disk: $MANAGED_DISK_CFG is missing UUID/MOUNTPOINT, skipping"
exit 0
fi
if mountpoint -q "$MOUNTPOINT" 2> /dev/null; then
podman_log "mount-managed-disk: $MOUNTPOINT already mounted"
exit 0
fi
mkdir -p "$MOUNTPOINT"
if mount "UUID=$UUID" "$MOUNTPOINT"; then
podman_log "mount-managed-disk: mounted UUID=$UUID at $MOUNTPOINT"
else
podman_log_error "mount-managed-disk: failed to mount UUID=$UUID at $MOUNTPOINT (disk removed/renamed?)"
fi
+8
View File
@@ -75,6 +75,14 @@ elif [ -d "$STORAGE_PATH" ]; then
else
fail "STORAGE_PATH ($STORAGE_PATH) does not appear to be on a mounted filesystem"
fi
elif podman_path_has_real_mount_ancestor "$STORAGE_PATH"; then
# The leaf directory doesn't exist yet, but a real filesystem IS mounted
# somewhere along its path (e.g. the cache pool itself) — podman-storage.sh
# create will mkdir -p it. Not a failure; see that check's own comment
# for the live bug this used to cause (a normal, already-mounted cache
# pool failing preflight just because its .../system/podman subdirectory
# had never been created).
ok "STORAGE_PATH ($STORAGE_PATH) doesn't exist yet, but resolves onto a mounted filesystem — will be created"
else
fail "STORAGE_PATH ($STORAGE_PATH) does not exist — is the configured cache pool/disk present and started?"
fi
+4
View File
@@ -46,10 +46,14 @@ cmd_create() {
fi
if [ ! -d "$STORAGE_PATH" ]; then
if ! podman_path_has_real_mount_ancestor "$STORAGE_PATH"; then
podman_log_error "storage: $STORAGE_PATH does not exist or is not mounted."
podman_log_error "storage: check that the configured cache pool/disk is present before starting podman."
return 1
fi
podman_log "storage: $STORAGE_PATH doesn't exist yet under an already-mounted filesystem — creating it"
mkdir -p "$STORAGE_PATH"
fi
# Free space check: refuse to create an image bigger than what's actually
# available, with a small safety margin, rather than letting truncate
+8 -3
View File
@@ -23,7 +23,7 @@
# needed — see docs/ARCHITECTURE.md section 13.1.
#
# Usage:
# podman-update-packages.sh # reconcile all 7 packages
# podman-update-packages.sh # reconcile all 11 packages
# podman-update-packages.sh podman # reconcile a single package
# =============================================================================
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman"
ALL_PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
if [ ! -f "$INSTALLED_VERSIONS_FILE" ]; then
podman_log_error "update-packages: $INSTALLED_VERSIONS_FILE missing — plugin install metadata not found"
@@ -68,7 +68,12 @@ for name in $targets; do
continue
fi
installed_record=$(find /var/log/packages -maxdepth 1 -name "${name}-*" -print 2> /dev/null | head -n1)
# See podman-common.sh's podman_find_installed_package_record() for why
# this isn't just a plain `find ... -name "$name-*" | head -n1` — note
# it's given $ALL_PACKAGES (not $targets), since prefix-collision
# detection needs the full component universe even when only updating
# a subset of it.
installed_record=$(podman_find_installed_package_record "$name" $ALL_PACKAGES)
installed_basename=$(basename "${installed_record:-__none__}")
case "$installed_basename" in
+6 -4
View File
@@ -2,7 +2,7 @@
# =============================================================================
# plugin/sbin/podman-verify-packages.sh
#
# "Pakete prüfen" — verifies the seven packages this plugin ships are
# "Pakete prüfen" — verifies the eleven packages this plugin ships are
# actually installed, at the version the plugin expects, and that the
# backed-up .txz copies (see podman-backup.sh) haven't bit-rotted on disk.
#
@@ -34,7 +34,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$SCRIPT_DIR/podman-common.sh"
INSTALLED_VERSIONS_FILE="/usr/local/share/unraid-podman/installed-versions.env"
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman"
PACKAGES="podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman"
QUIET=0
[ "${1:-}" = "--quiet" ] && QUIET=1
@@ -72,7 +72,9 @@ for name in $PACKAGES; do
report " expected version: $expected_version"
# --- Check 1: installed -----------------------------------------------
installed_record=$(find /var/log/packages -maxdepth 1 -name "${name}-*" -print 2> /dev/null | head -n1)
# See podman-common.sh's podman_find_installed_package_record() for why
# this isn't just a plain `find ... -name "$name-*" | head -n1`.
installed_record=$(podman_find_installed_package_record "$name" $PACKAGES)
if [ -z "$installed_record" ]; then
report " installed: NO"
podman_log_error "verify: $name is not installed (no /var/log/packages/$name-* record)"
@@ -95,7 +97,7 @@ for name in $PACKAGES; do
esac
# --- Check 3: backup artifact integrity, if present --------------------
# Packages of all 7 components released together as one plugin version
# Packages of all components released together as one plugin version
# are grouped under a single PLUGIN_VERSION directory (not per-component
# version) — a rollback targets "go back to plugin release X", matching
# podman-backup.sh's restore-packages <plugin-version>.
+354
View File
@@ -0,0 +1,354 @@
# Unraid Podman Plugin Anforderungen an die WebUI
Du bist ein Senior UX Designer und Senior Full-Stack Entwickler mit Erfahrung in Unraid, Podman und Self-Hosting.
Entwirf eine moderne, performante und vollständig in Unraid integrierte WebUI für ein natives Podman-Plugin.
Die Oberfläche soll sich optisch und funktional wie ein offizieller Bestandteil von Unraid anfühlen und keine Fremdanwendung darstellen.
## Ziel
Die WebUI soll alle Funktionen bieten, die Administratoren für die Verwaltung von Podman benötigen, ohne auf die Kommandozeile angewiesen zu sein.
Die Bedienung soll sowohl für Einsteiger als auch für erfahrene Nutzer geeignet sein.
---
# Allgemeine Anforderungen
* Responsives Design
* Dark-Mode kompatibel
* Passend zum Unraid-Design
* Sehr schnelle Ladezeiten
* Modular aufgebaut
* Erweiterbar
* AJAX/WebSocket statt kompletter Seitenreloads
* Suchfunktion auf allen Listen
* Sortierung aller Tabellen
* Filtermöglichkeiten
* Mehrfachauswahl
* Kontextmenüs
* Bestätigungsdialoge
* Benachrichtigungen (Success, Warning, Error)
* Ladeanimationen
* Fortschrittsanzeigen
* Automatische Aktualisierung wichtiger Statusinformationen
* Mehrsprachigkeit vorbereiten
---
# Dashboard
Das Dashboard soll auf einen Blick den Zustand der gesamten Podman-Umgebung zeigen.
Anzeigen:
* Anzahl Container
* Laufende Container
* Gestoppte Container
* Pods
* Images
* Netzwerke
* Volumes
* CPU-Auslastung
* RAM-Auslastung
* Storage-Auslastung
* Schreib-/Lese-I/O
* Netzwerkdurchsatz
* Podman-Version
* Conmon-Version
* crun-Version
* Netavark-Version
* Plugin-Version
Zusätzlich:
* Letzte Ereignisse
* Fehler
* Warnungen
* Updates verfügbar
* Container mit Problemen
* Container ohne Healthcheck
* Nicht verwendete Images
* Nicht verwendete Volumes
* Nicht verwendete Netzwerke
---
# Containerverwaltung
Für jeden Container:
* Start
* Stop
* Restart
* Pause
* Resume
* Kill
* Löschen
* Umbenennen
* Duplizieren
* Exportieren
* Commit als Image
* Snapshot (falls unterstützt)
Informationen:
* Name
* Image
* Status
* Uptime
* CPU
* RAM
* PID
* IP-Adresse
* Netzwerk
* Ports
* Volumes
* Labels
* Environment
* Health Status
* Restart Policy
Tabs:
* Übersicht
* Logs
* Konsole (TTY)
* Ressourcen
* Netzwerke
* Volumes
* Environment
* Mounts
* Labels
* Events
* Inspect (JSON)
* Healthcheck-Verlauf
---
# Pod-Verwaltung
* Pod erstellen
* Pod löschen
* Container hinzufügen
* Container entfernen
* Pod starten
* Pod stoppen
* Neustarten
* Logs
* Portübersicht
* Netzwerkübersicht
---
# Image-Verwaltung
* Images anzeigen
* Pull
* Push
* Taggen
* Löschen
* Prune
* Registry auswählen
* Größe anzeigen
* Erstellungsdatum
* Layer anzeigen
* Historie anzeigen
* Sicherheitsinformationen (wenn verfügbar)
---
# Registry-Unterstützung
Unterstützung für:
* Docker Hub
* GitHub Container Registry
* Quay.io
* GitLab Registry
* Harbor
* Eigene Registries
Funktionen:
* Login
* Logout
* Zugangsdaten speichern
* TLS-Konfiguration
* Unsichere Registry optional aktivieren
---
# Netzwerke
* Netzwerke erstellen
* Löschen
* Bearbeiten
* Verbundene Container anzeigen
* Treiber anzeigen
* Subnetz
* Gateway
* DNS
* MTU
* VLAN (falls unterstützt)
---
# Volumes
* Erstellen
* Löschen
* Mountpunkte anzeigen
* Speicherverbrauch
* Zugeordnete Container
* Backup starten
* Wiederherstellen
* Exportieren
---
# Compose-Unterstützung
Unterstützung für:
* podman compose
Funktionen:
* Compose-Datei importieren
* Stack erstellen
* Start
* Stop
* Neustart
* Logs
* Bearbeiten
* Aktualisieren
* Löschen
* YAML-Editor mit Syntax-Highlighting
* Validierung vor dem Speichern
---
# Template-Unterstützung
Kompatibilität mit Unraid-Templates.
Unterstützung für:
* XML importieren
* XML exportieren
* Template erstellen
* Template bearbeiten
* Icons
* Kategorien
* Repository-Links
---
# Container-Erstellung
Wizard mit mehreren Schritten:
* Name
* Image auswählen
* Registry auswählen
* Ports
* Volumes
* Environment
* Labels
* Geräte
* GPU
* USB
* Netzwerk
* Ressourcen
* Healthcheck
* Restart Policy
* Zusammenfassung
* Validierung
* Container erzeugen
---
# Logs
* Live-Logs
* Suchfunktion
* Filter
* Zeitfilter
* Auto-Scroll
* Download
* Kopieren
* ANSI-Farben korrekt darstellen
---
# Konsole
Browser-Terminal mit:
* Vollbildmodus
* Kopieren/Einfügen
* Mehrere Sitzungen
* Größenanpassung
* UTF-8-Unterstützung
---
# Ressourcenverwaltung
Container-Limits bearbeiten:
* CPU
* RAM
* Swap
* PIDs
* Block-I/O
* Geräte
* HugePages (falls unterstützt)
Visualisierung:
* CPU
* RAM
* Netzwerk
* Festplatten-I/O
---
# Ereignisse
Live-Event-Ansicht:
* Container gestartet
* Container gestoppt
* Image geladen
* Fehler
* Netzwerkänderungen
* Volumeänderungen
Filter nach:
* Container
* Typ
* Zeitraum
* Schweregrad
---
# Einstellungen
Plugin-Einstellungen:
* Storage-Pfad
* API
* Socket
* Registry-Einstellungen
* Standardnetzwerk
* Standard-Runtime
* Logging
* Autostart
* Updates
* Debugmodus
---
+17 -10
View File
@@ -2,10 +2,11 @@
# =============================================================================
# scripts/build-packages.sh
#
# Orchestrates building all seven Slackware .txz packages this plugin ships
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs), by
# running each package's <name>.SlackBuild in turn. See
# docs/ARCHITECTURE.md section 5.2 (Build-Strategie).
# Orchestrates building all Slackware .txz packages this plugin ships
# (podman, conmon, crun, netavark, aardvark-dns, passt, fuse-overlayfs,
# catatonit, nftables, podman-compose), by running each package's
# <name>.SlackBuild in turn. See docs/ARCHITECTURE.md section 5.2
# (Build-Strategie).
#
# This script itself does not containerize anything — it assumes it is
# already running inside a Slackware-compatible build environment (see
@@ -37,13 +38,19 @@ DIST_DIR="$REPO_ROOT/dist"
# see packages/unraid-podman/README.md) is built last since it's by far the
# fastest and has nothing useful to report on failure that earlier package
# failures wouldn't already explain.
ALL_PACKAGES=(conmon crun netavark aardvark-dns passt fuse-overlayfs podman unraid-podman)
ALL_PACKAGES=(catatonit nftables podman-compose conmon crun netavark aardvark-dns passt fuse-overlayfs podman unraid-podman)
# Podman is listed second-to-last on purpose: among the seven upstream
# components it is the slowest build and the one most likely to fail on a
# dependency/tag mistake, so faster packages surface problems first during
# local iteration. unraid-podman is genuinely last since it packages this
# repo's own files and has no compile step at all.
# catatonit, nftables, and podman-compose are listed first since none of
# them involve a compiler — catatonit/nftables are a plain
# fetch-and-repackage of an already-built upstream artifact, and
# podman-compose is vendored pure-Python source with nothing to compile
# (see their own README.md/SlackBuild for why) — fastest possible signal
# if a pinned URL/checksum in versions.env ever goes stale.
# Podman is listed second-to-last on purpose: among the compiled
# components it is the slowest build and the one most likely to fail on
# a dependency/tag mistake, so faster packages surface problems first
# during local iteration. unraid-podman is genuinely last since it
# packages this repo's own files and has no compile step at all.
requested=("$@")
if [ "${#requested[@]}" -eq 0 ]; then
+13 -1
View File
@@ -16,7 +16,7 @@ GO_VERSION="1.26.5"
GO_SRC_URL="https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz"
# Checksum as published by go.dev itself (`curl -s https://go.dev/dl/?mode=json`)
# — must be re-derived from the same source whenever GO_VERSION changes.
GO_SRC_SHA256="88c162b204e6eefcc32499453b492e80209f4a4c78c33092636901c540fb0d05"
GO_SRC_SHA256="5c2c3b16caefa1d968a94c1daca04a7ca301a496d9b086e17ad77bb81393f053"
# Rust toolchain (builds netavark, aardvark-dns) — installed via rustup
# rather than a pinned tarball, since rustup itself provides reproducible,
@@ -44,3 +44,15 @@ LIBSECCOMP_SRC_SHA256="f9a13e4c633d319a9240189760ca348caa0837c0ebe2a09b17061da8c
YAJL_VERSION="2.1.0"
YAJL_SRC_URL="https://github.com/lloyd/yajl/archive/refs/tags/${YAJL_VERSION}.tar.gz"
YAJL_SRC_SHA256="3fb73364a5a30efe615046d07e6db9d09fd2b41c763c5f7d3bfb121cd5c5ac5a"
# protoc (netavark's build.rs shells out to it via the prost-build crate) —
# Slackware ships no protobuf/protoc package at all, official prebuilt
# release binary used instead of a from-source C++ build.
PROTOC_VERSION="35.1"
PROTOC_SRC_URL="https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip"
PROTOC_SRC_SHA256="6930ebf62bd4ea607b98fff052596c6ee564b9835b4ce172c75a3f53ae9d91b7"
# go-md2man (conmon's `make install` shells out to it to generate its man
# page) — installed via `go install`, pinned to a tagged release rather
# than @latest so this build stays reproducible.
GO_MD2MAN_VERSION="2.0.7"
+117 -23
View File
@@ -3,16 +3,25 @@
# scripts/ci/setup-slackware-buildenv.sh
#
# Prepares a Slackware container (see .github/workflows/build-packages.yml)
# to build all seven packages under packages/. Idempotent and safe to re-run.
# to build all eleven packages under packages/. Idempotent and safe to re-run.
#
# Strategy: detect what's already present (a stock "full" Slackware 15.0
# install already provides gcc/make/autotools/glib2/libcap/fuse3) and only
# bootstrap what's genuinely missing (libseccomp, yajl — neither ships in
# stock Slackware — plus the Go and Rust toolchains, which no Slackware
# install ships). This makes the script tolerant of small differences
# between Slackware base image variants instead of assuming one exact image
# layout, while still failing loudly if something we cannot self-provision
# (a C compiler, basically) is missing.
# Strategy: vbatts/slackware:15.0 (the image build-packages.yml runs this
# in) is a minimal rootfs — it ships none of the 'D' (development) series,
# nor glib2/libcap/fuse3/curl. Step 0 below uses slackpkg (already present
# and pre-configured with a mirror in that image) to install the toolchain
# packages by name. Slackware packages carry no dependency metadata at all
# (unlike apt/dnf), so slackpkg does NOT resolve dependencies — the list
# below must name every package explicitly, including curl's HTTPS
# runtime libs (nghttp2, brotli, cyrus-sasl), or you get a shared-library
# error at the first invocation, not an install-time failure. What's left
# after this (libseccomp, yajl, protoc — none ship in stock Slackware —
# plus the Go/Rust toolchains and go-md2man, which no Slackware install
# ships) is bootstrapped from source or official upstream releases,
# further down. This makes the script tolerant
# of small differences between Slackware base image variants (it skips
# anything slackpkg reports as already installed) instead of assuming one
# exact image layout, while still failing loudly if something we cannot
# self-provision is missing.
#
# Exits non-zero with a clear message if a required tool cannot be found or
# provisioned — this script is meant to run early in CI so failures surface
@@ -49,6 +58,31 @@ require_pkgconfig() {
return 0
}
# -----------------------------------------------------------------------------
# 0. Bootstrap the Slackware toolchain packages via slackpkg, if missing.
#
# CHECKGPG is turned off here: slackpkg's default GPG-key bootstrap fetches
# Slackware's signing key from www.slackware.com, which is not reachable
# from every CI network (observed to hang/fail on the self-hosted Gitea
# Actions runner this project builds on). slackpkg's CHECKMD5 (on by
# default) still verifies every package against the mirror's own
# CHECKSUMS.md5 as a transit-integrity check. This is build-toolchain
# provisioning, not the shipped artifacts — those are independently
# checksummed by scripts/checksums.sh.
# -----------------------------------------------------------------------------
if command -v slackpkg > /dev/null 2>&1; then
echo "==> bootstrapping build toolchain via slackpkg"
sed -i 's/^CHECKGPG=on/CHECKGPG=off/' /etc/slackpkg/slackpkg.conf
slackpkg -batch=on -default_answer=y update
slackpkg -batch=on -default_answer=y install \
gcc gcc-g++ binutils make m4 perl autoconf automake libtool pkg-config \
curl nghttp2 brotli cyrus-sasl ca-certificates glib2 libcap fuse3 \
cmake libarchive lz4 libxml2 guile gc kernel-headers flex elfutils \
python3 json-c
else
echo "==> slackpkg not found, assuming toolchain is already provided by the base image"
fi
# -----------------------------------------------------------------------------
# 1. Baseline toolchain expected to already be present in the base image.
# -----------------------------------------------------------------------------
@@ -62,6 +96,7 @@ require_binary git "Needed to fetch crun's git submodules."
require_binary curl "Needed to fetch pinned source tarballs."
require_binary makepkg "Slackware's own packaging tool (pkgtools); should always be present."
require_binary strip "Part of binutils; part of Slackware's 'D' series."
require_binary python3 "Needed by crun's configure script (checks for Python >= 3)."
# -----------------------------------------------------------------------------
# 2. C library dependencies expected to already be present.
@@ -69,6 +104,7 @@ require_binary strip "Part of binutils; part of Slackware's 'D' series."
require_pkgconfig glib-2.0 "Install Slackware's glib2 package (needed by conmon)."
require_pkgconfig libcap "Install Slackware's libcap package (needed by crun)." || true
require_pkgconfig fuse3 "Install Slackware's fuse3 package (needed by fuse-overlayfs)." || true
require_pkgconfig json-c "Install Slackware's json-c package (needed by crun, >= 0.14)." || true
# -----------------------------------------------------------------------------
# 3. libseccomp — not part of stock Slackware, build from source if missing.
@@ -103,7 +139,9 @@ if ! pkg-config --exists yajl 2>/dev/null; then
exit 1
}
mkdir -p "$d/src" && tar -xf "$d/src.tar.gz" -C "$d/src" --strip-components=1
# yajl uses its own cmake-free ./configure wrapper script.
# yajl's ./configure is a thin wrapper around CMake (not a cmake-free
# autoconf script, despite its name) — cmake must already be on PATH,
# see the slackpkg install list in step 0 above.
( cd "$d/src" && ./configure -p /usr && make -C build install )
ldconfig 2>/dev/null || true
else
@@ -113,27 +151,54 @@ fi
# -----------------------------------------------------------------------------
# 5. Go toolchain (podman) — official upstream tarball.
# -----------------------------------------------------------------------------
if ! command -v go > /dev/null 2>&1; then
echo "==> Go not found, installing $GO_VERSION"
curl -fL --retry 3 -o "$WORK/go.tar.gz" "$GO_SRC_URL"
actual=$(sha256sum "$WORK/go.tar.gz" | awk '{print $1}')
[ "$actual" = "$GO_SRC_SHA256" ] || {
# Always install our pinned Go, unconditionally — do NOT skip this just
# because `command -v go` finds something. On this image, slackpkg's
# batch-mode "install gcc" (step 0 above) pulls in every gcc-<lang>
# sibling package built from the same Slackware gcc SlackBuild, including
# gcc-go, which ships an ancient bundled Go (gccgo, go1.16.5) at
# /usr/bin/go — old enough that its go.mod parser rejects the 3-component
# "go 1.25.x" directive modern modules use, and would silently shadow our
# intended $GO_VERSION if we only installed when `go` was missing.
# Overwriting /usr/local/go and prepending it to PATH/GITHUB_PATH here
# guarantees the pinned toolchain wins regardless of what else provides a
# `go` binary.
echo "==> installing Go $GO_VERSION (unconditionally, see comment above)"
curl -fL --retry 3 -o "$WORK/go.tar.gz" "$GO_SRC_URL"
actual=$(sha256sum "$WORK/go.tar.gz" | awk '{print $1}')
[ "$actual" = "$GO_SRC_SHA256" ] || {
echo "!! Go toolchain checksum mismatch (expected $GO_SRC_SHA256, got $actual)" >&2
exit 1
}
rm -rf /usr/local/go
tar -C /usr/local -xf "$WORK/go.tar.gz"
export PATH="/usr/local/go/bin:$PATH"
# Persist PATH for subsequent steps in the same GitHub Actions job.
if [ -n "${GITHUB_PATH:-}" ]; then
}
rm -rf /usr/local/go
tar -C /usr/local -xf "$WORK/go.tar.gz"
export PATH="/usr/local/go/bin:$PATH"
# Persist PATH for subsequent steps in the same GitHub Actions job.
if [ -n "${GITHUB_PATH:-}" ]; then
echo "/usr/local/go/bin" >> "$GITHUB_PATH"
fi
echo "==> using: $(go version)"
# -----------------------------------------------------------------------------
# 6. go-md2man (conmon) — its `make install` shells out to this to render
# docs/conmon.8.md into a man page; not packaged by Slackware, and no
# prebuilt binary release exists upstream, so `go install` it (now that
# our pinned Go from step 5 is on PATH). Pinned to a tagged release
# rather than @latest to keep this build reproducible.
# -----------------------------------------------------------------------------
if ! command -v go-md2man > /dev/null 2>&1; then
echo "==> installing go-md2man v$GO_MD2MAN_VERSION"
go install "github.com/cpuguy83/go-md2man/v2@v${GO_MD2MAN_VERSION}"
gobin="$(go env GOPATH)/bin"
export PATH="$gobin:$PATH"
if [ -n "${GITHUB_PATH:-}" ]; then
echo "$gobin" >> "$GITHUB_PATH"
fi
else
echo "==> Go already present: $(go version)"
echo "==> go-md2man already present: $(command -v go-md2man)"
fi
# -----------------------------------------------------------------------------
# 6. Rust toolchain (netavark, aardvark-dns) — via rustup.
# 7. Rust toolchain (netavark, aardvark-dns) — via rustup.
# -----------------------------------------------------------------------------
if ! command -v cargo > /dev/null 2>&1; then
echo "==> Rust/cargo not found, installing via rustup ($RUST_CHANNEL channel)"
@@ -148,5 +213,34 @@ else
echo "==> Rust already present: $(cargo --version)"
fi
# -----------------------------------------------------------------------------
# 8. protoc (netavark) — its build.rs (via the prost-build crate) shells
# out to a `protoc` binary to compile .proto files; Slackware packages no
# protobuf/protoc at all (checked: not in any of the main/extra/pasture/
# testing repos). Official prebuilt release binary used instead of a
# from-source C++ build. No `unzip` on this image either, so extract with
# `python3 -m zipfile` (python3 is already installed, see step 0/1, for
# crun's configure script) rather than adding yet another package.
# -----------------------------------------------------------------------------
if ! command -v protoc > /dev/null 2>&1; then
echo "==> installing protoc v$PROTOC_VERSION"
curl -fL --retry 3 -o "$WORK/protoc.zip" "$PROTOC_SRC_URL"
actual=$(sha256sum "$WORK/protoc.zip" | awk '{print $1}')
[ "$actual" = "$PROTOC_SRC_SHA256" ] || {
echo "!! protoc checksum mismatch (expected $PROTOC_SRC_SHA256, got $actual)" >&2
exit 1
}
rm -rf /usr/local/protoc
mkdir -p /usr/local/protoc
python3 -m zipfile -e "$WORK/protoc.zip" /usr/local/protoc
chmod +x /usr/local/protoc/bin/protoc
export PATH="/usr/local/protoc/bin:$PATH"
if [ -n "${GITHUB_PATH:-}" ]; then
echo "/usr/local/protoc/bin" >> "$GITHUB_PATH"
fi
else
echo "==> protoc already present: $(protoc --version)"
fi
echo
echo "==> Build environment ready."
+28 -4
View File
@@ -6,8 +6,9 @@
# Centralizing this logic means each individual SlackBuild only has to
# describe *how to compile* its component — fetching, checksum verification,
# and final .txz packaging are implemented once, here, and used the same way
# by all seven packages. This is what keeps the seven build scripts
# consistent and short instead of each reinventing (and potentially
# by all ten packages (unraid-podman's own SlackBuild has no upstream
# source to fetch, so it doesn't need this). This is what keeps the build
# scripts consistent and short instead of each reinventing (and potentially
# forgetting) checksum verification or Slackware package metadata.
#
# Every SlackBuild is expected to:
@@ -68,7 +69,13 @@ sb_fetch_and_verify() {
local dest_name="$3"
local dest_path="$TMP/$dest_name"
echo "==> [$PRGNAM] Fetching $url"
# All progress/diagnostic output here must go to stderr, not stdout:
# callers capture this function's return value via `tarball=$(sb_fetch_and_verify ...)`,
# and command substitution captures everything written to stdout — a
# stray stdout echo above the final `echo "$dest_path"` would get
# concatenated into that captured value instead of just printing to the
# log.
echo "==> [$PRGNAM] Fetching $url" >&2
curl -fL --retry 3 --retry-delay 2 -o "$dest_path" "$url"
local actual_sha256
@@ -84,7 +91,7 @@ sb_fetch_and_verify() {
exit 1
fi
echo "==> [$PRGNAM] SHA256 verified ($actual_sha256)"
echo "==> [$PRGNAM] SHA256 verified ($actual_sha256)" >&2
echo "$dest_path"
}
@@ -166,6 +173,23 @@ sb_make_package() {
find "$PKG" -type f \( -perm -u+x -o -name '*.so*' \) -exec sh -c \
'file "$1" | grep -q ELF && strip --strip-unneeded "$1" 2>/dev/null || true' _ {} \;
# Reproducible builds: Slackware's own makepkg already sorts its file
# list (LC_COLLATE=C sort) before archiving, so member ORDER is already
# deterministic — but it only clamps file mtimes in the resulting tar
# when $SOURCE_DATE_EPOCH is set (verified by reading a real
# /sbin/makepkg: `if [ -n "${SOURCE_DATE_EPOCH}" ]; then MTIME=
# "--clamp-mtime --mtime=@${SOURCE_DATE_EPOCH}"; fi`). Without it, every
# separate build run stamps freshly-compiled files with its own wall-clock
# time, so two builds of the *same* source produce byte-different .txz
# files — which is exactly what broke release.yml's "rebuild in CI and
# verify it matches the checksums committed in podman.plg" step (found
# live: aardvark-dns's checksum differed between two separate Gitea
# Actions runs of the identical tagged commit). Deriving it from the
# repo's last commit time (not `date`/a random per-build value) keeps it
# stable across any number of rebuilds of the same commit, while still
# changing whenever the source actually does.
export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(cd "$CWD/../.." && git log -1 --format=%ct 2>/dev/null || echo 0)}"
local pkg_file="$PRGNAM-$version-$arch-$build$tag.txz"
( cd "$PKG" && makepkg --linkadd y --chown y "$OUTPUT/$pkg_file" )
+45 -15
View File
@@ -4,7 +4,7 @@
#
# Cuts a release of the plugin itself:
# 1. Bumps the &version; entity in plugin/podman.plg to <new-version>.
# 2. Builds all seven packages (scripts/build-packages.sh) unless
# 2. Builds all eleven packages (scripts/build-packages.sh) unless
# SKIP_BUILD=1 is set (useful when CI already built them in a prior job
# and only wants this script to do the .plg/CHANGELOG bookkeeping).
# 3. Verifies + consolidates checksums (scripts/checksums.sh).
@@ -45,19 +45,24 @@ if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
exit 1
fi
# The GitHub Release tag/URL this release's assets will be published under.
# Must match whatever .github/workflows/release.yml actually creates the
# release as (tag "v<version>") — see that workflow for the release job.
# The release tag/URL this release's assets will be published under. This
# project is hosted on a self-hosted Gitea instance (git.mp-mueller.de),
# not GitHub — REPO_SLUG/RELEASE_HOST are overridable via env vars for a
# future move, but default to where this repo actually lives today. Must
# match plugin/podman.plg's &baseURL; entity exactly (see that file).
RELEASE_TAG="v$NEW_VERSION"
REPO_SLUG="${GITHUB_REPOSITORY:-OWNER/unraid-podman}"
RELEASE_BASE_URL="https://github.com/$REPO_SLUG/releases/download/$RELEASE_TAG"
REPO_SLUG="${GITEA_REPOSITORY:-${GITHUB_REPOSITORY:-magges/unraid-podman}}"
RELEASE_HOST="${RELEASE_HOST:-git.mp-mueller.de}"
RELEASE_BASE_URL="https://$RELEASE_HOST/$REPO_SLUG/releases/download/$RELEASE_TAG"
# Component name -> the entity name prefix used in podman.plg. Must match
# plugin/podman.plg's <!ENTITY NAME_txz_...> declarations exactly.
# unraid-podman is the plugin's own scaffolding package (see
# packages/unraid-podman/README.md), not an upstream component, but it's
# released and entity-updated exactly like the other seven.
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs unraid-podman)
# catatonit, nftables, and podman-compose are vendored runtime
# dependencies (not built from source, see their own packages/*/README.md)
# and unraid-podman is the plugin's own scaffolding package (see
# packages/unraid-podman/README.md), not an upstream component, but all
# four are released and entity-updated exactly like the seven upstream ones.
COMPONENTS=(podman conmon crun netavark aardvark-dns passt fuse-overlayfs catatonit nftables podman-compose unraid-podman)
echo "==> Releasing unraid-podman plugin v$NEW_VERSION (packages tag: $RELEASE_TAG)"
@@ -80,7 +85,31 @@ sed -i -E "s|(<!ENTITY baseURL[[:space:]]+\")[^\"]*(\">)|\1${RELEASE_BASE_URL}\2
for name in "${COMPONENTS[@]}"; do
# dist/ contains files like podman-6.0.1-x86_64-1_unraidpodman.txz — find
# the one for this component (there should be exactly one per release).
txz_path=$(find "$DIST_DIR" -maxdepth 1 -name "${name}-*-*-*.txz" | head -n1)
# NOT `find ... | head -n1`: "podman"'s own glob (podman-*-*-*.txz) also
# matches podman-compose's file (podman-compose-*-*-*.txz), since
# "podman" is a literal prefix of "podman-compose" — find's output order
# is filesystem-dependent, not sorted, so head -n1 silently picked
# podman-compose's package for the "podman" entity on one real run,
# publishing a release whose podman.plg pointed at the wrong .txz for
# the actual podman package entirely. Explicitly skip any match that
# actually belongs to a DIFFERENT, more specific name also in
# COMPONENTS (i.e. itself prefixed by another component's name).
txz_path=""
for candidate in "$DIST_DIR"/"${name}"-*-*-*.txz; do
[ -e "$candidate" ] || continue
base=$(basename "$candidate")
belongs_to_other=0
for other in "${COMPONENTS[@]}"; do
if [ "$other" != "$name" ] && [ "${base#"$other"-}" != "$base" ]; then
belongs_to_other=1
break
fi
done
if [ "$belongs_to_other" -eq 0 ]; then
txz_path="$candidate"
break
fi
done
if [ -z "$txz_path" ]; then
echo "!! No built .txz found for component '$name' in $DIST_DIR" >&2
echo "!! Did scripts/build-packages.sh run successfully for it?" >&2
@@ -140,7 +169,8 @@ echo " git add plugin/podman.plg CHANGELOG.md"
echo " git commit -m \"release: v$NEW_VERSION\""
echo " git tag $RELEASE_TAG"
echo " git push && git push origin $RELEASE_TAG"
echo "==> Pushing the tag triggers .github/workflows/release.yml, which"
echo "==> rebuilds artifacts in CI (for reproducibility/provenance) and"
echo "==> publishes the GitHub Release with dist/*.txz + CHECKSUMS.* + the"
echo "==> updated podman.plg attached."
echo "==> .github/workflows/release.yml (softprops/action-gh-release) only"
echo "==> knows how to publish to GitHub — this repo lives on Gitea"
echo "==> ($RELEASE_HOST), so for now, publish the Gitea Release and attach"
echo "==> dist/*.txz + CHECKSUMS.* + the updated podman.plg to it by hand"
echo "==> (or via the Gitea API) after pushing the tag."
+83
View File
@@ -37,6 +37,31 @@ CRUN_VERSION="1.28"
CRUN_SRC_URL="https://github.com/containers/crun/archive/refs/tags/${CRUN_VERSION}.tar.gz"
CRUN_SRC_SHA256="90284c7f097f8ee72a6447978c263e1b1355727c2f2ca0ac667e6d57788f46f5"
# crun's build depends on the libocispec git submodule, which GitHub's
# source archive (fetched above) never includes — a plain tarball has no
# .git directory for `git submodule update` to work against, so that has
# to be fetched as its own separate pinned source instead. Commit pinned
# to exactly what crun 1.28 references (verified via
# `curl https://api.github.com/repos/containers/crun/contents/libocispec?ref=1.28`);
# re-derive it the same way whenever CRUN_VERSION changes.
LIBOCISPEC_COMMIT="8034d0ecd27f646ba3ffae5ff24db234ce062825"
LIBOCISPEC_SRC_URL="https://github.com/containers/libocispec/archive/${LIBOCISPEC_COMMIT}.tar.gz"
LIBOCISPEC_SRC_SHA256="3e9170e54ddf487dc087ff1b88d0722e134a206cf36bba87cc946819ccf036ab"
# libocispec itself has two more git submodules (same problem, one level
# deeper) — its own generate.py needs both schema trees present at build
# time. Commits pinned to exactly what the LIBOCISPEC_COMMIT above
# references (verified the same way, via
# `curl https://api.github.com/repos/containers/libocispec/contents/image-spec?ref=$LIBOCISPEC_COMMIT`
# and .../runtime-spec?ref=...).
IMAGE_SPEC_COMMIT="26647a49f642c7d22a1cd3aa0a48e4650a542269"
IMAGE_SPEC_SRC_URL="https://github.com/opencontainers/image-spec/archive/${IMAGE_SPEC_COMMIT}.tar.gz"
IMAGE_SPEC_SRC_SHA256="8668357de6a1162220b2d1fb654a4182a55844b90ad2774c3b99640eec7e2f54"
RUNTIME_SPEC_COMMIT="d64c1d945da7cf6970061c7c9ff4391fafdf2a15"
RUNTIME_SPEC_SRC_URL="https://github.com/opencontainers/runtime-spec/archive/${RUNTIME_SPEC_COMMIT}.tar.gz"
RUNTIME_SPEC_SRC_SHA256="1698ebaa7ff07f8409c084fe9539d0391820e71b7a6e6d877aa1ce8b383a4b50"
# --- netavark --------------------------------------------------------------
# https://github.com/containers/netavark
NETAVARK_VERSION="2.0.0"
@@ -68,6 +93,64 @@ PASST_VERSION="git${PASST_COMMIT:0:7}"
PASST_SRC_URL="https://passt.top/passt/snapshot/passt-${PASST_COMMIT}.tar.gz"
PASST_SRC_SHA256="4c58a77504a77d613464dddf22ae69d749a5ba64cb87e44c3b8c252333e209fc"
# --- catatonit -----------------------------------------------------------
# https://github.com/openSUSE/catatonit — the init process podman runs
# inside every pod's infra container to reap zombies; not built by
# podman's own Makefile, not packaged by Slackware, needed at runtime on
# every target Unraid install (found by live-testing `podman pod create`
# against a real install: "finding catatonit binary: exec: catatonit: no
# such file or directory"). Upstream publishes a prebuilt static
# (non-dynamic-linked) x86_64 binary release asset — no from-source build
# needed, no runtime library surprises like the crun/yajl chain had.
CATATONIT_VERSION="0.2.1"
CATATONIT_SRC_URL="https://github.com/openSUSE/catatonit/releases/download/v${CATATONIT_VERSION}/catatonit.x86_64"
CATATONIT_SRC_SHA256="8293951eaa7767fa411e3b89777bd01bc5e56db9ba6d145ad10cc4d05b01e961"
# --- nftables --------------------------------------------------------------
# netavark >= 2.0 dropped its iptables firewall driver entirely (see
# config/containers.conf and docs/ARCHITECTURE.md section 8) — nftables is
# now the only viable firewall backend on Unraid (no systemd/dbus for
# firewalld), but Unraid OS ships no `nft` binary. Slackware — Unraid's own
# base distro — already builds and ships this as an official package, so
# it is vendored through as-is (verified working live) rather than
# rebuilt from source: nftables pulls in its own dependency chain
# (libmnl, libnftnl, gmp, ...) for no benefit over the distro's own build,
# which is already correctly built for this exact environment.
NFTABLES_VERSION="1.0.1"
NFTABLES_SRC_URL="http://slackware.osuosl.org/slackware64-15.0/slackware64/n/nftables-${NFTABLES_VERSION}-x86_64-1.txz"
NFTABLES_SRC_SHA256="239e70d48edd6667ce875ff0d339b6f63c1fc94c472524d58772310b1006d31c"
# --- podman-compose -----------------------------------------------------------
# https://github.com/containers/podman-compose — `podman compose` (backing
# webui/plugins/podman/ajax/compose.php, the WebUI's Compose panel) has no
# compose implementation of its own; it needs an external "compose
# provider" command. This project previously vendored docker/compose (the
# Go CLI-plugin binary) for that role, found by podman searching a fixed
# set of CLI-plugin directories for a binary named exactly
# "docker-compose". podman-compose is looked up differently — verified
# live (placing a fake executable and watching podman's own error output
# list its search order) that it's found as a plain command on $PATH,
# not from those same CLI-plugin directories — so it's installed as
# /usr/local/bin/podman-compose, not under any cli-plugins/ path.
#
# Unlike docker-compose, podman-compose is a single Python script, not a
# compiled binary — Unraid ships Python3 itself, but not either of its two
# runtime dependencies (PyYAML, python-dotenv), so those are vendored
# alongside it as plain pure-Python source (no C extension build; PyYAML's
# own __init__.py falls back gracefully when its optional C accelerator
# isn't importable — verified by reading it, not assumed).
PODMAN_COMPOSE_VERSION="1.6.0"
PODMAN_COMPOSE_SRC_URL="https://raw.githubusercontent.com/containers/podman-compose/v${PODMAN_COMPOSE_VERSION}/podman_compose.py"
PODMAN_COMPOSE_SRC_SHA256="10df1662477a673dc803c03e89c1bc1fba6c8c091e716fb6c7dd09c0081e1255"
PYYAML_VERSION="6.0.3"
PYYAML_SRC_URL="https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-${PYYAML_VERSION}.tar.gz"
PYYAML_SRC_SHA256="d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"
PYTHON_DOTENV_VERSION="1.2.2"
PYTHON_DOTENV_SRC_URL="https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-${PYTHON_DOTENV_VERSION}.tar.gz"
PYTHON_DOTENV_SRC_SHA256="2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"
# =============================================================================
# Slackware package BUILD number (not upstream version). Bump this if a
# package must be rebuilt without an upstream version change (e.g. a
+1 -1
View File
@@ -4,7 +4,7 @@ Dynamix-style WebUI pages, following Unraid's plugin GUI convention of
`/usr/local/emhttp/plugins/<name>/`. `webui/plugins/podman/` is staged to
that path by the `unraid-podman` scaffolding package (see
`packages/unraid-podman/unraid-podman.SlackBuild`), which podman.plg installs
alongside the seven compiled components.
alongside the other ten packages.
**Status: implemented**, covering all ten sections from
[docs/ARCHITECTURE.md, section 18](../docs/ARCHITECTURE.md#18-zukünftige-webui):
+164 -36
View File
@@ -1,4 +1,6 @@
Menu="Podman"
Menu="Tasks:66"
Type="xmenu"
Tabs="false"
Title="Podman"
Icon="podman"
---
@@ -16,8 +18,37 @@ Icon="podman"
* page's markup mirrors (same structure, same CSS classes, real data
* instead of static samples).
*/
/**
* Cache-busts every static asset with its own on-disk mtime. Unraid's
* webserver sends no explicit no-cache headers for /plugins/ static
* files, so without this, browsers can keep serving a stale app.js/
* podman.css for a long time after a plugin update.
*
* Deliberately a hardcoded absolute path, NOT __DIR__ — Unraid's own
* PageBuilder runs every .page file's PHP through eval() (see
* webGui/include/DefaultPageLayout/evalContent.php: "eval($evalContent)"),
* and __DIR__/__FILE__ inside eval()'d code resolve to the eval() CALL
* SITE (that file's own directory), not to Podman.page's real location —
* a standard PHP eval() gotcha. Confirmed live: this made
* podman_asset_version() return '0' for every single asset, always,
* completely independent of any browser or PHP-FPM caching (ruled both
* out first: neither a hard-reload nor a php-fpm restart changed the
* result) — the version query string was never actually cache-busting
* anything all session; every "hard refresh fixed it" moment was the
* browser's own Ctrl+Shift+R bypass, not this mechanism. The rest of
* this codebase already hardcodes this same install path elsewhere
* (e.g. include/Config.php's $bootDir, plugin/podman.plg's event hook
* paths) — Unraid plugins always install to /usr/local/emhttp/plugins/
* <name>, so this isn't a new class of fragility.
*/
function podman_asset_version(string $relPath): string
{
$full = '/usr/local/emhttp/plugins/podman' . $relPath;
return is_file($full) ? (string) filemtime($full) : '0';
}
?>
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css">
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css?v=<?=podman_asset_version('/styles/podman.css')?>">
<div class="podman-plugin">
@@ -39,6 +70,7 @@ Icon="podman"
<nav class="podman-subnav">
<button class="active" data-panel="dashboard">Dashboard</button>
<button data-panel="containers">Containers</button>
<button data-panel="templates">Apps</button>
<button data-panel="pods">Pods</button>
<button data-panel="images">Images</button>
<button data-panel="volumes">Volumes</button>
@@ -55,11 +87,30 @@ Icon="podman"
<section class="podman-panel active" id="podman-panel-dashboard">
<div class="podman-grid podman-stat-grid">
<div class="podman-stat"><div class="label">Running</div><div class="value tnum"><span id="stat-running">—</span> <small id="stat-running-total"></small></div></div>
<div class="podman-stat"><div class="label">Stopped</div><div class="value tnum" id="stat-stopped">—</div></div>
<div class="podman-stat"><div class="label">Pods</div><div class="value tnum" id="stat-pods">—</div></div>
<div class="podman-stat"><div class="label">Images</div><div class="value tnum" id="stat-images">—</div></div>
<div class="podman-stat"><div class="label">Volumes</div><div class="value tnum" id="stat-volumes">—</div></div>
<div class="podman-stat"><div class="label">Networks</div><div class="value tnum" id="stat-networks">—</div></div>
<div class="podman-stat"><div class="label">Images on Disk</div><div class="value tnum" id="stat-images-size">—</div></div>
</div>
<div class="podman-card" style="margin-top: 14px;">
<div class="podman-card-head">
<div><h2>Resource Usage</h2><div class="sub">Live CPU, memory, swap, and storage from the Podman host.</div></div>
</div>
<div class="podman-card-pad" id="dashboard-resource-rows">—</div>
</div>
<div class="podman-card" style="margin-top: 14px;" id="dashboard-autostart-card">
<div class="podman-card-head">
<div><h2>Autostart Queue</h2><div class="sub">/boot/config/plugins/podman/autostart</div></div>
</div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>#</th><th>Container</th><th>Delay</th><th>Last Result</th></tr></thead>
<tbody id="dashboard-autostart-tbody"></tbody>
</table>
</div>
</div>
</section>
@@ -68,21 +119,36 @@ Icon="podman"
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" id="containers-search" type="text" placeholder="Search containers by name or image…">
<div class="filterset" id="containers-filterset" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<div class="podman-segmented" id="containers-filterset">
<button class="active" data-filter="all" id="containers-count-all">All</button>
<button data-filter="running" id="containers-count-running">Running</button>
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
</div>
<button class="podman-btn podman-btn-primary" id="containers-check-updates-btn" style="margin-left:auto;">Check for Updates</button>
<button class="podman-btn podman-btn-primary" id="containers-update-all-btn" title="Also removes the old image versions this leaves unused">Update All</button>
<button class="podman-btn" id="containers-new-folder-btn">+ New Folder</button>
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
</div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>Status</th><th>Name</th><th>Image</th><th>CPU/Mem</th><th>Ports</th><th>Uptime</th><th></th></tr></thead>
<table id="containers-table">
<thead><tr>
<th style="width:10%;">Status</th>
<th style="width:20%;">Name</th>
<th style="width:26%;">Image</th>
<th style="width:12%;">CPU/Mem</th>
<th style="width:16%;">Ports</th>
<th style="width:8%;">Uptime</th>
<th style="width:8%;"></th>
</tr></thead>
<tbody id="containers-tbody"></tbody>
</table>
</div>
</div>
</section>
<!-- ============================= APPS (Store + My Templates) ============================= -->
<section class="podman-panel" id="podman-panel-templates"></section>
<!-- ============================= PODS ============================= -->
<section class="podman-panel" id="podman-panel-pods"></section>
@@ -91,6 +157,7 @@ Icon="podman"
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" type="text" placeholder="Search images…" disabled title="Client-side filtering not yet wired up for Images">
<button class="podman-btn" id="images-prune-btn" style="margin-left:auto;">Prune unused</button>
<button class="podman-btn" id="images-pull-btn">&#11015; Pull Image</button>
</div>
<div class="podman-table-wrap">
@@ -145,7 +212,7 @@ Icon="podman"
<div>
<div class="podman-toolbar">
<input class="podman-search" id="logs-filter" type="text" placeholder="Filter log output…" style="max-width:280px;">
<span id="logs-follow-toggle" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<span class="podman-segmented" id="logs-follow-toggle">
<button class="active" data-follow="true">Follow</button>
<button data-follow="false">Paused</button>
</span>
@@ -159,12 +226,22 @@ Icon="podman"
<!-- ============================= TERMINAL ============================= -->
<section class="podman-panel" id="podman-panel-terminal">
<div class="podman-card">
<div class="podman-card-head"><h2>Live Terminal</h2></div>
<div class="podman-card-pad">
<div style="display:flex; gap:8px; align-items:center; margin-bottom:12px; font-size:12.5px; color:var(--text-dim);">
Exec into: <select id="term-container-select"></select>
<div class="podman-term-launcher">
<label>Container <select class="podman-term-select" id="term-container-select"></select></label>
<label>Shell
<select class="podman-term-select" id="term-shell-select">
<option value="bash" selected>bash</option>
<option value="sh">sh</option>
</select>
</label>
<button class="podman-btn podman-btn-primary" id="term-open-btn">&#9654; Open Terminal</button>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="term-disconnect-btn" disabled>&#9632; Disconnect</button>
</div>
<div id="term-frame-wrap">
<p class="podman-empty-note">Pick a running container and click "Open Terminal" — the same live, fully interactive terminal Unraid's own Docker "Console" button opens (arrow-key history, tab completion, vim, etc. all work).</p>
</div>
<div class="podman-term" id="term-output"></div>
<input class="podman-term-input" id="term-input" type="text" placeholder="Type a command and press Enter… (one-shot exec — see Compose panel note on API scope)" autocomplete="off">
</div>
</div>
</section>
@@ -173,15 +250,22 @@ Icon="podman"
<section class="podman-panel" id="podman-panel-compose">
<div class="podman-card">
<div class="podman-compose-layout">
<div class="podman-compose-side" id="compose-sidebar"></div>
<div class="podman-compose-side">
<div class="podman-toolbar" style="border-bottom:1px solid var(--border); padding:10px;">
<button class="podman-btn podman-btn-primary" id="compose-new-btn" style="width:100%; justify-content:center;">+ New Project</button>
</div>
<div id="compose-sidebar"></div>
</div>
<div>
<div class="podman-toolbar">
<strong id="compose-title" style="flex:1;">—</strong>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="compose-action-delete">Delete</button>
<button class="podman-btn" id="compose-action-pull">&#11015; Pull</button>
<button class="podman-btn" id="compose-action-down">&#9632; Down</button>
<button class="podman-btn podman-btn-primary" id="compose-action-up">&#9654; Up</button>
<button class="podman-btn podman-btn-primary" id="compose-action-save">Save</button>
</div>
<pre class="podman-yaml" id="compose-yaml"></pre>
<textarea class="podman-yaml podman-yaml-editor mono" id="compose-yaml" spellcheck="false"></textarea>
</div>
</div>
</div>
@@ -189,9 +273,32 @@ Icon="podman"
<!-- ============================= SETTINGS ============================= -->
<section class="podman-panel" id="podman-panel-settings">
<div class="podman-settings-actions">
<span class="hint" id="settings-save-hint">Changes to storage/enabled/timeout need <span class="mono">rc.podman restart</span> to take effect.</span>
<button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button>
</div>
<div class="podman-grid">
<div class="podman-card">
<div class="podman-card-head"><h2>Storage</h2></div>
<div class="podman-card-head">
<div><h2>Podman Service</h2><div class="sub">Start, stop, restart, or check the podman.sock backend — no terminal needed.</div></div>
</div>
<div class="podman-card-pad">
<div class="podman-service-row">
<span class="podman-chip podman-chip-neutral" id="settings-service-chip"><span class="d"></span>Checking…</span>
<button class="podman-btn" id="settings-service-status-btn">Refresh Status</button>
<button class="podman-btn podman-btn-primary" id="settings-service-start-btn">&#9654; Start Podman</button>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="settings-service-stop-btn">&#9632; Stop Podman</button>
<button class="podman-btn" id="settings-service-restart-btn">&#8635; Restart Podman</button>
</div>
<div class="hint">Stop/Restart first stop all running containers (each gets its own configured grace period) — not just the API service.</div>
<div class="podman-log-pane" id="settings-service-log" style="display:none;"></div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head">
<div><h2>Storage</h2><div class="sub">Where podman keeps images, containers and volumes on disk.</div></div>
</div>
<div class="podman-field-row">
<label for="settings-storage-path">Storage path</label>
<div>
@@ -199,42 +306,65 @@ Icon="podman"
<div class="hint">Cache pool or dedicated disk — never a path under /mnt/user (FUSE).</div>
</div>
</div>
<div class="podman-field-row">
<label>No cache pool?</label>
<div>
<button class="podman-btn" id="settings-format-disk-btn">Format a Disk for Podman Storage…</button>
<div class="hint">Formats an unused disk with XFS and mounts it, for a single-disk system with no cache pool set up yet.</div>
</div>
</div>
<div class="podman-field-row">
<label for="settings-storage-size">podman.img size</label>
<div><input type="number" id="settings-storage-size" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">GB</span></div>
<div>
<div class="podman-input-suffix"><input type="number" id="settings-storage-size" min="1"> <span>GB</span></div>
<div class="hint">Overlay filesystem image size. Only applies the first time podman initializes storage at this path.</div>
</div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Autostart &amp; Lifecycle</h2></div>
<div class="podman-card-head">
<div><h2>Autostart &amp; Lifecycle</h2><div class="sub">What runs when the array starts, and how containers shut down.</div></div>
</div>
<div class="podman-field-row">
<label for="settings-enabled">Start podman on array start</label>
<div><input type="checkbox" id="settings-enabled"></div>
<div>
<label class="podman-switch">
<input type="checkbox" id="settings-enabled"><span class="podman-switch-track"><span class="podman-switch-thumb"></span></span>
</label>
</div>
</div>
<div class="podman-field-row">
<label for="settings-stop-timeout">Container stop timeout</label>
<div><input type="number" id="settings-stop-timeout" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">seconds</span></div>
<div>
<div class="podman-input-suffix"><input type="number" id="settings-stop-timeout" min="0"> <span>seconds</span></div>
<div class="hint">Grace period before a stop/restart escalates to SIGKILL.</div>
</div>
</div>
<div class="podman-field-row">
<label>Autostart order</label>
<div>
<div class="podman-term-launcher">
<label>Add container <select class="podman-term-select" id="autostart-add-select"><option value="">All containers already added</option></select></label>
<button class="podman-btn podman-btn-primary" id="autostart-add-btn">+ Add to Autostart</button>
</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 add/reorder/remove — no separate save step.</div>
</div>
<div class="podman-field-row">
<label></label>
<div><button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button></div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Installed Packages</h2></div>
<div class="podman-field-row">
<label>Versions</label>
<div class="hint mono" id="settings-package-versions" style="max-width:none;">—</div>
<div class="podman-card-head">
<div><h2>Installed Packages</h2><div class="sub">Versions currently installed on this system.</div></div>
</div>
<div class="podman-card-pad">
<div class="podman-version-chips" id="settings-package-versions">—</div>
</div>
</div>
</div>
@@ -243,14 +373,12 @@ Icon="podman"
</main>
</div>
<script src="/plugins/podman/javascript/app.js"></script>
<script src="/plugins/podman/javascript/dashboard.js"></script>
<script src="/plugins/podman/javascript/containers.js"></script>
<script src="/plugins/podman/javascript/pods.js"></script>
<script src="/plugins/podman/javascript/images.js"></script>
<script src="/plugins/podman/javascript/volumes.js"></script>
<script src="/plugins/podman/javascript/networks.js"></script>
<script src="/plugins/podman/javascript/logs.js"></script>
<script src="/plugins/podman/javascript/terminal.js"></script>
<script src="/plugins/podman/javascript/compose.js"></script>
<script src="/plugins/podman/javascript/settings.js"></script>
<?php
foreach ([
'app', 'dashboard', 'containers', 'templates', 'pods', 'images', 'volumes',
'networks', 'logs', 'terminal', 'compose', 'settings',
] as $podmanJsModule) {
$podmanJsPath = "/javascript/{$podmanJsModule}.js";
echo '<script src="/plugins/podman' . $podmanJsPath . '?v=' . podman_asset_version($podmanJsPath) . '"></script>' . "\n";
}
?>
+110 -7
View File
@@ -27,6 +27,8 @@
* Actions (?action=...):
* list GET -> known projects with up/down status
* get GET (&project=...) -> raw compose.yaml content
* save POST {"project": "...", "yaml": "..."} -> creates or overwrites a project's compose.yaml
* remove POST {"project": "..."} -> `down` (best-effort) then deletes the project's directory
* up POST {"project": "..."}
* down POST {"project": "..."}
* pull POST {"project": "..."}
@@ -49,6 +51,15 @@ switch ($action) {
podman_json_response(['yaml' => compose_read($composeDir, $project)]);
break;
case 'save':
$body = podman_read_json_body();
podman_json_response(compose_save($composeDir, require_project($body), (string) ($body['yaml'] ?? '')));
break;
case 'remove':
podman_json_response(compose_remove($composeDir, require_project(podman_read_json_body())));
break;
case 'up':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d']));
break;
@@ -121,8 +132,92 @@ function compose_status(string $composeDir, string $project): string
if ($result['exitCode'] !== 0) {
return 'unknown';
}
$decoded = json_decode($result['output'], true);
return (is_array($decoded) && count($decoded) > 0) ? 'up' : 'down';
// `podman compose ps --format json` emits one JSON object PER LINE
// (JSONL), not a single JSON array — decoding the whole blob in one
// json_decode() call fails silently (-> null) as soon as a project has
// more than one service (verified live with a 2-service project).
// stdout only, too: the "external compose provider" banner goes to
// stderr and would otherwise corrupt this either way.
$running = 0;
foreach (explode("\n", trim($result['stdout'])) as $line) {
if (trim($line) !== '' && is_array(json_decode($line, true))) {
$running++;
}
}
return $running > 0 ? 'up' : 'down';
}
/**
* 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
@@ -155,17 +250,17 @@ function compose_run(string $composeDir, string $project, array $subcommand): ar
* surface even though $project has already been validated above too).
*
* @param array<int,string> $subcommand
* @return array{exitCode:int,output:string}
* @return array{exitCode:int,stdout:string,output:string}
*/
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds, string $yamlFile = 'compose.yaml'): array
{
$yamlPath = $composeDir . '/' . $project . '/compose.yaml';
$yamlPath = $composeDir . '/' . $project . '/' . $yamlFile;
$argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project);
if (!is_resource($process)) {
return ['exitCode' => 127, 'output' => 'Could not start podman compose process'];
return ['exitCode' => 127, 'stdout' => '', 'output' => 'Could not start podman compose process'];
}
stream_set_timeout($pipes[1], $timeoutSeconds);
@@ -175,5 +270,13 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
fclose($pipes[2]);
$exitCode = proc_close($process);
return ['exitCode' => $exitCode, 'output' => trim($stdout . $stderr)];
// 'stdout' (raw) for callers that need to parse machine-readable
// output (e.g. compose_status()'s JSON); 'output' (combined, trimmed,
// ANSI-stripped) for human-facing success/error messages, where seeing
// podman's own stderr banner/warnings is actually useful context —
// just not the raw \x1b[4m/\x1b[0m escape codes wrapping it (found
// live: they showed up as literal garbage characters in the WebUI's
// error alerts).
$combined = preg_replace('/\x1b\[[0-9;]*m/', '', $stdout . $stderr) ?? ($stdout . $stderr);
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($combined)];
}
+437
View File
@@ -13,7 +13,34 @@
* stop POST {"id": "...", "timeout": 10}
* restart POST {"id": "...", "timeout": 10}
* remove POST {"id": "...", "force": false}
* pause POST {"id": "..."}
* unpause POST {"id": "..."}
* kill POST {"id": "...", "signal": "SIGKILL"}
* rename POST {"id": "...", "name": "..."}
* logs GET (&id=...&tail=200) -> plain text
* events GET (&id=...&since=<unix seconds, default 7d ago>) -> array
* of this one container's already-happened events (create,
* start, stop, died, ...) — a bounded historical query, not a
* live stream; see PodmanClient::containerEvents()'s own doc
* comment for why that distinction matters here.
* list_gpus GET -> detected AMD/Intel GPUs (/dev/dri), for the Create Container form's optional passthrough toggle
* check_updates GET -> {"<image ref>": {"updateAvailable": bool, "error": "..."?}} for every image currently in use
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
* "staticIp": "10.1.1.222" (only meaningful with a custom/macvlan networkMode),
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data",
* "readOnly": false}],
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"],
* "devices": [{"path": "/dev/ttyACM0"}] (arbitrary host device passthrough, same path on both
* sides — see build_container_spec()'s comment on why "same path both sides" is
* the only shape supported here),
* "privileged": false, "startAfterCreate": true, "icon": "https://..." (optional),
* "webuiUrl": "http://10.1.1.1:8080/" (optional),
* "user": "99:100" (optional — overrides the image's own default user; a real container
* migrated from Docker with an explicit --user needs this, since without it
* podman falls back to whatever USER the image itself declares, which may not
* own the bind-mounted appdata directory)}
*/
declare(strict_types=1);
@@ -44,6 +71,15 @@ switch ($action) {
podman_json_response(['text' => $client->containerLogs($id, $tail)]);
break;
case 'events':
$id = (string) ($_GET['id'] ?? '');
if ($id === '') {
podman_json_error('Missing id', 400);
}
$since = (int) ($_GET['since'] ?? (time() - 7 * 86400));
podman_json_response($client->containerEvents($id, $since));
break;
case 'start':
$body = podman_read_json_body();
$client->startContainer(require_id($body));
@@ -68,10 +104,371 @@ switch ($action) {
podman_json_response(['status' => 'removed']);
break;
case 'pause':
$body = podman_read_json_body();
$client->pauseContainer(require_id($body));
podman_json_response(['status' => 'paused']);
break;
case 'unpause':
$body = podman_read_json_body();
$client->unpauseContainer(require_id($body));
podman_json_response(['status' => 'unpaused']);
break;
case 'kill':
$body = podman_read_json_body();
$client->killContainer(require_id($body), (string) ($body['signal'] ?? 'SIGKILL'));
podman_json_response(['status' => 'killed']);
break;
case 'rename':
$body = podman_read_json_body();
$newName = trim((string) ($body['name'] ?? ''));
if ($newName === '') {
podman_json_error('Missing name in request body', 400);
}
$client->renameContainer(require_id($body), $newName);
podman_json_response(['status' => 'renamed']);
break;
case 'list_gpus':
podman_json_response(gpu_list());
break;
case 'check_updates':
podman_json_response(check_image_updates($client));
break;
case 'create':
$body = podman_read_json_body();
$image = trim((string) ($body['image'] ?? ''));
if ($image === '') {
podman_json_error('Missing image in request body', 400);
}
$spec = build_container_spec($image, $body);
// Unlike `podman run`, /containers/create does NOT auto-pull a
// missing image — it fails outright with a 404 "no such image"
// (found by live-testing the Create Container form against a
// freshly-typed image reference that wasn't pulled yet). Retry
// once after an explicit pull rather than always pulling
// up-front, so re-creating with an image the user already has
// stays fast and offline-friendly.
try {
$id = $client->createContainer($spec);
} catch (PodmanApiException $e) {
if ($e->httpStatus !== 404) {
throw $e;
}
$client->pullImage($image);
$id = $client->createContainer($spec);
}
if ($body['startAfterCreate'] ?? true) {
$client->startContainer($id);
}
podman_json_response(['id' => $id, 'status' => ($body['startAfterCreate'] ?? true) ? 'started' : 'created']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Checks every image currently backing a (non-infra) container against its
* origin registry — see RegistryClient for how, and why this isn't a
* podman/libpod feature at all. Deduplicated per unique image reference
* first (several containers commonly share the same image), so a host
* with e.g. five containers all on the same base image only makes one
* real registry request for it, not five.
*
* @return array<string,array<string,mixed>> keyed by image reference
*/
function check_image_updates(PodmanClient $client): array
{
$digestByImageId = [];
foreach ($client->listImages() as $img) {
$digestByImageId[(string) ($img['Id'] ?? '')] = (string) ($img['Digest'] ?? '');
}
$localDigestByRef = [];
foreach ($client->listContainers(true) as $c) {
if ($c['IsInfra'] ?? false) {
continue;
}
$ref = (string) ($c['Image'] ?? '');
$imageId = (string) ($c['ImageID'] ?? '');
if ($ref === '' || !isset($digestByImageId[$imageId])) {
continue;
}
$localDigestByRef[$ref] = $digestByImageId[$imageId];
}
$out = [];
foreach ($localDigestByRef as $ref => $localDigest) {
$out[$ref] = $localDigest === ''
? ['error' => 'No local digest recorded for this image.']
: RegistryClient::checkForUpdate($ref, $localDigest);
}
return $out;
}
/**
* Detects AMD/Intel GPUs via /dev/dri + sysfs — NOT via any podman/libpod
* API (libpod has no GPU inventory endpoint; this is plain host hardware
* detection). NVIDIA is deliberately excluded: it needs the separate
* nvidia-container-toolkit runtime, not a plain /dev/dri device passthrough,
* so listing it here would offer a checkbox that doesn't actually work.
* Verified live: card/render pairs from the same GPU share a "device"
* symlink target under /sys/class/drm, which is how they're grouped below;
* vendor 0x1002 = AMD, 0x8086 = Intel (PCI SIG IDs).
*
* @return array<int,array<string,mixed>>
*/
function gpu_list(): array
{
if (!is_dir('/sys/class/drm')) {
return [];
}
$byDevice = [];
foreach (scandir('/sys/class/drm') ?: [] as $entry) {
if (!preg_match('/^(card\d+|renderD\d+)$/', $entry)) {
continue;
}
$devicePath = "/sys/class/drm/{$entry}/device";
$target = @readlink($devicePath);
if ($target === false) {
continue;
}
$vendorFile = "{$devicePath}/vendor";
if (!is_file($vendorFile)) {
continue;
}
$vendorId = trim((string) @file_get_contents($vendorFile));
$byDevice[$target]['vendorId'] ??= $vendorId;
$byDevice[$target][str_starts_with($entry, 'card') ? 'card' : 'render'] = "/dev/dri/{$entry}";
}
$vendorNames = ['0x1002' => 'AMD', '0x8086' => 'Intel', '0x10de' => 'NVIDIA'];
$out = [];
foreach ($byDevice as $group) {
$vendorId = $group['vendorId'] ?? '';
$vendorName = $vendorNames[$vendorId] ?? $vendorId;
// NVIDIA needs the nvidia-container-toolkit runtime, not a plain
// /dev/dri passthrough — excluded so the checkbox we offer always
// actually works (see function comment).
if ($vendorName === 'NVIDIA' || !isset($group['render'])) {
continue;
}
$out[] = [
'vendor' => $vendorName,
'card' => $group['card'] ?? null,
'render' => $group['render'],
];
}
return $out;
}
/**
* Builds a libpod SpecGenerator body (POST /containers/create) from the
* WebUI's Create Container form fields. Field names/shapes here
* (portmappings, netns, networks, mounts, volumes, restart_policy) were
* verified live against a real podman system service — see
* PodmanClient::createContainer()'s header comment.
*
* @param array<string,mixed> $body
* @return array<string,mixed>
*/
function build_container_spec(string $image, array $body): array
{
$spec = ['image' => $image];
$name = trim((string) ($body['name'] ?? ''));
if ($name !== '') {
// Same character set podman itself enforces (define.NameRegex in
// libpod) — validated here too so a space/invalid character gets
// a clear message instead of podman's raw "running container
// create option: names must match ...: invalid argument" (found
// live: a template-derived container name with a space in it hit
// exactly this).
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) {
podman_json_error(
"Container name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" .
preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.',
400
);
}
$spec['name'] = $name;
}
$icon = trim((string) ($body['icon'] ?? ''));
$webuiUrl = trim((string) ($body['webuiUrl'] ?? ''));
$labels = [];
if ($icon !== '') {
$labels['podman-webui.icon'] = $icon;
}
if ($webuiUrl !== '') {
$labels['podman-webui.weburl'] = $webuiUrl;
}
if ($labels !== []) {
$spec['labels'] = $labels;
}
$env = [];
foreach (($body['env'] ?? []) as $row) {
$key = trim((string) ($row['key'] ?? ''));
if ($key !== '') {
$env[$key] = (string) ($row['value'] ?? '');
}
}
if ($env !== []) {
$spec['env'] = $env;
}
$ports = [];
foreach (($body['ports'] ?? []) as $row) {
$hostPort = (int) ($row['hostPort'] ?? 0);
$containerPort = (int) ($row['containerPort'] ?? 0);
if ($hostPort > 0 && $containerPort > 0) {
$ports[] = [
'host_ip' => '',
'host_port' => $hostPort,
'container_port' => $containerPort,
'protocol' => (string) ($row['protocol'] ?? 'tcp'),
];
}
}
if ($ports !== []) {
$spec['portmappings'] = $ports;
}
$mounts = [];
$volumes = [];
foreach (($body['volumes'] ?? []) as $row) {
$source = trim((string) ($row['source'] ?? ''));
$containerPath = trim((string) ($row['containerPath'] ?? ''));
if ($source === '' || $containerPath === '') {
continue;
}
// Verified live against a real bind mount (RW:false in the
// resulting inspect) that appending "ro" to the mount's own
// options is all read-only takes — no separate top-level flag.
$readOnly = (bool) ($row['readOnly'] ?? false);
if (($row['kind'] ?? 'named') === 'path') {
$options = ['rbind'];
if ($readOnly) {
$options[] = 'ro';
}
$mounts[] = ['destination' => $containerPath, 'type' => 'bind', 'source' => $source, 'options' => $options];
} else {
$volume = ['name' => $source, 'dest' => $containerPath];
if ($readOnly) {
$volume['options'] = ['ro'];
}
$volumes[] = $volume;
}
}
if ($mounts !== []) {
$spec['mounts'] = $mounts;
}
if ($volumes !== []) {
$spec['volumes'] = $volumes;
}
// "bridge"/"host"/"none" are podman's own reserved netns modes; any
// other value is an existing custom podman network's name, attached
// via the "networks" field instead (verified live: passing a
// network name through "networks" attaches it without needing an
// explicit netns mode at all).
$networkMode = (string) ($body['networkMode'] ?? 'bridge');
if (in_array($networkMode, ['bridge', 'host', 'none'], true)) {
$spec['netns'] = ['nsmode' => $networkMode];
} elseif ($networkMode !== '') {
// A static IP only makes sense on a custom (typically macvlan)
// network — verified live that "networks":{"<name>":{"static_ips":
// [...]}} assigns it, same as podman itself does for --ip. Basic
// IPv4-shape validation only (not full RFC-correctness) — this
// goes straight into a create request against the local podman
// socket, not anywhere it could reach untrusted input otherwise.
$staticIp = trim((string) ($body['staticIp'] ?? ''));
if ($staticIp !== '') {
if (preg_match('/^(\d{1,3}\.){3}\d{1,3}$/', $staticIp) !== 1) {
podman_json_error("Static IP (\"{$staticIp}\") doesn't look like a valid IPv4 address.", 400);
}
$spec['networks'] = [$networkMode => ['static_ips' => [$staticIp]]];
} else {
$spec['networks'] = [$networkMode => new \stdClass()];
}
}
if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
$spec['restart_policy'] = (string) $body['restartPolicy'];
}
if ($body['privileged'] ?? false) {
$spec['privileged'] = true;
}
$user = trim((string) ($body['user'] ?? ''));
if ($user !== '') {
// "99:100" (Unraid's own nobody:users, the overwhelming majority of
// real-world cases — a migrated container whose bind-mounted
// appdata was written by that user needs this override, since
// without it podman falls back to whatever USER the image itself
// declares), a bare UID, or a username — never anything that could
// be interpreted as a shell/path fragment, even though this goes
// straight into a podman API JSON body, not a shell.
if (preg_match('/^[a-zA-Z0-9_.-]+(:[a-zA-Z0-9_.-]+)?$/', $user) !== 1) {
podman_json_error("\"Run as user\" (\"{$user}\") must look like \"99:100\", \"1000\", or a username.", 400);
}
$spec['user'] = $user;
}
$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];
}
}
// Generic device passthrough (e.g. a USB serial adapter like
// /dev/ttyACM0) — unlike the curated GPU list above, this comes
// straight from a free-text field, so it's restricted to a path
// actually under /dev/ (verified live against podman's own API that
// {"path": "/dev/x"} maps that host device at the SAME path inside
// the container — there's no separate "container path" field to
// remap it, matching how the overwhelming majority of real-world
// USB/serial passthrough is done anyway, e.g. this plugin's own
// migrated aoostar-rs template using `--device=/dev/ttyACM0:/dev/ttyACM0`,
// identical on both sides).
foreach (($body['devices'] ?? []) as $row) {
$path = trim((string) ($row['path'] ?? ''));
if ($path === '') {
continue;
}
if (preg_match('#^/dev/[A-Za-z0-9_./-]+$#', $path) !== 1 || str_contains($path, '..')) {
podman_json_error("Device path (\"{$path}\") must be an absolute path under /dev/.", 400);
}
$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;
}
/** @param array<string,mixed> $body */
function require_id(array $body): string
{
@@ -96,6 +493,17 @@ function containers_list(PodmanClient $client): array
$out = [];
foreach ($raw as $c) {
// Every pod has a hidden "infra" container managing its shared
// network namespace — not something a user creates or can
// meaningfully stop/remove on its own (found live: it always
// shows "running" with no independent lifecycle, so Containers
// panel gets a permanently un-removable row once any pod exists;
// it already appears as its own row in the Pods panel). See
// ajax/pods.php for actual pod lifecycle management.
if ($c['IsInfra'] ?? false) {
continue;
}
$names = $c['Names'] ?? [];
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
@@ -111,6 +519,24 @@ function containers_list(PodmanClient $client): array
$startedAt = podman_parse_time($c['StartedAt'] ?? null);
$state = strtolower((string) ($c['State'] ?? 'unknown'));
// One extra local-socket round trip per running container (~15ms
// each, verified live — negligible for a home host's container
// count). Best-effort: a container that stops between the list
// call above and this one shouldn't blank out the whole table.
$cpuPercent = null;
$memUsageBytes = null;
$memLimitBytes = null;
if ($state === 'running') {
try {
$stats = $client->containerStats((string) ($c['Id'] ?? ''));
$cpuPercent = isset($stats['cpu_stats']['cpu']) ? round((float) $stats['cpu_stats']['cpu'], 1) : null;
$memUsageBytes = isset($stats['memory_stats']['usage']) ? (int) $stats['memory_stats']['usage'] : null;
$memLimitBytes = isset($stats['memory_stats']['limit']) ? (int) $stats['memory_stats']['limit'] : null;
} catch (PodmanApiException $e) {
// leave stats null
}
}
$out[] = [
'id' => (string) ($c['Id'] ?? ''),
'shortId' => podman_short_id((string) ($c['Id'] ?? '')),
@@ -124,6 +550,17 @@ function containers_list(PodmanClient $client): array
'podName' => $c['PodName'] ?? null,
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
'createdAt' => podman_parse_time($c['Created'] ?? null),
'cpuPercent' => $cpuPercent,
'memUsageBytes' => $memUsageBytes,
'memLimitBytes' => $memLimitBytes,
// Set at create time (see build_container_spec()) from either
// the template it was created from or a manually-entered URL —
// this plugin's own label, not Unraid's real Docker manager's
// net.unraid.docker.icon (this isn't Docker, so reusing that
// name would misleadingly imply real interop with tools that
// read it).
'icon' => $c['Labels']['podman-webui.icon'] ?? null,
'webUrl' => $c['Labels']['podman-webui.weburl'] ?? null,
];
}
+289
View File
@@ -0,0 +1,289 @@
<?php
/**
* ajax/disks.php
*
* Backs the Settings panel's "Format a Disk for Podman Storage" flow — for
* a single-disk system with no cache pool set up yet, where podman's own
* storage path (see include/Config.php's $storagePath default,
* /mnt/cache/system/podman) has nowhere real to live, and podman never
* fully starts as a result (found live: a fresh install on a system with
* no cache pool failed with "cannot reach the Podman API socket" because
* podman-storage.sh's `create` step refuses to run against a path that
* isn't an actual mounted filesystem — see that script's header comment
* for why /mnt/user/... FUSE paths don't work either).
*
* This is the one place in the plugin that formats a physical disk —
* deliberately narrow and defensive: every candidate the "format" action
* is asked to touch is re-derived server-side from the same safe-listing
* logic "list_candidates" uses, never trusted from the request alone, and
* only ever a whole, currently-unmounted, non-array disk with no existing
* partitions is eligible in the first place.
*
* Actions (?action=...):
* list_candidates GET -> [{device, sizeBytes, sizeFormatted, model, hasData}, ...]
* format POST {"device": "/dev/sdc"} -> {"mountPath": "/mnt/disks/podman-storage"}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
const MOUNT_PATH = '/mnt/disks/podman-storage';
const MANAGED_DISK_CFG = '/boot/config/plugins/podman/managed-disk.cfg';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list_candidates':
podman_json_response(list_candidate_disks());
break;
case 'format':
$body = podman_read_json_body();
$device = (string) ($body['device'] ?? '');
podman_json_response(format_disk($device));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Runs a command and returns [exitCode, stdout+stderr combined]. Every
* caller here passes a fixed argv array (never a shell string built from
* request input), so there is no injection surface even before the
* device-path validation in format_disk() below.
*
* @param array<int,string> $argv
* @return array{0:int,1:string}
*/
function run(array $argv, int $timeoutSeconds = 30): array
{
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($argv, $descriptors, $pipes);
if (!is_resource($process)) {
return [127, "could not start {$argv[0]}"];
}
stream_set_timeout($pipes[1], $timeoutSeconds);
$out = (stream_get_contents($pipes[1]) ?: '') . (stream_get_contents($pipes[2]) ?: '');
fclose($pipes[1]);
fclose($pipes[2]);
$code = proc_close($process);
return [$code, trim($out)];
}
/**
* Every whole disk (not partition) libpod's host isn't already using —
* mounted anywhere (itself or any partition), part of Unraid's own
* array/cache pool (cross-checked against /var/local/emhttp/disks.ini,
* the same state file Unraid's own array management writes — the boot
* flash device is covered by this same check, since Unraid lists it there
* too), or the disk backing the currently-booted root/flash filesystem.
*
* @return array<int,array<string,mixed>>
*/
function list_candidate_disks(): array
{
// Nested partitions come back automatically as a "children" array in
// -J's JSON tree — CHILDREN is not a real -o column (lsblk itself
// rejects it: "unknown column: CHILDREN"; verified live against a
// real host's lsblk). LABEL is included specifically to catch the
// Unraid boot flash drive, which FAT-labels itself "UNRAID" — see the
// safety note below for why that check exists at all.
[$code, $out] = run(['lsblk', '-J', '-b', '-p', '-o', 'NAME,SIZE,TYPE,FSTYPE,MOUNTPOINT,MODEL,LABEL'], 10);
if ($code !== 0) {
podman_json_error('Could not list block devices: ' . $out, 500);
}
$tree = json_decode($out, true);
if (!is_array($tree) || !isset($tree['blockdevices'])) {
podman_json_error('Unexpected lsblk output', 500);
}
$arrayDevices = unraid_array_device_names();
$candidates = [];
foreach ($tree['blockdevices'] as $dev) {
if (($dev['type'] ?? '') !== 'disk') {
continue;
}
$name = (string) ($dev['name'] ?? '');
$baseName = basename($name);
if (in_array($baseName, $arrayDevices, true)) {
continue;
}
// zram devices report TYPE "disk" too (RAM-backed, not persistent
// storage — pointless and misleading to offer for this) and an
// empty card-reader slot with no card inserted reports as a real
// "disk" at 0 bytes; both are excluded outright rather than left
// for the size-based hasData check below.
if (str_starts_with($baseName, 'zram') || (int) ($dev['size'] ?? 0) <= 0) {
continue;
}
if (device_or_children_mounted($dev)) {
continue;
}
if (device_or_children_labeled_unraid($dev)) {
continue;
}
// Deliberately excludes (not just warns about) ANY disk that
// already has a filesystem, partition, or other signature on it
// or any of its partitions — not just ones lsblk reports as
// currently mounted. Found live: a real host's cache pool disks
// showed up here as "safe" with only a soft warning, because
// they're ZFS pool members (fstype "zfs_member"/partition
// present) rather than plain mounts, so the earlier
// mounted-only check missed them entirely — the same blind spot
// that also let the Unraid boot USB stick's own partition
// through (FAT, not reported as "mounted" by lsblk either). A
// disk being reused for podman storage must be genuinely blank;
// asking a user to wipe it themselves first is a small price for
// this being impossible to get wrong.
if (!empty($dev['fstype']) || !empty($dev['children'])) {
continue;
}
$candidates[] = [
'device' => $name,
'sizeBytes' => (int) ($dev['size'] ?? 0),
'sizeFormatted' => podman_format_bytes((int) ($dev['size'] ?? 0)),
'model' => trim((string) ($dev['model'] ?? '')) ?: null,
];
}
return $candidates;
}
function device_or_children_mounted(array $dev): bool
{
if (!empty($dev['mountpoint'])) {
return true;
}
foreach ($dev['children'] ?? [] as $child) {
if (device_or_children_mounted($child)) {
return true;
}
}
return false;
}
/**
* Catches the Unraid boot flash drive specifically: it FAT-labels itself
* "UNRAID" (verified live: `blkid` on a real host's boot partition shows
* LABEL_FATBOOT="UNRAID" LABEL="UNRAID") and — on at least one real,
* modern Unraid setup — /boot is actually backed by a ZFS dataset
* ("flash/boot"), not a direct mount of that partition, so it does NOT
* show up as "mounted" via lsblk's own MOUNTPOINT column at all. This
* label check is a second, independent layer specifically because that
* gap meant the boot drive briefly passed every other check here during
* development — never rely on a single signal for something this
* destructive.
*/
function device_or_children_labeled_unraid(array $dev): bool
{
if (strtoupper(trim((string) ($dev['label'] ?? ''))) === 'UNRAID') {
return true;
}
foreach ($dev['children'] ?? [] as $child) {
if (device_or_children_labeled_unraid($child)) {
return true;
}
}
return false;
}
/**
* @return array<int,string> bare device names ("sda", "nvme0n1", ...) Unraid
* itself has assigned to the array or a cache pool, read from the same
* /var/local/emhttp/disks.ini Unraid's own array management writes —
* NOT parsed/guessed, so this stays correct across whatever array
* layout a given host actually has.
*/
function unraid_array_device_names(): array
{
$path = '/var/local/emhttp/disks.ini';
if (!is_readable($path)) {
return [];
}
$ini = @parse_ini_file($path, true);
if (!is_array($ini)) {
return [];
}
$names = [];
foreach ($ini as $section) {
if (is_array($section) && !empty($section['device'])) {
$names[] = basename((string) $section['device']);
}
}
return $names;
}
/** @return array<string,mixed> */
function format_disk(string $device): array
{
if (!preg_match('#^/dev/(sd[a-z]+|nvme\d+n\d+|vd[a-z]+)$#', $device)) {
podman_json_error('Invalid or unsupported device path', 400);
}
$allowed = array_column(list_candidate_disks(), 'device');
if (!in_array($device, $allowed, true)) {
podman_json_error("{$device} is not a currently-eligible disk (already in use, part of the array, or not found) — refusing to format it.", 400);
}
$partDevice = preg_match('#nvme\d+n\d+$#', $device) ? "{$device}p1" : "{$device}1";
[$code, $out] = run(['wipefs', '-a', $device], 30);
if ($code !== 0) {
podman_json_error("wipefs failed: {$out}", 500);
}
[$code, $out] = run(['parted', '-s', $device, 'mklabel', 'gpt', 'mkpart', 'primary', '1MiB', '100%'], 30);
if ($code !== 0) {
podman_json_error("parted failed: {$out}", 500);
}
run(['partprobe', $device], 10);
// Partition device nodes can take a moment to appear after partprobe.
for ($i = 0; $i < 20 && !file_exists($partDevice); $i++) {
usleep(250000);
}
if (!file_exists($partDevice)) {
podman_json_error("Partition {$partDevice} did not appear after partitioning {$device}", 500);
}
[$code, $out] = run(['mkfs.xfs', '-f', '-n', 'ftype=1', '-L', 'podmanstorage', $partDevice], 60);
if ($code !== 0) {
podman_json_error("mkfs.xfs failed: {$out}", 500);
}
[$code, $uuid] = run(['blkid', '-s', 'UUID', '-o', 'value', $partDevice], 10);
$uuid = trim($uuid);
if ($code !== 0 || $uuid === '') {
podman_json_error('Could not determine filesystem UUID after formatting', 500);
}
if (!is_dir(MOUNT_PATH) && !mkdir(MOUNT_PATH, 0755, true) && !is_dir(MOUNT_PATH)) {
podman_json_error('Could not create ' . MOUNT_PATH, 500);
}
[$code, $out] = run(['mount', 'UUID=' . $uuid, MOUNT_PATH], 15);
if ($code !== 0) {
podman_json_error("mount failed: {$out}", 500);
}
// Persisted so plugin/sbin/podman-mount-managed-disk.sh (called from
// the disks_mounted event hook, before rc.podman start) can remount
// this same disk by UUID on every future boot — nothing else on the
// system knows about this disk, since it's deliberately outside
// Unraid's own array/cache pool management.
$cfgDir = dirname(MANAGED_DISK_CFG);
if (!is_dir($cfgDir) && !mkdir($cfgDir, 0755, true) && !is_dir($cfgDir)) {
podman_json_error('Could not create ' . $cfgDir, 500);
}
$cfgContent = "# Written by the unraid-podman WebUI (ajax/disks.php) — do not edit by hand.\n"
. 'UUID="' . $uuid . "\"\n"
. 'MOUNTPOINT="' . MOUNT_PATH . "\"\n";
if (file_put_contents(MANAGED_DISK_CFG, $cfgContent, LOCK_EX) === false) {
podman_json_error('Formatted and mounted, but could not persist ' . MANAGED_DISK_CFG . ' for future boots', 500);
}
return ['mountPath' => MOUNT_PATH];
}
+127 -37
View File
@@ -3,35 +3,49 @@
* ajax/exec.php
*
* Backs the Terminal panel — and this is the one panel where "exclusively
* via podman system service, no shell hacks" needs an honest caveat
* spelled out rather than silently glossed over:
* via podman system service, no shell hacks" needs an honest caveat spelled
* out rather than silently glossed over (the same exception ajax/compose.php
* documents for the same underlying reason: some things have no REST
* equivalent).
*
* libpod's real exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) is used here — PodmanClient::execRun() never
* shells out to the `podman` binary. But that API's interactive mode works
* by HTTP connection hijacking: the HTTP connection is upgraded into a raw
* bidirectional byte stream for the lifetime of the shell session. That
* model assumes a long-lived process holding the socket open on both ends
* (an actual terminal emulator, or a WebSocket bridge) — it does not fit
* PHP-FPM's request/response lifecycle, where each AJAX call is a fresh,
* independent, short-lived process with no memory of any previous one.
* POST /exec/{id}/start) works by HTTP connection hijacking: the connection
* is upgraded into a raw bidirectional byte stream for the lifetime of the
* shell session. That model assumes a long-lived process holding the socket
* open on both ends (an actual terminal emulator, or a WebSocket bridge) —
* it does not fit PHP-FPM's request/response lifecycle, where each AJAX call
* is a fresh, independent, short-lived process with no memory of any
* previous one. An earlier version of this file worked around that by
* offering one-shot "run a command, see its output" exec calls — honest
* about not being a real terminal, but not what a user expects when they
* open a "Console" tab (no history, no vim, no persistent `cd`).
*
* Rather than fake interactivity with something that would break on the
* first multi-line prompt, `sudo`, or interactive editor, this endpoint
* offers a deliberately simpler, honest contract: one command in, its
* complete output back, using Tty=true so output reads like a real
* terminal (colors, prompts-in-output, etc. survive) but with no
* persistent shell state (`cd` does not carry over between calls — see
* the "cwd" parameter below, which javascript/terminal.js tracks
* client-side and resends every time instead).
* Unraid's own webGui already solves exactly this problem for its System
* Terminal and for `docker exec` (see
* /usr/local/emhttp/plugins/dynamix/include/OpenTerminal.php's 'docker'
* case, and /etc/nginx/conf.d/locations.conf's "logterminal" location
* block) — by spawning one `ttyd` instance per session, bound to a unix
* socket under /var/tmp, wrapping the real interactive command; nginx then
* proxies /logterminal/<name>/ to that socket with a WebSocket upgrade,
* generically, for ANY name. That proxy rule is already installed and
* already generic — this endpoint reuses it exactly the same way Unraid's
* own docker integration does, just with `podman exec -it` instead of
* `docker exec -it` as the wrapped command. `ttyd-exec` itself is a small
* wrapper script Unraid ships system-wide (sources /etc/default/ttyd for
* common xterm.js options, then execs ttyd in the background) — not
* something this plugin needs to vendor.
*
* A true interactive PTY (arrow-key history, tab completion, vim, ...)
* would need a WebSocket-capable process sitting between the browser and
* podman.sock — out of scope for this PHP/AJAX stack; tracked as a
* follow-up rather than implemented as a shell-out workaround.
* This is the one place in the plugin that shells out to the `podman`
* binary via proc invocation rather than the REST API — container names
* are validated against a fixed safe pattern and passed through
* escapeshellarg(), never concatenated into a shell string.
*
* Actions (?action=...):
* run POST {"id": "...", "cmd": "ls -la", "cwd": "/config"}
* open POST {"name": "...", "shell": "sh"|"bash"} -> {"sockName": "..."}
* Caller then points an iframe/window at /logterminal/<sockName>/.
* close POST {"name": "..."} -> {"status": "closed"}
* Kills the ttyd instance (and, via it, the `podman exec` it
* wraps) for that container, if one is running.
*/
declare(strict_types=1);
@@ -41,27 +55,103 @@ require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'run':
case 'open':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
$commandLine = (string) ($body['cmd'] ?? '');
$cwd = (string) ($body['cwd'] ?? '');
$name = (string) ($body['name'] ?? '');
$shell = (string) ($body['shell'] ?? 'sh');
if ($id === '' || trim($commandLine) === '') {
podman_json_error('Missing id or cmd in request body', 400);
// Same character set libpod itself allows in container names —
// rejecting anything else here (BEFORE it's ever used to build a
// socket path or shell command) is what makes escapeshellarg() on
// top of it a defense in depth rather than the only line of
// defense.
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
if (!in_array($shell, ['sh', 'bash'], true)) {
podman_json_error('Invalid shell', 400);
}
// The command line is run through the container's own shell
// (sh -c) so the user can type ordinary shell syntax (pipes,
// globs, env vars) in the terminal box, exactly like a real
// shell prompt would accept — still one real exec API call, just
// with /bin/sh as the interpreter instead of us parsing shell
// syntax ourselves in PHP.
$output = $client->execRun($id, ['/bin/sh', '-c', $commandLine], $cwd);
podman_json_response(open_terminal($name, $shell));
break;
podman_json_response(['output' => $output]);
case 'close':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
close_terminal($name);
podman_json_response(['status' => 'closed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
function sock_path_for(string $containerName): string
{
// "podman." prefix keeps this plugin's per-container sockets under
// /var/tmp from ever colliding with Unraid's own docker-exec sockets
// (/var/tmp/<name>.sock), which are named after the same container
// names a user might also give their podman containers.
return '/var/tmp/podman.' . $containerName . '.sock';
}
/**
* @return array<string,mixed>
*/
function open_terminal(string $containerName, string $shell): array
{
// Close out any previous session for this container first — sockets
// are named deterministically per-container (not per-open-call), so
// without this, re-opening the same container's terminal (or switching
// shells) would try to bind a second ttyd to the same path and leave
// the first one orphaned, still running, holding /dev resources for a
// client that will never come.
close_terminal($containerName);
$sockPath = sock_path_for($containerName);
// -s9: send SIGKILL to the wrapped command when the client disconnects
// (no orphaned `podman exec` process lingering after the window is
// closed). -o -m1: accept exactly one client, then exit instead of
// staying resident waiting for a next one — matching exactly the
// options Unraid's own OpenTerminal.php uses for `docker exec` (see
// that file's 'docker' case).
$cmd = sprintf(
'ttyd-exec -s9 -o -m1 -i %s podman exec -it %s %s',
escapeshellarg($sockPath),
escapeshellarg($containerName),
escapeshellarg($shell)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
podman_json_error('Could not start terminal session', 500);
}
return ['sockName' => 'podman.' . $containerName];
}
/**
* Kills the ttyd instance (if any) bound to this container's socket, and
* removes the socket file. Matched via `pgrep -f` against the socket path
* embedded in ttyd's own argv (the -i flag passed in open_terminal()) —
* that's a stable, unique needle since it includes the "podman." prefix
* and the validated container name. Killing ttyd itself (rather than
* just closing a client connection nothing is holding) tears down the
* `podman exec` child with it, same as closing a real terminal window
* would once a client was attached.
*/
function close_terminal(string $containerName): void
{
$sockPath = sock_path_for($containerName);
exec('pgrep -f ' . escapeshellarg($sockPath) . ' 2>/dev/null', $pids);
foreach ($pids as $pid) {
if (ctype_digit($pid)) {
exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null');
}
}
@unlink($sockPath);
}
+91
View File
@@ -0,0 +1,91 @@
<?php
/**
* ajax/folders.php
*
* Container folders — a purely cosmetic, plugin-owned organizational
* feature for the Containers panel. podman/libpod itself has no concept
* of "folders"; this is grouping metadata only (name + icon + which
* container names belong to it), similar to Unraid's own Docker page's
* folders. Stored as JSON at PodmanConfig::$foldersFile — not a
* PodmanClient/libpod concern, see Config.php.
*
* Actions (?action=...):
* list GET -> {"folders": [{"id": "...", "name": "...", "icon": "...", "containers": ["name", ...]}, ...]}
* save POST {"folders": [...]} -> persists the whole list (same
* whole-list-replace pattern settings.php's autostart_save uses
* — the frontend already holds the full, current structure in
* memory after any add/rename/reassign, so there's no need for
* narrower per-folder mutation endpoints)
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(['folders' => folders_read($podmanConfig)]);
break;
case 'save':
$body = podman_read_json_body();
podman_json_response(['folders' => folders_save($podmanConfig, is_array($body['folders'] ?? null) ? $body['folders'] : [])]);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @return array<int,array<string,mixed>> */
function folders_read(PodmanConfig $config): array
{
if (!is_readable($config->foldersFile)) {
return [];
}
$decoded = json_decode((string) file_get_contents($config->foldersFile), true);
return is_array($decoded) ? $decoded : [];
}
/**
* Re-validates and normalizes before writing — a folder with no name (or
* whose name became empty through some client-side bug) is silently
* dropped rather than persisted as junk that would then need cleaning up
* by hand in the JSON file directly.
*
* @param array<int,mixed> $folders
* @return array<int,array<string,mixed>> the normalized list actually written
*/
function folders_save(PodmanConfig $config, array $folders): array
{
$clean = [];
foreach ($folders as $f) {
if (!is_array($f)) {
continue;
}
$name = trim((string) ($f['name'] ?? ''));
if ($name === '') {
continue;
}
$containers = [];
foreach (($f['containers'] ?? []) as $n) {
$n = trim((string) $n);
if ($n !== '') {
$containers[] = $n;
}
}
$clean[] = [
'id' => (string) ($f['id'] ?? '') !== '' ? (string) $f['id'] : bin2hex(random_bytes(6)),
'name' => $name,
'icon' => trim((string) ($f['icon'] ?? '')),
'containers' => $containers,
];
}
if (file_put_contents($config->foldersFile, json_encode($clean, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX) === false) {
podman_json_error("Could not write {$config->foldersFile}", 500);
}
return $clean;
}
+23
View File
@@ -8,6 +8,8 @@
* list GET -> normalized image list
* pull POST {"reference": "docker.io/library/postgres:16"}
* remove POST {"id": "...", "force": false}
* prune POST {} -> removes every image not used by any container
* tag POST {"id": "...", "repo": "...", "tag": "latest"}
*/
declare(strict_types=1);
@@ -40,6 +42,27 @@ switch ($action) {
podman_json_response(['status' => 'removed']);
break;
case 'prune':
$removed = $client->pruneImages();
$reclaimed = 0;
foreach ($removed as $r) {
$reclaimed += (int) ($r['Size'] ?? 0);
}
podman_json_response(['removedCount' => count($removed), 'reclaimedBytes' => $reclaimed]);
break;
case 'tag':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
$repo = trim((string) ($body['repo'] ?? ''));
$tag = trim((string) ($body['tag'] ?? '')) ?: 'latest';
if ($id === '' || $repo === '') {
podman_json_error('Missing id or repo in request body', 400);
}
$client->tagImage($id, $repo, $tag);
podman_json_response(['status' => 'tagged']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
+83 -3
View File
@@ -8,7 +8,9 @@
*
* Actions (?action=...):
* list GET -> normalized network list with subnet/gateway/usage
* create POST {"name": "...", "driver": "bridge", "subnet": "...", "gateway": "..."}
* list_parent_interfaces GET -> host bridge/VLAN interfaces available as a macvlan parent
* create POST {"name": "...", "driver": "bridge"|"macvlan", "subnet": "...",
* "gateway": "...", "parentInterface": "br0"}
* remove POST {"name": "...", "force": false}
*/
@@ -23,17 +25,40 @@ switch ($action) {
podman_json_response(networks_list($client));
break;
case 'list_parent_interfaces':
podman_json_response(macvlan_parent_interfaces());
break;
case 'create':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
$driver = (string) ($body['driver'] ?? 'bridge');
$parentInterface = null;
if ($driver === 'macvlan') {
$parentInterface = (string) ($body['parentInterface'] ?? '');
// Only ever accept an interface this same host reported via
// macvlan_parent_interfaces() — the boundary preventing a
// tampered request from asking podman to attach to an
// arbitrary/unexpected interface name.
$known = array_column(macvlan_parent_interfaces(), 'interface');
if (!in_array($parentInterface, $known, true)) {
podman_json_error('Unknown parent interface — refresh the page and try again.', 400);
}
if (!isset($body['subnet']) || (string) $body['subnet'] === '') {
podman_json_error('Subnet is required for a macvlan network.', 400);
}
}
podman_json_response($client->createNetwork(
$name,
(string) ($body['driver'] ?? 'bridge'),
$driver,
isset($body['subnet']) ? (string) $body['subnet'] : null,
isset($body['gateway']) ? (string) $body['gateway'] : null
isset($body['gateway']) ? (string) $body['gateway'] : null,
$parentInterface
));
break;
@@ -54,6 +79,61 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Reads Unraid's own /boot/config/network.cfg (BRNAME[i]/VLANID[i,j]/
* DESCRIPTION[i,j]) to list the same host bridge + VLAN interfaces
* Unraid's own Docker Manager offers as "Custom: br0" / "Custom: br0.3
* (VPN)" network types — reusing Unraid's own config instead of guessing
* from raw `ip link` output, so the list always matches what Docker
* Manager shows for the same host. Verified live: this host's
* network.cfg has BRNAME[0]="br0" and VLANID[0,1]="3"/DESCRIPTION[0,1]=
* "VPN", producing "br0" and "br0.3 (VPN)" — matching the interface
* names shown in that other plugin's own network-type dropdown exactly.
* Each candidate is confirmed to actually exist in /sys/class/net before
* being offered, in case network.cfg mentions an interface that isn't
* currently up.
*
* @return array<int,array{interface:string,label:string}>
*/
function macvlan_parent_interfaces(): array
{
$cfgFile = '/boot/config/network.cfg';
if (!is_file($cfgFile)) {
return [];
}
$cfg = [];
foreach (file($cfgFile, FILE_IGNORE_NEW_LINES) ?: [] as $line) {
if (preg_match('/^([A-Z0-9_]+)\[(\d+)(?:,(\d+))?\]="([^"]*)"$/', $line, $m) !== 1) {
continue;
}
[, $key, $i, $j, $value] = $m + [3 => ''];
$i = (int) $i;
if ($j === '') {
$cfg[$key][$i] = $value;
} else {
$cfg[$key][$i][(int) $j] = $value;
}
}
$out = [];
foreach (($cfg['BRNAME'] ?? []) as $i => $brname) {
if (!is_string($brname) || $brname === '' || !is_dir("/sys/class/net/{$brname}")) {
continue;
}
$out[] = ['interface' => $brname, 'label' => $brname];
foreach (($cfg['VLANID'][$i] ?? []) as $j => $vlanId) {
$iface = "{$brname}.{$vlanId}";
if (!is_dir("/sys/class/net/{$iface}")) {
continue;
}
$desc = $cfg['DESCRIPTION'][$i][$j] ?? '';
$out[] = ['interface' => $iface, 'label' => $iface . ($desc !== '' ? " ({$desc})" : '')];
}
}
return $out;
}
/** @return array<int,array<string,mixed>> */
function networks_list(PodmanClient $client): array
{
+63
View File
@@ -9,8 +9,10 @@
*
* Actions (?action=...):
* list GET -> pods with nested container summaries
* create POST {"name": "...", "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}]}
* start POST {"name": "..."}
* stop POST {"name": "...", "timeout": 10}
* restart POST {"name": "...", "timeout": 10}
* remove POST {"name": "...", "force": false}
*/
@@ -25,6 +27,12 @@ switch ($action) {
podman_json_response(pods_list($client));
break;
case 'create':
$body = podman_read_json_body();
$id = $client->createPod(build_pod_spec($body));
podman_json_response(['id' => $id, 'status' => 'created']);
break;
case 'start':
$body = podman_read_json_body();
$client->startPod(require_name($body));
@@ -37,6 +45,12 @@ switch ($action) {
podman_json_response(['status' => 'stopped']);
break;
case 'restart':
$body = podman_read_json_body();
$client->restartPod(require_name($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
podman_json_response(['status' => 'restarted']);
break;
case 'remove':
$body = podman_read_json_body();
$client->removePod(require_name($body), (bool) ($body['force'] ?? false));
@@ -47,6 +61,55 @@ switch ($action) {
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Builds a libpod pod-create body from the "New Pod" form fields. Verified
* live against a real podman system service — {"name": "...",
* "portmappings": [...]} creates a pod with a shared infra container whose
* port bindings apply to every member container.
*
* @param array<string,mixed> $body
* @return array<string,mixed>
*/
function build_pod_spec(array $body): array
{
$name = trim((string) ($body['name'] ?? ''));
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
// Same character set podman enforces for container names (define.NameRegex
// in libpod applies to pods too) — validated here for the same reason
// ajax/containers.php validates it: a clear message instead of podman's
// raw "names must match ...: invalid argument".
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) {
podman_json_error(
"Pod name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" .
preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.',
400
);
}
$spec = ['name' => $name];
$ports = [];
foreach (($body['ports'] ?? []) as $row) {
$hostPort = (int) ($row['hostPort'] ?? 0);
$containerPort = (int) ($row['containerPort'] ?? 0);
if ($hostPort > 0 && $containerPort > 0) {
$ports[] = [
'host_ip' => '',
'host_port' => $hostPort,
'container_port' => $containerPort,
'protocol' => (string) ($row['protocol'] ?? 'tcp'),
];
}
}
if ($ports !== []) {
$spec['portmappings'] = $ports;
}
return $spec;
}
/** @param array<string,mixed> $body */
function require_name(array $body): string
{
+74 -29
View File
@@ -16,12 +16,18 @@
* save POST {"storagePath": "...", "storageImageSizeGb": 20,
* "enabled": true, "stopTimeoutSeconds": 10}
* autostart_save POST {"names": ["postgres", "nextcloud", ...]}
* service_status GET -> {"running": bool, "output": "..."}
* service_start POST -> {"running": bool, "output": "..."}
* service_stop POST -> {"running": bool, "output": "..."}
* service_restart POST -> {"running": bool, "output": "..."}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
const RC_PODMAN = '/etc/rc.d/rc.podman';
$action = $_GET['action'] ?? '';
switch ($action) {
@@ -45,10 +51,77 @@ switch ($action) {
podman_json_response(['status' => 'saved']);
break;
case 'service_status':
podman_json_response(rc_podman('status', 15));
break;
case 'service_start':
podman_json_response(rc_podman('start', 120));
break;
case 'service_stop':
podman_json_response(rc_podman('stop', 120));
break;
case 'service_restart':
podman_json_response(rc_podman('restart', 120));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Shells out to /etc/rc.d/rc.podman <verb> — the plugin's own real
* start/stop/status script, the SAME one the array-start event hook and
* a terminal `rc.podman status` use (see plugin/rc.d/rc.podman's header
* comment). This exists specifically so a fresh install where podman
* failed to start (e.g. no cache pool configured yet, see
* podman-storage.sh's "does not exist or is not mounted" error) can be
* diagnosed and retried from the WebUI itself — no SSH/terminal access
* needed, which is exactly what was missing when this was first needed
* live (a fresh install on a different Unraid box with nobody able to
* reach a terminal to run `rc.podman start` by hand).
*
* @return array{running: bool, output: string}
*/
function rc_podman(string $verb, int $timeoutSeconds): array
{
$output = run_rc_podman($verb, $timeoutSeconds);
// Only `rc.podman status` prints the "service: running
// (pid ..., socket ...)" line this regex looks for — start/stop/
// restart's OWN messages are worded differently ("start: already
// running (pid ...)", "stop: stopped", ...), so relying on THIS same
// regex against THEIR output silently reported "not running" right
// after a successful start (found live: a start that printed "start:
// already running" turned the status chip red). Always running a
// fresh `status` afterward — regardless of which verb was actually
// requested — is the one output format this check can trust.
$statusOutput = $verb === 'status' ? $output : run_rc_podman('status', 15);
$running = (bool) preg_match('/service:\s+running/', $statusOutput);
return ['running' => $running, 'output' => $output];
}
function run_rc_podman(string $verb, int $timeoutSeconds): string
{
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open([RC_PODMAN, $verb], $descriptors, $pipes);
if (!is_resource($process)) {
podman_json_error("Could not run rc.podman {$verb}", 500);
}
stream_set_timeout($pipes[1], $timeoutSeconds);
$stdout = stream_get_contents($pipes[1]) ?: '';
$stderr = stream_get_contents($pipes[2]) ?: '';
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
return trim($stdout . $stderr);
}
/** @return array<string,mixed> */
function settings_get(PodmanConfig $config): array
{
@@ -58,7 +131,7 @@ function settings_get(PodmanConfig $config): array
'enabled' => $config->enabled,
'stopTimeoutSeconds' => $config->stopTimeoutSeconds,
'autostart' => autostart_read($config),
'packageVersions' => installed_package_versions(),
'packageVersions' => podman_read_installed_versions(),
];
}
@@ -129,31 +202,3 @@ function autostart_save(PodmanConfig $config, array $names): void
}
}
/**
* Reads /usr/local/share/unraid-podman/installed-versions.env — the same
* manifest plugin/sbin/podman-verify-packages.sh and
* podman-update-packages.sh use (see plugin/podman.plg's postinstall step,
* which generates it) — so Settings shows exactly what those tools would
* report, not a second, possibly-diverging source of truth.
*
* @return array<string,string>
*/
function installed_package_versions(): array
{
$path = '/usr/local/share/unraid-podman/installed-versions.env';
if (!is_readable($path)) {
return [];
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$out = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (preg_match('/^([A-Z_][A-Z0-9_]*)="?([^"]*)"?$/', $line, $m)) {
$out[$m[1]] = $m[2];
}
}
return $out;
}
+194 -1
View File
@@ -9,6 +9,10 @@
*
* Actions (?action=...):
* summary GET -> counts, storage usage, engine version, ping status
* autostart_queue GET -> the autostart list in start order, each entry's
* configured delay, and its last-known outcome (reusing
* plugin/sbin/podman-autostart.sh's own failure-counter
* files rather than tracking a second copy of that state)
*/
declare(strict_types=1);
@@ -22,6 +26,10 @@ switch ($action) {
podman_json_response(system_summary($client, $podmanConfig));
break;
case 'autostart_queue':
podman_json_response(system_autostart_queue($client, $podmanConfig));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
@@ -56,13 +64,34 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
$imagesSize += (int) ($img['Size'] ?? 0);
}
$host = $info['host'] ?? [];
$store = $info['store'] ?? [];
$meminfo = podman_read_meminfo();
// libpod's host.memFree/swapFree are the kernel's raw "free" counters —
// they exclude reclaimable buffers/cache, so on a host that's been up a
// while they make used memory look dramatically higher than reality
// (found live: 67% "used" here vs. the 19% `free -h` actually reports).
// /proc/meminfo's MemAvailable is the same estimate `free -h`'s
// "available" column and most monitoring tools use, so read it
// directly instead of trusting libpod's numbers for this.
$memTotal = $meminfo['MemTotal'] ?? (int) ($host['memTotal'] ?? 0);
$memAvailable = $meminfo['MemAvailable'] ?? (int) ($host['memFree'] ?? 0);
$swapTotal = $meminfo['SwapTotal'] ?? (int) ($host['swapTotal'] ?? 0);
$swapFree = $meminfo['SwapFree'] ?? (int) ($host['swapFree'] ?? 0);
$graphAllocated = (int) ($store['graphRootAllocated'] ?? 0);
$graphUsed = (int) ($store['graphRootUsed'] ?? 0);
return [
'reachable' => true,
'socketPath' => $config->socketPath,
'podmanVersion' => $info['Version']['Version'] ?? null,
// libpod's /info nests the version block under lowercase "version"
// (unlike most other libpod endpoints, which are PascalCase
// throughout) — verified live against a real podman system service.
'podmanVersion' => $info['version']['Version'] ?? null,
'containers' => [
'total' => count($containers),
'running' => $running,
'stopped' => count($containers) - $running,
],
'pods' => count($pods),
'images' => count($images),
@@ -71,6 +100,170 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
'storage' => [
'imagesSizeBytes' => $imagesSize,
'imagesSizeFormatted' => podman_format_bytes($imagesSize),
'graphUsedBytes' => $graphUsed,
'graphAllocatedBytes' => $graphAllocated,
'graphUsedFormatted' => podman_format_bytes($graphUsed),
'graphAllocatedFormatted' => podman_format_bytes($graphAllocated),
'graphUsedPercent' => $graphAllocated > 0 ? round($graphUsed / $graphAllocated * 100, 1) : null,
],
'host' => [
'cpuCount' => (int) ($host['cpus'] ?? 0),
'cpuPercent' => podman_read_cpu_percent() ?? (isset($host['cpuUtilization']['idlePercent'])
? round(100 - (float) $host['cpuUtilization']['idlePercent'], 1)
: null),
'memUsedBytes' => max(0, $memTotal - $memAvailable),
'memTotalBytes' => $memTotal,
'memUsedFormatted' => podman_format_bytes(max(0, $memTotal - $memAvailable)),
'memTotalFormatted' => podman_format_bytes($memTotal),
'memPercent' => $memTotal > 0 ? round(($memTotal - $memAvailable) / $memTotal * 100, 1) : null,
'swapUsedBytes' => max(0, $swapTotal - $swapFree),
'swapTotalBytes' => $swapTotal,
'uptime' => (string) ($host['uptime'] ?? ''),
],
];
}
/**
* The Dashboard's "Autostart Queue" table: same list/order Settings already
* reads via autostart_read() in settings.php, enriched with each entry's
* configured post-start delay and its last-known outcome — derived from
* plugin/sbin/podman-autostart.sh's own per-container failure-counter
* files (under autostart-failures/<name>) plus the container's actual
* current state, rather than a second, separately-tracked history.
*
* @return array<string,mixed>
*/
function system_autostart_queue(PodmanClient $client, PodmanConfig $config): array
{
if (!is_readable($config->autostartFile)) {
return ['entries' => []];
}
$names = [];
foreach (file($config->autostartFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
$line = trim(preg_replace('/#.*$/', '', $line) ?? '');
if ($line !== '') {
$names[] = $line;
}
}
if (!$names) {
return ['entries' => []];
}
$delays = [];
if (is_readable($config->autostartDelayFile)) {
foreach (file($config->autostartDelayFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [] as $line) {
if (preg_match('/^([^=]+)=(\d+)$/', trim($line), $m)) {
$delays[$m[1]] = (int) $m[2];
}
}
}
// Mirrors podman-autostart.sh's own MAX_CONSECUTIVE_FAILURES — that
// script is what actually decides when a container is Safe-Mode-paused;
// this only needs to agree on the same threshold to describe it
// accurately, not to make the decision itself.
$maxFailures = 3;
$failuresDir = $config->bootDir . '/autostart-failures';
$stateByName = [];
foreach ($client->listContainers(true) as $c) {
foreach (($c['Names'] ?? []) as $n) {
$stateByName[ltrim((string) $n, '/')] = strtolower((string) ($c['State'] ?? ''));
}
}
$entries = [];
foreach ($names as $i => $name) {
$failCount = 0;
$failFile = $failuresDir . '/' . $name;
if (is_readable($failFile)) {
$failCount = (int) trim((string) file_get_contents($failFile));
}
if ($failCount >= $maxFailures) {
$status = 'safe-mode';
$label = "Safe-Mode ({$failCount} failures)";
} elseif ($failCount > 0) {
$status = 'failed';
$label = "Failed ({$failCount}/{$maxFailures})";
} elseif (($stateByName[$name] ?? '') === 'running') {
$status = 'started';
$label = 'Started';
} elseif (array_key_exists($name, $stateByName)) {
$status = 'stopped';
$label = 'Not running';
} else {
$status = 'unknown';
$label = 'Container not found';
}
$entries[] = [
'position' => $i + 1,
'name' => $name,
'delaySeconds' => $delays[$name] ?? 0,
'status' => $status,
'statusLabel' => $label,
];
}
return ['entries' => $entries];
}
/**
* Reads the fields we need out of /proc/meminfo directly, in bytes.
* ajax/*.php runs as PHP-FPM on the Unraid host itself (not inside a
* container), so this is just a local file read — no shelling out needed.
*
* @return array<string,int>
*/
function podman_read_meminfo(): array
{
$lines = @file('/proc/meminfo', FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$out = [];
foreach ($lines as $line) {
if (preg_match('/^(MemTotal|MemAvailable|SwapTotal|SwapFree):\s*(\d+)\s*kB$/', $line, $m)) {
$out[$m[1]] = ((int) $m[2]) * 1024;
}
}
return $out;
}
/**
* Live CPU usage via the classic /proc/stat delta technique: libpod's own
* /info.host.cpuUtilization turned out to be a value computed once and
* never resampled (verified live — three /info calls several seconds
* apart returned byte-identical numbers), which is why the Dashboard's
* CPU tile never moved. A single instantaneous read of /proc/stat can't
* give a percentage on its own either (its counters are cumulative
* jiffies since boot) — it needs two samples to diff. Since the Dashboard
* already re-fetches this endpoint every ~2s via auto-refresh, the
* previous sample is persisted to a small state file and diffed against
* the current one on each call, exactly like `top`/`htop` do between
* their own refresh ticks. Returns null (falls back to libpod's static
* figure) only on the very first call, before any previous sample exists.
*/
function podman_read_cpu_percent(): ?float
{
$stat = @file_get_contents('/proc/stat');
if ($stat === false || !preg_match('/^cpu\s+(\d+) (\d+) (\d+) (\d+) (\d+) (\d+) (\d+) (\d+)/m', $stat, $m)) {
return null;
}
[, $user, $nice, $system, $idle, $iowait, $irq, $softirq, $steal] = array_map('intval', $m);
$total = $user + $nice + $system + $idle + $iowait + $irq + $softirq + $steal;
$idleAll = $idle + $iowait;
$stateFile = '/var/tmp/podman-cpu-sample';
$prevRaw = @file_get_contents($stateFile);
@file_put_contents($stateFile, $total . ' ' . $idleAll, LOCK_EX);
if ($prevRaw === false || !preg_match('/^(\d+) (\d+)$/', trim($prevRaw), $pm)) {
return null;
}
$deltaTotal = $total - (int) $pm[1];
$deltaIdle = $idleAll - (int) $pm[2];
if ($deltaTotal <= 0) {
return null;
}
return round((1 - $deltaIdle / $deltaTotal) * 100, 1);
}
+677
View File
@@ -0,0 +1,677 @@
<?php
/**
* ajax/templates.php
*
* Backs the Templates panel — reusable container configs, saved and
* loaded as XML in the same schema Unraid's own Docker Manager uses for
* its Community Applications templates (<Container version="2"> with
* <Config Type="Port"|"Path"|"Variable"> entries). Deliberately the SAME
* schema, not a podman-specific one of our own: it's the one users and
* template authors already know, and it means a template exported here
* carries over the fields (image, ports, paths, variables, icon,
* category, overview) a Docker template would too, even though the two
* ecosystems' XML isn't fully interchangeable (this plugin's Config
* entries don't have every attribute dockerMan's does, e.g. no GPU/USB/
* device passthrough yet — see docs/ARCHITECTURE.md section 18).
*
* Stored at $bootDir/templates/<name>.xml — same boot-persistence
* reasoning as compose/autostart/networks (see ajax/compose.php).
*
* Actions (?action=...):
* list GET -> [{name, image, icon, category, overview}, ...]
* get GET (&name=...) -> full parsed config, for prefilling the
* Create Container form ("Use template")
* export GET (&name=...) -> {xml: "<raw XML text>"} for download
* save POST {"name": "...", "image": "...", "icon": "...", "category": "...",
* "overview": "...", "webUrl": "...", "networkMode": "...", "privileged": false,
* "restartPolicy": "...", "ports": [...], "volumes": [...], "env": [...]}
* import POST {"xml": "<raw XML text>"} -> parses + saves as a new template
* import_url POST {"url": "https://..."} -> fetches the URL server-side
* (see template_fetch_url() for the SSRF protections this
* needs, since the URL comes straight from the client) and
* imports it the same way as `import`
* list_local GET -> [{file, name, image, icon}, ...] from Unraid's own
* dockerMan template directories (real existing Docker
* templates the user already has — see
* local_dockerman_templates_list()), for a "browse local
* templates" picker instead of copy-pasting XML by hand
* import_local POST {"file": "gitea.xml"} -> imports one by filename
* (validated against the same directories list_local
* scanned, never an arbitrary path from the client)
* apps_search GET (&q=...&page=1&sort=newest|alpha) -> {results:
* [{name, image, icon, overview, templateUrl}, ...],
* total, page, pageSize, feedUpdatedAt} — searches
* Community Applications' own public app feed (the same
* catalog CA's own plugin uses, see ca_feed_search()) by
* name, so a template doesn't have to already be known-by-
* URL or already sitting in dockerMan's local directories
* to import it. An empty/omitted q browses the whole feed
* instead of searching, ordered by `sort` (default
* "newest"); a non-empty q always sorts alphabetically
* regardless of `sort` (see ca_feed_search() for why).
* Importing a match is just `import_url` again with that
* result's templateUrl — no separate import path needed.
* remove POST {"name": "..."}
*/
declare(strict_types=1);
/**
* The public catalog Unraid's own Community Applications plugin is built
* on — one JSON file listing every CA app (name, repository, icon,
* overview, and critically TemplateURL, the same kind of link
* template_fetch_url() already knows how to fetch). ~20MB and only
* updated a few times a day upstream, so it's cached to a temp file
* rather than re-downloaded on every search keystroke.
*/
const CA_FEED_URL = 'https://raw.githubusercontent.com/Squidly271/AppFeed/master/applicationFeed.json';
const CA_FEED_MAX_AGE_SECONDS = 86400;
const CA_FEED_PAGE_SIZE = 24;
require __DIR__ . '/../include/bootstrap.php';
$templatesDir = $podmanConfig->bootDir . '/templates';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(templates_list($templatesDir));
break;
case 'get':
podman_json_response(template_read(require_template_path($templatesDir, (string) ($_GET['name'] ?? ''))));
break;
case 'export':
$path = require_template_path($templatesDir, (string) ($_GET['name'] ?? ''));
podman_json_response(['xml' => file_get_contents($path)]);
break;
case 'save':
$body = podman_read_json_body();
$name = trim((string) ($body['name'] ?? ''));
if (!is_valid_template_name($name)) {
podman_json_error('Template name must be non-empty and contain only letters, digits, "-", "_".', 400);
}
template_write($templatesDir, $name, $body);
podman_json_response(['status' => 'saved', 'name' => $name]);
break;
case 'import':
$body = podman_read_json_body();
$xml = (string) ($body['xml'] ?? '');
if (trim($xml) === '') {
podman_json_error('Missing xml in request body', 400);
}
$name = template_import($templatesDir, $xml);
podman_json_response(['status' => 'imported', 'name' => $name]);
break;
case 'import_url':
$body = podman_read_json_body();
$url = trim((string) ($body['url'] ?? ''));
if ($url === '') {
podman_json_error('Missing url in request body', 400);
}
$name = template_import($templatesDir, template_fetch_url($url));
podman_json_response(['status' => 'imported', 'name' => $name]);
break;
case 'list_local':
podman_json_response(local_dockerman_templates_list());
break;
case 'import_local':
$body = podman_read_json_body();
$file = (string) ($body['file'] ?? '');
$name = template_import($templatesDir, local_dockerman_template_read($file));
podman_json_response(['status' => 'imported', 'name' => $name]);
break;
case 'apps_search':
$q = trim((string) ($_GET['q'] ?? ''));
if ($q !== '' && mb_strlen($q) < 2) {
podman_json_error('Search term must be at least 2 characters', 400);
}
$page = max(1, (int) ($_GET['page'] ?? 1));
$sort = (string) ($_GET['sort'] ?? 'newest');
podman_json_response(ca_feed_search($q, $page, $sort));
break;
case 'remove':
$body = podman_read_json_body();
$path = require_template_path($templatesDir, (string) ($body['name'] ?? ''));
unlink($path);
podman_json_response(['status' => 'removed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Template names become filenames — restricted to a fixed safe character
* set BEFORE ever being used to build a filesystem path, the same
* pattern ajax/compose.php's is_valid_project_name() uses for the same
* reason.
*/
function is_valid_template_name(string $name): bool
{
return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
}
function require_template_path(string $templatesDir, string $name): string
{
if (!is_valid_template_name($name)) {
podman_json_error('Invalid template name', 400);
}
$path = $templatesDir . '/' . $name . '.xml';
if (!is_file($path)) {
podman_json_error("Template '{$name}' not found", 404);
}
return $path;
}
/** @return array<int,array<string,mixed>> */
function templates_list(string $templatesDir): array
{
if (!is_dir($templatesDir)) {
return [];
}
$out = [];
foreach (scandir($templatesDir) ?: [] as $entry) {
if (!str_ends_with($entry, '.xml')) {
continue;
}
$name = substr($entry, 0, -4);
if (!is_valid_template_name($name)) {
continue;
}
try {
$parsed = template_read($templatesDir . '/' . $entry);
} catch (\Throwable $e) {
continue; // a hand-edited/corrupt file shouldn't break the whole list
}
$out[] = [
'name' => $name,
'image' => $parsed['image'],
'icon' => $parsed['icon'],
'category' => $parsed['category'],
'overview' => $parsed['overview'],
];
}
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
/**
* Parses one template XML file into the same shape
* ajax/containers.php's build_container_spec() (Create Container form)
* consumes, so "Use template" can feed straight into that form/action.
*
* @return array<string,mixed>
*/
function template_read(string $path): array
{
$xml = @simplexml_load_file($path);
if ($xml === false) {
podman_json_error("Could not parse template XML: {$path}", 500);
}
$ports = [];
$volumes = [];
$env = [];
foreach ($xml->Config as $cfg) {
$attrs = $cfg->attributes();
$type = (string) ($attrs['Type'] ?? '');
$value = trim((string) $cfg);
$target = (string) ($attrs['Target'] ?? '');
if ($value === '') {
continue;
}
switch ($type) {
case 'Port':
$ports[] = [
'hostPort' => (int) $value,
'containerPort' => (int) ($target !== '' ? $target : $value),
'protocol' => (string) ($attrs['Mode'] ?? 'tcp') ?: 'tcp',
];
break;
case 'Path':
$volumes[] = [
'kind' => 'path',
'source' => $value,
'containerPath' => $target !== '' ? $target : $value,
// dockerMan's own convention for a read-only bind — this
// plugin's own exports write it the same way (see
// template_write() below), and real CA templates use it
// too (e.g. a storage-stats sidecar mounting /mnt/user
// read-only rather than read-write for no reason).
'readOnly' => strtolower((string) ($attrs['Mode'] ?? 'rw')) === 'ro',
];
break;
case 'Variable':
$env[] = ['key' => $target !== '' ? $target : (string) ($attrs['Name'] ?? ''), 'value' => $value];
break;
}
}
return [
'image' => (string) $xml->Repository,
'networkMode' => (string) ($xml->Network ?: 'bridge'),
'privileged' => strtolower((string) $xml->Privileged) === 'true',
'icon' => (string) $xml->Icon,
'category' => (string) $xml->Category,
'overview' => (string) $xml->Overview,
'webUrl' => (string) $xml->WebUI,
'ports' => $ports,
'volumes' => $volumes,
'env' => $env,
];
}
/**
* Writes a template XML file from the Create Container form's field
* shapes (same as build_container_spec() in ajax/containers.php takes)
* plus template-only metadata (icon/category/overview). Uses DOMDocument
* rather than string concatenation so every value is properly escaped —
* no risk of a "<" or "&" in an image name/description breaking the XML.
*
* @param array<string,mixed> $body
*/
function template_write(string $templatesDir, string $name, array $body): void
{
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
podman_json_error("Could not create {$templatesDir}", 500);
}
$doc = new \DOMDocument('1.0');
$doc->formatOutput = true;
$root = $doc->createElement('Container');
$root->setAttribute('version', '2');
$doc->appendChild($root);
$append = static function (string $tag, string $value) use ($doc, $root): void {
$root->appendChild($doc->createElement($tag, $value));
};
$append('Name', $name);
$append('Repository', trim((string) ($body['image'] ?? '')));
$append('Network', (string) ($body['networkMode'] ?? 'bridge'));
$append('Privileged', ($body['privileged'] ?? false) ? 'true' : 'false');
$append('Overview', (string) ($body['overview'] ?? ''));
$append('Category', (string) ($body['category'] ?? ''));
$append('Icon', (string) ($body['icon'] ?? ''));
$append('WebUI', (string) ($body['webUrl'] ?? ''));
foreach (($body['ports'] ?? []) as $row) {
$hostPort = (string) ($row['hostPort'] ?? '');
$containerPort = (string) ($row['containerPort'] ?? '');
if ($hostPort === '' || $containerPort === '') {
continue;
}
$cfg = $doc->createElement('Config', $hostPort);
$cfg->setAttribute('Name', 'Port ' . $containerPort);
$cfg->setAttribute('Target', $containerPort);
$cfg->setAttribute('Mode', (string) ($row['protocol'] ?? 'tcp'));
$cfg->setAttribute('Type', 'Port');
$root->appendChild($cfg);
}
foreach (($body['volumes'] ?? []) as $row) {
$source = (string) ($row['source'] ?? '');
$containerPath = (string) ($row['containerPath'] ?? '');
if ($source === '' || $containerPath === '' || ($row['kind'] ?? 'named') !== 'path') {
continue; // named (podman-managed) volumes aren't portable across hosts, so templates only capture host-path binds
}
$cfg = $doc->createElement('Config', $source);
$cfg->setAttribute('Name', basename($containerPath));
$cfg->setAttribute('Target', $containerPath);
$cfg->setAttribute('Mode', ($row['readOnly'] ?? false) ? 'ro' : 'rw');
$cfg->setAttribute('Type', 'Path');
$root->appendChild($cfg);
}
foreach (($body['env'] ?? []) as $row) {
$key = (string) ($row['key'] ?? '');
if ($key === '') {
continue;
}
$cfg = $doc->createElement('Config', (string) ($row['value'] ?? ''));
$cfg->setAttribute('Name', $key);
$cfg->setAttribute('Target', $key);
$cfg->setAttribute('Type', 'Variable');
$root->appendChild($cfg);
}
if ($doc->save($templatesDir . '/' . $name . '.xml') === false) {
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
}
}
/**
* Imports a pasted/uploaded template XML (this plugin's own schema, or
* a plain Unraid dockerMan template — both use the same <Container>/
* <Config> shape). Validates it parses and has a usable <Name> before
* writing it under our own templates dir with a sanitized filename.
*/
function template_import(string $templatesDir, string $xmlText): string
{
$xml = @simplexml_load_string($xmlText);
if ($xml === false || $xml->getName() !== 'Container') {
podman_json_error('Not a valid template XML (expected a <Container> root element)', 400);
}
$rawName = trim((string) $xml->Name);
$name = preg_replace('/[^a-zA-Z0-9_-]/', '-', $rawName);
if (!is_valid_template_name((string) $name)) {
podman_json_error('Template XML has no usable <Name>', 400);
}
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
podman_json_error("Could not create {$templatesDir}", 500);
}
if (file_put_contents($templatesDir . '/' . $name . '.xml', $xmlText, LOCK_EX) === false) {
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
}
return (string) $name;
}
/**
* Fetches a template XML from a user-supplied URL for "Import from URL".
* Only plain http(s) with a hostname that resolves EXCLUSIVELY to public
* addresses is allowed — checked with FILTER_FLAG_NO_PRIV_RANGE |
* FILTER_FLAG_NO_RES_RANGE, which also covers loopback/link-local, not
* just RFC1918. curl is then pinned to that exact validated IP via
* CURLOPT_RESOLVE rather than letting it resolve the host itself again:
* resolving once here and connecting separately would leave a window for
* a DNS answer that changes between the check and the request (DNS
* rebinding) to point curl at an internal address anyway. Redirects are
* not followed for the same reason — a redirect target needs this same
* validation, and silently trusting one would reopen the hole this whole
* function exists to close. Response size is capped well above what any
* real template XML needs.
*/
function template_fetch_url(string $url): string
{
$parts = parse_url($url);
$scheme = strtolower((string) ($parts['scheme'] ?? ''));
$host = (string) ($parts['host'] ?? '');
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
podman_json_error('URL must be a plain http:// or https:// address', 400);
}
$port = (int) ($parts['port'] ?? ($scheme === 'https' ? 443 : 80));
$ips = [];
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
$ips[] = $host;
} else {
foreach (@dns_get_record($host, DNS_A + DNS_AAAA) ?: [] as $rec) {
$ip = $rec['type'] === 'AAAA' ? ($rec['ipv6'] ?? '') : ($rec['ip'] ?? '');
if ($ip !== '') {
$ips[] = $ip;
}
}
}
if (empty($ips)) {
podman_json_error("Could not resolve host '{$host}'", 400);
}
foreach ($ips as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
podman_json_error("Refusing to fetch from '{$host}': resolves to a private/reserved address ({$ip})", 400);
}
}
$maxBytes = 512 * 1024; // a template XML is a few KB; this is generous headroom
$received = '';
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RESOLVE => ["{$host}:{$port}:{$ips[0]}"],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_TIMEOUT => 10,
CURLOPT_CONNECTTIMEOUT => 5,
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
CURLOPT_HTTPHEADER => ['Accept: application/xml, text/xml, */*'],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$received, $maxBytes) {
$received .= $chunk;
return strlen($received) > $maxBytes ? 0 : strlen($chunk);
},
]);
curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($status >= 300 && $status < 400) {
podman_json_error('The URL returned a redirect — fetch its final target URL directly instead (redirects are not followed here, to keep this from becoming a way around the checks above).', 400);
}
if ($status !== 200) {
podman_json_error('Fetching the URL failed (HTTP ' . $status . ($error !== '' ? ": {$error}" : '') . ')', 400);
}
if (trim($received) === '') {
podman_json_error('The URL returned an empty response', 400);
}
return $received;
}
/**
* Path of the cached CA app feed. Deliberately sys_get_temp_dir(), not
* $bootDir — this is a large (~20MB), frequently-refreshed derived
* artifact, not source-of-truth config, so it doesn't belong on the flash
* drive (see ARCHITECTURE.md 4.1 on what belongs on /boot vs. not) and is
* fine to just re-download after a reboot.
*/
function ca_feed_cache_path(): string
{
return sys_get_temp_dir() . '/podman-ca-appfeed.json';
}
/**
* Downloads the CA feed if the cache is missing or older than
* CA_FEED_MAX_AGE_SECONDS, returning the path to a usable (possibly
* stale) cache file. A failed download falls back to a stale cache
* rather than failing the search outright — an out-of-date app list is
* still far more useful than none, and the feed realistically doesn't
* change meaningfully within a day anyway.
*/
function ca_feed_ensure_cached(): string
{
$path = ca_feed_cache_path();
if (is_file($path) && (time() - filemtime($path)) < CA_FEED_MAX_AGE_SECONDS) {
return $path;
}
$tmpPath = $path . '.' . getmypid() . '.tmp';
$fh = fopen($tmpPath, 'wb');
if ($fh === false) {
if (is_file($path)) {
return $path;
}
podman_json_error('Could not write app feed cache', 500);
}
$ch = curl_init(CA_FEED_URL);
curl_setopt_array($ch, [
CURLOPT_FILE => $fh,
CURLOPT_TIMEOUT => 30,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
]);
$ok = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
fclose($fh);
if (!$ok || $status !== 200 || filesize($tmpPath) < 1000) {
@unlink($tmpPath);
if (is_file($path)) {
return $path;
}
podman_json_error('Could not download the Community Applications feed', 502);
}
rename($tmpPath, $path);
return $path;
}
/**
* Searches the CA feed by name (and its ExtraSearchTerms, the same field
* CA's own search matches against) and returns one page of just enough
* per result to render a picker + import it — never the raw feed itself,
* which is far too large to ship to the client for what is, per page, a
* couple dozen entries.
*
* An empty query is "browse" mode instead of "search": ordered by
* $sort — "newest" (default, by the feed's own FirstSeen timestamp — when
* CA's feed first picked the template up) or "alpha". A non-empty query
* always sorts alphabetically regardless of $sort: the feed's "downloads"
* figure (the only other candidate) turns out to just be the underlying
* Docker image's Docker Hub pull count (verified live: dozens of unrelated
* templates that all happen to wrap the official nginx/postgres/redis
* images share the exact same, enormous number), which would make search
* results look ranked by relevance while actually just favoring whichever
* match wraps the most-pulled base image.
*
* @return array{results: array<int,array<string,string>>, total: int, page: int, pageSize: int, feedUpdatedAt: int}
*/
function ca_feed_search(string $query, int $page = 1, string $sort = 'newest'): array
{
$path = ca_feed_ensure_cached();
// Parsing ~20MB of JSON into PHP arrays needs more headroom than the
// default limit on some setups; scoped to this request only.
ini_set('memory_limit', '256M');
$data = json_decode((string) file_get_contents($path), true);
$apps = is_array($data) ? ($data['applist'] ?? []) : [];
$needle = mb_strtolower($query);
$matches = [];
foreach ($apps as $app) {
$name = (string) ($app['Name'] ?? '');
$templateUrl = (string) ($app['TemplateURL'] ?? '');
if ($name === '' || $templateUrl === '') {
continue;
}
// CA's feed lists real Unraid OS plugins (.plg installers — system
// add-ons like GPU drivers, mover tuning, Unraid Connect) alongside
// actual Docker app templates, both under the same "applist" — the
// giveaway is a Repository ending in ".plg" instead of a Docker
// image reference (verified against every one of the 268 entries
// tagged with CA's own "Plugins" category — every single one had
// exactly this). Podman only runs containers, so these would just
// fail nonsensically if "installed" as one.
if (str_ends_with(strtolower((string) ($app['Repository'] ?? '')), '.plg')) {
continue;
}
if ($needle !== '') {
$haystack = mb_strtolower($name . ' ' . (string) ($app['ExtraSearchTerms'] ?? ''));
if (mb_strpos($haystack, $needle) === false) {
continue;
}
}
// CA's own overview text uses forum-style bbcode ([b]/[br]/[li]/…)
// rather than plain text or HTML — strip the tags for a clean,
// short plain-text excerpt instead of showing the raw markup.
$overview = trim((string) preg_replace('/\s+/', ' ', (string) preg_replace('/\[[^\]]*\]/', ' ', (string) ($app['Overview'] ?? ''))));
if (mb_strlen($overview) > 140) {
$overview = mb_substr($overview, 0, 137) . '…';
}
$matches[] = [
'name' => $name,
'image' => (string) ($app['Repository'] ?? ''),
'icon' => (string) ($app['Icon'] ?? ''),
'overview' => $overview,
'templateUrl' => $templateUrl,
'firstSeen' => (int) ($app['FirstSeen'] ?? 0),
];
}
if ($needle !== '' || $sort === 'alpha') {
usort($matches, static fn($a, $b) => strcasecmp($a['name'], $b['name']));
} else {
usort($matches, static fn($a, $b) => $b['firstSeen'] <=> $a['firstSeen']);
}
$total = count($matches);
$pageSize = CA_FEED_PAGE_SIZE;
$lastPage = max(1, (int) ceil($total / $pageSize));
$page = min(max(1, $page), $lastPage);
$slice = array_slice($matches, ($page - 1) * $pageSize, $pageSize);
foreach ($slice as &$m) {
unset($m['firstSeen']);
}
unset($m);
return [
'results' => $slice,
'total' => $total,
'page' => $page,
'pageSize' => $pageSize,
'feedUpdatedAt' => filemtime($path),
];
}
/**
* Unraid's own Docker Manager plugin stores every template a user has
* ever saved/customized under templates-user/, plus a local cache of
* Community Applications' own catalog under templates-community/ (may
* be empty/absent if CA was never installed) — both in the same
* <Container>/<Config> XML shape this plugin reads. Read-only: this
* plugin never writes into dockerMan's own directories.
*
* @return array<int,string>
*/
function dockerman_template_dirs(): array
{
return array_values(array_filter([
'/boot/config/plugins/dockerMan/templates-user',
'/boot/config/plugins/dockerMan/templates-community',
], 'is_dir'));
}
/** @return array<int,array<string,string>> */
function local_dockerman_templates_list(): array
{
$out = [];
foreach (dockerman_template_dirs() as $dir) {
foreach (scandir($dir) ?: [] as $entry) {
if (!str_ends_with($entry, '.xml')) {
continue;
}
$xml = @simplexml_load_file($dir . '/' . $entry);
if ($xml === false) {
continue; // skip anything that doesn't parse rather than failing the whole list
}
$out[] = [
'file' => $entry,
'name' => (string) $xml->Name,
'image' => (string) $xml->Repository,
'icon' => (string) $xml->Icon,
];
}
}
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
/**
* Reads one local dockerMan template's raw XML by filename. $file comes
* straight from client input — reduced to its basename and required to
* actually exist in one of dockerman_template_dirs() (the same list
* list_local scanned), never treated as an arbitrary path.
*/
function local_dockerman_template_read(string $file): string
{
$file = basename($file);
if (!str_ends_with($file, '.xml')) {
podman_json_error('Invalid template file', 400);
}
foreach (dockerman_template_dirs() as $dir) {
$path = $dir . '/' . $file;
if (is_file($path)) {
$content = file_get_contents($path);
if ($content !== false) {
return $content;
}
}
}
podman_json_error("Local template '{$file}' not found", 404);
}
+15 -2
View File
@@ -9,7 +9,7 @@
*
* Actions (?action=...):
* list GET -> normalized volume list, with usedBy counts
* create POST {"name": "...", "driver": "local"}
* create POST {"name": "...", "driver": "local", "path": "/mnt/cache/..." (optional)}
* remove POST {"name": "...", "force": false}
*/
@@ -30,7 +30,11 @@ switch ($action) {
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local')));
$path = trim((string) ($body['path'] ?? ''));
if ($path !== '' && !str_starts_with($path, '/')) {
podman_json_error("Host path ({$path}) must be an absolute path.", 400);
}
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local'), $path !== '' ? $path : null));
break;
case 'remove':
@@ -65,10 +69,19 @@ function volumes_list(PodmanClient $client): array
$out = [];
foreach ($raw as $v) {
$name = (string) ($v['Name'] ?? '');
$options = $v['Options'] ?? [];
// A volume created with our "Host path" field carries
// type=none,o=bind,device=<path> (see PodmanClient::createVolume)
// — surfaced separately from 'mountpoint' (podman's own internal
// storage path, which stays populated even for bind-backed
// volumes) so the UI can show users the host path they actually
// asked for.
$hostPath = (is_array($options) && ($options['o'] ?? '') === 'bind') ? (string) ($options['device'] ?? '') : null;
$out[] = [
'name' => $name,
'driver' => (string) ($v['Driver'] ?? 'local'),
'mountpoint' => (string) ($v['Mountpoint'] ?? ''),
'hostPath' => $hostPath,
'createdAt' => podman_parse_time($v['CreatedAt'] ?? null),
'usedBy' => $usageCounts[$name] ?? 0,
];
+2
View File
@@ -21,6 +21,7 @@ final class PodmanConfig
public string $bootDir;
public string $autostartFile;
public string $autostartDelayFile;
public string $foldersFile;
private function __construct()
{
@@ -33,6 +34,7 @@ final class PodmanConfig
$this->socketPath = '/var/run/podman/podman.sock';
$this->autostartFile = $this->bootDir . '/autostart';
$this->autostartDelayFile = $this->bootDir . '/autostart-delay';
$this->foldersFile = $this->bootDir . '/folders.json';
}
public static function load(): self
+209 -44
View File
@@ -101,6 +101,31 @@ final class PodmanClient
return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']);
}
/**
* POST /containers/create — takes a libpod SpecGenerator body. Field
* names/shapes below (image, name, command, env, portmappings,
* netns, networks, mounts, volumes, restart_policy, privileged) were
* verified live against a real podman system service, not assumed
* from docs — see ajax/containers.php's create action, which builds
* this array from the WebUI's Create Container form.
*
* @param array<string,mixed> $spec
* @return string the new container's ID
*/
public function createContainer(array $spec): string
{
// 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'] ?? '');
}
public function startContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
@@ -121,6 +146,27 @@ final class PodmanClient
$this->request('DELETE', '/containers/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
public function pauseContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/pause', [], true);
}
public function unpauseContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/unpause', [], true);
}
/** $signal accepts both a name ("SIGKILL") and a bare number, matching libpod's own `kill?signal=` parsing. */
public function killContainer(string $id, string $signal = 'SIGKILL'): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/kill', ['signal' => $signal], true);
}
public function renameContainer(string $id, string $newName): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/rename', ['name' => $newName], true);
}
/**
* GET /containers/{id}/logs — returns the raw (already de-multiplexed
* where possible) log text. Podman's non-TTY log stream uses the same
@@ -139,6 +185,41 @@ final class PodmanClient
return self::demuxStream($raw);
}
/**
* GET /events?stream=false&since=...&filters={"container":["<id>"]} —
* a bounded, already-happened event history for one container, NOT a
* live stream (stream=false makes libpod return what already matched
* and close the connection immediately, verified live). This is
* enough for a container's "Events" detail tab without needing the
* persistent-connection event-streaming infrastructure a live,
* fleet-wide event feed would require (see dashboard.js's own header
* comment on why that's out of scope for now).
*
* @return array<int,array<string,mixed>>
*/
public function containerEvents(string $id, int $sinceUnixSeconds): array
{
$query = [
'stream' => 'false',
'since' => (string) $sinceUnixSeconds,
'filters' => json_encode(['container' => [$id]]),
];
$raw = $this->requestRaw('GET', '/events', $query);
// Newline-delimited JSON, not a single JSON array — each line is
// its own event object.
$events = [];
foreach (explode("\n", trim($raw)) as $line) {
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (is_array($decoded)) {
$events[] = $decoded;
}
}
return $events;
}
// -------------------------------------------------------------------
// Exec — see webui/plugins/podman/ajax/exec.php for the important
// caveat: this implements one-shot "run a command, return its output"
@@ -147,41 +228,6 @@ final class PodmanClient
// have — see that file's header comment for the full explanation).
// -------------------------------------------------------------------
/**
* Creates and immediately runs one command inside a container via the
* real libpod exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) and returns its combined stdout+stderr output.
* Tty=true is used deliberately so the response is a plain byte stream
* with no frame-header demultiplexing needed (see containerLogs() for
* the non-TTY case, which does need it).
*/
public function execRun(string $containerId, array $cmd, string $workingDir = ''): string
{
$createBody = [
'AttachStdin' => false,
'AttachStdout' => true,
'AttachStderr' => true,
'Tty' => true,
'Cmd' => $cmd,
];
if ($workingDir !== '') {
$createBody['WorkingDir'] = $workingDir;
}
$created = $this->request('POST', '/containers/' . rawurlencode($containerId) . '/exec', [], false, $createBody);
$execId = $created['Id'] ?? null;
if (!is_string($execId) || $execId === '') {
throw new PodmanApiException('exec create response did not include an Id');
}
$output = $this->requestRaw('POST', '/exec/' . rawurlencode($execId) . '/start', [], [
'Detach' => false,
'Tty' => true,
]);
return $output;
}
// -------------------------------------------------------------------
// Pods
// -------------------------------------------------------------------
@@ -196,6 +242,22 @@ final class PodmanClient
return $this->request('GET', '/pods/' . rawurlencode($name) . '/json');
}
/**
* POST /pods/create — takes a body of {name, portmappings, ...}.
* Verified live against a real podman system service: {"name":"...",
* "portmappings":[{"host_port":...,"container_port":...,"protocol":...}]}
* creates a pod with a shared infra container whose port bindings apply
* to every member container — see ajax/pods.php's build_pod_spec().
*
* @param array<string,mixed> $spec
* @return string the new pod's ID
*/
public function createPod(array $spec): string
{
$result = $this->request('POST', '/pods/create', [], false, $spec);
return (string) ($result['Id'] ?? '');
}
public function startPod(string $name): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true);
@@ -206,6 +268,11 @@ final class PodmanClient
$this->request('POST', '/pods/' . rawurlencode($name) . '/stop', ['t' => (string) $timeoutSeconds], true);
}
public function restartPod(string $name, int $timeoutSeconds = 10): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/restart', ['t' => (string) $timeoutSeconds], true);
}
public function removePod(string $name, bool $force = false): void
{
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
@@ -220,10 +287,61 @@ final class PodmanClient
return $this->request('GET', '/images/json');
}
/** POST /images/pull — pulls (or updates) an image by reference, e.g. "docker.io/library/postgres:16". */
/**
* POST /images/pull — pulls (or updates) an image by reference, e.g.
* "docker.io/library/postgres:16".
*
* Unlike virtually every other libpod endpoint, a successful pull's
* response body is NOT one JSON document — it's newline-delimited
* JSON, one progress object per line (verified live:
* `{"status":"pulling","stream":"..."}` repeated, then a final
* `{"status":"success","images":[...],"id":"..."}` line). Feeding
* that whole blob through the normal single-document request() here
* made json_decode() fail on every successful pull with "Expected a
* JSON object/array response from /images/pull" — found by
* live-testing a real pull through the WebUI's Images panel, not
* from reading libpod's docs. An error that happens before any
* image data is found (e.g. unknown reference) is unaffected: libpod
* sends that as a normal single-JSON-object 4xx response, which
* requestRaw()/request()'s existing status>=400 handling already
* covers correctly.
*/
public function pullImage(string $reference): array
{
return $this->request('POST', '/images/pull', ['reference' => $reference]);
// A real image (e.g. a Plex/media-server image, easily several
// hundred MB) routinely takes far longer than this client's normal
// 15s operation timeout to download — found live: a pull aborted
// mid-stream with "Operation timed out after 15001 milliseconds"
// after only ~1.4KB of progress data. nginx's own fastcgi_read_timeout
// (640s, see /etc/nginx/nginx.conf) already anticipates long-running
// plugin requests, so 600s here stays safely under that.
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference], null, 600);
$last = null;
foreach (explode("\n", trim($raw)) as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$decoded = json_decode($line, true);
if (!is_array($decoded)) {
continue;
}
// A mid-stream error (pull started, then failed — e.g. the
// connection dropped partway through a layer) is reported as
// an {"error": "..."} line rather than an HTTP error status,
// since headers/status are already committed by the time
// libpod knows the pull failed.
if (isset($decoded['error'])) {
throw new PodmanApiException((string) $decoded['error'], 502);
}
$last = $decoded;
}
if ($last === null) {
throw new PodmanApiException('Expected a JSON object/array response from /images/pull');
}
return $last;
}
public function removeImage(string $id, bool $force = false): void
@@ -231,6 +349,28 @@ final class PodmanClient
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
/**
* POST /images/prune?all=true — removes every image with zero containers
* (running or stopped) referencing it, matching this app's own "Used By"
* column — not just dangling/untagged images. Verified live: a tagged
* but unused image IS removed with all=true (found the hard way: it
* also removed every image on a host with no containers at all, which
* is correct behavior, just aggressive — see ajax/images.php's prune
* action for the confirmation-copy this justifies).
*
* @return array<int,array{Id:string,Size:int}> one entry per removed image
*/
public function pruneImages(): array
{
return $this->request('POST', '/images/prune', ['all' => 'true']);
}
/** POST /images/{id}/tag?repo=...&tag=... — adds a new repo:tag pointing at an existing image. */
public function tagImage(string $id, string $repo, string $tag): void
{
$this->request('POST', '/images/' . rawurlencode($id) . '/tag', ['repo' => $repo, 'tag' => $tag], true);
}
// -------------------------------------------------------------------
// Volumes
// -------------------------------------------------------------------
@@ -240,9 +380,21 @@ final class PodmanClient
return $this->request('GET', '/volumes/json');
}
public function createVolume(string $name, string $driver = 'local'): array
/**
* $hostPath, if given, binds the volume directly to an existing host
* directory instead of a podman-managed one — the local driver's
* `type=none,o=bind,device=<path>` option trio (same mechanism
* `podman volume create --opt type=none --opt o=bind --opt device=...`
* uses on the CLI). Verified live: a container mounting such a volume
* reads/writes the host path directly, not an internal copy.
*/
public function createVolume(string $name, string $driver = 'local', ?string $hostPath = null): array
{
return $this->request('POST', '/volumes/create', [], false, ['Name' => $name, 'Driver' => $driver]);
$body = ['Name' => $name, 'Driver' => $driver];
if ($hostPath !== null && $hostPath !== '') {
$body['Options'] = ['type' => 'none', 'device' => $hostPath, 'o' => 'bind'];
}
return $this->request('POST', '/volumes/create', [], false, $body);
}
public function removeVolume(string $name, bool $force = false): void
@@ -259,12 +411,25 @@ final class PodmanClient
return $this->request('GET', '/networks/json');
}
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null): array
/**
* $parentInterface (only meaningful for driver="macvlan") attaches the
* network directly to an existing host bridge/VLAN interface (e.g.
* Unraid's own "br0" or a VLAN sub-interface like "br0.3") via
* libpod's "network_interface" field — verified live: containers on
* such a network get a real address on that LAN/VLAN's own subnet,
* not a NATed one, matching Unraid Docker Manager's "Custom: br0"
* network type. See ajax/networks.php's macvlan_parent_interfaces()
* for where the interface list itself comes from.
*/
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null, ?string $parentInterface = null): array
{
$body = ['name' => $name, 'driver' => $driver];
if ($subnet !== null) {
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
}
if ($parentInterface !== null && $parentInterface !== '') {
$body['network_interface'] = $parentInterface;
}
return $this->request('POST', '/networks/create', [], false, $body);
}
@@ -285,9 +450,9 @@ final class PodmanClient
* @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
* @return array<mixed>
*/
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null): array
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null, ?int $timeoutSeconds = null): array
{
$raw = $this->requestRaw($method, $path, $query, $jsonBody);
$raw = $this->requestRaw($method, $path, $query, $jsonBody, $timeoutSeconds);
if ($expectEmptyBody || trim($raw) === '') {
return [];
}
@@ -306,7 +471,7 @@ final class PodmanClient
* @param array<string,string> $query
* @param array<mixed>|null $jsonBody
*/
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null): string
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null, ?int $timeoutSeconds = null): string
{
$url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
if (!empty($query)) {
@@ -319,7 +484,7 @@ final class PodmanClient
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_TIMEOUT => $timeoutSeconds ?? $this->timeoutSeconds,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
@@ -0,0 +1,172 @@
<?php
/**
* RegistryClient.php
*
* "Is a newer image available?" — deliberately NOT a podman/libpod feature
* (verified live: no libpod endpoint exists for this; every tool that
* offers it, Watchtower/Diun/Unraid's own Docker Manager included,
* re-implements the same registry-side check). This talks directly to the
* target image's own registry using the standard Docker Registry HTTP API
* V2: a GET on the manifest returns a "Docker-Content-Digest" header
* without downloading any image layers, which is compared against the
* digest of the image already pulled locally (PodmanClient::listImages()'s
* own "Digest" field) — no local image ever needs pulling just to check.
*
* The auth flow is the generic Bearer-challenge dance every compliant
* registry follows (RFC-ish, not just a Docker Hub thing): an
* unauthenticated request gets a 401 with a WWW-Authenticate header naming
* a token realm/service/scope, a token is fetched from that realm, and the
* manifest request is retried with it. Verified live against three
* different registries with three different auth setups — Docker Hub,
* ghcr.io, and a self-hosted Gitea registry — using this exact same code
* path for all three, not registry-specific special-casing.
*/
declare(strict_types=1);
final class RegistryClient
{
/**
* @return array{updateAvailable?:bool,remoteDigest?:string,error?:string}
*/
public static function checkForUpdate(string $reference, string $localDigest): array
{
[$registry, $repo, $tag] = self::parseReference($reference);
$manifestUrl = "https://{$registry}/v2/{$repo}/manifests/{$tag}";
$accept = 'application/vnd.docker.distribution.manifest.v2+json, ' .
'application/vnd.docker.distribution.manifest.list.v2+json, ' .
'application/vnd.oci.image.manifest.v1+json, ' .
'application/vnd.oci.image.index.v1+json';
[$status, $headers] = self::httpRequest($manifestUrl, $accept, null);
if ($status === 401) {
$challenge = self::parseAuthChallenge($headers['www-authenticate'] ?? '');
if ($challenge === null) {
return ['error' => 'Registry requires authentication this app cannot satisfy.'];
}
$token = self::fetchToken($challenge);
if ($token === null) {
return ['error' => 'Could not authenticate with the registry.'];
}
[$status, $headers] = self::httpRequest($manifestUrl, $accept, $token);
}
if ($status !== 200) {
return ['error' => "Registry returned HTTP {$status}."];
}
$remoteDigest = $headers['docker-content-digest'] ?? null;
if ($remoteDigest === null) {
return ['error' => 'Registry response did not include a digest.'];
}
return ['remoteDigest' => $remoteDigest, 'updateAvailable' => $remoteDigest !== $localDigest];
}
/**
* Splits "docker.io/library/nginx:alpine" (or shorthand forms like
* "nginx:alpine" or "someuser/repo:tag") into [registryHost, repoPath,
* tag] — same reference-parsing convention every registry client
* (including podman/Docker themselves) uses: the first path segment is
* a registry host only if it contains a "." or ":" or is "localhost";
* otherwise the whole reference is a Docker Hub repo, implicitly under
* "library/" if it has no namespace of its own. docker.io's actual API
* host is registry-1.docker.io, not docker.io itself — a Docker-Hub-
* specific quirk, not something inferred from the general rule above.
*
* @return array{0:string,1:string,2:string}
*/
private static function parseReference(string $reference): array
{
$reference = explode('@', $reference, 2)[0]; // strip any @sha256:... suffix
$tag = 'latest';
$lastSlash = strrpos($reference, '/');
$lastColon = strrpos($reference, ':');
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
$tag = substr($reference, $lastColon + 1);
$reference = substr($reference, 0, $lastColon);
}
$parts = explode('/', $reference);
$first = $parts[0];
$looksLikeHost = str_contains($first, '.') || str_contains($first, ':') || $first === 'localhost';
if ($looksLikeHost) {
$registry = $first;
$repo = implode('/', array_slice($parts, 1));
} else {
$registry = 'docker.io';
$repo = str_contains($reference, '/') ? $reference : "library/{$reference}";
}
if ($registry === 'docker.io') {
$registry = 'registry-1.docker.io';
}
return [$registry, $repo, $tag];
}
/** @return array{realm:string,service:string,scope:string}|null */
private static function parseAuthChallenge(string $header): ?array
{
if (preg_match('/realm="([^"]+)"/', $header, $m) !== 1) {
return null;
}
$service = preg_match('/service="([^"]+)"/', $header, $sm) === 1 ? $sm[1] : '';
$scope = preg_match('/scope="([^"]+)"/', $header, $om) === 1 ? $om[1] : '';
return ['realm' => $m[1], 'service' => $service, 'scope' => $scope];
}
/** @param array{realm:string,service:string,scope:string} $challenge */
private static function fetchToken(array $challenge): ?string
{
$params = array_filter(['service' => $challenge['service'], 'scope' => $challenge['scope']]);
$url = $challenge['realm'] . '?' . http_build_query($params);
[$status, , $body] = self::httpRequest($url, 'application/json', null, true);
if ($status !== 200 || $body === null) {
return null;
}
$decoded = json_decode($body, true);
// The spec allows either key; registries are inconsistent about
// which one they actually send.
return is_array($decoded) ? (string) ($decoded['token'] ?? $decoded['access_token'] ?? '') ?: null : null;
}
/**
* @return array{0:int,1:array<string,string>,2:?string} [status, lowercased response headers, body (only when $withBody)]
*/
private static function httpRequest(string $url, string $accept, ?string $token, bool $withBody = false): array
{
$ch = curl_init($url);
$headers = ['Accept: ' . $accept];
if ($token !== null) {
$headers[] = "Authorization: Bearer {$token}";
}
curl_setopt_array($ch, [
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HEADER => true,
CURLOPT_TIMEOUT => 15,
CURLOPT_FOLLOWLOCATION => true,
]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
curl_close($ch);
if ($raw === false) {
return [0, [], null];
}
$parsedHeaders = [];
foreach (explode("\r\n", substr($raw, 0, $headerSize)) as $line) {
if (str_contains($line, ':')) {
[$k, $v] = explode(':', $line, 2);
$parsedHeaders[strtolower(trim($k))] = trim($v);
}
}
$body = $withBody ? substr($raw, $headerSize) : null;
return [$status, $parsedHeaders, $body];
}
}
@@ -20,6 +20,7 @@ declare(strict_types=1);
require_once __DIR__ . '/PodmanClient.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/RegistryClient.php';
set_exception_handler(static function (\Throwable $e): void {
if ($e instanceof PodmanApiException) {
+47 -4
View File
@@ -11,6 +11,37 @@
declare(strict_types=1);
/**
* Reads /usr/local/share/unraid-podman/installed-versions.env — the same
* manifest plugin/sbin/podman-verify-packages.sh and
* podman-update-packages.sh use (see plugin/podman.plg's postinstall step,
* which generates it) — shared here so settings.php (package version
* chips) and system.php (Dashboard's plugin version chip) both report
* exactly what those tools would, not two possibly-diverging readers of
* the same file.
*
* @return array<string,string>
*/
function podman_read_installed_versions(): array
{
$path = '/usr/local/share/unraid-podman/installed-versions.env';
if (!is_readable($path)) {
return [];
}
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$out = [];
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (preg_match('/^([A-Z_][A-Z0-9_]*)="?([^"]*)"?$/', $line, $m)) {
$out[$m[1]] = $m[2];
}
}
return $out;
}
function podman_format_bytes(int $bytes): string
{
if ($bytes <= 0) {
@@ -42,13 +73,25 @@ function podman_format_duration(int $seconds): string
return "{$minutes}m";
}
/** Converts a libpod RFC3339 timestamp (as found in inspect output) to a Unix timestamp, or null if unparsable. */
function podman_parse_time(?string $rfc3339): ?int
/**
* Converts a libpod timestamp to a Unix timestamp, or null if unparsable.
* Accepts both the RFC3339 strings `inspect`-style endpoints return and
* the raw Unix-epoch integers `list`-style endpoints return for the same
* logical field (e.g. containers/json's StartedAt/Created vs. inspect's)
* — verified live against a real podman system service, not just docs.
*/
function podman_parse_time(string|int|null $value): ?int
{
if ($rfc3339 === null || $rfc3339 === '' || str_starts_with($rfc3339, '0001-01-01')) {
if ($value === null || $value === '') {
return null;
}
$ts = strtotime($rfc3339);
if (is_int($value)) {
return $value > 0 ? $value : null;
}
if (str_starts_with($value, '0001-01-01')) {
return null;
}
$ts = strtotime($value);
return $ts === false ? null : $ts;
}
+395
View File
@@ -36,6 +36,17 @@ window.Podman = (function () {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
// Unraid's own webGui/include/local_prepend.php (auto_prepend_file on
// every PHP request, not something this plugin controls) kills any
// POST request with no output at all unless it carries the page's
// CSRF token — either as a "csrf_token" POST field or this header.
// `csrf_token` itself is a global var HeadInlineJS.php sets on every
// Unraid page before plugin JS loads (verified live: without this
// header, every mutating action failed with "JSON.parse: unexpected
// end of data", i.e. an empty response body from csrf_terminate()).
if (method === 'POST' && typeof window.csrf_token === 'string') {
opts.headers['X-CSRF-Token'] = window.csrf_token;
}
return fetch(url, opts)
.then(function (res) {
@@ -115,6 +126,357 @@ window.Podman = (function () {
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
}
// --- Toast notifications ----------------------------------------------------
//
// Replaces alert() for one-way feedback ("Saved.", "Removed 3 image(s)",
// "Save failed: ..."). Confirmations use confirmModal() below instead —
// a toast is for telling the user something happened, not for asking
// them a yes/no question. Command output that can run to hundreds of lines
// (e.g. `podman compose up`) stays in the existing openLogModal()
// pattern instead — a toast has to stay short and auto-dismiss, which
// doesn't fit a scrolling log.
const TOAST_ICON = { success: '✓', warn: '!', error: '✕', info: '' };
const TOAST_DURATION_MS = { success: 4000, warn: 5000, error: 7000, info: 4000 };
let toastContainer = null;
function toastRoot() {
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.className = 'podman-toast-container';
(document.querySelector('.podman-plugin') || document.body).appendChild(toastContainer);
}
return toastContainer;
}
/**
* @param {string} message
* @param {('success'|'warn'|'error'|'info')} [type='info']
*/
function toast(message, type) {
const kind = TOAST_ICON[type] ? type : 'info';
const root = toastRoot();
const node = document.createElement('div');
node.className = 'podman-toast podman-toast-' + kind;
node.innerHTML =
'<span class="ico">' + TOAST_ICON[kind] + '</span>' +
'<span class="msg"></span>' +
'<button type="button" class="close" aria-label="Dismiss">&#10005;</button>';
node.querySelector('.msg').textContent = message;
root.appendChild(node);
let dismissed = false;
function dismiss() {
if (dismissed) return;
dismissed = true;
node.classList.add('leaving');
// Not animationend — that never fires when prefers-reduced-motion
// disables the animation (podman.css sets animation:none for it),
// which would leave the toast stuck on screen forever.
setTimeout(function () { node.remove(); }, 160);
}
node.querySelector('.close').addEventListener('click', dismiss);
setTimeout(dismiss, TOAST_DURATION_MS[kind]);
}
// --- Confirm dialog ----------------------------------------------------
/**
* In-app replacement for browser-native confirm() — a native confirm()
* blocks the entire tab (including this plugin's own ~2s auto-refresh,
* and any browser automation driving the page) until dismissed, can't
* be styled/themed, and — found live — is easy to lose track of: a
* hung dialog blocked screenshots/JS entirely, and a follow-up
* keypress meant to dismiss just one of them ended up confirming a
* second, unrelated one too. Returns a Promise<boolean> (true =
* confirmed) instead of blocking synchronously.
*
* @param {string} message
* @param {object} [opts]
* @param {string} [opts.title='Confirm']
* @param {string} [opts.confirmLabel='Confirm']
* @param {string} [opts.cancelLabel='Cancel']
* @param {boolean} [opts.danger=false] solid red confirm button, for
* destructive/data-losing actions (delete, remove, format, ...).
*/
function confirmModal(message, opts) {
opts = opts || {};
return new Promise(function (resolve) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="alertdialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title || 'Confirm') + '</h3></div>' +
'<div class="podman-modal-body"><p class="podman-confirm-message"></p></div>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">' + escapeHtml(opts.cancelLabel || 'Cancel') + '</button>' +
'<button type="button" class="podman-btn ' + (opts.danger ? 'podman-btn-ghost podman-btn-danger' : 'podman-btn-primary') + '" data-role="confirm">' +
escapeHtml(opts.confirmLabel || 'Confirm') + '</button>' +
'</div></div>';
backdrop.querySelector('.podman-confirm-message').textContent = message;
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
backdrop.querySelector('[data-role="confirm"]').focus();
function settle(result) {
backdrop.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') settle(false);
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', function () { settle(false); });
backdrop.querySelector('[data-role="confirm"]').addEventListener('click', function () { settle(true); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) settle(false); });
document.addEventListener('keydown', onKey);
});
}
// --- Modal form dialog -----------------------------------------------------
/**
* Shows a small form modal in place of browser-native prompt()/confirm()
* — needed for any action that takes more than one related value (e.g.
* "New Volume" wants a name AND an optional host path together; chaining
* prompt() calls for that is both bad UX and can't show both fields at
* once, or offer a hint under the path field explaining what it does).
*
* @param {object} opts
* @param {string} opts.title
* @param {Array<{name:string, label:string, placeholder?:string, hint?:string, required?:boolean}>} opts.fields
* @param {string} [opts.submitLabel]
* @param {(values: Object<string,string>) => Promise<any>} opts.onSubmit
* Called with {fieldName: value}. Rejecting keeps the modal open and
* shows the error inline; resolving closes it.
*/
function openFormModal(opts) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
const fieldsHtml = opts.fields.map(function (f) {
return '' +
'<div class="podman-modal-field">' +
'<label for="podman-modal-' + f.name + '">' + escapeHtml(f.label) + '</label>' +
'<input type="text" id="podman-modal-' + f.name + '" name="' + f.name + '"' +
(f.placeholder ? ' placeholder="' + escapeHtml(f.placeholder) + '"' : '') + '>' +
(f.hint ? '<div class="hint">' + escapeHtml(f.hint) + '</div>' : '') +
'</div>';
}).join('');
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title) + '</h3></div>' +
'<form class="podman-modal-body">' + fieldsHtml + '</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' +
escapeHtml(opts.submitLabel || 'Create') + '</button>' +
'</div></div>';
// Appended inside .podman-plugin, not document.body: the --surface/
// --border/etc. custom properties this modal's CSS relies on are
// scoped to .podman-plugin (see podman.css's token strategy comment),
// so a modal appended to body would resolve none of them — verified
// live: the backdrop dimming and card background were both missing,
// only the (inherited-from-body) text was visible. position:fixed
// still overlays the full viewport regardless of this nesting.
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const firstInput = backdrop.querySelector('input');
if (firstInput) firstInput.focus();
function close() {
backdrop.remove();
}
function submit() {
const values = {};
opts.fields.forEach(function (f) {
values[f.name] = backdrop.querySelector('#podman-modal-' + f.name).value.trim();
});
for (const f of opts.fields) {
if (f.required && !values[f.name]) {
showError('"' + f.label + '" is required.');
return;
}
}
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
Promise.resolve(opts.onSubmit(values)).then(close).catch(function (err) {
submitBtn.disabled = false;
showError(err.message || String(err));
});
}
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) {
e.preventDefault();
submit();
});
backdrop.addEventListener('click', function (e) {
if (e.target === backdrop) close();
});
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') {
close();
document.removeEventListener('keydown', onKey);
}
});
}
/**
* Small modal with a scrolling monospace log pane — for actions that run
* several steps in sequence (checking/updating containers) where a plain
* confirm()/alert() at the very end leaves the user with no feedback
* that anything is happening while it runs. Returns {log, done} rather
* than closing itself, since the caller knows when the whole sequence
* (not just one call) has actually finished.
*
* @param {string} title
* @returns {{log: (line: string) => void, done: (closeLabel?: string) => void}}
*/
function openLogModal(title) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + escapeHtml(title) + '</h3></div>' +
'<div class="podman-modal-body"><div class="podman-log-pane" id="podman-log-modal-pane"></div></div>' +
'<div class="podman-modal-actions"><button type="button" class="podman-btn podman-btn-primary" data-role="close" disabled>Working…</button></div>' +
'</div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const pane = backdrop.querySelector('#podman-log-modal-pane');
const closeBtn = backdrop.querySelector('[data-role="close"]');
function close() { backdrop.remove(); }
closeBtn.addEventListener('click', close);
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape' && !closeBtn.disabled) { close(); document.removeEventListener('keydown', onKey); }
});
function log(line) {
const row = document.createElement('div');
row.textContent = line;
pane.appendChild(row);
pane.scrollTop = pane.scrollHeight;
}
function done(closeLabel) {
closeBtn.disabled = false;
closeBtn.textContent = closeLabel || 'Close';
}
return { log: log, done: done };
}
/**
* Small anchored dropdown menu — used for secondary per-row actions
* (pause/kill/rename/...) that would otherwise clutter a table row with
* one icon button each. Only one menu is ever open at a time.
*
* @param {HTMLElement} anchorEl button the menu opens from/closes on
* @param {Array<{label:string, danger?:boolean, disabled?:boolean, onClick?:Function}|'separator'>} items
*/
let openMenuCloser = null;
function openContextMenu(anchorEl, items) {
if (openMenuCloser) {
openMenuCloser();
return;
}
const menu = document.createElement('div');
menu.className = 'podman-context-menu';
menu.innerHTML = items.map(function (item) {
if (item === 'separator') return '<div class="podman-context-menu-sep"></div>';
return '<button type="button" class="' + (item.danger ? 'danger' : '') + '"' +
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
}).join('');
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
// Viewport-relative (see the "position: fixed" comment on
// .podman-context-menu in podman.css) — no scrollY/scrollX added.
// Measured AFTER appending (not assumed) since the menu's height
// varies with its item count — a long menu (e.g. a container's row
// menu with Details/Pause/Kill/Rename/Edit/Remove) opened from a row
// near the bottom of the viewport used to run off-screen with no way
// to reach its last few items. Flips above the anchor instead when
// there isn't enough room below, and clamps horizontally the same way.
const rect = anchorEl.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const margin = 8;
let top = rect.bottom + 4;
if (top + menuRect.height > window.innerHeight - margin) {
top = rect.top - menuRect.height - 4;
}
top = Math.max(margin, top);
let left = rect.right - menuRect.width;
left = Math.min(left, window.innerWidth - menuRect.width - margin);
left = Math.max(margin, left);
menu.style.top = top + 'px';
menu.style.left = left + 'px';
// menu.children includes the separator <div>s too, so indexing into it
// directly (by a counter that only advances for real items) drifts by
// one after every separator — e.g. "Remove" (after a separator) ended
// up wired to the separator <div> instead of its own <button>, so
// clicking it did nothing. querySelectorAll('button') only ever
// returns the actual buttons, in the same order as the non-separator
// items, so indexing into that stays aligned regardless of separators.
const buttons = menu.querySelectorAll('button');
let buttonIndex = 0;
items.forEach(function (item) {
if (item === 'separator') return;
const btn = buttons[buttonIndex];
buttonIndex++;
if (item.disabled) return;
btn.addEventListener('click', function (e) {
e.stopPropagation();
close();
if (item.onClick) item.onClick();
});
});
function close() {
menu.remove();
document.removeEventListener('click', onOutsideClick);
document.removeEventListener('keydown', onKey);
openMenuCloser = null;
}
function onOutsideClick(e) {
if (!menu.contains(e.target)) close();
}
function onKey(e) {
if (e.key === 'Escape') close();
}
openMenuCloser = close;
// Deferred so the click that opened the menu doesn't immediately
// trigger onOutsideClick via event bubbling.
setTimeout(function () {
document.addEventListener('click', onOutsideClick);
document.addEventListener('keydown', onKey);
}, 0);
}
// --- Panel router ----------------------------------------------------------
const panelModules = {};
@@ -165,6 +527,34 @@ window.Podman = (function () {
// (Dashboard, by default — see Podman.page).
const initial = document.querySelector('.podman-subnav button.active');
activatePanel(initial ? initial.dataset.panel : 'dashboard');
setInterval(autoRefreshTick, AUTO_REFRESH_INTERVAL_MS);
}
// Only panels that opt in via `autoRefresh: true` (Dashboard, Containers)
// get polled — most panels (Settings, Compose, Terminal, ...) have
// in-progress forms or connections an unexpected refresh would disrupt.
// Paused while the tab is hidden (nothing to look at), while any modal
// is open (a full-panel re-render mid-edit would be jarring), and while
// a context menu is open — found live: a re-render replaces every row's
// DOM node wholesale, so a menu opened from a row (its anchor button)
// still LOOKS open but is now anchored to a detached element; opening a
// submenu from it (e.g. "Move to Folder") then positions itself
// relative to that stale anchor instead of anywhere sensible.
const AUTO_REFRESH_INTERVAL_MS = 2000;
function autoRefreshTick() {
if (document.hidden) return;
if (document.querySelector('.podman-modal-backdrop')) return;
if (document.querySelector('.podman-context-menu')) return;
const activeBtn = document.querySelector('.podman-subnav button.active');
if (!activeBtn) return;
const name = activeBtn.dataset.panel;
const module = panelModules[name];
if (module && module.autoRefresh && initialized[name] && typeof module.refresh === 'function') {
module.refresh();
}
}
document.addEventListener('DOMContentLoaded', boot);
@@ -180,6 +570,11 @@ window.Podman = (function () {
stateChipClass: stateChipClass,
loadingRow: loadingRow,
errorRow: errorRow,
toast: toast,
confirm: confirmModal,
openFormModal: openFormModal,
openLogModal: openLogModal,
openContextMenu: openContextMenu,
registerPanel: registerPanel,
activatePanel: activatePanel,
};
+100 -11
View File
@@ -1,10 +1,10 @@
/**
* javascript/compose.js
*
* Compose panel: project list + read-only YAML view + up/down/pull,
* backed by ajax/compose.php. See that file's header comment — this is
* the one panel whose backend shells out to the `podman compose` CLI,
* because no REST equivalent for Compose exists in libpod.
* Compose panel: project list + an editable YAML view + save/up/down/pull/
* delete, backed by ajax/compose.php. See that file's header comment —
* this is the one panel whose backend shells out to the `podman compose`
* CLI, because no REST equivalent for Compose exists in libpod.
*/
(function () {
'use strict';
@@ -12,6 +12,13 @@
let projects = [];
let selected = null;
const STARTER_YAML =
'services:\n' +
' app:\n' +
' image: docker.io/library/nginx:alpine\n' +
' ports:\n' +
' - "8080:80"\n';
function statusChip(status) {
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn');
return '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
@@ -23,21 +30,32 @@
'<div class="name" style="display:flex; justify-content:space-between; gap:8px;">' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '</div>' +
'<div class="path">' + P.escapeHtml(p.path) + '</div>' +
'</div>';
}).join('') || '<div class="podman-empty-note">No compose projects under /boot/config/plugins/podman/compose/</div>';
}).join('') || '<div class="podman-empty-note">No compose projects yet — click "+ New Project".</div>';
}
// Up/Down/Pull/Save/Delete all need an actual selected project to act on
// — disabled (rather than left clickable and erroring) whenever nothing
// is selected, e.g. right after deleting the last project.
function setToolbarEnabled(enabled) {
['compose-action-up', 'compose-action-down', 'compose-action-pull', 'compose-action-save', 'compose-action-delete'].forEach(function (id) {
P.el(id).disabled = !enabled;
});
P.el('compose-yaml').disabled = !enabled;
}
function loadYaml(name) {
P.el('compose-title').textContent = name + ' / compose.yaml';
P.el('compose-yaml').textContent = 'Loading…';
P.el('compose-yaml').value = 'Loading…';
return P.get('compose', 'get', { project: name }).then(function (data) {
P.el('compose-yaml').textContent = data.yaml;
P.el('compose-yaml').value = data.yaml;
}).catch(function (err) {
P.el('compose-yaml').textContent = 'Error: ' + err.message;
P.el('compose-yaml').value = 'Error: ' + err.message;
});
}
function selectProject(name) {
selected = name;
setToolbarEnabled(true);
renderSidebar();
loadYaml(name);
}
@@ -45,9 +63,19 @@
function loadProjects() {
return P.get('compose', 'list').then(function (data) {
projects = data;
if (selected && !projects.some(function (p) { return p.name === selected; })) {
selected = null;
}
if (!selected && projects.length > 0) selected = projects[0].name;
renderSidebar();
if (selected) loadYaml(selected);
if (selected) {
setToolbarEnabled(true);
loadYaml(selected);
} else {
setToolbarEnabled(false);
P.el('compose-title').textContent = '—';
P.el('compose-yaml').value = '';
}
}).catch(function (err) {
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
});
@@ -57,25 +85,86 @@
if (!selected) return;
const btn = P.el('compose-action-' + action);
btn.disabled = true;
// `podman compose` output can run to many lines — a log modal (same
// pattern as Settings' service Start/Stop/Restart) fits that; a toast
// has to stay short and auto-dismiss.
const modal = P.openLogModal('podman compose ' + action + ' — ' + selected);
P.post('compose', action, { project: selected }).then(function (data) {
alert((data.output || 'Done.').slice(0, 2000));
(data.output || 'Done.').split('\n').forEach(function (line) { modal.log(line); });
modal.done();
return loadProjects();
}).catch(function (err) {
alert('podman compose ' + action + ' failed: ' + err.message);
modal.log('Failed: ' + err.message);
modal.done('Close');
}).finally(function () {
btn.disabled = false;
});
}
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 () {
P.toast('Saved ' + selected + '.', 'success');
return loadProjects();
}).catch(function (err) {
P.toast('Save failed: ' + err.message, 'error');
}).finally(function () {
btn.disabled = false;
});
}
function deleteProject() {
if (!selected) return;
P.confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
if (!ok) return;
const btn = P.el('compose-action-delete');
btn.disabled = true;
const name = selected;
P.post('compose', 'remove', { project: selected }).then(function () {
selected = null;
P.toast('Deleted ' + name + '.', 'success');
return loadProjects();
}).catch(function (err) {
P.toast('Delete failed: ' + err.message, 'error');
btn.disabled = false;
});
});
}
function openNewProjectModal() {
P.openFormModal({
title: 'New Compose Project',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Project name', required: true, placeholder: 'my-stack', hint: 'Letters, digits, "_", "-" only — no spaces.' },
],
onSubmit: function (values) {
if (!/^[a-zA-Z0-9_-]+$/.test(values.name)) {
return Promise.reject(new Error('Project name can only contain letters, digits, "_", "-" — no spaces.'));
}
return P.post('compose', 'save', { project: values.name, yaml: STARTER_YAML }).then(function () {
selected = values.name;
return loadProjects();
});
},
});
}
function init() {
P.el('compose-sidebar').addEventListener('click', function (e) {
const item = e.target.closest('.podman-compose-proj[data-name]');
if (item) selectProject(item.dataset.name);
});
P.el('compose-new-btn').addEventListener('click', openNewProjectModal);
P.el('compose-action-up').addEventListener('click', function () { runAction('up'); });
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); });
P.el('compose-action-save').addEventListener('click', saveYaml);
P.el('compose-action-delete').addEventListener('click', deleteProject);
setToolbarEnabled(false);
return loadProjects();
}
File diff suppressed because it is too large Load Diff
+104 -12
View File
@@ -1,15 +1,18 @@
/**
* javascript/dashboard.js
*
* Dashboard panel: summary stat tiles fed by ajax/system.php?action=summary.
* The Activity list and the CPU/Memory sparkline in the mockup were
* illustrative sample data with no backing API (libpod has no "recent
* events for a container fleet" convenience endpoint beyond raw
* /events streaming, which is a separate follow-up — see the note
* rendered in place of it below) — rather than fake data pretending to be
* live, this real implementation shows what's genuinely available now
* (the summary counts) and a clear placeholder for what needs the events
* stream, so nobody mistakes a mock for a working feature.
* Dashboard panel: plain-count stat tiles (Running/Stopped/Pods/Images/
* Volumes/Networks) plus a single "Resource Usage" card (CPU/Memory/Swap/
* Storage as meter rows) — kept as two visually distinct groups rather
* than forcing bar-and-percentage metrics into the same tile shape as
* simple counts, which is what produced the awkward spanning-tile/dead-
* grid-cell layout this replaced. Both fed by ajax/system.php?action=
* summary, plus the Autostart Queue table fed by action=autostart_queue.
* A live scrolling event feed is deliberately out of scope here — libpod
* has no "recent events for a container fleet" convenience endpoint, only
* raw /events streaming, which is a separate feature (its own connection
* lifecycle, not a snapshot this summary call can produce) rather than
* something to fake with sample data.
*/
(function () {
'use strict';
@@ -26,12 +29,16 @@
}
P.el('stat-running').textContent = summary.containers.running;
P.el('stat-running').className = summary.containers.running > 0 ? 'tone-good' : '';
P.el('stat-running-total').textContent = '/ ' + summary.containers.total;
P.el('stat-stopped').textContent = summary.containers.stopped;
P.el('stat-stopped').classList.toggle('tone-bad', summary.containers.stopped > 0);
P.el('stat-pods').textContent = summary.pods;
P.el('stat-images').textContent = summary.images;
P.el('stat-volumes').textContent = summary.volumes;
P.el('stat-networks').textContent = summary.networks;
P.el('stat-images-size').textContent = summary.storage.imagesSizeFormatted;
renderResourceUsage(summary.host, summary.storage);
const meta = P.el('podman-header-meta');
if (meta) {
@@ -42,11 +49,96 @@
}
}
// Bars stay accent-colored under normal load and only shift to warn/bad
// once usage is high enough to actually be worth noticing at a glance —
// a bar that's always the same color regardless of value doesn't tell
// you anything a number alone didn't.
function barSeverityClass(percent) {
if (percent === null || percent === undefined) {
return '';
}
if (percent >= 90) {
return 'bad';
}
if (percent >= 75) {
return 'warn';
}
return '';
}
function resourceRow(label, valueText, percent) {
const pct = percent === null || percent === undefined ? 0 : percent;
return (
'<div class="podman-resource-row">' +
'<span class="k">' + P.escapeHtml(label) + '</span>' +
'<div class="podman-usage-mini" style="flex:1;">' +
'<div class="track"><span class="' + barSeverityClass(percent) + '" style="width:' + pct + '%"></span></div>' +
'<span class="num">' + pct + '%</span>' +
'</div>' +
'<span class="v">' + P.escapeHtml(valueText) + '</span>' +
'</div>'
);
}
function renderResourceUsage(host, storage) {
if (!host || !storage) {
return;
}
let html = resourceRow('CPU (' + host.cpuCount + ')', '', host.cpuPercent) +
resourceRow('Memory', P.formatBytes(host.memUsedBytes) + ' / ' + P.formatBytes(host.memTotalBytes), host.memPercent);
if (host.swapTotalBytes > 0) {
html += resourceRow('Swap', P.formatBytes(host.swapUsedBytes) + ' / ' + P.formatBytes(host.swapTotalBytes),
Math.round(host.swapUsedBytes / host.swapTotalBytes * 100));
}
html += resourceRow('Storage',
storage.graphUsedFormatted + (storage.graphAllocatedBytes ? ' / ' + storage.graphAllocatedFormatted : '') +
' (' + storage.imagesSizeFormatted + ' images)',
storage.graphUsedPercent);
if (host.uptime) {
html += '<div class="hint" style="margin-top:10px;">Uptime: ' + P.escapeHtml(host.uptime) + '</div>';
}
P.el('dashboard-resource-rows').innerHTML = html;
}
const STATUS_CHIP_CLASS = {
started: 'podman-chip-good',
stopped: 'podman-chip-neutral',
failed: 'podman-chip-bad',
'safe-mode': 'podman-chip-warn',
unknown: 'podman-chip-neutral',
};
function renderAutostartQueue(data) {
const tbody = P.el('dashboard-autostart-tbody');
if (!tbody) {
return;
}
const entries = data.entries || [];
if (!entries.length) {
tbody.innerHTML = '<tr><td colspan="4" class="podman-empty-note">No containers configured for autostart.</td></tr>';
return;
}
tbody.innerHTML = entries.map(function (e) {
const chipClass = STATUS_CHIP_CLASS[e.status] || 'podman-chip-neutral';
return '<tr>' +
'<td class="tnum">' + e.position + '</td>' +
'<td>' + P.escapeHtml(e.name) + '</td>' +
'<td class="tnum">' + (e.delaySeconds > 0 ? e.delaySeconds + 's' : '—') + '</td>' +
'<td><span class="podman-chip ' + chipClass + '"><span class="d"></span>' + P.escapeHtml(e.statusLabel) + '</span></td>' +
'</tr>';
}).join('');
}
function load() {
return P.get('system', 'summary').then(render).catch(function (err) {
return P.get('system', 'summary').then(function (summary) {
render(summary);
if (summary.reachable) {
return P.get('system', 'autostart_queue').then(renderAutostartQueue);
}
}).catch(function (err) {
P.el('podman-panel-dashboard').innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
});
}
P.registerPanel('dashboard', { init: load, refresh: load });
P.registerPanel('dashboard', { init: load, refresh: load, autoRefresh: true });
})();
+70 -9
View File
@@ -18,8 +18,10 @@
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
'<td class="tnum">' + created + '</td>' +
'<td class="tnum">' + img.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row">' +
'<button class="podman-btn podman-btn-icon" data-action="tag" title="Add tag">&#127991;</button>' +
'<button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>';
}
@@ -43,24 +45,83 @@
function init() {
P.el('images-pull-btn').addEventListener('click', function () {
const reference = prompt('Image to pull (e.g. docker.io/library/postgres:16):');
if (!reference) return;
P.post('images', 'pull', { reference: reference }).then(load).catch(function (err) {
alert('Pull failed: ' + err.message);
P.openFormModal({
title: 'Pull Image',
submitLabel: 'Pull',
fields: [
{ name: 'reference', label: 'Image reference', required: true, placeholder: 'docker.io/library/postgres:16' },
],
onSubmit: function (values) {
return P.post('images', 'pull', { reference: values.reference }).then(load);
},
});
});
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) {
P.toast('No unused images to remove — every image is referenced by at least one container.', 'info');
return;
}
const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
const btn = this;
P.confirm(
'Remove ' + unused.length + ' image(s) not used by any container (' + P.formatBytes(totalBytes) + ')? ' +
'This removes any tagged image with zero containers using it, not just dangling ones.',
{ danger: true, confirmLabel: 'Remove' }
).then(function (ok) {
if (!ok) return;
btn.disabled = true;
P.post('images', 'prune').then(function (result) {
btn.disabled = false;
P.toast('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.', 'success');
return load();
}).catch(function (err) {
btn.disabled = false;
P.toast('Prune failed: ' + err.message, 'error');
});
});
});
P.el('images-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const id = btn.closest('tr').dataset.id;
if (!confirm('Remove this image?')) return;
if (btn.dataset.action === 'tag') {
P.openFormModal({
title: 'Add Tag',
submitLabel: 'Add tag',
fields: [
{ name: 'repo', label: 'Repository', required: true, placeholder: 'my-registry.local/my-image' },
{ name: 'tag', label: 'Tag', placeholder: 'latest' },
],
onSubmit: function (values) {
return P.post('images', 'tag', { id: id, repo: values.repo, tag: values.tag || 'latest' }).then(load);
},
});
return;
}
if (btn.dataset.action === 'remove') {
P.confirm('Remove this image?', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
if (!ok) return;
btn.disabled = true;
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
P.toast('Remove failed: ' + err.message, 'error');
btn.disabled = false;
});
});
}
});
return load();
}
+118 -12
View File
@@ -20,8 +20,8 @@
'<td class="mono">' + P.escapeHtml(n.subnet || '&mdash;') + '</td>' +
'<td class="mono">' + P.escapeHtml(n.gateway || '&mdash;') + '</td>' +
'<td class="tnum">' + n.containers + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></div></td>' +
'</tr>';
}
@@ -43,27 +43,133 @@
});
}
// Purpose-built modal (not app.js's generic openFormModal, which only
// supports flat always-visible text fields) — the parent-interface
// dropdown and gateway field only make sense for "macvlan" and need to
// show/hide based on the driver choice.
function openCreateNetworkModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Network</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Network name</label>' +
'<input type="text" id="cn-name" placeholder="my-network"></div>' +
'<div class="podman-modal-field"><label>Type</label>' +
'<select id="cn-driver">' +
'<option value="bridge">Bridge (isolated, NAT — default)</option>' +
'<option value="macvlan">Macvlan (containers get a real IP on your LAN)</option>' +
'</select></div>' +
'<div class="podman-modal-field" id="cn-parent-field" style="display:none;">' +
'<label>Parent interface</label><select id="cn-parent"></select>' +
'<div class="hint">Same interface Docker Manager\'s "Custom: br0"-style networks use.</div></div>' +
'<div class="podman-modal-field"><label id="cn-subnet-label">Subnet (optional)</label>' +
'<input type="text" class="mono" id="cn-subnet" placeholder="10.89.2.0/24"></div>' +
'<div class="podman-modal-field" id="cn-gateway-field" style="display:none;">' +
'<label>Gateway</label><input type="text" class="mono" id="cn-gateway" placeholder="10.1.1.1"></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
let parentInterfaces = [];
P.get('networks', 'list_parent_interfaces').then(function (interfaces) {
parentInterfaces = interfaces;
const select = backdrop.querySelector('#cn-parent');
select.innerHTML = interfaces.map(function (i) {
return '<option value="' + P.escapeHtml(i.interface) + '">' + P.escapeHtml(i.label) + '</option>';
}).join('');
}).catch(function () { /* macvlan option just won't have anything to pick if this fails */ });
backdrop.querySelector('#cn-driver').addEventListener('change', function (e) {
const isMacvlan = e.target.value === 'macvlan';
backdrop.querySelector('#cn-parent-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cn-gateway-field').style.display = isMacvlan ? '' : 'none';
backdrop.querySelector('#cn-subnet-label').textContent = isMacvlan ? 'Subnet' : 'Subnet (optional)';
});
backdrop.querySelector('#cn-name').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const name = backdrop.querySelector('#cn-name').value.trim();
if (!name) {
showError('"Network name" is required.');
return;
}
const driver = backdrop.querySelector('#cn-driver').value;
const subnet = backdrop.querySelector('#cn-subnet').value.trim();
const gateway = backdrop.querySelector('#cn-gateway').value.trim();
const parentInterface = backdrop.querySelector('#cn-parent').value;
if (driver === 'macvlan') {
if (!subnet) {
showError('"Subnet" is required for a macvlan network.');
return;
}
if (!parentInterfaces.length) {
showError('No host bridge/VLAN interface available to attach to.');
return;
}
}
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('networks', 'create', {
name: name,
driver: driver,
subnet: subnet || undefined,
gateway: gateway || undefined,
parentInterface: driver === 'macvlan' ? parentInterface : undefined,
}).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function init() {
P.el('networks-create-btn').addEventListener('click', function () {
const name = prompt('New network name:');
if (!name) return;
const subnet = prompt('Subnet (optional, e.g. 10.89.2.0/24):') || undefined;
P.post('networks', 'create', { name: name, driver: 'bridge', subnet: subnet }).then(load).catch(function (err) {
alert('Create failed: ' + err.message);
});
});
P.el('networks-create-btn').addEventListener('click', openCreateNetworkModal);
P.el('networks-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
if (!btn || btn.disabled) return;
const name = btn.closest('tr').dataset.name;
if (!confirm('Remove network "' + name + '"?')) return;
P.confirm('Remove network "' + name + '"?', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
if (!ok) return;
btn.disabled = true;
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
P.toast('Remove failed: ' + err.message, 'error');
btn.disabled = false;
});
});
});
return load();
}
+158 -6
View File
@@ -8,6 +8,107 @@
(function () {
'use strict';
const P = window.Podman;
let allPods = [];
function portRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
'<span>&rarr;</span>' +
'<input type="text" class="mono podman-input-narrow" data-field="containerPort" placeholder="Container port">' +
'<select data-field="protocol"><option value="tcp">TCP</option><option value="udp">UDP</option></select>' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function addRow(groupEl) {
const div = document.createElement('div');
div.innerHTML = portRowHtml();
const row = div.firstElementChild;
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
groupEl.appendChild(row);
}
function readRows(groupEl) {
return Array.from(groupEl.children).map(function (row) {
const values = {};
row.querySelectorAll('[data-field]').forEach(function (input) {
values[input.dataset.field] = input.value.trim();
});
return values;
});
}
function openCreatePodModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Pod</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Name</label>' +
'<input type="text" id="cp-name" placeholder="my-pod">' +
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
'<div class="podman-modal-field"><label>Port mappings</label>' +
'<div class="podman-row-group" id="cp-ports"></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
'<div class="hint">Shared by every container later added to this pod.</div></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const portsGroup = backdrop.querySelector('#cp-ports');
addRow(portsGroup);
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup); });
backdrop.querySelector('#cp-name').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const name = backdrop.querySelector('#cp-name').value.trim();
if (!name) {
showError('"Name" is required.');
return;
}
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
return;
}
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('pods', 'create', { name: name, ports: ports }).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
function memberRow(m) {
return '' +
@@ -24,27 +125,78 @@
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
return '' +
'<div class="podman-pod-card">' +
'<div class="podman-pod-card" data-name="' + P.escapeHtml(pod.name) + '">' +
'<div class="podman-pod-head">' +
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
'<button type="button" class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>' +
'</div>' +
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
'<tbody>' + members + '</tbody></table></div>' +
'</div>';
}
function render() {
const grid = P.el('pods-grid');
grid.innerHTML = allPods.length
? allPods.map(podCard).join('')
: '<div class="podman-empty-note">No pods yet — create one, or run a container with a "pod" set from the Create Container form.</div>';
}
function load() {
const container = P.el('podman-panel-pods');
return P.get('pods', 'list').then(function (pods) {
container.innerHTML = pods.length
? pods.map(podCard).join('')
: '<div class="podman-card"><div class="podman-empty-note">No pods yet.</div></div>';
if (!P.el('pods-grid')) {
container.innerHTML = '' +
'<div class="podman-card">' +
'<div class="podman-toolbar">' +
'<strong style="flex:1;">Group containers sharing network/storage namespaces</strong>' +
'<button class="podman-btn podman-btn-primary" id="pods-create-btn">+ New Pod</button>' +
'</div>' +
'<div id="pods-grid"></div>' +
'</div>';
P.el('pods-create-btn').addEventListener('click', openCreatePodModal);
P.el('pods-grid').addEventListener('click', handleCardClick);
}
return P.get('pods', 'list').then(function (data) {
allPods = data;
render();
}).catch(function (err) {
container.innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
P.el('pods-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function handleAction(name, action, extra) {
return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) {
P.toast('Action failed: ' + err.message, 'error');
});
}
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 () {
P.confirm('Remove pod "' + pod.name + '" and all its member containers?', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
if (ok) handleAction(pod.name, 'remove', { force: true });
});
},
});
P.openContextMenu(btn, items);
}
P.registerPanel('pods', { init: load, refresh: load });
})();
+189 -10
View File
@@ -9,6 +9,7 @@
'use strict';
const P = window.Podman;
let autostartNames = [];
let allContainerNames = [];
function renderAutostart() {
const tbody = P.el('autostart-tbody');
@@ -17,18 +18,31 @@
return '<tr data-index="' + i + '">' +
'<td class="tnum">' + (i + 1) + '</td>' +
'<td>' + P.escapeHtml(name) + '</td>' +
'<td class="podman-actions">' +
'<td class="podman-actions"><div class="podman-actions-row">' +
'<button class="podman-btn podman-btn-icon" data-action="up"' + (i === 0 ? ' disabled' : '') + ' title="Move up">&#8593;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">&#8595;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">&#128465;</button>' +
'</td></tr>';
'</div></td></tr>';
}).join('')
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
renderAddOptions();
}
// Repopulated after every add/remove so it only ever offers containers
// that exist and aren't already in the chain — avoids duplicate entries
// and offering a container name that no longer exists.
function renderAddOptions() {
const select = P.el('autostart-add-select');
const available = allContainerNames.filter(function (name) { return autostartNames.indexOf(name) === -1; });
select.innerHTML = available.length
? available.map(function (name) { return '<option value="' + P.escapeHtml(name) + '">' + P.escapeHtml(name) + '</option>'; }).join('')
: '<option value="">All containers already added</option>';
P.el('autostart-add-btn').disabled = !available.length;
}
function saveAutostart() {
return P.post('settings', 'autostart_save', { names: autostartNames }).catch(function (err) {
alert('Could not save autostart order: ' + err.message);
P.toast('Could not save autostart order: ' + err.message, 'error');
});
}
@@ -43,17 +57,158 @@
const versions = settings.packageVersions || {};
const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS'];
P.el('settings-package-versions').textContent = order
.map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); })
.join(' · ');
const chipsHtml = order.map(function (k) {
const name = k.toLowerCase().replace(/_/g, '-');
const version = versions[k + '_INSTALLED_VERSION'];
return '<span class="podman-version-chip">' + P.escapeHtml(name) + ' <b>' + P.escapeHtml(version || '?') + '</b></span>';
}).join('');
P.el('settings-package-versions').innerHTML = chipsHtml || '<span class="podman-empty-note">No version manifest found.</span>';
}
function load() {
return P.get('settings', 'get').then(fillForm).catch(function (err) {
alert('Could not load settings: ' + err.message);
return Promise.all([
P.get('settings', 'get').then(fillForm),
// Needed for the "Add container" dropdown — a separate, tolerant
// fetch so a containers.php hiccup doesn't also break the rest of
// Settings (autostart reorder/remove don't need this list at all).
P.get('containers', 'list').then(function (containers) {
allContainerNames = containers.map(function (c) { return c.name; }).sort();
renderAddOptions();
}).catch(function () {
allContainerNames = [];
}),
]).catch(function (err) {
P.toast('Could not load settings: ' + err.message, 'error');
});
}
// --- Podman service status/start/stop/restart ------------------------------
function renderServiceChip(data) {
const chip = P.el('settings-service-chip');
chip.className = 'podman-chip ' + (data.running ? 'podman-chip-good' : 'podman-chip-bad');
chip.innerHTML = '<span class="d"></span>' + (data.running ? 'Running' : 'Not running');
}
function serviceButtons() {
return ['settings-service-status-btn', 'settings-service-start-btn', 'settings-service-stop-btn', 'settings-service-restart-btn']
.map(function (id) { return P.el(id); });
}
// "Refresh Status" is quick and read-only — shown inline, no modal needed.
function refreshServiceStatus() {
const buttons = serviceButtons();
buttons.forEach(function (b) { b.disabled = true; });
return P.get('settings', 'service_status').then(function (data) {
renderServiceChip(data);
const log = P.el('settings-service-log');
log.style.display = '';
log.textContent = data.output || '';
log.scrollTop = log.scrollHeight;
}).catch(function (err) {
P.el('settings-service-chip').className = 'podman-chip podman-chip-bad';
P.el('settings-service-chip').innerHTML = '<span class="d"></span>Unknown';
P.el('settings-service-log').style.display = '';
P.el('settings-service-log').textContent = err.message;
}).finally(function () {
buttons.forEach(function (b) { b.disabled = false; });
});
}
// Start/Stop/Restart can take a while (storage checks, container
// stop grace periods, ...) and are exactly the actions someone reaches
// for when something's actually wrong — a small log modal (same pattern
// as container update checking) shows what's happening instead of
// leaving the button just spinning with no feedback.
function runServiceAction(action, title) {
const buttons = serviceButtons();
buttons.forEach(function (b) { b.disabled = true; });
const modal = P.openLogModal(title);
return P.post('settings', action, {}).then(function (data) {
renderServiceChip(data);
(data.output || '').split('\n').forEach(function (line) { modal.log(line); });
modal.done();
}).catch(function (err) {
modal.log('Error: ' + err.message);
modal.done('Close');
}).finally(function () {
buttons.forEach(function (b) { b.disabled = false; });
});
}
// --- Format a disk for podman storage --------------------------------------
function openFormatDiskModal() {
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>Format a Disk for Podman Storage</h3></div>' +
'<div class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Disk</label><select id="fd-device"><option value="">Loading…</option></select>' +
'<div class="hint" id="fd-warning">Only disks with no existing partitions or filesystem are listed — anything already in use, part of the array/cache, or the boot flash is never shown here.</div></div>' +
'<div class="podman-modal-field">' +
'<label style="display:flex; align-items:flex-start; gap:8px; font-weight:400;">' +
'<input type="checkbox" id="fd-confirm" style="margin-top:3px;">' +
'<span>I understand this permanently erases all data on this disk, with no undo.</span>' +
'</label></div>' +
'</div>' +
'<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 podman-btn-danger" data-role="submit" disabled>Format Disk</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
const select = backdrop.querySelector('#fd-device');
const warning = backdrop.querySelector('#fd-warning');
const confirmBox = backdrop.querySelector('#fd-confirm');
const submitBtn = backdrop.querySelector('[data-role="submit"]');
function updateSubmitEnabled() {
submitBtn.disabled = !(select.value && confirmBox.checked);
}
P.get('disks', 'list_candidates').then(function (list) {
select.innerHTML = list.length
? list.map(function (d) {
return '<option value="' + P.escapeHtml(d.device) + '">' + P.escapeHtml(d.device) +
' — ' + P.escapeHtml(d.sizeFormatted) + (d.model ? ' (' + P.escapeHtml(d.model) + ')' : '') + '</option>';
}).join('')
: '<option value="">No eligible blank disks found</option>';
updateSubmitEnabled();
}).catch(function (err) {
select.innerHTML = '<option value="">Error loading disks</option>';
warning.textContent = err.message;
});
select.addEventListener('change', updateSubmitEnabled);
confirmBox.addEventListener('change', updateSubmitEnabled);
function close() { backdrop.remove(); }
submitBtn.addEventListener('click', function () {
if (!select.value || !confirmBox.checked) return;
P.confirm('Format ' + select.value + '? This cannot be undone.', { danger: true, confirmLabel: 'Format' }).then(function (ok) {
if (!ok) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Formatting…';
P.post('disks', 'format', { device: select.value }).then(function (data) {
close();
P.el('settings-storage-path').value = data.mountPath;
P.toast('Formatted and mounted at ' + data.mountPath + '. Click "Save Settings" below, then Restart Podman.', 'warn');
}).catch(function (err) {
submitBtn.disabled = false;
submitBtn.textContent = 'Format Disk';
P.toast('Format failed: ' + err.message, 'error');
});
});
});
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
}
function save() {
const body = {
storagePath: P.el('settings-storage-path').value.trim(),
@@ -62,15 +217,24 @@
stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10,
};
return P.post('settings', 'save', body).then(function () {
alert('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.');
P.toast('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.', 'warn');
}).catch(function (err) {
alert('Save failed: ' + err.message);
P.toast('Save failed: ' + err.message, 'error');
});
}
function init() {
P.el('settings-save-btn').addEventListener('click', save);
P.el('autostart-add-btn').addEventListener('click', function () {
const select = P.el('autostart-add-select');
const name = select.value;
if (!name) return;
autostartNames.push(name);
renderAutostart();
saveAutostart();
});
P.el('autostart-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
@@ -88,6 +252,21 @@
saveAutostart();
});
P.el('settings-service-status-btn').addEventListener('click', refreshServiceStatus);
P.el('settings-service-start-btn').addEventListener('click', function () { runServiceAction('service_start', 'Starting Podman'); });
P.el('settings-service-stop-btn').addEventListener('click', function () {
P.confirm('Stop podman? All running containers will be stopped first (each with its own configured grace period).', { confirmLabel: 'Stop' }).then(function (ok) {
if (ok) runServiceAction('service_stop', 'Stopping Podman');
});
});
P.el('settings-service-restart-btn').addEventListener('click', function () {
P.confirm('Restart podman? All running containers will be stopped and podman.sock will be unavailable until it comes back up.', { confirmLabel: 'Restart' }).then(function (ok) {
if (ok) runServiceAction('service_restart', 'Restarting Podman');
});
});
P.el('settings-format-disk-btn').addEventListener('click', openFormatDiskModal);
refreshServiceStatus();
return load();
}
@@ -0,0 +1,405 @@
/**
* javascript/templates.js
*
* The "Apps" panel: two sub-views toggled by a segmented control —
* "Store" (browses/searches Community Applications' own public app feed
* directly, see ajax/templates.php's ca_feed_search()) and "My Templates"
* (this plugin's own saved, reusable container configs, XML in the same
* schema Unraid's own Docker Manager templates use). "Use"/"Install" both
* hand off to containers.js's Create Container modal, pre-filled;
* templates are themselves created either from that same modal's "Save as
* template" checkbox, or by installing a Store app (which saves it as a
* template too, so it shows up under My Templates afterward).
*/
(function () {
'use strict';
const P = window.Podman;
let allTemplates = [];
let activeSubview = 'store';
let storeResults = [];
let storeSearchTimer = null;
function iconHtml(t) {
if (t.icon) {
return '<img class="podman-template-icon" src="' + P.escapeHtml(t.icon) + '" alt="" loading="lazy" ' +
'onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'podman-template-icon podman-template-icon-fallback\',textContent:\'' +
P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '\'}))">';
}
return '<div class="podman-template-icon podman-template-icon-fallback">' + P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '</div>';
}
// --- My Templates --------------------------------------------------------
function cardHtml(t) {
const overview = t.overview && t.overview.length > 110 ? t.overview.slice(0, 107) + '…' : (t.overview || '');
return '' +
'<div class="podman-template-card" data-name="' + P.escapeHtml(t.name) + '">' +
iconHtml(t) +
'<div class="podman-template-body">' +
'<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' +
'<div class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</div>' +
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
'</div>' +
'<div class="podman-template-actions">' +
'<button class="podman-btn podman-btn-primary" data-action="use">Use</button>' +
'<button class="podman-btn podman-btn-ghost" data-action="export">Export</button>' +
'<button class="podman-btn podman-btn-ghost podman-btn-danger" data-action="delete">Delete</button>' +
'</div></div>';
}
function renderTemplatesGrid() {
const grid = P.el('templates-grid');
grid.innerHTML = allTemplates.length
? allTemplates.map(cardHtml).join('')
: '<div class="podman-empty-note">No templates yet — save one from the "New Container" form, install one from the Store, or import an XML template.</div>';
}
function load() {
return P.get('templates', 'list').then(function (data) {
allTemplates = data;
renderTemplatesGrid();
}).catch(function (err) {
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function handleTemplatesGridClick(e) {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const name = btn.closest('.podman-template-card').dataset.name;
if (btn.dataset.action === 'use') {
btn.disabled = true;
P.get('templates', 'get', { name: name }).then(function (config) {
btn.disabled = false;
P.openCreateContainerModal(config);
}).catch(function (err) {
btn.disabled = false;
P.toast('Could not load template: ' + err.message, 'error');
});
return;
}
if (btn.dataset.action === 'export') {
btn.disabled = true;
P.get('templates', 'export', { name: name }).then(function (data) {
btn.disabled = false;
const blob = new Blob([data.xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name + '.xml';
a.click();
URL.revokeObjectURL(url);
}).catch(function (err) {
btn.disabled = false;
P.toast('Export failed: ' + err.message, 'error');
});
return;
}
if (btn.dataset.action === 'delete') {
P.confirm('Delete template "' + name + '"? This does not affect any running containers.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
if (!ok) return;
btn.disabled = true;
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
btn.disabled = false;
P.toast('Delete failed: ' + err.message, 'error');
});
});
}
}
// --- Store -----------------------------------------------------------------
function storeCardHtml(a) {
const overview = a.overview && a.overview.length > 110 ? a.overview.slice(0, 107) + '…' : (a.overview || '');
return '' +
'<div class="podman-template-card" data-template-url="' + P.escapeHtml(a.templateUrl) + '">' +
iconHtml(a) +
'<div class="podman-template-body">' +
'<div class="podman-template-name">' + P.escapeHtml(a.name) + '</div>' +
'<div class="podman-row-sub mono">' + P.escapeHtml(a.image) + '</div>' +
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
'</div>' +
'<div class="podman-template-actions">' +
'<button class="podman-btn podman-btn-primary" data-action="install">Install</button>' +
'</div></div>';
}
let storeQuery = '';
let storeSort = 'newest';
let storePage = 1;
let storeTotalPages = 1;
function renderStoreGrid() {
const grid = P.el('store-grid');
grid.innerHTML = storeResults.length
? storeResults.map(storeCardHtml).join('')
: '<div class="podman-empty-note">No matches.</div>';
}
function renderStorePager() {
P.el('store-pager').style.display = storeTotalPages > 1 ? '' : 'none';
P.el('store-pager-label').textContent = 'Page ' + storePage + ' of ' + storeTotalPages;
P.el('store-pager-prev').disabled = storePage <= 1;
P.el('store-pager-next').disabled = storePage >= storeTotalPages;
}
// The sort toggle only means anything while browsing (no search term) —
// a search is always alphabetical (see ca_feed_search()'s own comment on
// why "newest"/downloads-based ordering doesn't make sense for a filtered
// result set), so the toggle is hidden rather than left present but inert.
function updateSortToggleVisibility() {
P.el('store-sort-toggle').style.display = storeQuery ? 'none' : '';
}
function loadStore() {
const grid = P.el('store-grid');
grid.innerHTML = '<div class="podman-empty-note">Loading…</div>';
updateSortToggleVisibility();
return P.get('templates', 'apps_search', { q: storeQuery, page: storePage, sort: storeSort }).then(function (data) {
storeResults = data.results;
storePage = data.page;
storeTotalPages = Math.max(1, Math.ceil(data.total / data.pageSize));
renderStoreGrid();
renderStorePager();
}).catch(function (err) {
grid.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function handleStoreGridClick(e) {
const btn = e.target.closest('button[data-action="install"]');
if (!btn) return;
const templateUrl = btn.closest('.podman-template-card').dataset.templateUrl;
btn.disabled = true;
btn.textContent = 'Installing…';
P.post('templates', 'import_url', { url: templateUrl }).then(function (result) {
return P.get('templates', 'get', { name: result.name });
}).then(function (config) {
btn.disabled = false;
btn.textContent = 'Install';
P.openCreateContainerModal(config);
load(); // refreshes "My Templates" in the background — it's now saved there too
}).catch(function (err) {
btn.disabled = false;
btn.textContent = 'Install';
P.toast('Install failed: ' + err.message, 'error');
});
}
// --- Import Template modal ---------------------------------------------
/**
* A modal (not its own tab — tried that, but a modal is enough room for
* this and keeps the nav from growing another entry for what's really a
* secondary action off "My Templates").
*/
function openImportModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>Import Template</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Your existing Docker templates</label>' +
'<input type="text" id="ti-local-search" placeholder="Search by name…">' +
'<div class="podman-local-template-list" id="ti-local-list"><div class="podman-empty-note">Loading…</div></div>' +
'</div>' +
'<div class="podman-modal-field"><label>Or import from a URL</label>' +
'<div style="display:flex; gap:6px;">' +
'<input type="url" id="ti-url" placeholder="https://raw.githubusercontent.com/.../template.xml" style="flex:1;">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="fetch-url">Fetch &amp; Import</button>' +
'</div></div>' +
'<div class="podman-modal-field"><label>Or paste XML directly</label>' +
'<textarea id="ti-xml" rows="8" class="mono" placeholder="This plugin\'s own export format, or an Unraid/Community Applications Docker template." style="width:100%; resize:vertical;"></textarea></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Import pasted XML</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
let localTemplates = [];
function renderLocalList(filter) {
const list = backdrop.querySelector('#ti-local-list');
const visible = filter
? localTemplates.filter(function (t) { return t.name.toLowerCase().indexOf(filter) !== -1; })
: localTemplates;
if (!visible.length) {
list.innerHTML = '<div class="podman-empty-note">' + (localTemplates.length ? 'No match.' : 'None found.') + '</div>';
return;
}
list.innerHTML = visible.map(function (t) {
return '<div class="podman-local-template-item" data-file="' + P.escapeHtml(t.file) + '">' +
'<span class="podman-local-template-name">' + P.escapeHtml(t.name) + '</span>' +
'<span class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</span>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-action="import-local">Import</button>' +
'</div>';
}).join('');
}
P.get('templates', 'list_local').then(function (data) {
localTemplates = data;
renderLocalList('');
}).catch(function () {
backdrop.querySelector('#ti-local-list').innerHTML = '<div class="podman-empty-note">Could not read local templates.</div>';
});
backdrop.querySelector('#ti-local-search').addEventListener('input', function (e) {
renderLocalList(e.target.value.trim().toLowerCase());
});
backdrop.querySelector('#ti-local-list').addEventListener('click', function (e) {
const btn = e.target.closest('[data-action="import-local"]');
if (!btn) return;
const file = btn.closest('.podman-local-template-item').dataset.file;
btn.disabled = true;
P.post('templates', 'import_local', { file: file }).then(function () {
close();
return load();
}).catch(function (err) {
btn.disabled = false;
showError(err.message);
});
});
backdrop.querySelector('#ti-xml').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const xml = backdrop.querySelector('#ti-xml').value.trim();
if (!xml) {
showError('Paste a template XML first.');
return;
}
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('templates', 'import', { xml: xml }).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
function submitUrl() {
const url = backdrop.querySelector('#ti-url').value.trim();
if (!url) {
showError('Enter a URL first.');
return;
}
const fetchBtn = backdrop.querySelector('[data-role="fetch-url"]');
fetchBtn.disabled = true;
P.post('templates', 'import_url', { url: url }).then(function () {
close();
return load();
}).catch(function (err) {
fetchBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('[data-role="fetch-url"]').addEventListener('click', submitUrl);
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); }
});
}
// --- Shell / sub-view toggle --------------------------------------------
function switchSubview(view) {
activeSubview = view;
document.querySelectorAll('#apps-subview-toggle button').forEach(function (b) {
b.classList.toggle('active', b.dataset.view === view);
});
P.el('apps-store-view').style.display = view === 'store' ? '' : 'none';
P.el('apps-templates-view').style.display = view === 'templates' ? '' : 'none';
P.el('apps-store-search').style.display = view === 'store' ? '' : 'none';
P.el('templates-import-btn').style.display = view === 'templates' ? '' : 'none';
if (view === 'store') {
updateSortToggleVisibility();
} else {
P.el('store-sort-toggle').style.display = 'none';
}
}
function init() {
const container = P.el('podman-panel-templates');
container.innerHTML = '' +
'<div class="podman-card">' +
'<div class="podman-toolbar">' +
'<div class="podman-segmented" id="apps-subview-toggle">' +
'<button class="active" data-view="store">Store</button>' +
'<button data-view="templates">My Templates</button>' +
'</div>' +
'<input class="podman-search" id="apps-store-search" type="text" placeholder="Search Community Applications…">' +
'<div class="podman-segmented" id="store-sort-toggle">' +
'<button class="active" data-sort="newest">Newest</button>' +
'<button data-sort="alpha">A-Z</button>' +
'</div>' +
'<button class="podman-btn podman-btn-ghost" id="templates-import-btn" style="display:none;">&#11014; Import Template</button>' +
'</div>' +
'<div id="apps-store-view">' +
'<div class="podman-template-grid" id="store-grid"></div>' +
'<div class="podman-pager" id="store-pager" style="display:none;">' +
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-prev">&#8249; Prev</button>' +
'<span id="store-pager-label"></span>' +
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-next">Next &#8250;</button>' +
'</div>' +
'</div>' +
'<div id="apps-templates-view" style="display:none;"><div class="podman-template-grid" id="templates-grid"></div></div>' +
'</div>';
document.querySelectorAll('#apps-subview-toggle button').forEach(function (b) {
b.addEventListener('click', function () { switchSubview(b.dataset.view); });
});
P.el('apps-store-search').addEventListener('input', function (e) {
storeQuery = e.target.value.trim();
storePage = 1;
if (storeSearchTimer) clearTimeout(storeSearchTimer);
storeSearchTimer = setTimeout(loadStore, 400);
});
document.querySelectorAll('#store-sort-toggle button').forEach(function (b) {
b.addEventListener('click', function () {
storeSort = b.dataset.sort;
storePage = 1;
document.querySelectorAll('#store-sort-toggle button').forEach(function (x) { x.classList.toggle('active', x === b); });
loadStore();
});
});
P.el('store-pager-prev').addEventListener('click', function () {
if (storePage <= 1) return;
storePage -= 1;
loadStore();
});
P.el('store-pager-next').addEventListener('click', function () {
if (storePage >= storeTotalPages) return;
storePage += 1;
loadStore();
});
P.el('store-grid').addEventListener('click', handleStoreGridClick);
P.el('templates-grid').addEventListener('click', handleTemplatesGridClick);
P.el('templates-import-btn').addEventListener('click', openImportModal);
loadStore();
return load();
}
P.registerPanel('templates', { init: init, refresh: load });
})();
+72 -64
View File
@@ -1,89 +1,97 @@
/**
* javascript/terminal.js
*
* Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that
* file's header comment for the full, honest explanation of why this is
* "type a command, see its output" rather than a true interactive PTY —
* the short version is that libpod's interactive exec needs a persistent
* bidirectional connection this PHP/AJAX stack doesn't have, and faking
* interactivity on top of that would break the moment a user ran anything
* that expects a real terminal (vim, an interactive prompt, etc).
* Terminal panel: opens a real, fully interactive terminal inline (as an
* <iframe>, not a popup window) — the same mechanism Unraid's own webGui
* uses for its System Terminal and for `docker exec` (see ajax/exec.php's
* header comment for the full explanation). This module's own job is just:
*
* `cd` is handled client-side: this module tracks a per-session `cwd` and
* passes it as the exec's working directory on every call, so at least
* directory navigation feels persistent even though nothing else is.
* 1. Ask ajax/exec.php to spawn a ttyd instance wrapping
* `podman exec -it <container> <shell>`, bound to a unix socket.
* 2. Point an <iframe> at /logterminal/<sockName>/ — nginx's own
* "logterminal" location block (already installed system-wide by
* Unraid, not something this plugin configures) proxies that,
* WebSocket upgrade included, straight to ttyd's socket.
* 3. Track which container's session (if any) is currently open, so
* "Disconnect" — or opening a different container/shell — can kill
* the right ttyd process server-side instead of just discarding the
* iframe and leaving it running.
*/
(function () {
'use strict';
const P = window.Podman;
let cwd = '/';
let containerId = null;
let openName = null;
function appendLine(html) {
const out = P.el('term-output');
const div = document.createElement('div');
div.innerHTML = html;
out.appendChild(div);
out.scrollTop = out.scrollHeight;
function populateContainerSelect(list) {
const select = P.el('term-container-select');
if (!select) return;
const running = list.filter(function (c) { return c.state === 'running'; });
select.innerHTML = running
.map(function (c) { return '<option value="' + P.escapeHtml(c.name) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('') || '<option value="">No running containers</option>';
}
function promptHtml() {
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$';
function loadContainers() {
return P.get('containers', 'list').then(populateContainerSelect);
}
function runCommand(cmd) {
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd));
// `cd <dir>` is intercepted client-side (see file header) rather than
// sent as a real command, since a one-shot exec has no way to report
// "the working directory changed" back to us otherwise.
const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/);
if (cdMatch) {
cwd = cdMatch[1].startsWith('/') ? cdMatch[1] : (cwd.replace(/\/$/, '') + '/' + cdMatch[1]);
return Promise.resolve();
function resetFrame(message) {
P.el('term-frame-wrap').innerHTML = '<p class="podman-empty-note">' + message + '</p>';
P.el('term-disconnect-btn').disabled = true;
openName = null;
}
return P.post('exec', 'run', { id: containerId, cmd: cmd, cwd: cwd }).then(function (data) {
if (data.output) appendLine('<span class="mono">' + P.escapeHtml(data.output).replace(/\n/g, '<br>') + '</span>');
/** Best-effort: tells the backend to kill the ttyd/podman-exec session, if any is open. Never rejects. */
function closeCurrent() {
if (!openName) return Promise.resolve();
const name = openName;
return P.post('exec', 'close', { name: name }).catch(function () {});
}
function openLiveTerminal() {
const name = P.el('term-container-select').value;
if (!name) return;
const shell = P.el('term-shell-select').value;
const wrap = P.el('term-frame-wrap');
const btn = P.el('term-open-btn');
wrap.innerHTML = '<p class="podman-empty-note">Opening terminal…</p>';
btn.disabled = true;
closeCurrent().then(function () {
return P.post('exec', 'open', { name: name, shell: shell });
}).then(function (data) {
openName = name;
P.el('term-disconnect-btn').disabled = false;
// Matches the ~200ms delay Unraid's own openTerminal() uses between
// asking the backend to spawn ttyd and navigating to its socket —
// ttyd needs a brief moment to bind before nginx can proxy to it.
setTimeout(function () {
wrap.innerHTML = '<iframe class="podman-term-frame" src="/logterminal/' + encodeURIComponent(data.sockName) + '/"></iframe>';
}, 200);
}).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
resetFrame('Could not open terminal: ' + P.escapeHtml(err.message));
}).finally(function () {
btn.disabled = false;
});
}
function populateContainerSelect(containers) {
const select = P.el('term-container-select');
select.innerHTML = containers
.filter(function (c) { return c.state === 'running'; })
.map(function (c) { return '<option value="' + P.escapeHtml(c.id) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('');
containerId = select.value || null;
function disconnect() {
if (!openName) return;
const btn = P.el('term-disconnect-btn');
btn.disabled = true;
closeCurrent().finally(function () {
resetFrame('Disconnected. Pick a container and click "Open Terminal" to start a new session.');
});
}
function init() {
const input = P.el('term-input');
P.el('term-container-select').addEventListener('change', function (e) {
containerId = e.target.value;
cwd = '/';
P.el('term-output').innerHTML = '';
});
input.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
const cmd = input.value;
input.value = '';
if (!containerId) {
appendLine('<span style="color:#ef6470;">No running container selected.</span>');
return;
}
if (cmd.trim() === '') return;
runCommand(cmd);
});
return P.get('containers', 'list').then(populateContainerSelect).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
});
P.el('term-open-btn').addEventListener('click', openLiveTerminal);
P.el('term-disconnect-btn').addEventListener('click', disconnect);
return loadContainers();
}
P.registerPanel('terminal', { init: init });
// refresh() only repopulates the container select — it must never touch
// #term-frame-wrap, or an already-open terminal would be torn down out
// from under the user just by switching tabs and back.
P.registerPanel('terminal', { init: init, refresh: loadContainers });
})();
+30 -11
View File
@@ -2,8 +2,12 @@
* javascript/volumes.js
*
* Volumes panel: named-volume table + create/remove, backed by
* ajax/volumes.php. Bind mounts are deliberately not shown here — see
* that file's header comment.
* ajax/volumes.php. Container-level bind mounts (e.g. appdata under
* /mnt/user/appdata/...) are deliberately not shown here, since they
* aren't a libpod-managed resource at all — see that file's header
* comment. A named volume created here WITH a host path (v.hostPath) IS
* still a real, listed podman volume, just backed by that path instead
* of podman's own internal storage — see PodmanClient::createVolume().
*/
(function () {
'use strict';
@@ -11,14 +15,17 @@
let volumes = [];
function rowHtml(v) {
const pathCell = v.hostPath
? P.escapeHtml(v.hostPath) + ' <span class="podman-chip podman-chip-neutral" title="Bind-mounted to this host path">bind</span>'
: P.escapeHtml(v.mountpoint);
return '' +
'<tr data-name="' + P.escapeHtml(v.name) + '">' +
'<td>' + P.escapeHtml(v.name) + '</td>' +
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(v.mountpoint) + '</td>' +
'<td class="mono podman-row-sub">' + pathCell + '</td>' +
'<td class="tnum">' + v.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>';
}
@@ -42,10 +49,20 @@
function init() {
P.el('volumes-create-btn').addEventListener('click', function () {
const name = prompt('New volume name:');
if (!name) return;
P.post('volumes', 'create', { name: name }).then(load).catch(function (err) {
alert('Create failed: ' + err.message);
P.openFormModal({
title: 'New Volume',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Volume name', required: true, placeholder: 'my-volume' },
{
name: 'path', label: 'Host path (optional)', placeholder: '/mnt/cache/appdata/my-volume',
hint: 'Leave empty for a podman-managed volume. Set this to bind the volume ' +
'directly to an existing directory on disk (e.g. a cache pool path) instead.',
},
],
onSubmit: function (values) {
return P.post('volumes', 'create', { name: values.name, path: values.path || undefined }).then(load);
},
});
});
@@ -53,13 +70,15 @@
const btn = e.target.closest('button[data-action="remove"]');
if (!btn || btn.disabled) return;
const name = btn.closest('tr').dataset.name;
if (!confirm('Remove volume "' + name + '"? This deletes its data.')) return;
P.confirm('Remove volume "' + name + '"? This deletes its data.', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
if (!ok) return;
btn.disabled = true;
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
P.toast('Remove failed: ' + err.message, 'error');
btn.disabled = false;
});
});
});
return load();
}
+532 -22
View File
@@ -20,7 +20,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20, 22, 26, .06), 0 4px 12px rgba(20, 22, 26, .05);
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace;
@@ -35,7 +35,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
}
@@ -44,7 +44,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
:root[data-theme="light"] .podman-plugin {
@@ -52,7 +52,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05);
}
@@ -76,19 +76,87 @@
.podman-pagehead .meta .dot-good { color: var(--good); }
.podman-pagehead .meta .dot-bad { color: var(--bad); }
/*
* margin: 0 — Unraid's own webGui theme applies a 10px top/bottom margin
* to plain <button> elements site-wide. Without resetting it, every
* .podman-btn carries an invisible 10px gap above and below its own box,
* which silently breaks flex cross-axis alignment anywhere a button sits
* next to a non-button sibling (e.g. align-items: flex-end next to a
* <select> — verified live: the button's margin, not its content, was
* what left it floating 10px above the dropdown it should line up with).
*/
.podman-btn {
appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text);
padding: 8px 14px; border-radius: 7px; font-size: 13px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; transition: border-color .12s, background .12s;
font-family: var(--font-ui);
font-family: var(--font-ui); margin: 0;
}
.podman-btn:hover { border-color: var(--text-faint); }
.podman-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); }
.podman-btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
/*
* !important here for the same reason as .podman-btn-ghost below: Unraid's
* own webGUI theme applies a default border/hover treatment to every
* <button> that otherwise silently wins over this rule at rest — verified
* live via a screen recording: without !important, "Create" only ever
* looked filled while under the mouse (Unraid's generic hover glow, applied
* to literally any button), never at rest, making it indistinguishable
* from Cancel except by coincidence of cursor position.
*/
.podman-btn-primary {
background: var(--accent) !important; border-color: var(--accent) !important; color: var(--accent-contrast) !important;
}
.podman-btn-primary:hover {
background: var(--accent-strong) !important; border-color: var(--accent-strong) !important;
}
.podman-btn-danger { color: var(--bad); }
.podman-btn-danger:hover { border-color: var(--bad); }
.podman-btn-icon { padding: 6px 8px; }
.podman-btn-icon { padding: 6px 8px; min-width: 32px; min-height: 32px; justify-content: center; font-size: 15px; line-height: 1; }
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
/*
* Secondary action (Cancel, "+ Add row") — every button previously shared
* the same bordered/accent-colored treatment as Create, so nothing in a
* form stood out as THE primary action (found from a live screenshot AND
* a screen recording: Cancel/Create/+Add/× all read as equally weighted,
* with the exact same hover glow even). Unraid's own webGUI applies a
* site-wide default border+hover-gradient to every <button>, at higher
* effective priority than a plain single-class selector here — verified
* live: a bare `.podman-btn-ghost { border-color: transparent }` was
* silently losing to it, on both the rest AND hover state. !important is
* the only reliable way to guarantee this specific, deliberate style
* wins regardless of what Unraid's base theme does elsewhere.
*/
.podman-btn-ghost {
background: transparent !important; border-color: transparent !important;
color: var(--text-dim) !important; box-shadow: none !important;
}
.podman-btn-ghost:hover {
background: var(--surface-2) !important; border-color: transparent !important;
color: var(--text) !important; box-shadow: none !important;
}
/*
* A ghost button can still carry danger intent (Disconnect, Delete,
* template "Delete") — needs its own !important since .podman-btn-ghost's
* color/background/border would otherwise win by rule order. Solid fill
* at rest (not just a tint, and not just on hover) so it reads with the
* same weight as .podman-btn-primary, just in red instead of accent —
* a merely tinted/outlined button still read as "just another secondary
* action" per live feedback.
*/
.podman-btn-ghost.podman-btn-danger {
color: var(--bad-contrast) !important; background: var(--bad) !important; border-color: var(--bad) !important;
}
.podman-btn-ghost.podman-btn-danger:hover {
background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; color: var(--bad-contrast) !important;
}
.podman-btn-ghost.podman-btn-danger[disabled] {
color: var(--text-faint) !important; background: transparent !important; border-color: var(--border) !important;
}
/* Icon-only danger buttons (row "remove" trash icons) carry an emoji
glyph, not text — .podman-btn-danger's `color` alone doesn't recolor an
emoji, so these get the same solid red fill instead, at rest not just
on hover, so "destructive" reads at a glance across a whole table. */
.podman-btn-icon.podman-btn-danger { border-color: var(--bad) !important; background: var(--bad) !important; }
.podman-btn-icon.podman-btn-danger:hover { background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; }
.podman-btn-icon.podman-btn-danger[disabled] { border-color: var(--border) !important; background: transparent !important; }
.podman-subnav {
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
@@ -118,12 +186,25 @@
@media (max-width: 1080px) { .podman-stat-grid { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 620px) { .podman-stat-grid { grid-template-columns: repeat(2, 1fr); } }
.podman-stat { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; box-shadow: var(--shadow); }
.podman-stat {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px;
box-shadow: var(--shadow); transition: transform .12s ease, box-shadow .12s ease;
}
.podman-stat:hover { transform: translateY(-1px); box-shadow: 0 2px 4px rgba(20, 22, 26, .08), 0 8px 20px rgba(20, 22, 26, .08); }
.podman-stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; }
.podman-stat .value { font-size: 24px; font-weight: 700; margin-top: 6px; }
.podman-stat .value small { font-size: 13px; color: var(--text-dim); font-weight: 600; }
.podman-stat .bar { height: 5px; border-radius: 3px; background: var(--surface-3); margin-top: 10px; overflow: hidden; }
.podman-stat .bar > span { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
.podman-stat .tone-good { color: var(--good); }
.podman-stat .tone-warn { color: var(--warn); }
.podman-stat .tone-bad { color: var(--bad); }
.podman-usage-mini .track > span.warn { background: var(--warn); }
.podman-usage-mini .track > span.bad { background: var(--bad); }
.podman-resource-rows { margin-top: 8px; }
.podman-resource-row { display: flex; align-items: center; gap: 12px; padding: 7px 0; }
.podman-resource-row + .podman-resource-row { border-top: 1px solid var(--border); }
.podman-resource-row .k { font-size: 12.5px; color: var(--text-dim); font-weight: 600; min-width: 84px; flex: none; }
.podman-resource-row .v { font-size: 12.5px; color: var(--text-faint); font-weight: 600; white-space: nowrap; flex: none; font-variant-numeric: tabular-nums; }
.podman-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 100px;
@@ -144,21 +225,117 @@
.podman-plugin tbody tr:last-child td { border-bottom: none; }
.podman-plugin tbody tr:hover { background: var(--surface-2); }
.podman-table-wrap { overflow-x: auto; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
/*
* table-layout: auto (the default) sizes every column from the widest
* content across only the CURRENTLY VISIBLE rows — so expanding/
* collapsing a folder (which adds/removes rows) changed what counted as
* "widest" and shifted every column, including the header text, on
* every toggle (found live). Fixed widths (set on the <th>s in
* Podman.page) make column sizing depend only on those, never on row
* content, so toggling a folder can no longer move anything.
*/
#containers-table { table-layout: fixed; }
/* Only the plain-text columns truncate with an ellipsis (Image/Ports —
both are just escaped text directly in the <td>, so ellipsis renders
correctly) — NOT the Name column (its text sits inside a flex
button+icon, where overflow:hidden would hard-clip instead of
ellipsis-truncate), the folder-header row's colspan cell (its member
chips need to wrap, see .podman-folder-members), or the actions
column (icon buttons, not text, would otherwise get clipped if they
ever didn't quite fit). */
#containers-table tbody > tr:not(.podman-folder-row) > td:nth-child(3),
#containers-table tbody > tr:not(.podman-folder-row) > td:nth-child(5) {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* .podman-name-cell is a <div> INSIDE the <td>, not the <td> itself —
display:flex directly on a table cell pulls it out of table-cell
layout entirely, so with table-layout:fixed it stops respecting the
column width the <th> assigns and throws off alignment with every
other column (found live: the whole Name column visibly drifted).
The div gives the same flex row (so a WebUI link, see
.podman-webui-link, sits on the same line as the name button instead
of wrapping below it — .podman-row-name-btn's own display:flex makes
IT block-level by default, which would otherwise push everything
after it in the cell onto its own line) without that side effect.
min-width:0 lets the button actually shrink instead of forcing the
cell wider than its fixed column width; .text (not the plain text
node it used to be) is what actually ellipsis-truncates, since
text-overflow only applies to the element whose own inline content
overflows, not a nested flex child's. */
.podman-name-cell { display: flex; align-items: center; gap: 6px; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; min-width: 0; }
.podman-row-name .text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.podman-row-name-btn {
/* !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; min-width: 0;
}
.podman-row-name-btn:hover { color: var(--accent-strong); }
.podman-row-name-btn:hover .ico { border-color: var(--accent); }
.podman-row-name .ico {
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
overflow: hidden;
}
.podman-row-name .ico img { width: 100%; height: 100%; object-fit: cover; }
.podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; }
.podman-actions { display: flex; gap: 4px; justify-content: flex-end; }
/*
* The actions <td> itself stays a plain table-cell (default display) so
* every row's column width is computed the same way by the table's layout
* algorithm — putting "display: flex" directly on the <td> used to take it
* out of that algorithm, so browsers could size/position it slightly
* differently row to row (found live: the trash-can button in Images drifted
* a few pixels between rows instead of lining up in one column). The actual
* flex/gap/alignment lives on this inner wrapper instead.
*/
.podman-actions { text-align: right; white-space: nowrap; }
.podman-actions-row { display: inline-flex; gap: 4px; justify-content: flex-end; }
/*
* Segmented toggle (Containers' All/Running/Stopped filter, Logs' Follow/
* Paused) — previously just an inline-styled wrapper <div> around plain
* <button>s with no CSS of their own at all, so every option (not just the
* active one) showed Unraid's own default button border permanently,
* all three chips looking identically "selected". !important for the same
* site-wide-theme-override reason as .podman-btn-ghost/-primary.
*/
.podman-segmented { display: flex; gap: 2px; background: var(--surface-3); border: 1px solid var(--text-faint); padding: 3px; border-radius: 8px; }
.podman-segmented button {
appearance: none; border: none !important; background: transparent !important; color: var(--text-dim) !important;
padding: 6px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; cursor: pointer;
font-family: var(--font-ui); transition: background .12s, color .12s;
}
.podman-segmented button:hover { color: var(--text) !important; }
/* Filled with the accent color (not just a slightly different neutral
shade) — the previous var(--surface) vs. var(--surface-2) contrast
between active/inactive was too close in the dark theme to notice at a
glance (found live). */
.podman-segmented button.active { background: var(--accent) !important; color: var(--accent-contrast) !important; box-shadow: var(--shadow); }
.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
.podman-usage-mini .track > span { display: block; height: 100%; background: var(--accent); }
.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; font-variant-numeric: tabular-nums; }
.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.podman-search { flex: 1; min-width: 180px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 11px; font-size: 13px; color: var(--text); font-family: var(--font-ui); }
/*
* !important throughout: Unraid's own webGui/styles/default-base.css
* targets input[type="text"] with an attribute selector (higher
* specificity than our single .podman-search class, :where() around it
* notwithstanding) forcing border-width:0 / border-bottom-width:1px /
* background:transparent — an underline-only text field, not a boxed one.
* Found live: our border/background were being silently dropped even
* though this rule appears later in the stylesheet.
*/
.podman-search {
flex: 1; min-width: 180px; max-width: 320px; font-size: 13px; color: var(--text); font-family: var(--font-ui);
background: var(--surface-3) !important; border: 1px solid var(--text-faint) !important;
border-radius: 7px !important; padding: 7px 11px !important;
}
.podman-search::placeholder { color: var(--text-faint); }
.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; }
@@ -175,7 +352,45 @@
.podman-pod-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 14px; background: var(--surface); box-shadow: var(--shadow); }
.podman-pod-head { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); margin-right: auto; }
.podman-badge { display: inline-block; font-size: 10.5px; font-weight: 700; color: var(--text-dim); background: var(--surface-3); padding: 2px 8px; border-radius: 100px; margin-top: 6px; }
.podman-template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; padding: 18px; }
.podman-template-grid .podman-empty-note { grid-column: 1 / -1; }
.podman-pager { display: flex; align-items: center; justify-content: center; gap: 14px; padding: 4px 18px 18px; font-size: 12.5px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
.podman-template-card {
border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--surface);
display: flex; flex-direction: column; gap: 10px;
}
.podman-template-icon { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--surface-2); }
.podman-template-icon-fallback {
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 16px;
color: var(--accent); border: 1px solid var(--border);
}
.podman-template-name { font-weight: 700; font-size: 13.5px; }
.podman-template-overview { font-size: 12px; color: var(--text-dim); line-height: 1.4; }
.podman-template-actions { display: flex; gap: 6px; margin-top: auto; padding-top: 4px; }
/*
* min-width: 0 overrides the flex-item default of min-width: auto, which
* otherwise refuses to shrink a button below its own label's intrinsic
* width — without it, "Delete" (the widest label, and uppercased by
* Unraid's own site-wide button theme) pushed past the card's right edge
* instead of actually sharing the row evenly with Use/Export (found live).
*/
.podman-template-actions .podman-btn {
flex: 1; min-width: 0; justify-content: center; padding: 6px 8px; font-size: 11.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.podman-local-template-list { max-height: 220px; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; margin-top: 8px; }
.podman-local-template-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid var(--border);
}
.podman-local-template-item:last-child { border-bottom: none; }
.podman-local-template-name { font-weight: 600; font-size: 12.5px; white-space: nowrap; }
.podman-local-template-item .podman-row-sub { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.podman-local-template-item .podman-btn { flex: none; padding: 5px 10px; font-size: 11.5px; }
.podman-logs-layout { display: grid; grid-template-columns: 200px 1fr; min-height: 460px; }
@media (max-width: 760px) { .podman-logs-layout { grid-template-columns: 1fr; } }
@@ -185,19 +400,35 @@
.podman-log-pane {
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.3px;
padding: 14px 16px; height: 400px; overflow-y: auto; line-height: 1.65;
/* Settings' service log (and the log modal) set .textContent directly
on this element rather than wrapping every line in a child .l div —
needs its own white-space here too, or real newlines in rc.podman's
output collapse into one run-together line (found live: the whole
multi-line `rc.podman status` output rendered as a single paragraph). */
white-space: pre-wrap;
}
.podman-log-pane .l { white-space: pre-wrap; word-break: break-word; }
.podman-log-pane .ts { color: #6b7280; }
.podman-log-pane .lvl-warn { color: #e0b23d; }
.podman-log-pane .lvl-error { color: #ef6470; }
.podman-term { background: #0f1114; color: #d7dbe0; font-family: var(--font-mono); font-size: 12.6px; border-radius: 8px; padding: 14px 16px; height: 380px; overflow-y: auto; line-height: 1.7; }
.podman-term .prompt { color: #4cc785; }
.podman-term .path { color: #6fb2f5; }
.podman-term-input {
width: 100%; margin-top: 10px; background: #0f1114; color: #d7dbe0; border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; font-family: var(--font-mono); font-size: 12.6px;
.podman-term-launcher {
display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
padding: 12px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
}
.podman-term-launcher label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--text-dim); }
/*
* !important here for the same reason as .podman-search's: Unraid's own
* webGui/styles/default-base.css has `select:where(:not(.unapi *))` rules
* for background/border/padding that would otherwise still show through
* around this class's box-model properties.
*/
.podman-term-select {
min-width: 180px !important; padding: 7px 12px !important; font-size: 13px !important;
font-family: var(--font-mono) !important; color: var(--text) !important;
background: var(--surface-3) !important; border: 1px solid var(--accent) !important; border-radius: 6px !important;
}
.podman-term-frame { display: block; width: 100%; height: 480px; border: 1px solid var(--border); border-radius: 8px; background: #0f1114; }
.podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; }
@media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } }
@@ -206,6 +437,19 @@
.podman-compose-proj.active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); }
.podman-compose-proj .path { font-size: 11px; color: var(--text-faint); margin-top: 2px; font-family: var(--font-mono); }
.podman-yaml { background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.4px; padding: 16px 18px; height: 420px; overflow: auto; line-height: 1.7; white-space: pre-wrap; }
/*
* !important: this is now a real <textarea>, not a read-only <pre> —
* Unraid's own webGui/styles/default-base.css targets textarea the same
* way it targets input[type="text"] (see .podman-search's comment for
* the exact rule), forcing border-width:0/border-bottom-width:1px/
* background:transparent/border-radius:0, which would otherwise make the
* whole editor look like a barely-visible underline instead of an actual
* text area.
*/
.podman-yaml-editor {
display: block; width: 100%; box-sizing: border-box; resize: vertical;
border: none !important; border-radius: 0 !important; outline: none;
}
.podman-field-row { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); align-items: start; }
.podman-field-row:last-child { border-bottom: none; }
@@ -216,10 +460,276 @@
font-size: 13px; color: var(--text); width: 100%; max-width: 340px; font-family: var(--font-ui);
}
.podman-danger-card { border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
/* Settings panel: a shared save action above all cards (Storage's and
Autostart & Lifecycle's fields save together in one call — see
settings.js's save() — so one button belongs above both, not buried in
either card, and definitely not in its own row with an empty label).
Framed as its own small bar (background/border), not bare text+button
floating at the top of the page. */
.podman-settings-actions {
display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px;
padding: 12px 16px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px;
}
.podman-settings-actions .hint { margin: 0; max-width: 52ch; color: var(--text-dim); }
.podman-card-head .sub { margin-top: 3px; }
/* Number field + unit label (GB, seconds) — the input itself stays compact
instead of stretching to .podman-field-row's normal 340px text-field width. */
.podman-input-suffix { display: flex; align-items: center; gap: 8px; }
.podman-input-suffix input[type="number"] { max-width: 100px; width: auto; }
.podman-input-suffix span { font-size: 12px; color: var(--text-dim); }
.podman-service-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.podman-service-row:last-child { margin-bottom: 0; }
/*
* Toggle switch — a plain checkbox reads as a leftover form control next
* to everything else in this panel getting a designed treatment; this
* hides the native checkbox (still the real, accessible input driving
* state) and draws a track+thumb off its :checked state instead. Sized in
* em off the track's own font-size so it scales if that ever changes.
*/
.podman-switch { position: relative; display: inline-flex; align-items: center; cursor: pointer; font-size: 22px; }
.podman-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.podman-switch-track {
display: inline-block; width: 1.9em; height: 1.05em; border-radius: 999px;
background: var(--surface-3); border: 1px solid var(--border); transition: background .15s, border-color .15s;
}
.podman-switch-thumb {
display: block; width: 0.75em; height: 0.75em; margin: 0.13em; border-radius: 50%;
background: var(--text-faint); transition: transform .15s, background .15s;
}
.podman-switch input:checked + .podman-switch-track { background: var(--accent); border-color: var(--accent); }
.podman-switch input:checked + .podman-switch-track .podman-switch-thumb { background: var(--accent-contrast); transform: translateX(0.85em); }
.podman-switch input:focus-visible + .podman-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.podman-version-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.podman-version-chip {
font-size: 11.5px; font-family: var(--font-mono); background: var(--surface-3); color: var(--text-dim);
border: 1px solid var(--border); padding: 5px 11px; border-radius: 100px;
}
.podman-version-chip b { color: var(--text); font-weight: 600; margin-left: 5px; }
.podman-danger-card .podman-card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); }
.podman-danger-card .podman-card-head h2 { color: var(--bad); }
.podman-badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; }
.podman-webui-link {
display: inline-flex; align-items: center; justify-content: center; height: 20px; padding: 0 7px;
border-radius: 5px; color: var(--text-faint); text-decoration: none; font-size: 10px; font-weight: 700;
letter-spacing: .03em; flex: none; border: 1px solid var(--border); background: var(--surface-3);
vertical-align: middle;
}
.podman-webui-link:hover { color: var(--accent-strong); border-color: var(--accent); }
.podman-loading, .podman-error { padding: 32px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
/**
* Modal form dialog — replaces browser-native prompt()/confirm() for any
* action that needs more than a single yes/no (e.g. "New Volume" needs a
* name AND an optional host path together, which prompt() can't express
* as one coherent form). See app.js's openFormModal().
*/
.podman-modal-backdrop {
position: fixed; inset: 0; background: rgba(15, 17, 20, .55); z-index: 1000;
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.podman-modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
box-shadow: var(--shadow); width: 100%; max-width: 420px; max-height: calc(100vh - 40px);
overflow-y: auto; color: var(--text); font-family: var(--font-ui);
}
.podman-modal-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
.podman-modal-head h3 { font-size: 15px; }
.podman-modal-body { padding: 16px 20px; display: grid; gap: 14px; }
.podman-modal-field label { display: block; font-weight: 600; font-size: 12.5px; margin-bottom: 6px; }
.podman-modal-field input[type="text"], .podman-modal-field input[type="url"] {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
font-size: 13px; color: var(--text); width: 100%; font-family: var(--font-ui);
}
.podman-modal-field .hint { font-size: 11.5px; color: var(--text-faint); margin-top: 4px; }
.podman-modal-field select {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
font-size: 13px; color: var(--text); font-family: var(--font-ui);
}
.podman-modal-checkbox label { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 12.5px; margin-bottom: 0; }
.podman-modal-error { font-size: 12.5px; color: var(--bad); background: var(--bad-bg); border-radius: 7px; padding: 8px 10px; }
.podman-modal-actions { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
.podman-error { color: var(--bad); }
/* Wider variant + repeatable row groups, for forms with more than 1-2 fields (e.g. Create Container). */
.podman-modal-wide { max-width: 640px; }
.podman-row-group { display: grid; gap: 8px; margin-bottom: 8px; }
.podman-row-group-item {
display: flex; align-items: center; gap: 8px;
}
.podman-row-group-item input[type="text"] {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px;
font-size: 12.5px; color: var(--text); font-family: var(--font-mono); flex: 1; min-width: 0;
}
.podman-row-group-item select {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px;
font-size: 12.5px; color: var(--text); font-family: var(--font-ui);
/* flex:none alone wasn't enough — its auto flex-basis still let the
select stretch to fill the row (verified live: "TCP"/"Volume"
dropdowns spanned almost the entire row width). An explicit width
pins it to content-appropriate size regardless. */
flex: none; width: 110px;
}
.podman-row-group-item span { color: var(--text-faint); font-size: 12px; flex: none; }
/*
* Port number fields share the row with a select + remove button, unlike
* the wide source/path/key/value fields elsewhere in these row groups —
* left on flex:1 like everything else, both port inputs fought the fixed-
* width select/button for space and got squeezed down to a few pixels
* (found live: they rendered as near-invisible slivers). Fixed width,
* not flex-grown.
*/
.podman-row-group-item input.podman-input-narrow { flex: none; width: 90px; }
.podman-row-checkbox-label {
display: flex; align-items: center; gap: 4px; flex: none; font-size: 12px; color: var(--text-dim);
white-space: nowrap; cursor: pointer;
}
/* Row-remove (x) reads as a normal button like everything else at full
.podman-btn weight — muted/borderless by default, only turning
"danger" red on hover, so it registers as a quiet per-row affordance
rather than competing with the form's actual actions. */
.podman-row-group-item .podman-row-remove-btn {
background: transparent; border-color: transparent; color: var(--text-faint); flex: none;
}
.podman-row-group-item .podman-row-remove-btn:hover { background: var(--bad-bg); border-color: transparent; color: var(--bad); }
/* Anchored dropdown context menu — see app.js openContextMenu(). */
.podman-context-menu {
/*
* "fixed", not "absolute": this menu is appended to .podman-plugin, not
* document.body, and Unraid's own page wrapper around .podman-plugin
* turned out to have its own positioned ancestor — with "absolute" the
* menu was positioning itself relative to THAT ancestor's box while the
* JS math (getBoundingClientRect + scrollY/X) assumed the viewport,
* so it rendered far from the button that opened it (found live: it
* appeared well below and to the side of the anchor). "fixed" is always
* viewport-relative regardless of any ancestor, which is what the JS
* math actually assumes.
*/
position: fixed; width: 180px; background: var(--surface); border: 1px solid var(--border);
border-radius: 9px; box-shadow: var(--shadow); z-index: 1001; padding: 4px; display: grid; gap: 1px;
}
.podman-context-menu button {
appearance: none; border: none; background: none; text-align: left; padding: 8px 10px;
font-size: 12.5px; font-weight: 600; color: var(--text); border-radius: 6px; cursor: pointer;
font-family: var(--font-ui); width: 100%;
}
.podman-context-menu button:hover { background: var(--surface-2); }
.podman-context-menu button.danger { color: var(--bad); background: var(--bad-bg); }
.podman-context-menu button.danger:hover { background: var(--bad); color: var(--bad-contrast); }
.podman-context-menu button[disabled] { opacity: .4; cursor: not-allowed; }
.podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }
/* Container detail modal — see containers.js openDetailModal(). */
.podman-modal-xwide { max-width: 760px; }
.podman-detail-tabs {
display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--border); overflow-x: auto;
}
.podman-detail-tabs button {
appearance: none; background: none; border: none; border-bottom: 2px solid transparent;
color: var(--text-dim); padding: 10px 12px; font-size: 12.5px; font-weight: 600; cursor: pointer;
white-space: nowrap; font-family: var(--font-ui);
}
.podman-detail-tabs button:hover { color: var(--text); }
.podman-detail-tabs button.active { color: var(--accent-strong); border-bottom-color: var(--accent); }
.podman-detail-body { padding: 16px 20px; max-height: 50vh; overflow-y: auto; }
.podman-detail-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.podman-detail-table td { padding: 6px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
.podman-detail-table td:first-child { color: var(--text-faint); font-weight: 600; width: 160px; padding-right: 12px; }
.podman-detail-table td.mono { font-family: var(--font-mono); word-break: break-all; }
.podman-detail-json {
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 11.8px;
padding: 14px 16px; border-radius: 8px; white-space: pre-wrap; word-break: break-all; line-height: 1.6;
}
.podman-detail-empty { color: var(--text-faint); font-size: 12.5px; padding: 8px 0; }
/*
* Toast notifications — replaces alert() for one-way feedback (saved,
* removed, failed, ...) across every panel. Confirmations stay native
* confirm() (a decision, not a notice); long-running command output
* stays in the existing log-modal pattern (a toast has to stay short to
* be readable while it's animating/auto-dismissing). z-index above the
* modal backdrop (1000) so a toast fired from within a modal's own
* action (e.g. "Save failed") is never hidden behind it.
*/
.podman-toast-container {
position: fixed; right: 20px; bottom: 20px; z-index: 1100;
display: flex; flex-direction: column-reverse; gap: 10px; pointer-events: none;
max-width: min(380px, calc(100vw - 40px));
}
.podman-toast {
pointer-events: auto; display: flex; align-items: flex-start; gap: 10px;
background: var(--surface); border: 1px solid var(--border); border-left: 3px solid var(--neutral);
border-radius: 9px; box-shadow: var(--shadow); padding: 12px 14px; font-size: 13px; color: var(--text);
animation: podman-toast-in .18s ease-out;
}
.podman-toast.leaving { animation: podman-toast-out .16s ease-in forwards; }
.podman-toast-success { border-left-color: var(--good); }
.podman-toast-warn { border-left-color: var(--warn); }
.podman-toast-error { border-left-color: var(--bad); }
.podman-toast .ico { flex: none; margin-top: 1px; font-size: 15px; line-height: 1; }
.podman-toast-success .ico { color: var(--good); }
.podman-toast-warn .ico { color: var(--warn); }
.podman-toast-error .ico { color: var(--bad); }
.podman-toast .msg { flex: 1; line-height: 1.45; word-break: break-word; }
.podman-toast .close {
appearance: none; background: none; border: none; color: var(--text-faint); cursor: pointer;
font-size: 15px; line-height: 1; padding: 0; flex: none; font-family: var(--font-ui);
}
.podman-toast .close:hover { color: var(--text); }
@keyframes podman-toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@keyframes podman-toast-out { from { opacity: 1; transform: translateY(0); } to { opacity: 0; transform: translateY(8px); } }
@media (prefers-reduced-motion: reduce) {
.podman-toast, .podman-toast.leaving { animation: none; }
}
/* Container folders — a group header row spanning the full table width,
sitting between normal <tr>s in the same <tbody> (see containers.js's
renderTable()) rather than a separate nested table, so column
alignment with member rows stays free. */
.podman-folder-row td { padding: 0; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.podman-folder-head { display: flex; align-items: center; gap: 14px; padding: 9px 16px; flex-wrap: wrap; }
.podman-folder-toggle {
appearance: none; background: none; border: none; display: flex; align-items: center; gap: 10px;
cursor: pointer; font-family: var(--font-ui); color: var(--text); padding: 0; flex: none; text-align: left;
}
.podman-folder-toggle .chevron { color: var(--text-faint); font-size: 10px; width: 10px; flex: none; }
.podman-folder-toggle .ico {
width: 22px; height: 22px; border-radius: 6px; flex: none; background: var(--surface-3);
display: grid; place-items: center; font-size: 10.5px; border: 1px solid var(--border); color: var(--text-dim);
overflow: hidden;
}
.podman-folder-toggle .ico img { width: 100%; height: 100%; object-fit: cover; }
.podman-folder-toggle .name { font-weight: 700; font-size: 12.5px; }
.podman-folder-toggle .count {
font-size: 11px; color: var(--text-faint); font-weight: 700; background: var(--surface-3);
padding: 1px 8px; border-radius: 100px; font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* The always-visible per-member preview inside a folder's header row —
see containers.js's folderMemberChipHtml(). Each chip opens that
container's detail modal directly, without needing to expand the
folder first. */
.podman-folder-members { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; flex: 1; min-width: 0; }
.podman-folder-member {
appearance: none; display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--border);
background: var(--surface); border-radius: 100px; padding: 3px 10px 3px 4px; cursor: pointer;
font-family: var(--font-ui); color: var(--text-dim); font-size: 11.5px;
}
.podman-folder-member:hover { border-color: var(--accent); color: var(--text); }
.podman-folder-member .ico {
width: 18px; height: 18px; border-radius: 5px; font-size: 9px; flex: none; background: var(--surface-3);
display: grid; place-items: center; overflow: hidden;
}
.podman-folder-member .ico img { width: 100%; height: 100%; object-fit: cover; }
.podman-folder-member .name { font-weight: 600; }
.podman-folder-member .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; }
.podman-folder-member .dot.good { background: var(--good); }
.podman-folder-member .dot.bad { background: var(--bad); }
.podman-folder-member .state { color: var(--text-faint); }