Add Apps/Store tab, template WebUI/URL import, container RO volumes/device passthrough/run-as-user, and in-app confirm dialogs
Lint / ShellCheck (push) Successful in 14s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 6s

Replaces every native confirm() with a shared P.confirm() modal (a hung
native dialog was found live to block the whole tab, including
auto-refresh, and once even double-confirmed an unrelated deletion).
Also fixes Edit Container silently resetting to Bridge/blanking the
Static IP for any container on a custom network, and a context menu
losing its anchor to a mid-read auto-refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 19:24:32 +00:00
co-authored by Claude Sonnet 5
parent 9d46547ef4
commit 7e2ed451e3
14 changed files with 911 additions and 134 deletions
+93
View File
@@ -9,6 +9,99 @@ 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 ### Fixed
- `podman-verify-packages.sh`/`podman-update-packages.sh` reported every - `podman-verify-packages.sh`/`podman-update-packages.sh` reported every
single package as "not installed" right after a genuinely successful single package as "not installed" right after a genuinely successful
+2 -2
View File
@@ -70,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>
@@ -146,7 +146,7 @@ function podman_asset_version(string $relPath): string
</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 ============================= -->
+59 -4
View File
@@ -28,11 +28,19 @@
* 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"],
* "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), * "privileged": false, "startAfterCreate": true, "icon": "https://..." (optional),
* "webuiUrl": "http://10.1.1.1:8080/" (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);
@@ -342,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 !== []) {
@@ -388,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()
@@ -399,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;
} }
+294 -2
View File
@@ -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
+66 -6
View File
@@ -129,9 +129,9 @@ window.Podman = (function () {
// --- Toast notifications ---------------------------------------------------- // --- Toast notifications ----------------------------------------------------
// //
// Replaces alert() for one-way feedback ("Saved.", "Removed 3 image(s)", // Replaces alert() for one-way feedback ("Saved.", "Removed 3 image(s)",
// "Save failed: ..."). Confirmations stay native confirm() — a toast is // "Save failed: ..."). Confirmations use confirmModal() below instead —
// for telling the user something happened, not for asking them a // a toast is for telling the user something happened, not for asking
// yes/no question. Command output that can run to hundreds of lines // them a yes/no question. Command output that can run to hundreds of lines
// (e.g. `podman compose up`) stays in the existing openLogModal() // (e.g. `podman compose up`) stays in the existing openLogModal()
// pattern instead — a toast has to stay short and auto-dismiss, which // pattern instead — a toast has to stay short and auto-dismiss, which
// doesn't fit a scrolling log. // doesn't fit a scrolling log.
@@ -180,6 +180,60 @@ window.Podman = (function () {
setTimeout(dismiss, TOAST_DURATION_MS[kind]); 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 -----------------------------------------------------
/** /**
@@ -480,14 +534,19 @@ window.Podman = (function () {
// Only panels that opt in via `autoRefresh: true` (Dashboard, Containers) // Only panels that opt in via `autoRefresh: true` (Dashboard, Containers)
// get polled — most panels (Settings, Compose, Terminal, ...) have // get polled — most panels (Settings, Compose, Terminal, ...) have
// in-progress forms or connections an unexpected refresh would disrupt. // in-progress forms or connections an unexpected refresh would disrupt.
// Paused while the tab is hidden (nothing to look at) and while any // Paused while the tab is hidden (nothing to look at), while any modal
// modal is open (a full-panel re-render mid-edit would be jarring), // is open (a full-panel re-render mid-edit would be jarring), and while
// rather than fighting those cases with more state. // 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; const AUTO_REFRESH_INTERVAL_MS = 2000;
function autoRefreshTick() { function autoRefreshTick() {
if (document.hidden) return; if (document.hidden) return;
if (document.querySelector('.podman-modal-backdrop')) return; if (document.querySelector('.podman-modal-backdrop')) return;
if (document.querySelector('.podman-context-menu')) return;
const activeBtn = document.querySelector('.podman-subnav button.active'); const activeBtn = document.querySelector('.podman-subnav button.active');
if (!activeBtn) return; if (!activeBtn) return;
@@ -512,6 +571,7 @@ window.Podman = (function () {
loadingRow: loadingRow, loadingRow: loadingRow,
errorRow: errorRow, errorRow: errorRow,
toast: toast, toast: toast,
confirm: confirmModal,
openFormModal: openFormModal, openFormModal: openFormModal,
openLogModal: openLogModal, openLogModal: openLogModal,
openContextMenu: openContextMenu, openContextMenu: openContextMenu,
+13 -11
View File
@@ -117,17 +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');
const name = selected; btn.disabled = true;
P.post('compose', 'remove', { project: selected }).then(function () { const name = selected;
selected = null; P.post('compose', 'remove', { project: selected }).then(function () {
P.toast('Deleted ' + name + '.', 'success'); selected = null;
return loadProjects(); P.toast('Deleted ' + name + '.', 'success');
}).catch(function (err) { return loadProjects();
P.toast('Delete failed: ' + err.message, 'error'); }).catch(function (err) {
btn.disabled = false; P.toast('Delete failed: ' + err.message, 'error');
btn.disabled = false;
});
}); });
} }
+100 -34
View File
@@ -96,9 +96,11 @@
} }
function deleteFolder(f) { function deleteFolder(f) {
if (!confirm('Delete folder "' + f.name + '"? Its containers are not affected — they just become ungrouped.')) return; P.confirm('Delete folder "' + f.name + '"? Its containers are not affected — they just become ungrouped.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
folders = folders.filter(function (x) { return x.id !== f.id; }); if (!ok) return;
saveFolders().then(renderTable); folders = folders.filter(function (x) { return x.id !== f.id; });
saveFolders().then(renderTable);
});
} }
function assignToFolder(containerName, folderId) { function assignToFolder(containerName, folderId) {
@@ -189,7 +191,7 @@
return '' + return '' +
'<tr data-id="' + P.escapeHtml(c.id) + '">' + '<tr data-id="' + P.escapeHtml(c.id) + '">' +
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' + '<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
'<td><div class="podman-name-cell"><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">' +
containerIconHtml(c) + '<span class="text">' + P.escapeHtml(c.name) + '</span></button>' + webuiLink + updateBadge + '</div></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>' +
@@ -292,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;
}, []); }, []);
@@ -316,26 +318,49 @@
.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 || '', icon: c.icon || '',
webUrl: c.webUrl || '', webUrl: c.webUrl || '',
}; };
@@ -390,7 +415,9 @@
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, icon: prefill.icon,
webuiUrl: prefill.webUrl, webuiUrl: prefill.webUrl,
startAfterCreate: true, startAfterCreate: true,
@@ -906,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>&rarr;</span>' + '<span>&rarr;</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">&times;</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">&times;</button>' + '<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>'; '</div>';
} }
@@ -927,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);
@@ -937,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;
}); });
@@ -997,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>' +
@@ -1025,6 +1074,7 @@
if (prefill.webUrl) backdrop.querySelector('#cc-weburl').value = prefill.webUrl; 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;
@@ -1049,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
@@ -1140,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.');
@@ -1173,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();
@@ -1213,7 +1272,9 @@
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(), icon: backdrop.querySelector('#cc-icon').value.trim(),
webuiUrl: backdrop.querySelector('#cc-weburl').value.trim(), webuiUrl: backdrop.querySelector('#cc-weburl').value.trim(),
startAfterCreate: backdrop.querySelector('#cc-start').checked, startAfterCreate: backdrop.querySelector('#cc-start').checked,
@@ -1233,6 +1294,7 @@
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) {
P.toast('Container created, but saving the template failed: ' + err.message, 'warn'); P.toast('Container created, but saving the template failed: ' + err.message, 'warn');
}); });
@@ -1263,11 +1325,13 @@
}); });
}; };
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({});
} }
@@ -1318,16 +1382,18 @@
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);
+22 -18
View File
@@ -72,20 +72,22 @@
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.',
P.toast('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.', 'success'); { danger: true, confirmLabel: 'Remove' }
return load(); ).then(function (ok) {
}).catch(function (err) { if (!ok) return;
btn.disabled = false; btn.disabled = true;
P.toast('Prune failed: ' + err.message, 'error'); 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;
P.toast('Remove failed: ' + err.message, 'error'); P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
btn.disabled = false; P.toast('Remove failed: ' + err.message, 'error');
btn.disabled = false;
});
}); });
} }
}); });
+7 -5
View File
@@ -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;
P.toast('Remove failed: ' + err.message, 'error'); P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
btn.disabled = false; P.toast('Remove failed: ' + err.message, 'error');
btn.disabled = false;
});
}); });
}); });
+3 -2
View File
@@ -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);
+19 -15
View File
@@ -189,17 +189,19 @@
submitBtn.addEventListener('click', function () { submitBtn.addEventListener('click', function () {
if (!select.value || !confirmBox.checked) return; if (!select.value || !confirmBox.checked) return;
if (!confirm('Format ' + select.value + '? This cannot be undone.')) return; P.confirm('Format ' + select.value + '? This cannot be undone.', { danger: true, confirmLabel: 'Format' }).then(function (ok) {
submitBtn.disabled = true; if (!ok) return;
submitBtn.textContent = 'Formatting…'; submitBtn.disabled = true;
P.post('disks', 'format', { device: select.value }).then(function (data) { submitBtn.textContent = 'Formatting…';
close(); P.post('disks', 'format', { device: select.value }).then(function (data) {
P.el('settings-storage-path').value = data.mountPath; close();
P.toast('Formatted and mounted at ' + data.mountPath + '. Click "Save Settings" below, then Restart Podman.', 'warn'); P.el('settings-storage-path').value = data.mountPath;
}).catch(function (err) { P.toast('Formatted and mounted at ' + data.mountPath + '. Click "Save Settings" below, then Restart Podman.', 'warn');
submitBtn.disabled = false; }).catch(function (err) {
submitBtn.textContent = 'Format Disk'; submitBtn.disabled = false;
P.toast('Format failed: ' + err.message, 'error'); submitBtn.textContent = 'Format Disk';
P.toast('Format failed: ' + err.message, 'error');
});
}); });
}); });
@@ -253,12 +255,14 @@
P.el('settings-service-status-btn').addEventListener('click', refreshServiceStatus); 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-start-btn').addEventListener('click', function () { runServiceAction('service_start', 'Starting Podman'); });
P.el('settings-service-stop-btn').addEventListener('click', function () { P.el('settings-service-stop-btn').addEventListener('click', function () {
if (!confirm('Stop podman? All running containers will be stopped first (each with its own configured grace period).')) return; P.confirm('Stop podman? All running containers will be stopped first (each with its own configured grace period).', { confirmLabel: 'Stop' }).then(function (ok) {
runServiceAction('service_stop', 'Stopping Podman'); if (ok) runServiceAction('service_stop', 'Stopping Podman');
});
}); });
P.el('settings-service-restart-btn').addEventListener('click', function () { P.el('settings-service-restart-btn').addEventListener('click', function () {
if (!confirm('Restart podman? All running containers will be stopped and podman.sock will be unavailable until it comes back up.')) return; 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) {
runServiceAction('service_restart', 'Restarting Podman'); if (ok) runServiceAction('service_restart', 'Restarting Podman');
});
}); });
P.el('settings-format-disk-btn').addEventListener('click', openFormatDiskModal); P.el('settings-format-disk-btn').addEventListener('click', openFormatDiskModal);
+215 -28
View File
@@ -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">&#11014; 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;
@@ -103,15 +99,103 @@
} }
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) {
P.toast('Delete failed: ' + err.message, 'error'); 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 &amp; 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;">&#11014; Import Template</button>' +
'</div>' +
'<div id="apps-store-view">' +
'<div class="podman-template-grid" id="store-grid"></div>' +
'<div class="podman-pager" id="store-pager" style="display:none;">' +
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-prev">&#8249; Prev</button>' +
'<span id="store-pager-label"></span>' +
'<button type="button" class="podman-btn podman-btn-ghost" id="store-pager-next">Next &#8250;</button>' +
'</div>' +
'</div>' +
'<div id="apps-templates-view" style="display:none;"><div class="podman-template-grid" id="templates-grid"></div></div>' +
'</div>';
document.querySelectorAll('#apps-subview-toggle button').forEach(function (b) {
b.addEventListener('click', function () { switchSubview(b.dataset.view); });
});
P.el('apps-store-search').addEventListener('input', function (e) {
storeQuery = e.target.value.trim();
storePage = 1;
if (storeSearchTimer) clearTimeout(storeSearchTimer);
storeSearchTimer = setTimeout(loadStore, 400);
});
document.querySelectorAll('#store-sort-toggle button').forEach(function (b) {
b.addEventListener('click', function () {
storeSort = b.dataset.sort;
storePage = 1;
document.querySelectorAll('#store-sort-toggle button').forEach(function (x) { x.classList.toggle('active', x === b); });
loadStore();
});
});
P.el('store-pager-prev').addEventListener('click', function () {
if (storePage <= 1) return;
storePage -= 1;
loadStore();
});
P.el('store-pager-next').addEventListener('click', function () {
if (storePage >= storeTotalPages) return;
storePage += 1;
loadStore();
});
P.el('store-grid').addEventListener('click', handleStoreGridClick);
P.el('templates-grid').addEventListener('click', handleTemplatesGridClick);
P.el('templates-import-btn').addEventListener('click', openImportModal);
loadStore();
return load();
}
P.registerPanel('templates', { init: init, refresh: load });
})(); })();
+7 -5
View File
@@ -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;
P.toast('Remove failed: ' + err.message, 'error'); P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
btn.disabled = false; P.toast('Remove failed: ' + err.message, 'error');
btn.disabled = false;
});
}); });
}); });
+11 -2
View File
@@ -358,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;
@@ -542,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);
} }
@@ -585,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
@@ -718,7 +723,11 @@
font-family: var(--font-ui); color: var(--text-dim); font-size: 11.5px; 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:hover { border-color: var(--accent); color: var(--text); }
.podman-folder-member .ico { width: 18px; height: 18px; border-radius: 5px; font-size: 9px; } .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 .name { font-weight: 600; }
.podman-folder-member .dot { width: 6px; height: 6px; border-radius: 50%; flex: none; } .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.good { background: var(--good); }