Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e2ed451e3 | ||
|
|
9d46547ef4 | ||
|
|
d931d8e9e9 | ||
|
|
23898ff62e | ||
|
|
1ca78e7115 | ||
|
|
166f0d96d1 | ||
|
|
9b9d1a05ac | ||
|
|
66ef830234 | ||
|
|
676fd8bc89 | ||
|
|
20d8686b61 | ||
|
|
9557f8c8f8 | ||
|
|
5fb7376b64 | ||
|
|
7f6fcb9166 | ||
|
|
80e006c73f |
@@ -1,6 +1,10 @@
|
|||||||
name: Release
|
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
|
# By design, this workflow does NOT bump versions or modify podman.plg
|
||||||
# itself — that happens locally via `scripts/release.sh <version>`, which a
|
# 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:
|
# script's own printed instructions). This workflow's only job is to:
|
||||||
# 1. Rebuild all packages from the tagged commit in a clean Slackware
|
# 1. Rebuild all packages from the tagged commit in a clean Slackware
|
||||||
# container (reproducibility check + provenance — we don't trust
|
# 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
|
# 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
|
# at this tag (catches a release.sh run that wasn't followed by a
|
||||||
# matching commit — see the "Verify plg matches build" step).
|
# matching commit — see the "Verify plg matches build" step).
|
||||||
# 3. Create the GitHub Release and attach the .txz packages, checksum
|
# 3. Create the Gitea Release and attach the .txz packages, checksum
|
||||||
# manifests, and podman.plg.
|
# 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).
|
# See docs/ARCHITECTURE.md section 13 (Updates).
|
||||||
|
|
||||||
@@ -25,13 +42,17 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
GITEA_HOST: git.mp-mueller.de
|
||||||
|
GITEA_REPO: magges/unraid-podman
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build release packages
|
name: Build release packages
|
||||||
uses: ./.github/workflows/build-packages.yml
|
uses: ./.github/workflows/build-packages.yml
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
name: Publish GitHub Release
|
name: Publish Gitea Release
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -79,18 +100,52 @@ jobs:
|
|||||||
' CHANGELOG.md > /tmp/release-notes.md
|
' CHANGELOG.md > /tmp/release-notes.md
|
||||||
echo "path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT"
|
echo "path=/tmp/release-notes.md" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Publish Gitea Release
|
||||||
uses: softprops/action-gh-release@v2
|
env:
|
||||||
with:
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
name: "unraid-podman v${{ steps.version.outputs.value }}"
|
TAG: ${{ github.ref_name }}
|
||||||
body_path: ${{ steps.changelog.outputs.path }}
|
run: |
|
||||||
# v0.x tags are treated as pre-releases until the plugin reaches a
|
set -eu
|
||||||
# first stable 1.0.0 — see docs/ROADMAP.md.
|
API="https://${GITEA_HOST}/api/v1/repos/${GITEA_REPO}"
|
||||||
prerelease: ${{ startsWith(steps.version.outputs.value, '0.') }}
|
# Every release is a normal release, not a "pre-release" — the
|
||||||
files: |
|
# earlier 0.x-is-always-prerelease default didn't match what
|
||||||
dist/*.txz
|
# this project actually wants published (v0.1.3 was explicitly
|
||||||
dist/*.sha256
|
# corrected off "pre-release" after the fact).
|
||||||
dist/*.md5
|
prerelease=false
|
||||||
dist/CHECKSUMS.sha256
|
|
||||||
dist/CHECKSUMS.md5
|
# Idempotent: if a release for this tag already exists (e.g. a
|
||||||
plugin/podman.plg
|
# 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}"
|
||||||
|
|||||||
+261
@@ -9,6 +9,267 @@ see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#52-build-strategie)).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.1.0] - 2026-07-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -20,6 +20,12 @@
|
|||||||
# blocked on podman's full startup sequence (preflight, storage mount,
|
# blocked on podman's full startup sequence (preflight, storage mount,
|
||||||
# service start, autostart chain) — mirrors how unassigned.devices
|
# service start, autostart chain) — mirrors how unassigned.devices
|
||||||
# backgrounds its own longer-running "started" hook.
|
# 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
|
||||||
|
|||||||
+36
-80
@@ -50,7 +50,7 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "podman">
|
<!ENTITY name "podman">
|
||||||
<!ENTITY author "unraid-podman contributors">
|
<!ENTITY author "unraid-podman contributors">
|
||||||
<!ENTITY version "0.1.0">
|
<!ENTITY version "0.1.5">
|
||||||
<!-- "Podman" (no parent) — Podman.page declares Menu="Podman", making it
|
<!-- "Podman" (no parent) — Podman.page declares Menu="Podman", making it
|
||||||
its own top-level nav tab next to Docker/VMs, not nested under
|
its own top-level nav tab next to Docker/VMs, not nested under
|
||||||
Settings — see webui/plugins/podman/Podman.page. -->
|
Settings — see webui/plugins/podman/Podman.page. -->
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<!-- Release asset base — matches scripts/release.sh's RELEASE_BASE_URL
|
<!-- Release asset base — matches scripts/release.sh's RELEASE_BASE_URL
|
||||||
exactly; both must agree since release.sh is what publishes the
|
exactly; both must agree since release.sh is what publishes the
|
||||||
packages this URL is expected to find. -->
|
packages this URL is expected to find. -->
|
||||||
<!ENTITY baseURL "https://git.mp-mueller.de/magges/unraid-podman/releases/download/v0.1.0">
|
<!ENTITY baseURL "https://git.mp-mueller.de/magges/unraid-podman/releases/download/v0.1.5">
|
||||||
|
|
||||||
<!-- Slackware package naming components — must match versions.env's
|
<!-- Slackware package naming components — must match versions.env's
|
||||||
PKG_ARCH/PKG_BUILD/PKG_TAG (see that file). Kept as entities here so
|
PKG_ARCH/PKG_BUILD/PKG_TAG (see that file). Kept as entities here so
|
||||||
@@ -88,31 +88,31 @@
|
|||||||
|
|
||||||
<!ENTITY podman_txz_version "6.0.1">
|
<!ENTITY podman_txz_version "6.0.1">
|
||||||
<!ENTITY podman_txz_file "podman-6.0.1-x86_64-1_unraidpodman.txz">
|
<!ENTITY podman_txz_file "podman-6.0.1-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY podman_txz_md5 "4a9fdb25800fac506876903b09f64e7b">
|
<!ENTITY podman_txz_md5 "692b8df0a1ac25544748dea4ff518705">
|
||||||
|
|
||||||
<!ENTITY conmon_txz_version "2.2.1">
|
<!ENTITY conmon_txz_version "2.2.1">
|
||||||
<!ENTITY conmon_txz_file "conmon-2.2.1-x86_64-1_unraidpodman.txz">
|
<!ENTITY conmon_txz_file "conmon-2.2.1-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY conmon_txz_md5 "358136a2fbc8e629d50863466aed5cd3">
|
<!ENTITY conmon_txz_md5 "fc0af377c1beeee452040e8991c4aafa">
|
||||||
|
|
||||||
<!ENTITY crun_txz_version "1.28">
|
<!ENTITY crun_txz_version "1.28">
|
||||||
<!ENTITY crun_txz_file "crun-1.28-x86_64-1_unraidpodman.txz">
|
<!ENTITY crun_txz_file "crun-1.28-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY crun_txz_md5 "4108cca9a2e0673d1206e15a3d51cf73">
|
<!ENTITY crun_txz_md5 "2e787b0f6826fc61b7a76ca42573d6c4">
|
||||||
|
|
||||||
<!ENTITY netavark_txz_version "2.0.0">
|
<!ENTITY netavark_txz_version "2.0.0">
|
||||||
<!ENTITY netavark_txz_file "netavark-2.0.0-x86_64-1_unraidpodman.txz">
|
<!ENTITY netavark_txz_file "netavark-2.0.0-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY netavark_txz_md5 "4970584505c056fd18995daf2728cb56">
|
<!ENTITY netavark_txz_md5 "27a2128b2fcbeb590773c5dd004d911e">
|
||||||
|
|
||||||
<!ENTITY aardvark_dns_txz_version "2.0.0">
|
<!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_file "aardvark-dns-2.0.0-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY aardvark_dns_txz_md5 "168bbe9298db17fe51d6849b0b670dd4">
|
<!ENTITY aardvark_dns_txz_md5 "f775f20903c2301736594fdde0081c6d">
|
||||||
|
|
||||||
<!ENTITY passt_txz_version "git6ef3d1c">
|
<!ENTITY passt_txz_version "git6ef3d1c">
|
||||||
<!ENTITY passt_txz_file "passt-git6ef3d1c-x86_64-1_unraidpodman.txz">
|
<!ENTITY passt_txz_file "passt-git6ef3d1c-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY passt_txz_md5 "cedd2d4b4ff1c2c22b13947bb754d427">
|
<!ENTITY passt_txz_md5 "9257eb90fb218b047e9f099e9b2f0418">
|
||||||
|
|
||||||
<!ENTITY fuse_overlayfs_txz_version "1.17">
|
<!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_file "fuse-overlayfs-1.17-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY fuse_overlayfs_txz_md5 "95bf694c5480be2069a581748b2dfb09">
|
<!ENTITY fuse_overlayfs_txz_md5 "431b1d3ab36d05f817379ef36ba70ae2">
|
||||||
|
|
||||||
<!-- catatonit and nftables are runtime dependencies this plugin ships,
|
<!-- catatonit and nftables are runtime dependencies this plugin ships,
|
||||||
not upstream podman-ecosystem components — see packages/catatonit/
|
not upstream podman-ecosystem components — see packages/catatonit/
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
systemd/dbus for firewalld) and why neither is built from source. -->
|
systemd/dbus for firewalld) and why neither is built from source. -->
|
||||||
<!ENTITY catatonit_txz_version "0.2.1">
|
<!ENTITY catatonit_txz_version "0.2.1">
|
||||||
<!ENTITY catatonit_txz_file "catatonit-0.2.1-x86_64-1_unraidpodman.txz">
|
<!ENTITY catatonit_txz_file "catatonit-0.2.1-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY catatonit_txz_md5 "a3fe1f08981a7fb07337838ba7d00de3">
|
<!ENTITY catatonit_txz_md5 "a335c4924f244a41662f891d79b1251d">
|
||||||
|
|
||||||
<!ENTITY nftables_txz_version "1.0.1">
|
<!ENTITY nftables_txz_version "1.0.1">
|
||||||
<!ENTITY nftables_txz_file "nftables-1.0.1-x86_64-1_unraidpodman.txz">
|
<!ENTITY nftables_txz_file "nftables-1.0.1-x86_64-1_unraidpodman.txz">
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
every Compose panel action fails outright on a clean install. -->
|
every Compose panel action fails outright on a clean install. -->
|
||||||
<!ENTITY podman_compose_txz_version "1.6.0">
|
<!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_file "podman-compose-1.6.0-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY podman_compose_txz_md5 "a4e1bdbfe397f1b693815e003e371990">
|
<!ENTITY podman_compose_txz_md5 "f4344ec947cab1a73c50f9afa0b24bee">
|
||||||
|
|
||||||
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
|
<!-- unraid-podman is this project's OWN scaffolding package (rc.podman,
|
||||||
sbin/ scripts, event/ hooks, config templates — see
|
sbin/ scripts, event/ hooks, config templates — see
|
||||||
@@ -141,9 +141,9 @@
|
|||||||
version always equals the plugin's own &version; — see
|
version always equals the plugin's own &version; — see
|
||||||
packages/unraid-podman/unraid-podman.SlackBuild, which reads it
|
packages/unraid-podman/unraid-podman.SlackBuild, which reads it
|
||||||
straight out of this very file rather than tracking it twice. -->
|
straight out of this very file rather than tracking it twice. -->
|
||||||
<!ENTITY unraid_podman_txz_version "0.1.0">
|
<!ENTITY unraid_podman_txz_version "0.1.5">
|
||||||
<!ENTITY unraid_podman_txz_file "unraid-podman-0.1.0-x86_64-1_unraidpodman.txz">
|
<!ENTITY unraid_podman_txz_file "unraid-podman-0.1.5-x86_64-1_unraidpodman.txz">
|
||||||
<!ENTITY unraid_podman_txz_md5 "25e5933e113e09bfa07c0d5169ccac18">
|
<!ENTITY unraid_podman_txz_md5 "750fc748b054865b41297a658864d261">
|
||||||
]>
|
]>
|
||||||
|
|
||||||
<PLUGIN name="&name;"
|
<PLUGIN name="&name;"
|
||||||
@@ -201,93 +201,53 @@ fi
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&podman_txz_file;</URL>
|
||||||
&baseURL;/&podman_txz_file;
|
<MD5>&podman_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&podman_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&conmon_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&conmon_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&conmon_txz_file;</URL>
|
||||||
&baseURL;/&conmon_txz_file;
|
<MD5>&conmon_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&conmon_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&crun_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&crun_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&crun_txz_file;</URL>
|
||||||
&baseURL;/&crun_txz_file;
|
<MD5>&crun_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&crun_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&netavark_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&netavark_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&netavark_txz_file;</URL>
|
||||||
&baseURL;/&netavark_txz_file;
|
<MD5>&netavark_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&netavark_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&aardvark_dns_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&aardvark_dns_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&aardvark_dns_txz_file;</URL>
|
||||||
&baseURL;/&aardvark_dns_txz_file;
|
<MD5>&aardvark_dns_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&aardvark_dns_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&passt_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&passt_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&passt_txz_file;</URL>
|
||||||
&baseURL;/&passt_txz_file;
|
<MD5>&passt_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&passt_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&fuse_overlayfs_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&fuse_overlayfs_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&fuse_overlayfs_txz_file;</URL>
|
||||||
&baseURL;/&fuse_overlayfs_txz_file;
|
<MD5>&fuse_overlayfs_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&fuse_overlayfs_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&catatonit_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&catatonit_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&catatonit_txz_file;</URL>
|
||||||
&baseURL;/&catatonit_txz_file;
|
<MD5>&catatonit_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&catatonit_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&nftables_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&nftables_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&nftables_txz_file;</URL>
|
||||||
&baseURL;/&nftables_txz_file;
|
<MD5>&nftables_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&nftables_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&podman_compose_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&podman_compose_txz_file;</URL>
|
||||||
&baseURL;/&podman_compose_txz_file;
|
<MD5>&podman_compose_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&podman_compose_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -296,12 +256,8 @@ fi
|
|||||||
packages/unraid-podman/README.md.
|
packages/unraid-podman/README.md.
|
||||||
-->
|
-->
|
||||||
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&unraid_podman_txz_file;" Run="upgradepkg --install-new --reinstall">
|
<FILE Name="/boot/config/plugins/&name;/backup/packages/&version;/&unraid_podman_txz_file;" Run="upgradepkg --install-new --reinstall">
|
||||||
<URL>
|
<URL>&baseURL;/&unraid_podman_txz_file;</URL>
|
||||||
&baseURL;/&unraid_podman_txz_file;
|
<MD5>&unraid_podman_txz_md5;</MD5>
|
||||||
</URL>
|
|
||||||
<MD5>
|
|
||||||
&unraid_podman_txz_md5;
|
|
||||||
</MD5>
|
|
||||||
</FILE>
|
</FILE>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -168,6 +168,82 @@ podman_storage_path_is_safe() {
|
|||||||
return 0
|
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>
|
# podman_require_command <binary>
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -75,6 +75,14 @@ elif [ -d "$STORAGE_PATH" ]; then
|
|||||||
else
|
else
|
||||||
fail "STORAGE_PATH ($STORAGE_PATH) does not appear to be on a mounted filesystem"
|
fail "STORAGE_PATH ($STORAGE_PATH) does not appear to be on a mounted filesystem"
|
||||||
fi
|
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
|
else
|
||||||
fail "STORAGE_PATH ($STORAGE_PATH) does not exist — is the configured cache pool/disk present and started?"
|
fail "STORAGE_PATH ($STORAGE_PATH) does not exist — is the configured cache pool/disk present and started?"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -46,9 +46,13 @@ cmd_create() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -d "$STORAGE_PATH" ]; then
|
if [ ! -d "$STORAGE_PATH" ]; then
|
||||||
podman_log_error "storage: $STORAGE_PATH does not exist or is not mounted."
|
if ! podman_path_has_real_mount_ancestor "$STORAGE_PATH"; then
|
||||||
podman_log_error "storage: check that the configured cache pool/disk is present before starting podman."
|
podman_log_error "storage: $STORAGE_PATH does not exist or is not mounted."
|
||||||
return 1
|
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
|
fi
|
||||||
|
|
||||||
# Free space check: refuse to create an image bigger than what's actually
|
# Free space check: refuse to create an image bigger than what's actually
|
||||||
|
|||||||
@@ -68,7 +68,12 @@ for name in $targets; do
|
|||||||
continue
|
continue
|
||||||
fi
|
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__}")
|
installed_basename=$(basename "${installed_record:-__none__}")
|
||||||
|
|
||||||
case "$installed_basename" in
|
case "$installed_basename" in
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ for name in $PACKAGES; do
|
|||||||
report " expected version: $expected_version"
|
report " expected version: $expected_version"
|
||||||
|
|
||||||
# --- Check 1: installed -----------------------------------------------
|
# --- 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
|
if [ -z "$installed_record" ]; then
|
||||||
report " installed: NO"
|
report " installed: NO"
|
||||||
podman_log_error "verify: $name is not installed (no /var/log/packages/$name-* record)"
|
podman_log_error "verify: $name is not installed (no /var/log/packages/$name-* record)"
|
||||||
|
|||||||
@@ -173,6 +173,23 @@ sb_make_package() {
|
|||||||
find "$PKG" -type f \( -perm -u+x -o -name '*.so*' \) -exec sh -c \
|
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' _ {} \;
|
'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"
|
local pkg_file="$PRGNAM-$version-$arch-$build$tag.txz"
|
||||||
( cd "$PKG" && makepkg --linkadd y --chown y "$OUTPUT/$pkg_file" )
|
( cd "$PKG" && makepkg --linkadd y --chown y "$OUTPUT/$pkg_file" )
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -85,7 +85,31 @@ sed -i -E "s|(<!ENTITY baseURL[[:space:]]+\")[^\"]*(\">)|\1${RELEASE_BASE_URL}\2
|
|||||||
for name in "${COMPONENTS[@]}"; do
|
for name in "${COMPONENTS[@]}"; do
|
||||||
# dist/ contains files like podman-6.0.1-x86_64-1_unraidpodman.txz — find
|
# 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).
|
# 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
|
if [ -z "$txz_path" ]; then
|
||||||
echo "!! No built .txz found for component '$name' in $DIST_DIR" >&2
|
echo "!! No built .txz found for component '$name' in $DIST_DIR" >&2
|
||||||
echo "!! Did scripts/build-packages.sh run successfully for it?" >&2
|
echo "!! Did scripts/build-packages.sh run successfully for it?" >&2
|
||||||
|
|||||||
@@ -23,14 +23,28 @@ Icon="podman"
|
|||||||
* Cache-busts every static asset with its own on-disk mtime. Unraid's
|
* Cache-busts every static asset with its own on-disk mtime. Unraid's
|
||||||
* webserver sends no explicit no-cache headers for /plugins/ static
|
* webserver sends no explicit no-cache headers for /plugins/ static
|
||||||
* files, so without this, browsers can keep serving a stale app.js/
|
* files, so without this, browsers can keep serving a stale app.js/
|
||||||
* podman.css for a long time after a plugin update — verified live: a
|
* podman.css for a long time after a plugin update.
|
||||||
* bugfix to app.js's CSRF handling silently kept failing in a real
|
*
|
||||||
* browser after redeploy until this was added, even though the deployed
|
* Deliberately a hardcoded absolute path, NOT __DIR__ — Unraid's own
|
||||||
* file on disk was byte-for-byte correct.
|
* 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
|
function podman_asset_version(string $relPath): string
|
||||||
{
|
{
|
||||||
$full = __DIR__ . $relPath;
|
$full = '/usr/local/emhttp/plugins/podman' . $relPath;
|
||||||
return is_file($full) ? (string) filemtime($full) : '0';
|
return is_file($full) ? (string) filemtime($full) : '0';
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -56,7 +70,7 @@ function podman_asset_version(string $relPath): string
|
|||||||
<nav class="podman-subnav">
|
<nav class="podman-subnav">
|
||||||
<button class="active" data-panel="dashboard">Dashboard</button>
|
<button class="active" data-panel="dashboard">Dashboard</button>
|
||||||
<button data-panel="containers">Containers</button>
|
<button data-panel="containers">Containers</button>
|
||||||
<button data-panel="templates">Templates</button>
|
<button data-panel="templates">Apps</button>
|
||||||
<button data-panel="pods">Pods</button>
|
<button data-panel="pods">Pods</button>
|
||||||
<button data-panel="images">Images</button>
|
<button data-panel="images">Images</button>
|
||||||
<button data-panel="volumes">Volumes</button>
|
<button data-panel="volumes">Volumes</button>
|
||||||
@@ -73,11 +87,30 @@ function podman_asset_version(string $relPath): string
|
|||||||
<section class="podman-panel active" id="podman-panel-dashboard">
|
<section class="podman-panel active" id="podman-panel-dashboard">
|
||||||
<div class="podman-grid podman-stat-grid">
|
<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">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">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">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">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">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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -92,19 +125,28 @@ function podman_asset_version(string $relPath): string
|
|||||||
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
|
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="podman-btn podman-btn-primary" id="containers-check-updates-btn" style="margin-left:auto;">Check for Updates</button>
|
<button class="podman-btn podman-btn-primary" id="containers-check-updates-btn" style="margin-left:auto;">Check for Updates</button>
|
||||||
<button class="podman-btn podman-btn-primary" id="containers-update-all-btn">Update All</button>
|
<button class="podman-btn podman-btn-primary" id="containers-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>
|
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="podman-table-wrap">
|
<div class="podman-table-wrap">
|
||||||
<table>
|
<table id="containers-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>
|
<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>
|
<tbody id="containers-tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ============================= TEMPLATES ============================= -->
|
<!-- ============================= APPS (Store + My Templates) ============================= -->
|
||||||
<section class="podman-panel" id="podman-panel-templates"></section>
|
<section class="podman-panel" id="podman-panel-templates"></section>
|
||||||
|
|
||||||
<!-- ============================= PODS ============================= -->
|
<!-- ============================= PODS ============================= -->
|
||||||
@@ -236,6 +278,23 @@ function podman_asset_version(string $relPath): string
|
|||||||
<button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button>
|
<button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="podman-grid">
|
<div class="podman-grid">
|
||||||
|
<div class="podman-card">
|
||||||
|
<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">▶ Start Podman</button>
|
||||||
|
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="settings-service-stop-btn">■ Stop Podman</button>
|
||||||
|
<button class="podman-btn" id="settings-service-restart-btn">↻ 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">
|
||||||
<div class="podman-card-head">
|
<div class="podman-card-head">
|
||||||
<div><h2>Storage</h2><div class="sub">Where podman keeps images, containers and volumes on disk.</div></div>
|
<div><h2>Storage</h2><div class="sub">Where podman keeps images, containers and volumes on disk.</div></div>
|
||||||
@@ -247,6 +306,13 @@ function podman_asset_version(string $relPath): string
|
|||||||
<div class="hint">Cache pool or dedicated disk — never a path under /mnt/user (FUSE).</div>
|
<div class="hint">Cache pool or dedicated disk — never a path under /mnt/user (FUSE).</div>
|
||||||
</div>
|
</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">
|
<div class="podman-field-row">
|
||||||
<label for="settings-storage-size">podman.img size</label>
|
<label for="settings-storage-size">podman.img size</label>
|
||||||
<div>
|
<div>
|
||||||
@@ -278,13 +344,17 @@ function podman_asset_version(string $relPath): string
|
|||||||
<div class="podman-field-row">
|
<div class="podman-field-row">
|
||||||
<label>Autostart order</label>
|
<label>Autostart order</label>
|
||||||
<div>
|
<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">
|
<div class="podman-table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
|
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
|
||||||
<tbody id="autostart-tbody"></tbody>
|
<tbody id="autostart-tbody"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">Saved immediately on reorder/remove — no separate save step.</div>
|
<div class="hint">Saved immediately on add/reorder/remove — no separate save step.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,15 +18,29 @@
|
|||||||
* kill POST {"id": "...", "signal": "SIGKILL"}
|
* kill POST {"id": "...", "signal": "SIGKILL"}
|
||||||
* rename POST {"id": "...", "name": "..."}
|
* rename POST {"id": "...", "name": "..."}
|
||||||
* logs GET (&id=...&tail=200) -> plain text
|
* logs GET (&id=...&tail=200) -> plain text
|
||||||
|
* 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
|
* 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
|
* check_updates GET -> {"<image ref>": {"updateAvailable": bool, "error": "..."?}} for every image currently in use
|
||||||
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
|
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
|
||||||
* "staticIp": "10.1.1.222" (only meaningful with a custom/macvlan networkMode),
|
* "staticIp": "10.1.1.222" (only meaningful with a custom/macvlan networkMode),
|
||||||
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
|
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
|
||||||
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
|
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data",
|
||||||
|
* "readOnly": false}],
|
||||||
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
|
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no", "pod": "<existing-pod-name>",
|
||||||
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"],
|
* "gpuDevices": ["/dev/dri/renderD128", "/dev/dri/card0"],
|
||||||
* "privileged": false, "startAfterCreate": true}
|
* "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);
|
declare(strict_types=1);
|
||||||
@@ -57,6 +71,15 @@ switch ($action) {
|
|||||||
podman_json_response(['text' => $client->containerLogs($id, $tail)]);
|
podman_json_response(['text' => $client->containerLogs($id, $tail)]);
|
||||||
break;
|
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':
|
case 'start':
|
||||||
$body = podman_read_json_body();
|
$body = podman_read_json_body();
|
||||||
$client->startContainer(require_id($body));
|
$client->startContainer(require_id($body));
|
||||||
@@ -278,6 +301,19 @@ function build_container_spec(string $image, array $body): array
|
|||||||
$spec['name'] = $name;
|
$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 = [];
|
$env = [];
|
||||||
foreach (($body['env'] ?? []) as $row) {
|
foreach (($body['env'] ?? []) as $row) {
|
||||||
$key = trim((string) ($row['key'] ?? ''));
|
$key = trim((string) ($row['key'] ?? ''));
|
||||||
@@ -314,10 +350,22 @@ function build_container_spec(string $image, array $body): array
|
|||||||
if ($source === '' || $containerPath === '') {
|
if ($source === '' || $containerPath === '') {
|
||||||
continue;
|
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') {
|
if (($row['kind'] ?? 'named') === 'path') {
|
||||||
$mounts[] = ['destination' => $containerPath, 'type' => 'bind', 'source' => $source, 'options' => ['rbind']];
|
$options = ['rbind'];
|
||||||
|
if ($readOnly) {
|
||||||
|
$options[] = 'ro';
|
||||||
|
}
|
||||||
|
$mounts[] = ['destination' => $containerPath, 'type' => 'bind', 'source' => $source, 'options' => $options];
|
||||||
} else {
|
} else {
|
||||||
$volumes[] = ['name' => $source, 'dest' => $containerPath];
|
$volume = ['name' => $source, 'dest' => $containerPath];
|
||||||
|
if ($readOnly) {
|
||||||
|
$volume['options'] = ['ro'];
|
||||||
|
}
|
||||||
|
$volumes[] = $volume;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($mounts !== []) {
|
if ($mounts !== []) {
|
||||||
@@ -360,6 +408,21 @@ function build_container_spec(string $image, array $body): array
|
|||||||
$spec['privileged'] = true;
|
$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 = [];
|
$devices = [];
|
||||||
foreach (($body['gpuDevices'] ?? []) as $path) {
|
foreach (($body['gpuDevices'] ?? []) as $path) {
|
||||||
// Only ever pass through paths matching the exact shape gpu_list()
|
// Only ever pass through paths matching the exact shape gpu_list()
|
||||||
@@ -371,6 +434,26 @@ function build_container_spec(string $image, array $body): array
|
|||||||
$devices[] = ['path' => $path];
|
$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 !== []) {
|
if ($devices !== []) {
|
||||||
$spec['devices'] = $devices;
|
$spec['devices'] = $devices;
|
||||||
}
|
}
|
||||||
@@ -470,6 +553,14 @@ function containers_list(PodmanClient $client): array
|
|||||||
'cpuPercent' => $cpuPercent,
|
'cpuPercent' => $cpuPercent,
|
||||||
'memUsageBytes' => $memUsageBytes,
|
'memUsageBytes' => $memUsageBytes,
|
||||||
'memLimitBytes' => $memLimitBytes,
|
'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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,12 +16,18 @@
|
|||||||
* save POST {"storagePath": "...", "storageImageSizeGb": 20,
|
* save POST {"storagePath": "...", "storageImageSizeGb": 20,
|
||||||
* "enabled": true, "stopTimeoutSeconds": 10}
|
* "enabled": true, "stopTimeoutSeconds": 10}
|
||||||
* autostart_save POST {"names": ["postgres", "nextcloud", ...]}
|
* 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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require __DIR__ . '/../include/bootstrap.php';
|
require __DIR__ . '/../include/bootstrap.php';
|
||||||
|
|
||||||
|
const RC_PODMAN = '/etc/rc.d/rc.podman';
|
||||||
|
|
||||||
$action = $_GET['action'] ?? '';
|
$action = $_GET['action'] ?? '';
|
||||||
|
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
@@ -45,10 +51,77 @@ switch ($action) {
|
|||||||
podman_json_response(['status' => 'saved']);
|
podman_json_response(['status' => 'saved']);
|
||||||
break;
|
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:
|
default:
|
||||||
podman_json_error("Unknown action '{$action}'", 400);
|
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> */
|
/** @return array<string,mixed> */
|
||||||
function settings_get(PodmanConfig $config): array
|
function settings_get(PodmanConfig $config): array
|
||||||
{
|
{
|
||||||
@@ -58,7 +131,7 @@ function settings_get(PodmanConfig $config): array
|
|||||||
'enabled' => $config->enabled,
|
'enabled' => $config->enabled,
|
||||||
'stopTimeoutSeconds' => $config->stopTimeoutSeconds,
|
'stopTimeoutSeconds' => $config->stopTimeoutSeconds,
|
||||||
'autostart' => autostart_read($config),
|
'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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@
|
|||||||
* doesn't have to make (and wait on) five separate round trips.
|
* doesn't have to make (and wait on) five separate round trips.
|
||||||
*
|
*
|
||||||
* Actions (?action=...):
|
* Actions (?action=...):
|
||||||
* summary GET -> counts, storage usage, engine version, ping status
|
* 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);
|
declare(strict_types=1);
|
||||||
@@ -22,6 +26,10 @@ switch ($action) {
|
|||||||
podman_json_response(system_summary($client, $podmanConfig));
|
podman_json_response(system_summary($client, $podmanConfig));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'autostart_queue':
|
||||||
|
podman_json_response(system_autostart_queue($client, $podmanConfig));
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
podman_json_error("Unknown action '{$action}'", 400);
|
podman_json_error("Unknown action '{$action}'", 400);
|
||||||
}
|
}
|
||||||
@@ -56,6 +64,23 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
|
|||||||
$imagesSize += (int) ($img['Size'] ?? 0);
|
$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 [
|
return [
|
||||||
'reachable' => true,
|
'reachable' => true,
|
||||||
'socketPath' => $config->socketPath,
|
'socketPath' => $config->socketPath,
|
||||||
@@ -66,6 +91,7 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
|
|||||||
'containers' => [
|
'containers' => [
|
||||||
'total' => count($containers),
|
'total' => count($containers),
|
||||||
'running' => $running,
|
'running' => $running,
|
||||||
|
'stopped' => count($containers) - $running,
|
||||||
],
|
],
|
||||||
'pods' => count($pods),
|
'pods' => count($pods),
|
||||||
'images' => count($images),
|
'images' => count($images),
|
||||||
@@ -74,6 +100,170 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
|
|||||||
'storage' => [
|
'storage' => [
|
||||||
'imagesSizeBytes' => $imagesSize,
|
'imagesSizeBytes' => $imagesSize,
|
||||||
'imagesSizeFormatted' => podman_format_bytes($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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,9 +23,13 @@
|
|||||||
* Create Container form ("Use template")
|
* Create Container form ("Use template")
|
||||||
* export GET (&name=...) -> {xml: "<raw XML text>"} for download
|
* export GET (&name=...) -> {xml: "<raw XML text>"} for download
|
||||||
* save POST {"name": "...", "image": "...", "icon": "...", "category": "...",
|
* save POST {"name": "...", "image": "...", "icon": "...", "category": "...",
|
||||||
* "overview": "...", "networkMode": "...", "privileged": false,
|
* "overview": "...", "webUrl": "...", "networkMode": "...", "privileged": false,
|
||||||
* "restartPolicy": "...", "ports": [...], "volumes": [...], "env": [...]}
|
* "restartPolicy": "...", "ports": [...], "volumes": [...], "env": [...]}
|
||||||
* import POST {"xml": "<raw XML text>"} -> parses + saves as a new template
|
* 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
|
* list_local GET -> [{file, name, image, icon}, ...] from Unraid's own
|
||||||
* dockerMan template directories (real existing Docker
|
* dockerMan template directories (real existing Docker
|
||||||
* templates the user already has — see
|
* templates the user already has — see
|
||||||
@@ -34,11 +38,36 @@
|
|||||||
* import_local POST {"file": "gitea.xml"} -> imports one by filename
|
* import_local POST {"file": "gitea.xml"} -> imports one by filename
|
||||||
* (validated against the same directories list_local
|
* (validated against the same directories list_local
|
||||||
* scanned, never an arbitrary path from the client)
|
* 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": "..."}
|
* remove POST {"name": "..."}
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare(strict_types=1);
|
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';
|
require __DIR__ . '/../include/bootstrap.php';
|
||||||
|
|
||||||
$templatesDir = $podmanConfig->bootDir . '/templates';
|
$templatesDir = $podmanConfig->bootDir . '/templates';
|
||||||
@@ -78,6 +107,16 @@ switch ($action) {
|
|||||||
podman_json_response(['status' => 'imported', 'name' => $name]);
|
podman_json_response(['status' => 'imported', 'name' => $name]);
|
||||||
break;
|
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':
|
case 'list_local':
|
||||||
podman_json_response(local_dockerman_templates_list());
|
podman_json_response(local_dockerman_templates_list());
|
||||||
break;
|
break;
|
||||||
@@ -89,6 +128,16 @@ switch ($action) {
|
|||||||
podman_json_response(['status' => 'imported', 'name' => $name]);
|
podman_json_response(['status' => 'imported', 'name' => $name]);
|
||||||
break;
|
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':
|
case 'remove':
|
||||||
$body = podman_read_json_body();
|
$body = podman_read_json_body();
|
||||||
$path = require_template_path($templatesDir, (string) ($body['name'] ?? ''));
|
$path = require_template_path($templatesDir, (string) ($body['name'] ?? ''));
|
||||||
@@ -193,6 +242,12 @@ function template_read(string $path): array
|
|||||||
'kind' => 'path',
|
'kind' => 'path',
|
||||||
'source' => $value,
|
'source' => $value,
|
||||||
'containerPath' => $target !== '' ? $target : $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;
|
break;
|
||||||
case 'Variable':
|
case 'Variable':
|
||||||
@@ -208,6 +263,7 @@ function template_read(string $path): array
|
|||||||
'icon' => (string) $xml->Icon,
|
'icon' => (string) $xml->Icon,
|
||||||
'category' => (string) $xml->Category,
|
'category' => (string) $xml->Category,
|
||||||
'overview' => (string) $xml->Overview,
|
'overview' => (string) $xml->Overview,
|
||||||
|
'webUrl' => (string) $xml->WebUI,
|
||||||
'ports' => $ports,
|
'ports' => $ports,
|
||||||
'volumes' => $volumes,
|
'volumes' => $volumes,
|
||||||
'env' => $env,
|
'env' => $env,
|
||||||
@@ -245,6 +301,7 @@ function template_write(string $templatesDir, string $name, array $body): void
|
|||||||
$append('Overview', (string) ($body['overview'] ?? ''));
|
$append('Overview', (string) ($body['overview'] ?? ''));
|
||||||
$append('Category', (string) ($body['category'] ?? ''));
|
$append('Category', (string) ($body['category'] ?? ''));
|
||||||
$append('Icon', (string) ($body['icon'] ?? ''));
|
$append('Icon', (string) ($body['icon'] ?? ''));
|
||||||
|
$append('WebUI', (string) ($body['webUrl'] ?? ''));
|
||||||
|
|
||||||
foreach (($body['ports'] ?? []) as $row) {
|
foreach (($body['ports'] ?? []) as $row) {
|
||||||
$hostPort = (string) ($row['hostPort'] ?? '');
|
$hostPort = (string) ($row['hostPort'] ?? '');
|
||||||
@@ -268,7 +325,7 @@ function template_write(string $templatesDir, string $name, array $body): void
|
|||||||
$cfg = $doc->createElement('Config', $source);
|
$cfg = $doc->createElement('Config', $source);
|
||||||
$cfg->setAttribute('Name', basename($containerPath));
|
$cfg->setAttribute('Name', basename($containerPath));
|
||||||
$cfg->setAttribute('Target', $containerPath);
|
$cfg->setAttribute('Target', $containerPath);
|
||||||
$cfg->setAttribute('Mode', 'rw');
|
$cfg->setAttribute('Mode', ($row['readOnly'] ?? false) ? 'ro' : 'rw');
|
||||||
$cfg->setAttribute('Type', 'Path');
|
$cfg->setAttribute('Type', 'Path');
|
||||||
$root->appendChild($cfg);
|
$root->appendChild($cfg);
|
||||||
}
|
}
|
||||||
@@ -317,6 +374,241 @@ function template_import(string $templatesDir, string $xmlText): string
|
|||||||
return (string) $name;
|
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
|
* Unraid's own Docker Manager plugin stores every template a user has
|
||||||
* ever saved/customized under templates-user/, plus a local cache of
|
* ever saved/customized under templates-user/, plus a local cache of
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ final class PodmanConfig
|
|||||||
public string $bootDir;
|
public string $bootDir;
|
||||||
public string $autostartFile;
|
public string $autostartFile;
|
||||||
public string $autostartDelayFile;
|
public string $autostartDelayFile;
|
||||||
|
public string $foldersFile;
|
||||||
|
|
||||||
private function __construct()
|
private function __construct()
|
||||||
{
|
{
|
||||||
@@ -33,6 +34,7 @@ final class PodmanConfig
|
|||||||
$this->socketPath = '/var/run/podman/podman.sock';
|
$this->socketPath = '/var/run/podman/podman.sock';
|
||||||
$this->autostartFile = $this->bootDir . '/autostart';
|
$this->autostartFile = $this->bootDir . '/autostart';
|
||||||
$this->autostartDelayFile = $this->bootDir . '/autostart-delay';
|
$this->autostartDelayFile = $this->bootDir . '/autostart-delay';
|
||||||
|
$this->foldersFile = $this->bootDir . '/folders.json';
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function load(): self
|
public static function load(): self
|
||||||
|
|||||||
@@ -185,6 +185,41 @@ final class PodmanClient
|
|||||||
return self::demuxStream($raw);
|
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
|
// Exec — see webui/plugins/podman/ajax/exec.php for the important
|
||||||
// caveat: this implements one-shot "run a command, return its output"
|
// caveat: this implements one-shot "run a command, return its output"
|
||||||
|
|||||||
@@ -11,6 +11,37 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
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
|
function podman_format_bytes(int $bytes): string
|
||||||
{
|
{
|
||||||
if ($bytes <= 0) {
|
if ($bytes <= 0) {
|
||||||
|
|||||||
@@ -126,6 +126,114 @@ window.Podman = (function () {
|
|||||||
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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">✕</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 -----------------------------------------------------
|
// --- Modal form dialog -----------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -299,12 +407,32 @@ window.Podman = (function () {
|
|||||||
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
|
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
|
||||||
|
|
||||||
// Viewport-relative (see the "position: fixed" comment on
|
// Viewport-relative (see the "position: fixed" comment on
|
||||||
// .podman-context-menu in podman.css) — no scrollY/scrollX added.
|
// .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 rect = anchorEl.getBoundingClientRect();
|
||||||
menu.style.top = (rect.bottom + 4) + 'px';
|
const menuRect = menu.getBoundingClientRect();
|
||||||
menu.style.left = (rect.right - 180) + 'px';
|
const margin = 8;
|
||||||
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
|
|
||||||
|
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
|
// menu.children includes the separator <div>s too, so indexing into it
|
||||||
// directly (by a counter that only advances for real items) drifts by
|
// directly (by a counter that only advances for real items) drifts by
|
||||||
@@ -399,6 +527,34 @@ window.Podman = (function () {
|
|||||||
// (Dashboard, by default — see Podman.page).
|
// (Dashboard, by default — see Podman.page).
|
||||||
const initial = document.querySelector('.podman-subnav button.active');
|
const initial = document.querySelector('.podman-subnav button.active');
|
||||||
activatePanel(initial ? initial.dataset.panel : 'dashboard');
|
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);
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
@@ -414,6 +570,8 @@ window.Podman = (function () {
|
|||||||
stateChipClass: stateChipClass,
|
stateChipClass: stateChipClass,
|
||||||
loadingRow: loadingRow,
|
loadingRow: loadingRow,
|
||||||
errorRow: errorRow,
|
errorRow: errorRow,
|
||||||
|
toast: toast,
|
||||||
|
confirm: confirmModal,
|
||||||
openFormModal: openFormModal,
|
openFormModal: openFormModal,
|
||||||
openLogModal: openLogModal,
|
openLogModal: openLogModal,
|
||||||
openContextMenu: openContextMenu,
|
openContextMenu: openContextMenu,
|
||||||
|
|||||||
@@ -85,11 +85,17 @@
|
|||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
const btn = P.el('compose-action-' + action);
|
const btn = P.el('compose-action-' + action);
|
||||||
btn.disabled = true;
|
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) {
|
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();
|
return loadProjects();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
alert('podman compose ' + action + ' failed: ' + err.message);
|
modal.log('Failed: ' + err.message);
|
||||||
|
modal.done('Close');
|
||||||
}).finally(function () {
|
}).finally(function () {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
});
|
});
|
||||||
@@ -100,9 +106,10 @@
|
|||||||
const btn = P.el('compose-action-save');
|
const btn = P.el('compose-action-save');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () {
|
P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () {
|
||||||
|
P.toast('Saved ' + selected + '.', 'success');
|
||||||
return loadProjects();
|
return loadProjects();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
alert('Save failed: ' + err.message);
|
P.toast('Save failed: ' + err.message, 'error');
|
||||||
}).finally(function () {
|
}).finally(function () {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
});
|
});
|
||||||
@@ -110,15 +117,19 @@
|
|||||||
|
|
||||||
function deleteProject() {
|
function deleteProject() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return;
|
P.confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
|
||||||
const btn = P.el('compose-action-delete');
|
if (!ok) return;
|
||||||
btn.disabled = true;
|
const btn = P.el('compose-action-delete');
|
||||||
P.post('compose', 'remove', { project: selected }).then(function () {
|
btn.disabled = true;
|
||||||
selected = null;
|
const name = selected;
|
||||||
return loadProjects();
|
P.post('compose', 'remove', { project: selected }).then(function () {
|
||||||
}).catch(function (err) {
|
selected = null;
|
||||||
alert('Delete failed: ' + err.message);
|
P.toast('Deleted ' + name + '.', 'success');
|
||||||
btn.disabled = false;
|
return loadProjects();
|
||||||
|
}).catch(function (err) {
|
||||||
|
P.toast('Delete failed: ' + err.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,158 @@
|
|||||||
// badge doesn't disappear on the next auto-refresh; only re-running
|
// badge doesn't disappear on the next auto-refresh; only re-running
|
||||||
// "Check for Updates" replaces it.
|
// "Check for Updates" replaces it.
|
||||||
let imageUpdateStatus = {};
|
let imageUpdateStatus = {};
|
||||||
|
// Container folders — purely cosmetic grouping (podman itself has no
|
||||||
|
// such concept), backed by ajax/folders.php. Collapse state is
|
||||||
|
// intentionally in-memory only (not persisted): it's a per-visit UI
|
||||||
|
// convenience, not data worth a config-file round trip.
|
||||||
|
let folders = [];
|
||||||
|
let collapsedFolders = {};
|
||||||
|
|
||||||
function iconLabel(name) {
|
function iconLabel(name) {
|
||||||
return P.escapeHtml(name.slice(0, 2).toUpperCase());
|
return P.escapeHtml(name.slice(0, 2).toUpperCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The fallback text goes into a data-* attribute (plain HTML-attribute
|
||||||
|
// escaping) and is read back via .dataset in the error handler, rather
|
||||||
|
// than being concatenated into the onerror string as JS source — that
|
||||||
|
// second approach only stays safe as long as the text can never contain
|
||||||
|
// a quote, which is true for container names today (letters/digits/
|
||||||
|
// ./_/- only) but not for the free-text folder names below, so both
|
||||||
|
// use this same safer pattern rather than having two different rules
|
||||||
|
// depending on which kind of name is involved.
|
||||||
|
function iconWithFallbackHtml(iconUrl, fallbackText) {
|
||||||
|
const fallback = P.escapeHtml(fallbackText);
|
||||||
|
if (!iconUrl) {
|
||||||
|
return '<span class="ico">' + fallback + '</span>';
|
||||||
|
}
|
||||||
|
return '<span class="ico"><img src="' + P.escapeHtml(iconUrl) + '" alt="" loading="lazy" data-fallback="' + fallback + '" ' +
|
||||||
|
'onerror="this.replaceWith(document.createTextNode(this.dataset.fallback))"></span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function containerIconHtml(c) {
|
||||||
|
return iconWithFallbackHtml(c.icon, c.name.slice(0, 2).toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Folders -----------------------------------------------------------
|
||||||
|
|
||||||
|
function saveFolders() {
|
||||||
|
return P.post('folders', 'save', { folders: folders }).then(function (data) {
|
||||||
|
folders = data.folders;
|
||||||
|
}).catch(function (err) {
|
||||||
|
P.toast('Could not save folders: ' + err.message, 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNewFolderModal(containerNameToAssign) {
|
||||||
|
P.openFormModal({
|
||||||
|
title: 'New Folder',
|
||||||
|
submitLabel: 'Create',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Folder name', required: true, placeholder: 'Media' },
|
||||||
|
{ name: 'icon', label: 'Icon URL (optional)', placeholder: 'https://...' },
|
||||||
|
],
|
||||||
|
onSubmit: function (values) {
|
||||||
|
folders.push({
|
||||||
|
id: '',
|
||||||
|
name: values.name,
|
||||||
|
icon: values.icon || '',
|
||||||
|
containers: containerNameToAssign ? [containerNameToAssign] : [],
|
||||||
|
});
|
||||||
|
return saveFolders().then(renderTable);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditFolderModal(f) {
|
||||||
|
P.openFormModal({
|
||||||
|
title: 'Edit Folder',
|
||||||
|
submitLabel: 'Save',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', label: 'Folder name', required: true, placeholder: f.name },
|
||||||
|
{ name: 'icon', label: 'Icon URL (optional)', placeholder: f.icon || 'https://...' },
|
||||||
|
],
|
||||||
|
onSubmit: function (values) {
|
||||||
|
f.name = values.name;
|
||||||
|
f.icon = values.icon || '';
|
||||||
|
return saveFolders().then(renderTable);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFolder(f) {
|
||||||
|
P.confirm('Delete folder "' + f.name + '"? Its containers are not affected — they just become ungrouped.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
|
||||||
|
if (!ok) return;
|
||||||
|
folders = folders.filter(function (x) { return x.id !== f.id; });
|
||||||
|
saveFolders().then(renderTable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignToFolder(containerName, folderId) {
|
||||||
|
folders.forEach(function (f) {
|
||||||
|
const idx = f.containers.indexOf(containerName);
|
||||||
|
if (idx !== -1) f.containers.splice(idx, 1);
|
||||||
|
});
|
||||||
|
if (folderId) {
|
||||||
|
const target = folders.find(function (f) { return f.id === folderId; });
|
||||||
|
if (target) target.containers.push(containerName);
|
||||||
|
}
|
||||||
|
saveFolders().then(renderTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMoveToFolderMenu(c, anchorBtn) {
|
||||||
|
const currentFolder = folders.find(function (f) { return f.containers.indexOf(c.name) !== -1; });
|
||||||
|
const items = folders.map(function (f) {
|
||||||
|
return {
|
||||||
|
label: (f.id === (currentFolder && currentFolder.id) ? '✓ ' : '') + f.name,
|
||||||
|
onClick: function () { assignToFolder(c.name, f.id); },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (items.length) items.push('separator');
|
||||||
|
if (currentFolder) {
|
||||||
|
items.push({ label: 'Remove from folder', onClick: function () { assignToFolder(c.name, null); } });
|
||||||
|
}
|
||||||
|
items.push({ label: '+ New folder…', onClick: function () { openNewFolderModal(c.name); } });
|
||||||
|
P.openContextMenu(anchorBtn, items);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFolderMenu(f, anchorBtn) {
|
||||||
|
P.openContextMenu(anchorBtn, [
|
||||||
|
{ label: 'Rename / Edit Icon', onClick: function () { openEditFolderModal(f); } },
|
||||||
|
{ label: 'Delete Folder', danger: true, onClick: function () { deleteFolder(f); } },
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches Unraid's own Docker page folder rows: the header always
|
||||||
|
// shows a compact icon+name+status chip per member — collapsed or
|
||||||
|
// expanded — so folding a group away doesn't hide its state entirely.
|
||||||
|
// Expand/collapse only controls whether the FULL per-container detail
|
||||||
|
// rows also render underneath (see renderTable()).
|
||||||
|
function folderMemberChipHtml(c) {
|
||||||
|
return '<button type="button" class="podman-folder-member" data-action="menu" data-id="' + P.escapeHtml(c.id) + '">' +
|
||||||
|
iconWithFallbackHtml(c.icon, c.name.slice(0, 2).toUpperCase()) +
|
||||||
|
'<span class="name">' + P.escapeHtml(c.name) + '</span>' +
|
||||||
|
'<span class="dot ' + (c.state === 'running' ? 'good' : 'bad') + '"></span>' +
|
||||||
|
'<span class="state">' + P.escapeHtml(c.state) + '</span>' +
|
||||||
|
'</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderHeaderHtml(f, members) {
|
||||||
|
const collapsed = !!collapsedFolders[f.id];
|
||||||
|
const runningCount = members.filter(function (c) { return c.state === 'running'; }).length;
|
||||||
|
return '<tr class="podman-folder-row" data-folder-id="' + P.escapeHtml(f.id) + '">' +
|
||||||
|
'<td colspan="7">' +
|
||||||
|
'<div class="podman-folder-head">' +
|
||||||
|
'<button type="button" class="podman-folder-toggle" data-action="toggle-folder">' +
|
||||||
|
'<span class="chevron">' + (collapsed ? '▸' : '▾') + '</span>' +
|
||||||
|
iconWithFallbackHtml(f.icon, f.name.slice(0, 2).toUpperCase()) +
|
||||||
|
'<span class="name">' + P.escapeHtml(f.name) + '</span>' +
|
||||||
|
'<span class="count">' + runningCount + '/' + members.length + ' running</span>' +
|
||||||
|
'</button>' +
|
||||||
|
'<div class="podman-folder-members">' + members.map(folderMemberChipHtml).join('') + '</div>' +
|
||||||
|
'<button type="button" class="podman-btn podman-btn-icon" data-action="folder-menu" title="Folder options">⋮</button>' +
|
||||||
|
'</div></td></tr>';
|
||||||
|
}
|
||||||
|
|
||||||
function hasUpdate(c) {
|
function hasUpdate(c) {
|
||||||
const status = imageUpdateStatus[c.image];
|
const status = imageUpdateStatus[c.image];
|
||||||
return !!(status && status.updateAvailable);
|
return !!(status && status.updateAvailable);
|
||||||
@@ -34,12 +181,18 @@
|
|||||||
const updateBadge = hasUpdate(c)
|
const updateBadge = hasUpdate(c)
|
||||||
? ' <span class="podman-badge-update" title="A newer image is available">↑ Update</span>'
|
? ' <span class="podman-badge-update" title="A newer image is available">↑ Update</span>'
|
||||||
: '';
|
: '';
|
||||||
|
// A plain <a>, not a data-action button — the tbody's click handler
|
||||||
|
// only ever looks for button[data-action], so this just navigates
|
||||||
|
// normally with no extra wiring.
|
||||||
|
const webuiLink = c.webUrl
|
||||||
|
? ' <a class="podman-webui-link" href="' + P.escapeHtml(c.webUrl) + '" target="_blank" rel="noopener noreferrer" title="Open WebUI">URL</a>'
|
||||||
|
: '';
|
||||||
|
|
||||||
return '' +
|
return '' +
|
||||||
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
|
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
|
||||||
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
|
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
|
||||||
'<td><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
|
'<td><div class="podman-name-cell"><button type="button" class="podman-row-name podman-row-name-btn" data-action="menu">' +
|
||||||
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button>' + updateBadge + '</td>' +
|
containerIconHtml(c) + '<span class="text">' + P.escapeHtml(c.name) + '</span></button>' + webuiLink + updateBadge + '</div></td>' +
|
||||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
|
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
|
||||||
'<td>' + cpuMem + '</td>' +
|
'<td>' + cpuMem + '</td>' +
|
||||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '—') + '</td>' +
|
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '—') + '</td>' +
|
||||||
@@ -70,12 +223,24 @@
|
|||||||
|
|
||||||
function openRowMenu(c, anchorBtn) {
|
function openRowMenu(c, anchorBtn) {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
items.push({ label: 'Details', onClick: function () { openDetailModal(c); } });
|
||||||
if (c.state === 'running') {
|
if (c.state === 'running') {
|
||||||
items.push({ label: 'Pause', onClick: function () { handleAction(c.id, 'pause'); } });
|
items.push({ label: 'Pause', onClick: function () { handleAction(c.id, 'pause'); } });
|
||||||
items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
|
items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
|
||||||
}
|
}
|
||||||
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
|
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
|
||||||
items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } });
|
items.push({ label: 'Edit', onClick: function () { openEditContainerModal(c); } });
|
||||||
|
// Once a container is already grouped, "Move to Folder" (which reads
|
||||||
|
// as "add to a folder") is redundant and ambiguous — the one action
|
||||||
|
// that actually makes sense from here is taking it back out. Moving
|
||||||
|
// it to a *different* folder still works, just via ungrouping first;
|
||||||
|
// that's a deliberately rarer path than "add" or "remove".
|
||||||
|
const currentFolder = folders.find(function (f) { return f.containers.indexOf(c.name) !== -1; });
|
||||||
|
if (currentFolder) {
|
||||||
|
items.push({ label: 'Remove from Folder', onClick: function () { assignToFolder(c.name, null); } });
|
||||||
|
} else {
|
||||||
|
items.push({ label: 'Move to Folder', onClick: function () { openMoveToFolderMenu(c, anchorBtn); } });
|
||||||
|
}
|
||||||
items.push('separator');
|
items.push('separator');
|
||||||
items.push({
|
items.push({
|
||||||
label: 'Remove',
|
label: 'Remove',
|
||||||
@@ -129,9 +294,9 @@
|
|||||||
|
|
||||||
const volumes = (d.Mounts || []).reduce(function (list, m) {
|
const volumes = (d.Mounts || []).reduce(function (list, m) {
|
||||||
if (m.Type === 'bind') {
|
if (m.Type === 'bind') {
|
||||||
list.push({ kind: 'path', source: m.Source, containerPath: m.Destination });
|
list.push({ kind: 'path', source: m.Source, containerPath: m.Destination, readOnly: m.RW === false });
|
||||||
} else if (m.Type === 'volume') {
|
} else if (m.Type === 'volume') {
|
||||||
list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination });
|
list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination, readOnly: m.RW === false });
|
||||||
}
|
}
|
||||||
return list;
|
return list;
|
||||||
}, []);
|
}, []);
|
||||||
@@ -153,26 +318,51 @@
|
|||||||
.map(function (dev) { return dev.PathOnHost; })
|
.map(function (dev) { return dev.PathOnHost; })
|
||||||
.filter(function (path) { return /^\/dev\/dri\/(card|renderD)\d+$/.test(path); });
|
.filter(function (path) { return /^\/dev\/dri\/(card|renderD)\d+$/.test(path); });
|
||||||
|
|
||||||
// Only meaningful on a macvlan network (see updateNetworkFieldsVisibility()
|
// Anything under /dev/ that ISN'T one of the GPU paths above — the
|
||||||
// in openCreateContainerModal) — the container's actual address on
|
// plugin's own generic device-passthrough field (see build_container_
|
||||||
// that network, so editing one doesn't blank out an IP it was
|
// spec()'s comment on why this is host-path-equals-container-path only).
|
||||||
// deliberately given.
|
const devices = (hostCfg.Devices || [])
|
||||||
const netName = hostCfg.NetworkMode;
|
.map(function (dev) { return dev.PathOnHost; })
|
||||||
const netInfo = d.NetworkSettings && d.NetworkSettings.Networks && d.NetworkSettings.Networks[netName];
|
.filter(function (path) { return path && !/^\/dev\/dri\/(card|renderD)\d+$/.test(path); })
|
||||||
|
.map(function (path) { return { path: path }; });
|
||||||
|
|
||||||
|
// HostConfig.NetworkMode is only reliable for "host"/"none" — a
|
||||||
|
// container attached to a CUSTOM network (e.g. a macvlan like "Lan")
|
||||||
|
// still reports NetworkMode as the generic "bridge", regardless of
|
||||||
|
// what it's actually on (verified live: a running container on "Lan"
|
||||||
|
// showed NetworkMode:"bridge" while NetworkSettings.Networks only had
|
||||||
|
// a "Lan" entry, not a "bridge" one at all). The real network's name
|
||||||
|
// is that one NetworkSettings.Networks key instead — except when it's
|
||||||
|
// podman's own literal default bridge network, named "podman", which
|
||||||
|
// maps back to our own "bridge" nsmode option. Getting this wrong
|
||||||
|
// silently reset the Network dropdown to Bridge on every edit and
|
||||||
|
// blanked out the Static IP field, even for a container that had one.
|
||||||
|
const networksMap = (d.NetworkSettings && d.NetworkSettings.Networks) || {};
|
||||||
|
const networkKeys = Object.keys(networksMap);
|
||||||
|
let networkMode = hostCfg.NetworkMode || 'bridge';
|
||||||
|
let netInfo = null;
|
||||||
|
if (networkMode === 'bridge' && networkKeys.length === 1 && networkKeys[0] !== 'podman') {
|
||||||
|
networkMode = networkKeys[0];
|
||||||
|
netInfo = networksMap[networkMode];
|
||||||
|
}
|
||||||
const staticIp = netInfo && netInfo.IPAddress ? netInfo.IPAddress : '';
|
const staticIp = netInfo && netInfo.IPAddress ? netInfo.IPAddress : '';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: (d.Name || c.name || '').replace(/^\//, ''),
|
name: (d.Name || c.name || '').replace(/^\//, ''),
|
||||||
image: cfg.Image || c.image,
|
image: cfg.Image || c.image,
|
||||||
networkMode: hostCfg.NetworkMode || 'bridge',
|
networkMode: networkMode,
|
||||||
staticIp: staticIp,
|
staticIp: staticIp,
|
||||||
pod: c.podName || '',
|
pod: c.podName || '',
|
||||||
privileged: !!hostCfg.Privileged,
|
privileged: !!hostCfg.Privileged,
|
||||||
restartPolicy: (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || 'no',
|
restartPolicy: (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || 'no',
|
||||||
|
user: cfg.User || '',
|
||||||
ports: ports,
|
ports: ports,
|
||||||
volumes: volumes,
|
volumes: volumes,
|
||||||
env: env,
|
env: env,
|
||||||
gpuDevices: gpuDevices,
|
gpuDevices: gpuDevices,
|
||||||
|
devices: devices,
|
||||||
|
icon: c.icon || '',
|
||||||
|
webUrl: c.webUrl || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,7 +370,7 @@
|
|||||||
P.get('containers', 'inspect', { id: c.id }).then(function (d) {
|
P.get('containers', 'inspect', { id: c.id }).then(function (d) {
|
||||||
openCreateContainerModal(inspectToPrefill(c, d), { id: c.id });
|
openCreateContainerModal(inspectToPrefill(c, d), { id: c.id });
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
alert('Could not load container config: ' + err.message);
|
P.toast('Could not load container config: ' + err.message, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +415,11 @@
|
|||||||
env: prefill.env,
|
env: prefill.env,
|
||||||
restartPolicy: prefill.restartPolicy,
|
restartPolicy: prefill.restartPolicy,
|
||||||
gpuDevices: prefill.gpuDevices,
|
gpuDevices: prefill.gpuDevices,
|
||||||
|
devices: prefill.devices,
|
||||||
privileged: prefill.privileged,
|
privileged: prefill.privileged,
|
||||||
|
user: prefill.user,
|
||||||
|
icon: prefill.icon,
|
||||||
|
webuiUrl: prefill.webUrl,
|
||||||
startAfterCreate: true,
|
startAfterCreate: true,
|
||||||
});
|
});
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
@@ -298,6 +492,25 @@
|
|||||||
modal.log(failures.length
|
modal.log(failures.length
|
||||||
? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.'
|
? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.'
|
||||||
: 'All ' + targets.length + ' updated.');
|
: 'All ' + targets.length + ' updated.');
|
||||||
|
|
||||||
|
// The image(s) each updated container used before are now
|
||||||
|
// superseded (recreate() points it at the freshly-pulled one) and
|
||||||
|
// have zero containers referencing them — the same "unused"
|
||||||
|
// definition images.js's own Prune button uses. Only worth doing
|
||||||
|
// if at least one container actually updated; skipped entirely if
|
||||||
|
// every update failed, since nothing changed to clean up.
|
||||||
|
if (failures.length < targets.length) {
|
||||||
|
modal.log('');
|
||||||
|
modal.log('Removing old, now-unused images…');
|
||||||
|
return P.post('images', 'prune').then(function (result) {
|
||||||
|
modal.log(result.removedCount
|
||||||
|
? 'Removed ' + result.removedCount + ' old image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.'
|
||||||
|
: 'No unused images left to remove.');
|
||||||
|
}).catch(function (err) {
|
||||||
|
modal.log('Image cleanup failed: ' + err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).then(function () {
|
||||||
modal.done();
|
modal.done();
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
return load();
|
return load();
|
||||||
@@ -311,11 +524,15 @@
|
|||||||
|
|
||||||
// --- Detail view -----------------------------------------------------------
|
// --- Detail view -----------------------------------------------------------
|
||||||
//
|
//
|
||||||
// Fed entirely by the existing inspect action (raw libpod inspect JSON) —
|
// Most tabs are sliced straight from the one inspect payload already
|
||||||
// no new backend endpoint needed, just slicing that one payload into
|
// fetched when the modal opens (Config.Env, Config.Labels, Mounts,
|
||||||
// tabs. Field names below (Config.Env, Config.Labels, Mounts,
|
// NetworkSettings.Networks, HostConfig.RestartPolicy, State.Health.Log,
|
||||||
// NetworkSettings.Networks, HostConfig.RestartPolicy, ...) were checked
|
// ... all checked live against a real inspect response, not assumed
|
||||||
// live against a real inspect response, not assumed from docs.
|
// from docs). A few need more: Logs and Events fetch lazily when their
|
||||||
|
// tab is first opened (a render() may return a Promise<string> instead
|
||||||
|
// of a string — see showTab() below), and Console opens a real
|
||||||
|
// ttyd/podman-exec session instead of just rendering (see
|
||||||
|
// renderConsoleTab/wireConsoleTab).
|
||||||
|
|
||||||
function kvTable(rows) {
|
function kvTable(rows) {
|
||||||
if (rows.length === 0) return '<div class="podman-detail-empty">None.</div>';
|
if (rows.length === 0) return '<div class="podman-detail-empty">None.</div>';
|
||||||
@@ -387,12 +604,165 @@
|
|||||||
return '<div class="podman-detail-json">' + P.escapeHtml(JSON.stringify(d, null, 2)) + '</div>';
|
return '<div class="podman-detail-json">' + P.escapeHtml(JSON.stringify(d, null, 2)) + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live stats (cpuPercent/memUsageBytes/memLimitBytes) come from the row
|
||||||
|
// data already fetched for the table (containers.php's list action) —
|
||||||
|
// no extra request needed, and it's a one-shot snapshot either way
|
||||||
|
// (this modal doesn't auto-refresh internally). Configured limits come
|
||||||
|
// from the inspect payload's HostConfig; a 0 in any of these fields is
|
||||||
|
// podman's own way of saying "unlimited", not a real zero.
|
||||||
|
function renderResourcesTab(d, c) {
|
||||||
|
const hostCfg = d.HostConfig || {};
|
||||||
|
const rows = [];
|
||||||
|
if (c.state === 'running') {
|
||||||
|
rows.push(['CPU usage', c.cpuPercent != null ? c.cpuPercent.toFixed(1) + '%' : '—']);
|
||||||
|
// memLimitBytes is podman's cgroup-reported limit, which is the
|
||||||
|
// HOST's total memory when no real limit is configured (found
|
||||||
|
// live: showed "110 MB / 38.6 GB" for a container with no memory
|
||||||
|
// limit set, right above a "Memory limit: unlimited" row that
|
||||||
|
// contradicted it) — only show a "/ limit" suffix when HostConfig
|
||||||
|
// says a limit was actually configured.
|
||||||
|
rows.push(['Memory usage', c.memUsageBytes != null
|
||||||
|
? P.formatBytes(c.memUsageBytes) + (hostCfg.Memory > 0 ? ' / ' + P.formatBytes(c.memLimitBytes) : '')
|
||||||
|
: '—']);
|
||||||
|
}
|
||||||
|
const nanoCpus = hostCfg.NanoCpus || 0;
|
||||||
|
const cpuQuota = hostCfg.CpuQuota || 0;
|
||||||
|
const cpuPeriod = hostCfg.CpuPeriod || 0;
|
||||||
|
rows.push(
|
||||||
|
['Memory limit', hostCfg.Memory > 0 ? P.formatBytes(hostCfg.Memory) : 'unlimited'],
|
||||||
|
['Swap limit', hostCfg.MemorySwap > 0 ? P.formatBytes(hostCfg.MemorySwap) : 'unlimited'],
|
||||||
|
['CPU limit', nanoCpus > 0 ? (nanoCpus / 1e9) + ' core(s)' : (cpuQuota > 0 && cpuPeriod > 0 ? (cpuQuota / cpuPeriod).toFixed(2) + ' core(s)' : 'unlimited')],
|
||||||
|
['CPU shares', hostCfg.CpuShares > 0 ? String(hostCfg.CpuShares) : 'default (1024)'],
|
||||||
|
['PIDs limit', hostCfg.PidsLimit > 0 ? String(hostCfg.PidsLimit) : 'unlimited'],
|
||||||
|
['Block I/O weight', hostCfg.BlkioWeight > 0 ? String(hostCfg.BlkioWeight) : 'default']
|
||||||
|
);
|
||||||
|
return kvTable(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogsTab(d, c) {
|
||||||
|
return P.get('containers', 'logs', { id: c.id, tail: 300 }).then(function (data) {
|
||||||
|
const lines = (data.text || '').split('\n').filter(function (l) { return l.length > 0; });
|
||||||
|
if (!lines.length) return '<div class="podman-detail-empty">No log output.</div>';
|
||||||
|
return '<div class="podman-log-pane">' + lines.map(function (line) {
|
||||||
|
const cls = /\berror\b/i.test(line) ? 'lvl-error' : (/\bwarn(ing)?\b/i.test(line) ? 'lvl-warn' : '');
|
||||||
|
return '<div class="l' + (cls ? ' ' + cls : '') + '">' + P.escapeHtml(line) + '</div>';
|
||||||
|
}).join('') + '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded, one-shot history (last 7 days) via PodmanClient::containerEvents()
|
||||||
|
// — NOT a live stream, see that method's own doc comment. Good enough for
|
||||||
|
// "what happened to this container recently" without the persistent-
|
||||||
|
// connection infrastructure a live feed would need.
|
||||||
|
function renderEventsTab(d, c) {
|
||||||
|
return P.get('containers', 'events', { id: c.id }).then(function (events) {
|
||||||
|
if (!events.length) return '<div class="podman-detail-empty">No events in the last 7 days.</div>';
|
||||||
|
const rows = events.slice().reverse();
|
||||||
|
return '<table class="podman-detail-table">' +
|
||||||
|
'<tr><td>Time</td><td>Event</td></tr>' +
|
||||||
|
rows.map(function (e) {
|
||||||
|
const when = new Date(e.time * 1000).toLocaleString();
|
||||||
|
return '<tr><td class="mono" title="' + P.escapeHtml(when) + '">' + P.escapeHtml(P.formatRelativeTime(e.time)) + '</td>' +
|
||||||
|
'<td>' + P.escapeHtml(e.Action || e.status || '') + '</td></tr>';
|
||||||
|
}).join('') + '</table>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHealthTab(d) {
|
||||||
|
const health = (d.State && d.State.Health) || null;
|
||||||
|
if (!health || !Array.isArray(health.Log) || !health.Log.length) {
|
||||||
|
return '<div class="podman-detail-empty">No healthcheck configured for this container.</div>';
|
||||||
|
}
|
||||||
|
const rows = health.Log.slice().reverse();
|
||||||
|
return '<table class="podman-detail-table">' +
|
||||||
|
'<tr><td>Time</td><td>Result</td><td>Output</td></tr>' +
|
||||||
|
rows.map(function (entry) {
|
||||||
|
const ok = entry.ExitCode === 0;
|
||||||
|
return '<tr><td class="mono">' + P.escapeHtml(entry.Start || '') + '</td>' +
|
||||||
|
'<td><span class="podman-chip ' + (ok ? 'podman-chip-good' : 'podman-chip-bad') + '"><span class="d"></span>' +
|
||||||
|
(ok ? 'ok' : 'exit ' + entry.ExitCode) + '</span></td>' +
|
||||||
|
'<td class="mono">' + P.escapeHtml((entry.Output || '').trim().slice(0, 300)) + '</td></tr>';
|
||||||
|
}).join('') + '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Console is the one tab that isn't a static render — it opens a real
|
||||||
|
// ttyd/podman-exec session (same mechanism as the standalone Terminal
|
||||||
|
// panel, see terminal.js's own header comment for why this can't be a
|
||||||
|
// true persistent PTY over plain HTTP). Returns a cleanup function the
|
||||||
|
// modal calls when leaving this tab or closing altogether, so a session
|
||||||
|
// opened just to peek at a container's console doesn't leak an orphaned
|
||||||
|
// ttyd process the way a stale one did before terminal.js's own fix
|
||||||
|
// earlier this project (see openLiveTerminal()'s closeCurrent()).
|
||||||
|
function renderConsoleTab(d, c) {
|
||||||
|
if (c.state !== 'running') {
|
||||||
|
return '<div class="podman-detail-empty">Container must be running to open a console.</div>';
|
||||||
|
}
|
||||||
|
return '' +
|
||||||
|
'<div class="podman-term-launcher">' +
|
||||||
|
'<label>Shell <select class="podman-term-select" id="detail-term-shell"><option value="bash" selected>bash</option><option value="sh">sh</option></select></label>' +
|
||||||
|
'<button type="button" class="podman-btn podman-btn-primary" id="detail-term-open-btn">▶ Open Console</button>' +
|
||||||
|
'<button type="button" class="podman-btn podman-btn-ghost podman-btn-danger" id="detail-term-disconnect-btn" disabled>■ Disconnect</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div id="detail-term-frame-wrap"><p class="podman-empty-note">Pick a shell and click "Open Console".</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireConsoleTab(body, d, c) {
|
||||||
|
const openBtn = body.querySelector('#detail-term-open-btn');
|
||||||
|
if (!openBtn) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const disconnectBtn = body.querySelector('#detail-term-disconnect-btn');
|
||||||
|
let sessionOpen = false;
|
||||||
|
|
||||||
|
function closeSession() {
|
||||||
|
if (!sessionOpen) return Promise.resolve();
|
||||||
|
sessionOpen = false;
|
||||||
|
return P.post('exec', 'close', { name: c.name }).catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
openBtn.addEventListener('click', function () {
|
||||||
|
const shell = body.querySelector('#detail-term-shell').value;
|
||||||
|
const wrap = body.querySelector('#detail-term-frame-wrap');
|
||||||
|
wrap.innerHTML = '<p class="podman-empty-note">Opening console…</p>';
|
||||||
|
openBtn.disabled = true;
|
||||||
|
closeSession().then(function () {
|
||||||
|
return P.post('exec', 'open', { name: c.name, shell: shell });
|
||||||
|
}).then(function (data) {
|
||||||
|
sessionOpen = true;
|
||||||
|
disconnectBtn.disabled = false;
|
||||||
|
// Same brief delay openLiveTerminal() uses — ttyd needs a moment
|
||||||
|
// to bind its socket 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) {
|
||||||
|
wrap.innerHTML = '<p class="podman-empty-note">Could not open console: ' + P.escapeHtml(err.message) + '</p>';
|
||||||
|
}).finally(function () {
|
||||||
|
openBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
disconnectBtn.addEventListener('click', function () {
|
||||||
|
disconnectBtn.disabled = true;
|
||||||
|
closeSession().finally(function () {
|
||||||
|
body.querySelector('#detail-term-frame-wrap').innerHTML = '<p class="podman-empty-note">Disconnected.</p>';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return closeSession;
|
||||||
|
}
|
||||||
|
|
||||||
const DETAIL_TABS = [
|
const DETAIL_TABS = [
|
||||||
{ id: 'overview', label: 'Overview', render: renderOverviewTab },
|
{ id: 'overview', label: 'Overview', render: renderOverviewTab },
|
||||||
|
{ id: 'resources', label: 'Resources', render: renderResourcesTab },
|
||||||
|
{ id: 'logs', label: 'Logs', render: renderLogsTab },
|
||||||
|
{ id: 'console', label: 'Console', render: renderConsoleTab, wire: wireConsoleTab },
|
||||||
|
{ id: 'networks', label: 'Networks', render: renderNetworksTab },
|
||||||
|
{ id: 'mounts', label: 'Mounts', render: renderMountsTab },
|
||||||
{ id: 'env', label: 'Environment', render: renderEnvTab },
|
{ id: 'env', label: 'Environment', render: renderEnvTab },
|
||||||
{ id: 'labels', label: 'Labels', render: renderLabelsTab },
|
{ id: 'labels', label: 'Labels', render: renderLabelsTab },
|
||||||
{ id: 'mounts', label: 'Mounts', render: renderMountsTab },
|
{ id: 'events', label: 'Events', render: renderEventsTab },
|
||||||
{ id: 'networks', label: 'Networks', render: renderNetworksTab },
|
{ id: 'health', label: 'Healthcheck', render: renderHealthTab },
|
||||||
{ id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab },
|
{ id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -410,17 +780,46 @@
|
|||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||||
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', function () { backdrop.remove(); });
|
|
||||||
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) backdrop.remove(); });
|
// Any tab can leave behind something that needs cleanup on the way
|
||||||
|
// out (currently only Console's ttyd session) — tracked here so
|
||||||
|
// every way of leaving the modal (switching tabs, Close button,
|
||||||
|
// backdrop click, Escape) goes through the same cleanup path.
|
||||||
|
let activeCleanup = null;
|
||||||
|
function closeModal() {
|
||||||
|
const cleanup = activeCleanup;
|
||||||
|
activeCleanup = null;
|
||||||
|
Promise.resolve(cleanup ? cleanup() : null).finally(function () { backdrop.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', closeModal);
|
||||||
|
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) closeModal(); });
|
||||||
document.addEventListener('keydown', function onKey(e) {
|
document.addEventListener('keydown', function onKey(e) {
|
||||||
if (e.key === 'Escape') { backdrop.remove(); document.removeEventListener('keydown', onKey); }
|
if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', onKey); }
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = backdrop.querySelector('.podman-detail-body');
|
const body = backdrop.querySelector('.podman-detail-body');
|
||||||
P.get('containers', 'inspect', { id: c.id }).then(function (data) {
|
P.get('containers', 'inspect', { id: c.id }).then(function (data) {
|
||||||
function showTab(tabId) {
|
function showTab(tabId) {
|
||||||
|
if (activeCleanup) {
|
||||||
|
const cleanup = activeCleanup;
|
||||||
|
activeCleanup = null;
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
const tab = DETAIL_TABS.find(function (t) { return t.id === tabId; });
|
const tab = DETAIL_TABS.find(function (t) { return t.id === tabId; });
|
||||||
body.innerHTML = tab.render(data);
|
const result = tab.render(data, c);
|
||||||
|
if (result && typeof result.then === 'function') {
|
||||||
|
body.innerHTML = '<div class="podman-loading">Loading…</div>';
|
||||||
|
result.then(function (html) {
|
||||||
|
body.innerHTML = html;
|
||||||
|
if (tab.wire) activeCleanup = tab.wire(body, data, c) || null;
|
||||||
|
}).catch(function (err) {
|
||||||
|
body.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
body.innerHTML = result;
|
||||||
|
if (tab.wire) activeCleanup = tab.wire(body, data, c) || null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
backdrop.querySelectorAll('[data-tab]').forEach(function (btn) {
|
backdrop.querySelectorAll('[data-tab]').forEach(function (btn) {
|
||||||
btn.addEventListener('click', function () {
|
btn.addEventListener('click', function () {
|
||||||
@@ -447,9 +846,40 @@
|
|||||||
function renderTable() {
|
function renderTable() {
|
||||||
const tbody = P.el('containers-tbody');
|
const tbody = P.el('containers-tbody');
|
||||||
const visible = applyFilters();
|
const visible = applyFilters();
|
||||||
tbody.innerHTML = visible.length
|
if (!visible.length) {
|
||||||
? visible.map(rowHtml).join('')
|
tbody.innerHTML = '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
|
||||||
: '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No folders defined at all: render the flat list exactly as before —
|
||||||
|
// nobody using this feature for the first time sees any change.
|
||||||
|
if (!folders.length) {
|
||||||
|
tbody.innerHTML = visible.map(rowHtml).join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleByName = {};
|
||||||
|
visible.forEach(function (c) { visibleByName[c.name] = c; });
|
||||||
|
|
||||||
|
const assigned = {};
|
||||||
|
let html = '';
|
||||||
|
folders.forEach(function (f) {
|
||||||
|
const members = f.containers.map(function (name) { return visibleByName[name]; }).filter(Boolean);
|
||||||
|
// Hide a folder only when the current filter/search hid every one of
|
||||||
|
// its actual members — a genuinely empty folder (nothing assigned
|
||||||
|
// yet, right after creating it) still needs to show up so there's
|
||||||
|
// somewhere to drag/assign a container into.
|
||||||
|
if (!members.length && f.containers.length > 0) return;
|
||||||
|
members.forEach(function (c) { assigned[c.name] = true; });
|
||||||
|
html += folderHeaderHtml(f, members);
|
||||||
|
if (!collapsedFolders[f.id]) {
|
||||||
|
html += members.map(rowHtml).join('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const ungrouped = visible.filter(function (c) { return !assigned[c.name]; });
|
||||||
|
html += ungrouped.map(rowHtml).join('');
|
||||||
|
|
||||||
|
tbody.innerHTML = html || '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCounts() {
|
function renderCounts() {
|
||||||
@@ -461,7 +891,13 @@
|
|||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
const tbody = P.el('containers-tbody');
|
const tbody = P.el('containers-tbody');
|
||||||
tbody.innerHTML = P.loadingRow(7);
|
// Only show the loading placeholder on the very first load — once
|
||||||
|
// rows are already on screen, auto-refresh (every ~2s) and manual
|
||||||
|
// Refresh clicks should swap data in place, not flash back to a
|
||||||
|
// spinner and lose the user's place every cycle.
|
||||||
|
if (!allContainers.length) {
|
||||||
|
tbody.innerHTML = P.loadingRow(7);
|
||||||
|
}
|
||||||
return P.get('containers', 'list').then(function (data) {
|
return P.get('containers', 'list').then(function (data) {
|
||||||
allContainers = data;
|
allContainers = data;
|
||||||
renderCounts();
|
renderCounts();
|
||||||
@@ -497,6 +933,16 @@
|
|||||||
'<input type="text" class="mono" data-field="source" placeholder="my-volume or /mnt/cache/...">' +
|
'<input type="text" class="mono" data-field="source" placeholder="my-volume or /mnt/cache/...">' +
|
||||||
'<span>→</span>' +
|
'<span>→</span>' +
|
||||||
'<input type="text" class="mono" data-field="containerPath" placeholder="/data">' +
|
'<input type="text" class="mono" data-field="containerPath" placeholder="/data">' +
|
||||||
|
'<label class="podman-row-checkbox-label" title="Mount read-only">' +
|
||||||
|
'<input type="checkbox" data-field="readOnly"> RO</label>' +
|
||||||
|
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deviceRowHtml() {
|
||||||
|
return '' +
|
||||||
|
'<div class="podman-row-group-item">' +
|
||||||
|
'<input type="text" class="mono" data-field="path" placeholder="/dev/ttyACM0">' +
|
||||||
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
@@ -518,7 +964,12 @@
|
|||||||
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
|
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
|
||||||
if (values) {
|
if (values) {
|
||||||
row.querySelectorAll('[data-field]').forEach(function (input) {
|
row.querySelectorAll('[data-field]').forEach(function (input) {
|
||||||
if (values[input.dataset.field] !== undefined) input.value = values[input.dataset.field];
|
if (values[input.dataset.field] === undefined) return;
|
||||||
|
if (input.type === 'checkbox') {
|
||||||
|
input.checked = !!values[input.dataset.field];
|
||||||
|
} else {
|
||||||
|
input.value = values[input.dataset.field];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
groupEl.appendChild(row);
|
groupEl.appendChild(row);
|
||||||
@@ -528,7 +979,7 @@
|
|||||||
return Array.from(groupEl.children).map(function (row) {
|
return Array.from(groupEl.children).map(function (row) {
|
||||||
const values = {};
|
const values = {};
|
||||||
row.querySelectorAll('[data-field]').forEach(function (input) {
|
row.querySelectorAll('[data-field]').forEach(function (input) {
|
||||||
values[input.dataset.field] = input.value.trim();
|
values[input.dataset.field] = input.type === 'checkbox' ? input.checked : input.value.trim();
|
||||||
});
|
});
|
||||||
return values;
|
return values;
|
||||||
});
|
});
|
||||||
@@ -560,6 +1011,12 @@
|
|||||||
'<div class="podman-modal-field"><label>Name (optional)</label>' +
|
'<div class="podman-modal-field"><label>Name (optional)</label>' +
|
||||||
'<input type="text" id="cc-name" placeholder="my-container">' +
|
'<input type="text" id="cc-name" placeholder="my-container">' +
|
||||||
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
|
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
|
||||||
|
'<div class="podman-modal-field"><label>Icon URL (optional)</label>' +
|
||||||
|
'<input type="text" id="cc-icon" placeholder="https://...">' +
|
||||||
|
'<div class="hint">Shown in the Containers table and Folders. Filled in automatically from a template.</div></div>' +
|
||||||
|
'<div class="podman-modal-field"><label>WebUI URL (optional)</label>' +
|
||||||
|
'<input type="text" id="cc-weburl" placeholder="http://10.1.1.1:8080/">' +
|
||||||
|
'<div class="hint">Adds a small open-in-new-tab button next to the name in the Containers table.</div></div>' +
|
||||||
'<div class="podman-modal-field"><label>Network</label>' +
|
'<div class="podman-modal-field"><label>Network</label>' +
|
||||||
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
|
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
|
||||||
'<option value="host">Host</option><option value="none">None</option></select></div>' +
|
'<option value="host">Host</option><option value="none">None</option></select></div>' +
|
||||||
@@ -582,8 +1039,15 @@
|
|||||||
'<div class="podman-modal-field"><label>Restart policy</label>' +
|
'<div class="podman-modal-field"><label>Restart policy</label>' +
|
||||||
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
|
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
|
||||||
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
|
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
|
||||||
|
'<div class="podman-modal-field"><label>Run as user (optional)</label>' +
|
||||||
|
'<input type="text" class="mono" id="cc-user" placeholder="99:100">' +
|
||||||
|
'<div class="hint">Overrides the image\'s own default user — needed when a bind-mounted directory is owned by a specific UID:GID (Unraid\'s own containers commonly use "99:100").</div></div>' +
|
||||||
'<div class="podman-modal-field" id="cc-gpu-field" style="display:none;"><label>GPU passthrough</label>' +
|
'<div class="podman-modal-field" id="cc-gpu-field" style="display:none;"><label>GPU passthrough</label>' +
|
||||||
'<select id="cc-gpu-select"><option value="">None</option></select></div>' +
|
'<select id="cc-gpu-select"><option value="">None</option></select></div>' +
|
||||||
|
'<div class="podman-modal-field"><label>Device passthrough (optional)</label>' +
|
||||||
|
'<div class="podman-row-group" id="cc-devices"></div>' +
|
||||||
|
'<button type="button" class="podman-btn podman-btn-ghost" data-add="device">+ Add device</button>' +
|
||||||
|
'<div class="hint">A host device path (e.g. a USB serial adapter) mounted at the same path inside the container — for a GPU, use the field above instead.</div></div>' +
|
||||||
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
||||||
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
|
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
|
||||||
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
||||||
@@ -606,8 +1070,11 @@
|
|||||||
|
|
||||||
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
|
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
|
||||||
if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name;
|
if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name;
|
||||||
|
if (prefill.icon) backdrop.querySelector('#cc-icon').value = prefill.icon;
|
||||||
|
if (prefill.webUrl) backdrop.querySelector('#cc-weburl').value = prefill.webUrl;
|
||||||
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
|
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
|
||||||
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
|
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
|
||||||
|
if (prefill.user) backdrop.querySelector('#cc-user').value = prefill.user;
|
||||||
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
|
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
|
||||||
if (prefill.staticIp) backdrop.querySelector('#cc-static-ip').value = prefill.staticIp;
|
if (prefill.staticIp) backdrop.querySelector('#cc-static-ip').value = prefill.staticIp;
|
||||||
|
|
||||||
@@ -632,16 +1099,19 @@
|
|||||||
const portsGroup = backdrop.querySelector('#cc-ports');
|
const portsGroup = backdrop.querySelector('#cc-ports');
|
||||||
const volumesGroup = backdrop.querySelector('#cc-volumes');
|
const volumesGroup = backdrop.querySelector('#cc-volumes');
|
||||||
const envGroup = backdrop.querySelector('#cc-env');
|
const envGroup = backdrop.querySelector('#cc-env');
|
||||||
|
const devicesGroup = backdrop.querySelector('#cc-devices');
|
||||||
// A template may carry zero, one, or several rows of each kind — always
|
// A template may carry zero, one, or several rows of each kind — always
|
||||||
// leave at least one (blank) row so the user has somewhere to type,
|
// leave at least one (blank) row so the user has somewhere to type,
|
||||||
// matching the blank-form behavior.
|
// matching the blank-form behavior.
|
||||||
(prefill.ports && prefill.ports.length ? prefill.ports : [{}]).forEach(function (row) { addRow(portsGroup, portRowHtml, row); });
|
(prefill.ports && prefill.ports.length ? prefill.ports : [{}]).forEach(function (row) { addRow(portsGroup, portRowHtml, row); });
|
||||||
(prefill.volumes && prefill.volumes.length ? prefill.volumes : [{}]).forEach(function (row) { addRow(volumesGroup, volumeRowHtml, row); });
|
(prefill.volumes && prefill.volumes.length ? prefill.volumes : [{}]).forEach(function (row) { addRow(volumesGroup, volumeRowHtml, row); });
|
||||||
(prefill.env && prefill.env.length ? prefill.env : [{}]).forEach(function (row) { addRow(envGroup, envRowHtml, row); });
|
(prefill.env && prefill.env.length ? prefill.env : [{}]).forEach(function (row) { addRow(envGroup, envRowHtml, row); });
|
||||||
|
(prefill.devices && prefill.devices.length ? prefill.devices : [{}]).forEach(function (row) { addRow(devicesGroup, deviceRowHtml, row); });
|
||||||
|
|
||||||
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup, portRowHtml); });
|
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup, portRowHtml); });
|
||||||
backdrop.querySelector('[data-add="volume"]').addEventListener('click', function () { addRow(volumesGroup, volumeRowHtml); });
|
backdrop.querySelector('[data-add="volume"]').addEventListener('click', function () { addRow(volumesGroup, volumeRowHtml); });
|
||||||
backdrop.querySelector('[data-add="env"]').addEventListener('click', function () { addRow(envGroup, envRowHtml); });
|
backdrop.querySelector('[data-add="env"]').addEventListener('click', function () { addRow(envGroup, envRowHtml); });
|
||||||
|
backdrop.querySelector('[data-add="device"]').addEventListener('click', function () { addRow(devicesGroup, deviceRowHtml); });
|
||||||
|
|
||||||
// Populate the network dropdown with any existing custom (non-default)
|
// Populate the network dropdown with any existing custom (non-default)
|
||||||
// podman networks, in addition to the built-in bridge/host/none modes
|
// podman networks, in addition to the built-in bridge/host/none modes
|
||||||
@@ -723,13 +1193,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function submit() {
|
function submit() {
|
||||||
if (editing && !confirm(
|
if (editing) {
|
||||||
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
|
P.confirm(
|
||||||
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?'
|
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
|
||||||
)) {
|
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?',
|
||||||
return;
|
{ confirmLabel: 'Continue' }
|
||||||
|
).then(function (ok) { if (ok) proceed(); });
|
||||||
|
} else {
|
||||||
|
proceed();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function proceed() {
|
||||||
const image = backdrop.querySelector('#cc-image').value.trim();
|
const image = backdrop.querySelector('#cc-image').value.trim();
|
||||||
if (!image) {
|
if (!image) {
|
||||||
showError('"Image" is required.');
|
showError('"Image" is required.');
|
||||||
@@ -756,6 +1231,7 @@
|
|||||||
const staticIp = isMacvlan ? backdrop.querySelector('#cc-static-ip').value.trim() : '';
|
const staticIp = isMacvlan ? backdrop.querySelector('#cc-static-ip').value.trim() : '';
|
||||||
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
|
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
|
||||||
const env = readRows(envGroup).filter(function (r) { return r.key; });
|
const env = readRows(envGroup).filter(function (r) { return r.key; });
|
||||||
|
const devices = readRows(devicesGroup).filter(function (r) { return r.path; });
|
||||||
|
|
||||||
const saveAsTemplate = backdrop.querySelector('#cc-save-template').checked;
|
const saveAsTemplate = backdrop.querySelector('#cc-save-template').checked;
|
||||||
const templateName = backdrop.querySelector('#cc-template-name').value.trim();
|
const templateName = backdrop.querySelector('#cc-template-name').value.trim();
|
||||||
@@ -796,7 +1272,11 @@
|
|||||||
env: env,
|
env: env,
|
||||||
restartPolicy: backdrop.querySelector('#cc-restart').value,
|
restartPolicy: backdrop.querySelector('#cc-restart').value,
|
||||||
gpuDevices: gpuDevices,
|
gpuDevices: gpuDevices,
|
||||||
|
devices: devices,
|
||||||
privileged: privileged,
|
privileged: privileged,
|
||||||
|
user: backdrop.querySelector('#cc-user').value.trim(),
|
||||||
|
icon: backdrop.querySelector('#cc-icon').value.trim(),
|
||||||
|
webuiUrl: backdrop.querySelector('#cc-weburl').value.trim(),
|
||||||
startAfterCreate: backdrop.querySelector('#cc-start').checked,
|
startAfterCreate: backdrop.querySelector('#cc-start').checked,
|
||||||
});
|
});
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
@@ -814,8 +1294,9 @@
|
|||||||
icon: backdrop.querySelector('#cc-template-icon').value.trim(),
|
icon: backdrop.querySelector('#cc-template-icon').value.trim(),
|
||||||
category: backdrop.querySelector('#cc-template-category').value.trim(),
|
category: backdrop.querySelector('#cc-template-category').value.trim(),
|
||||||
overview: backdrop.querySelector('#cc-template-overview').value.trim(),
|
overview: backdrop.querySelector('#cc-template-overview').value.trim(),
|
||||||
|
webUrl: backdrop.querySelector('#cc-weburl').value.trim(),
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
alert('Container created, but saving the template failed: ' + err.message);
|
P.toast('Container created, but saving the template failed: ' + err.message, 'warn');
|
||||||
});
|
});
|
||||||
}).then(function () {
|
}).then(function () {
|
||||||
close();
|
close();
|
||||||
@@ -839,16 +1320,18 @@
|
|||||||
const doIt = function (extra) {
|
const doIt = function (extra) {
|
||||||
if (btn) btn.disabled = true;
|
if (btn) btn.disabled = true;
|
||||||
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
|
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
|
||||||
alert('Action failed: ' + err.message);
|
P.toast('Action failed: ' + err.message, 'error');
|
||||||
if (btn) btn.disabled = false;
|
if (btn) btn.disabled = false;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
if (action === 'remove') {
|
if (action === 'remove') {
|
||||||
if (!confirm('Remove this container? This does not remove its volumes.')) return;
|
P.confirm('Remove this container? This does not remove its volumes.', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
|
||||||
doIt({ force: true });
|
if (ok) doIt({ force: true });
|
||||||
|
});
|
||||||
} else if (action === 'kill') {
|
} else if (action === 'kill') {
|
||||||
if (!confirm('Send SIGKILL to this container? Unlike Stop, this does not let it shut down gracefully.')) return;
|
P.confirm('Send SIGKILL to this container? Unlike Stop, this does not let it shut down gracefully.', { danger: true, confirmLabel: 'Kill' }).then(function (ok) {
|
||||||
doIt({});
|
if (ok) doIt({});
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
doIt({});
|
doIt({});
|
||||||
}
|
}
|
||||||
@@ -875,23 +1358,42 @@
|
|||||||
const btn = e.target.closest('button[data-action]');
|
const btn = e.target.closest('button[data-action]');
|
||||||
if (!btn || btn.disabled) return;
|
if (!btn || btn.disabled) return;
|
||||||
const row = btn.closest('tr');
|
const row = btn.closest('tr');
|
||||||
const id = row.dataset.id;
|
|
||||||
|
if (btn.dataset.action === 'toggle-folder' || btn.dataset.action === 'folder-menu') {
|
||||||
|
const folderId = row.dataset.folderId;
|
||||||
|
const f = folders.find(function (x) { return x.id === folderId; });
|
||||||
|
if (!f) return;
|
||||||
|
if (btn.dataset.action === 'toggle-folder') {
|
||||||
|
collapsedFolders[folderId] = !collapsedFolders[folderId];
|
||||||
|
renderTable();
|
||||||
|
} else {
|
||||||
|
openFolderMenu(f, btn);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A folder-header's member chips (see folderMemberChipHtml()) carry
|
||||||
|
// their own data-id directly on the <button>, since the row they
|
||||||
|
// sit in is the folder header (data-folder-id), not that container.
|
||||||
|
const id = btn.dataset.id || row.dataset.id;
|
||||||
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') {
|
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') {
|
||||||
const c = allContainers.find(function (x) { return x.id === id; });
|
const c = allContainers.find(function (x) { return x.id === id; });
|
||||||
if (!c) return;
|
if (!c) return;
|
||||||
if (btn.dataset.action === 'menu') {
|
if (btn.dataset.action === 'menu') {
|
||||||
openRowMenu(c, btn);
|
openRowMenu(c, btn);
|
||||||
} else if (btn.dataset.action === 'update') {
|
} else if (btn.dataset.action === 'update') {
|
||||||
if (!confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.')) return;
|
P.confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.', { confirmLabel: 'Update' }).then(function (ok) {
|
||||||
btn.disabled = true;
|
if (!ok) return;
|
||||||
const modal = P.openLogModal('Updating ' + c.name);
|
btn.disabled = true;
|
||||||
updateContainer(c, modal.log).then(function () {
|
const modal = P.openLogModal('Updating ' + c.name);
|
||||||
modal.done();
|
updateContainer(c, modal.log).then(function () {
|
||||||
return load();
|
modal.done();
|
||||||
}).catch(function (err) {
|
return load();
|
||||||
modal.log('Failed: ' + err.message);
|
}).catch(function (err) {
|
||||||
modal.done();
|
modal.log('Failed: ' + err.message);
|
||||||
btn.disabled = false;
|
modal.done();
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
openDetailModal(c);
|
openDetailModal(c);
|
||||||
@@ -903,6 +1405,15 @@
|
|||||||
|
|
||||||
P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates);
|
P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates);
|
||||||
P.el('containers-update-all-btn').addEventListener('click', updateAll);
|
P.el('containers-update-all-btn').addEventListener('click', updateAll);
|
||||||
|
P.el('containers-new-folder-btn').addEventListener('click', function () { openNewFolderModal(null); });
|
||||||
|
|
||||||
|
// Fetched once here, not inside load() — folders change rarely
|
||||||
|
// compared to container state, so there's no reason for every ~2s
|
||||||
|
// auto-refresh tick to re-fetch and re-render them too.
|
||||||
|
P.get('folders', 'list').then(function (data) {
|
||||||
|
folders = data.folders || [];
|
||||||
|
renderTable();
|
||||||
|
}).catch(function () { /* folders just stay empty — not fatal to the panel */ });
|
||||||
|
|
||||||
return load();
|
return load();
|
||||||
}
|
}
|
||||||
@@ -912,5 +1423,5 @@
|
|||||||
// (see Podman.page's script list), so this is already set by then.
|
// (see Podman.page's script list), so this is already set by then.
|
||||||
P.openCreateContainerModal = openCreateContainerModal;
|
P.openCreateContainerModal = openCreateContainerModal;
|
||||||
|
|
||||||
P.registerPanel('containers', { init: init, refresh: load });
|
P.registerPanel('containers', { init: init, refresh: load, autoRefresh: true });
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* javascript/dashboard.js
|
* javascript/dashboard.js
|
||||||
*
|
*
|
||||||
* Dashboard panel: summary stat tiles fed by ajax/system.php?action=summary.
|
* Dashboard panel: plain-count stat tiles (Running/Stopped/Pods/Images/
|
||||||
* The Activity list and the CPU/Memory sparkline in the mockup were
|
* Volumes/Networks) plus a single "Resource Usage" card (CPU/Memory/Swap/
|
||||||
* illustrative sample data with no backing API (libpod has no "recent
|
* Storage as meter rows) — kept as two visually distinct groups rather
|
||||||
* events for a container fleet" convenience endpoint beyond raw
|
* than forcing bar-and-percentage metrics into the same tile shape as
|
||||||
* /events streaming, which is a separate follow-up — see the note
|
* simple counts, which is what produced the awkward spanning-tile/dead-
|
||||||
* rendered in place of it below) — rather than fake data pretending to be
|
* grid-cell layout this replaced. Both fed by ajax/system.php?action=
|
||||||
* live, this real implementation shows what's genuinely available now
|
* summary, plus the Autostart Queue table fed by action=autostart_queue.
|
||||||
* (the summary counts) and a clear placeholder for what needs the events
|
* A live scrolling event feed is deliberately out of scope here — libpod
|
||||||
* stream, so nobody mistakes a mock for a working feature.
|
* 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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
@@ -26,12 +29,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
P.el('stat-running').textContent = summary.containers.running;
|
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-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-pods').textContent = summary.pods;
|
||||||
P.el('stat-images').textContent = summary.images;
|
P.el('stat-images').textContent = summary.images;
|
||||||
P.el('stat-volumes').textContent = summary.volumes;
|
P.el('stat-volumes').textContent = summary.volumes;
|
||||||
P.el('stat-networks').textContent = summary.networks;
|
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');
|
const meta = P.el('podman-header-meta');
|
||||||
if (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() {
|
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.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 });
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -68,24 +68,26 @@
|
|||||||
// removes every image on the host.
|
// removes every image on the host.
|
||||||
const unused = images.filter(function (img) { return img.usedBy === 0; });
|
const unused = images.filter(function (img) { return img.usedBy === 0; });
|
||||||
if (!unused.length) {
|
if (!unused.length) {
|
||||||
alert('No unused images to remove — every image is referenced by at least one container.');
|
P.toast('No unused images to remove — every image is referenced by at least one container.', 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
|
const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
|
||||||
if (!confirm(
|
|
||||||
'Remove ' + unused.length + ' image(s) not used by any container (' + P.formatBytes(totalBytes) + ')?\n\n' +
|
|
||||||
'This removes any tagged image with zero containers using it, not just dangling ones.'
|
|
||||||
)) return;
|
|
||||||
|
|
||||||
const btn = this;
|
const btn = this;
|
||||||
btn.disabled = true;
|
P.confirm(
|
||||||
P.post('images', 'prune').then(function (result) {
|
'Remove ' + unused.length + ' image(s) not used by any container (' + P.formatBytes(totalBytes) + ')? ' +
|
||||||
btn.disabled = false;
|
'This removes any tagged image with zero containers using it, not just dangling ones.',
|
||||||
alert('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.');
|
{ danger: true, confirmLabel: 'Remove' }
|
||||||
return load();
|
).then(function (ok) {
|
||||||
}).catch(function (err) {
|
if (!ok) return;
|
||||||
btn.disabled = false;
|
btn.disabled = true;
|
||||||
alert('Prune failed: ' + err.message);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,11 +112,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (btn.dataset.action === 'remove') {
|
if (btn.dataset.action === 'remove') {
|
||||||
if (!confirm('Remove this image?')) return;
|
P.confirm('Remove this image?', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
|
||||||
btn.disabled = true;
|
if (!ok) return;
|
||||||
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
|
btn.disabled = true;
|
||||||
alert('Remove failed: ' + err.message);
|
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
|
||||||
btn.disabled = false;
|
P.toast('Remove failed: ' + err.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -161,11 +161,13 @@
|
|||||||
const btn = e.target.closest('button[data-action="remove"]');
|
const btn = e.target.closest('button[data-action="remove"]');
|
||||||
if (!btn || btn.disabled) return;
|
if (!btn || btn.disabled) return;
|
||||||
const name = btn.closest('tr').dataset.name;
|
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) {
|
||||||
btn.disabled = true;
|
if (!ok) return;
|
||||||
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
|
btn.disabled = true;
|
||||||
alert('Remove failed: ' + err.message);
|
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
|
||||||
btn.disabled = false;
|
P.toast('Remove failed: ' + err.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -168,7 +168,7 @@
|
|||||||
|
|
||||||
function handleAction(name, action, extra) {
|
function handleAction(name, action, extra) {
|
||||||
return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) {
|
return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) {
|
||||||
alert('Action failed: ' + err.message);
|
P.toast('Action failed: ' + err.message, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,8 +190,9 @@
|
|||||||
label: 'Remove',
|
label: 'Remove',
|
||||||
danger: true,
|
danger: true,
|
||||||
onClick: function () {
|
onClick: function () {
|
||||||
if (!confirm('Remove pod "' + pod.name + '" and all its member containers?')) return;
|
P.confirm('Remove pod "' + pod.name + '" and all its member containers?', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
|
||||||
handleAction(pod.name, 'remove', { force: true });
|
if (ok) handleAction(pod.name, 'remove', { force: true });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
P.openContextMenu(btn, items);
|
P.openContextMenu(btn, items);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
const P = window.Podman;
|
const P = window.Podman;
|
||||||
let autostartNames = [];
|
let autostartNames = [];
|
||||||
|
let allContainerNames = [];
|
||||||
|
|
||||||
function renderAutostart() {
|
function renderAutostart() {
|
||||||
const tbody = P.el('autostart-tbody');
|
const tbody = P.el('autostart-tbody');
|
||||||
@@ -24,11 +25,24 @@
|
|||||||
'</div></td></tr>';
|
'</div></td></tr>';
|
||||||
}).join('')
|
}).join('')
|
||||||
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
|
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
|
||||||
|
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() {
|
function saveAutostart() {
|
||||||
return P.post('settings', 'autostart_save', { names: autostartNames }).catch(function (err) {
|
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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +66,149 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
return P.get('settings', 'get').then(fillForm).catch(function (err) {
|
return Promise.all([
|
||||||
alert('Could not load settings: ' + err.message);
|
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() {
|
function save() {
|
||||||
const body = {
|
const body = {
|
||||||
storagePath: P.el('settings-storage-path').value.trim(),
|
storagePath: P.el('settings-storage-path').value.trim(),
|
||||||
@@ -65,15 +217,24 @@
|
|||||||
stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10,
|
stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10,
|
||||||
};
|
};
|
||||||
return P.post('settings', 'save', body).then(function () {
|
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) {
|
}).catch(function (err) {
|
||||||
alert('Save failed: ' + err.message);
|
P.toast('Save failed: ' + err.message, 'error');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
P.el('settings-save-btn').addEventListener('click', save);
|
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) {
|
P.el('autostart-tbody').addEventListener('click', function (e) {
|
||||||
const btn = e.target.closest('button[data-action]');
|
const btn = e.target.closest('button[data-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
@@ -91,6 +252,21 @@
|
|||||||
saveAutostart();
|
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();
|
return load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* javascript/templates.js
|
* javascript/templates.js
|
||||||
*
|
*
|
||||||
* Templates panel: reusable container configs saved as XML (Unraid
|
* The "Apps" panel: two sub-views toggled by a segmented control —
|
||||||
* Docker-template-compatible schema — see ajax/templates.php's header
|
* "Store" (browses/searches Community Applications' own public app feed
|
||||||
* comment for why). "Use template" hands off to containers.js's Create
|
* directly, see ajax/templates.php's ca_feed_search()) and "My Templates"
|
||||||
* Container modal, pre-filled; templates are themselves created from
|
* (this plugin's own saved, reusable container configs, XML in the same
|
||||||
* that same modal's "Save as template" checkbox, not from here.
|
* 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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
const P = window.Podman;
|
const P = window.Podman;
|
||||||
let allTemplates = [];
|
let allTemplates = [];
|
||||||
|
let activeSubview = 'store';
|
||||||
|
let storeResults = [];
|
||||||
|
let storeSearchTimer = null;
|
||||||
|
|
||||||
function iconHtml(t) {
|
function iconHtml(t) {
|
||||||
if (t.icon) {
|
if (t.icon) {
|
||||||
@@ -21,6 +28,8 @@
|
|||||||
return '<div class="podman-template-icon podman-template-icon-fallback">' + P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '</div>';
|
return '<div class="podman-template-icon podman-template-icon-fallback">' + P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- My Templates --------------------------------------------------------
|
||||||
|
|
||||||
function cardHtml(t) {
|
function cardHtml(t) {
|
||||||
const overview = t.overview && t.overview.length > 110 ? t.overview.slice(0, 107) + '…' : (t.overview || '');
|
const overview = t.overview && t.overview.length > 110 ? t.overview.slice(0, 107) + '…' : (t.overview || '');
|
||||||
return '' +
|
return '' +
|
||||||
@@ -38,36 +47,23 @@
|
|||||||
'</div></div>';
|
'</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function renderTemplatesGrid() {
|
||||||
const grid = P.el('templates-grid');
|
const grid = P.el('templates-grid');
|
||||||
grid.innerHTML = allTemplates.length
|
grid.innerHTML = allTemplates.length
|
||||||
? allTemplates.map(cardHtml).join('')
|
? allTemplates.map(cardHtml).join('')
|
||||||
: '<div class="podman-empty-note">No templates yet — save one from the "New Container" form, or import an XML template.</div>';
|
: '<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() {
|
function load() {
|
||||||
const container = P.el('podman-panel-templates');
|
|
||||||
if (!P.el('templates-grid')) {
|
|
||||||
container.innerHTML = '' +
|
|
||||||
'<div class="podman-card">' +
|
|
||||||
'<div class="podman-toolbar">' +
|
|
||||||
'<strong style="flex:1;">Reusable container configs</strong>' +
|
|
||||||
'<button class="podman-btn podman-btn-ghost" id="templates-import-btn">⬆ Import Template</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'<div class="podman-template-grid" id="templates-grid"></div>' +
|
|
||||||
'</div>';
|
|
||||||
P.el('templates-import-btn').addEventListener('click', openImportModal);
|
|
||||||
P.el('templates-grid').addEventListener('click', handleCardClick);
|
|
||||||
}
|
|
||||||
return P.get('templates', 'list').then(function (data) {
|
return P.get('templates', 'list').then(function (data) {
|
||||||
allTemplates = data;
|
allTemplates = data;
|
||||||
render();
|
renderTemplatesGrid();
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCardClick(e) {
|
function handleTemplatesGridClick(e) {
|
||||||
const btn = e.target.closest('button[data-action]');
|
const btn = e.target.closest('button[data-action]');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
const name = btn.closest('.podman-template-card').dataset.name;
|
const name = btn.closest('.podman-template-card').dataset.name;
|
||||||
@@ -79,7 +75,7 @@
|
|||||||
P.openCreateContainerModal(config);
|
P.openCreateContainerModal(config);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
alert('Could not load template: ' + err.message);
|
P.toast('Could not load template: ' + err.message, 'error');
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -97,21 +93,109 @@
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
alert('Export failed: ' + err.message);
|
P.toast('Export failed: ' + err.message, 'error');
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (btn.dataset.action === 'delete') {
|
if (btn.dataset.action === 'delete') {
|
||||||
if (!confirm('Delete template "' + name + '"? This does not affect any running containers.')) return;
|
P.confirm('Delete template "' + name + '"? This does not affect any running containers.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
|
||||||
btn.disabled = true;
|
if (!ok) return;
|
||||||
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
|
btn.disabled = true;
|
||||||
btn.disabled = false;
|
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
|
||||||
alert('Delete failed: ' + err.message);
|
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() {
|
function openImportModal() {
|
||||||
const backdrop = document.createElement('div');
|
const backdrop = document.createElement('div');
|
||||||
backdrop.className = 'podman-modal-backdrop';
|
backdrop.className = 'podman-modal-backdrop';
|
||||||
@@ -123,6 +207,11 @@
|
|||||||
'<input type="text" id="ti-local-search" placeholder="Search by name…">' +
|
'<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 class="podman-local-template-list" id="ti-local-list"><div class="podman-empty-note">Loading…</div></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 & Import</button>' +
|
||||||
|
'</div></div>' +
|
||||||
'<div class="podman-modal-field"><label>Or paste XML directly</label>' +
|
'<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>' +
|
'<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>' +
|
'</form>' +
|
||||||
@@ -205,8 +294,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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="cancel"]').addEventListener('click', close);
|
||||||
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
|
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.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
|
||||||
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
|
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
|
||||||
document.addEventListener('keydown', function onKey(e) {
|
document.addEventListener('keydown', function onKey(e) {
|
||||||
@@ -214,5 +321,85 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
P.registerPanel('templates', { init: load, refresh: load });
|
// --- 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;">⬆ 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">‹ Prev</button>' +
|
||||||
|
'<span id="store-pager-label"></span>' +
|
||||||
|
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-next">Next ›</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 });
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -70,11 +70,13 @@
|
|||||||
const btn = e.target.closest('button[data-action="remove"]');
|
const btn = e.target.closest('button[data-action="remove"]');
|
||||||
if (!btn || btn.disabled) return;
|
if (!btn || btn.disabled) return;
|
||||||
const name = btn.closest('tr').dataset.name;
|
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) {
|
||||||
btn.disabled = true;
|
if (!ok) return;
|
||||||
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
|
btn.disabled = true;
|
||||||
alert('Remove failed: ' + err.message);
|
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
|
||||||
btn.disabled = false;
|
P.toast('Remove failed: ' + err.message, 'error');
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -186,12 +186,25 @@
|
|||||||
@media (max-width: 1080px) { .podman-stat-grid { grid-template-columns: repeat(3, 1fr); } }
|
@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); } }
|
@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 .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 { 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 .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 .tone-good { color: var(--good); }
|
||||||
.podman-stat .bar > span { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
|
.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 {
|
.podman-chip {
|
||||||
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 100px;
|
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 100px;
|
||||||
@@ -212,7 +225,46 @@
|
|||||||
.podman-plugin tbody tr:last-child td { border-bottom: none; }
|
.podman-plugin tbody tr:last-child td { border-bottom: none; }
|
||||||
.podman-plugin tbody tr:hover { background: var(--surface-2); }
|
.podman-plugin tbody tr:hover { background: var(--surface-2); }
|
||||||
.podman-table-wrap { overflow-x: auto; }
|
.podman-table-wrap { overflow-x: auto; }
|
||||||
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
/*
|
||||||
|
* 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 {
|
.podman-row-name-btn {
|
||||||
/* !important for the same reason as .podman-btn-ghost/-primary — Unraid's
|
/* !important for the same reason as .podman-btn-ghost/-primary — Unraid's
|
||||||
own site-wide button theme otherwise still shows its default border
|
own site-wide button theme otherwise still shows its default border
|
||||||
@@ -220,14 +272,16 @@
|
|||||||
click away from every table row still looked like a bordered button
|
click away from every table row still looked like a bordered button
|
||||||
forever, not a plain label. */
|
forever, not a plain label. */
|
||||||
appearance: none; border: none !important; background: none !important; padding: 0; cursor: pointer;
|
appearance: none; border: none !important; background: none !important; padding: 0; cursor: pointer;
|
||||||
color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left;
|
color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left; min-width: 0;
|
||||||
}
|
}
|
||||||
.podman-row-name-btn:hover { color: var(--accent-strong); }
|
.podman-row-name-btn:hover { color: var(--accent-strong); }
|
||||||
.podman-row-name-btn:hover .ico { border-color: var(--accent); }
|
.podman-row-name-btn:hover .ico { border-color: var(--accent); }
|
||||||
.podman-row-name .ico {
|
.podman-row-name .ico {
|
||||||
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
|
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
|
||||||
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
|
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
|
||||||
|
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-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; }
|
||||||
/*
|
/*
|
||||||
* The actions <td> itself stays a plain table-cell (default display) so
|
* The actions <td> itself stays a plain table-cell (default display) so
|
||||||
@@ -265,7 +319,7 @@
|
|||||||
.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
|
.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
|
||||||
.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
|
.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
|
||||||
.podman-usage-mini .track > span { display: block; height: 100%; background: var(--accent); }
|
.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-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||||
/*
|
/*
|
||||||
@@ -304,6 +358,7 @@
|
|||||||
|
|
||||||
.podman-template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; padding: 18px; }
|
.podman-template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; padding: 18px; }
|
||||||
.podman-template-grid .podman-empty-note { grid-column: 1 / -1; }
|
.podman-template-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 {
|
.podman-template-card {
|
||||||
border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--surface);
|
border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--surface);
|
||||||
display: flex; flex-direction: column; gap: 10px;
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
@@ -345,6 +400,12 @@
|
|||||||
.podman-log-pane {
|
.podman-log-pane {
|
||||||
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.3px;
|
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;
|
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 .l { white-space: pre-wrap; word-break: break-word; }
|
||||||
.podman-log-pane .ts { color: #6b7280; }
|
.podman-log-pane .ts { color: #6b7280; }
|
||||||
@@ -418,6 +479,9 @@
|
|||||||
.podman-input-suffix input[type="number"] { max-width: 100px; width: auto; }
|
.podman-input-suffix input[type="number"] { max-width: 100px; width: auto; }
|
||||||
.podman-input-suffix span { font-size: 12px; color: var(--text-dim); }
|
.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
|
* Toggle switch — a plain checkbox reads as a leftover form control next
|
||||||
* to everything else in this panel getting a designed treatment; this
|
* to everything else in this panel getting a designed treatment; this
|
||||||
@@ -450,6 +514,14 @@
|
|||||||
|
|
||||||
.podman-badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; }
|
.podman-badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; }
|
||||||
|
|
||||||
|
.podman-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; }
|
.podman-loading, .podman-error { padding: 32px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -471,7 +543,7 @@
|
|||||||
.podman-modal-head h3 { font-size: 15px; }
|
.podman-modal-head h3 { font-size: 15px; }
|
||||||
.podman-modal-body { padding: 16px 20px; display: grid; gap: 14px; }
|
.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 label { display: block; font-weight: 600; font-size: 12.5px; margin-bottom: 6px; }
|
||||||
.podman-modal-field input[type="text"] {
|
.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;
|
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);
|
font-size: 13px; color: var(--text); width: 100%; font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
@@ -514,6 +586,10 @@
|
|||||||
* not flex-grown.
|
* not flex-grown.
|
||||||
*/
|
*/
|
||||||
.podman-row-group-item input.podman-input-narrow { flex: none; width: 90px; }
|
.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
|
/* Row-remove (x) reads as a normal button like everything else at full
|
||||||
.podman-btn weight — muted/borderless by default, only turning
|
.podman-btn weight — muted/borderless by default, only turning
|
||||||
"danger" red on hover, so it registers as a quiet per-row affordance
|
"danger" red on hover, so it registers as a quiet per-row affordance
|
||||||
@@ -572,3 +648,88 @@
|
|||||||
padding: 14px 16px; border-radius: 8px; white-space: pre-wrap; word-break: break-all; line-height: 1.6;
|
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; }
|
.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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user