Add reproducible build system, native Unraid plugin, and WebUI
Build Packages / Build .txz packages (push) Failing after 9s
Lint / ShellCheck (push) Failing after 43s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Failing after 6s

- versions.env pins podman, conmon, crun, netavark, aardvark-dns, passt,
  and fuse-overlayfs to verified upstream source checksums; SlackBuild
  recipes, scripts/build-packages.sh, checksums.sh, release.sh, and
  update-versions.sh implement the reproducible pipeline; GitHub Actions
  workflows build in a Slackware container and publish releases without
  committing any binaries.

- plugin/podman.plg installs/updates/removes all eight packages (the
  seven components plus the plugin's own unraid-podman scaffolding
  package) via upgradepkg, using the official Unraid array-event hook
  mechanism (event/disks_mounted, event/stopping) instead of editing
  /boot/config/go. rc.podman and the sbin/ helper scripts implement
  storage creation, config seeding/sync, preflight checks, autostart
  with per-container Safe-Mode, and package verify/update/rollback.

- webui/plugins/podman implements the Dashboard, Containers, Pods,
  Images, Volumes, Networks, Logs, Terminal, Compose, and Settings
  panels against the approved mockup (webui/mockups/prototype.html),
  talking to podman system service exclusively via PodmanClient.php
  (libpod REST API over the Unix socket), with two documented
  exceptions: Terminal's one-shot exec model and Compose's use of the
  podman compose CLI, since libpod has no REST equivalent for either.

- docs/ARCHITECTURE.md and docs/ROADMAP.md record the design decisions
  and honest current status (syntax-checked, unit- and
  integration-tested against fake sockets/servers; not yet run against
  a real Unraid/Podman/Slackware system).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 10:51:14 +00:00
co-authored by Claude Sonnet 5
parent 58ffc0c226
commit e2fefcdf9c
124 changed files with 9611 additions and 0 deletions
+256
View File
@@ -0,0 +1,256 @@
Menu="Podman"
Title="Podman"
Icon="podman"
---
<?php
/**
* Podman.page — Unraid Dynamix-style GUI page for unraid-podman.
*
* This file only renders the page shell: the header, the sub-navigation
* for the ten sections, and one (mostly empty) container per panel. Every
* panel fills itself in via AJAX after load — see javascript/app.js's
* router and each javascript/<panel>.js module — so this file has no
* business logic of its own and does not talk to PodmanClient directly.
* See docs/ARCHITECTURE.md section 18 for the overall WebUI design and
* webui/mockups/prototype.html for the approved visual reference this
* page's markup mirrors (same structure, same CSS classes, real data
* instead of static samples).
*/
?>
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css">
<div class="podman-plugin">
<header class="podman-pagehead">
<div class="titlewrap">
<div class="icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 2l8 4.5v9L12 20l-8-4.5v-9L12 2z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M12 11l8-4.5M12 11v9M12 11L4 6.5" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
</div>
<div>
<h1>Podman</h1>
<div class="meta" id="podman-header-meta">Connecting…</div>
</div>
</div>
<div style="display:flex; gap:8px;">
<button class="podman-btn" id="podman-refresh-all">&#8635; Refresh</button>
</div>
</header>
<nav class="podman-subnav">
<button class="active" data-panel="dashboard">Dashboard</button>
<button data-panel="containers">Containers</button>
<button data-panel="pods">Pods</button>
<button data-panel="images">Images</button>
<button data-panel="volumes">Volumes</button>
<button data-panel="networks">Networks</button>
<button data-panel="logs">Logs</button>
<button data-panel="terminal">Terminal</button>
<button data-panel="compose">Compose</button>
<button data-panel="settings">Settings</button>
</nav>
<main class="podman-main">
<!-- ============================= DASHBOARD ============================= -->
<section class="podman-panel active" id="podman-panel-dashboard">
<div class="podman-grid podman-stat-grid">
<div class="podman-stat"><div class="label">Running</div><div class="value tnum"><span id="stat-running">—</span> <small id="stat-running-total"></small></div></div>
<div class="podman-stat"><div class="label">Pods</div><div class="value tnum" id="stat-pods">—</div></div>
<div class="podman-stat"><div class="label">Images</div><div class="value tnum" id="stat-images">—</div></div>
<div class="podman-stat"><div class="label">Volumes</div><div class="value tnum" id="stat-volumes">—</div></div>
<div class="podman-stat"><div class="label">Networks</div><div class="value tnum" id="stat-networks">—</div></div>
<div class="podman-stat"><div class="label">Images on Disk</div><div class="value tnum" id="stat-images-size">—</div></div>
</div>
</section>
<!-- ============================= CONTAINERS ============================= -->
<section class="podman-panel" id="podman-panel-containers">
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" id="containers-search" type="text" placeholder="Search containers by name or image…">
<div class="filterset" id="containers-filterset" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<button class="active" data-filter="all" id="containers-count-all">All</button>
<button data-filter="running" id="containers-count-running">Running</button>
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
</div>
</div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>Status</th><th>Name</th><th>Image</th><th>CPU/Mem</th><th>Ports</th><th>Uptime</th><th></th></tr></thead>
<tbody id="containers-tbody"></tbody>
</table>
</div>
</div>
</section>
<!-- ============================= PODS ============================= -->
<section class="podman-panel" id="podman-panel-pods"></section>
<!-- ============================= IMAGES ============================= -->
<section class="podman-panel" id="podman-panel-images">
<div class="podman-card">
<div class="podman-toolbar">
<input class="podman-search" type="text" placeholder="Search images…" disabled title="Client-side filtering not yet wired up for Images">
<button class="podman-btn" id="images-pull-btn">&#11015; Pull Image</button>
</div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>Repository</th><th>Tag</th><th>Image ID</th><th>Size</th><th>Created</th><th>Used By</th><th></th></tr></thead>
<tbody id="images-tbody"></tbody>
</table>
</div>
</div>
</section>
<!-- ============================= VOLUMES ============================= -->
<section class="podman-panel" id="podman-panel-volumes">
<div class="podman-card">
<div class="podman-toolbar">
<div style="flex:1;"></div>
<button class="podman-btn podman-btn-primary" id="volumes-create-btn">+ New Volume</button>
</div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>Name</th><th>Driver</th><th>Mountpoint</th><th>Used By</th><th></th></tr></thead>
<tbody id="volumes-tbody"></tbody>
</table>
</div>
<div class="podman-empty-note" style="border-top:1px solid var(--border); text-align:left; padding:12px 18px;">
App data mapped from Unraid shares (e.g. <span class="mono">/mnt/user/appdata/…</span>) is managed as bind mounts per-container and does not appear here.
</div>
</div>
</section>
<!-- ============================= NETWORKS ============================= -->
<section class="podman-panel" id="podman-panel-networks">
<div class="podman-card">
<div class="podman-toolbar">
<div style="flex:1;"></div>
<button class="podman-btn podman-btn-primary" id="networks-create-btn">+ New Network</button>
</div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>Name</th><th>Driver</th><th>Subnet</th><th>Gateway</th><th>Containers</th><th></th></tr></thead>
<tbody id="networks-tbody"></tbody>
</table>
</div>
</div>
</section>
<!-- ============================= LOGS ============================= -->
<section class="podman-panel" id="podman-panel-logs">
<div class="podman-card">
<div class="podman-logs-layout">
<div class="podman-logs-side" id="logs-sidebar"></div>
<div>
<div class="podman-toolbar">
<input class="podman-search" id="logs-filter" type="text" placeholder="Filter log output…" style="max-width:280px;">
<span id="logs-follow-toggle" style="display:flex; gap:4px; background:var(--surface-2); padding:3px; border-radius:8px;">
<button class="active" data-follow="true">Follow</button>
<button data-follow="false">Paused</button>
</span>
</div>
<div class="podman-log-pane" id="log-pane" role="log"></div>
</div>
</div>
</div>
</section>
<!-- ============================= TERMINAL ============================= -->
<section class="podman-panel" id="podman-panel-terminal">
<div class="podman-card">
<div class="podman-card-pad">
<div style="display:flex; gap:8px; align-items:center; margin-bottom:12px; font-size:12.5px; color:var(--text-dim);">
Exec into: <select id="term-container-select"></select>
</div>
<div class="podman-term" id="term-output"></div>
<input class="podman-term-input" id="term-input" type="text" placeholder="Type a command and press Enter… (one-shot exec — see Compose panel note on API scope)" autocomplete="off">
</div>
</div>
</section>
<!-- ============================= COMPOSE ============================= -->
<section class="podman-panel" id="podman-panel-compose">
<div class="podman-card">
<div class="podman-compose-layout">
<div class="podman-compose-side" id="compose-sidebar"></div>
<div>
<div class="podman-toolbar">
<strong id="compose-title" style="flex:1;">—</strong>
<button class="podman-btn" id="compose-action-pull">&#11015; Pull</button>
<button class="podman-btn" id="compose-action-down">&#9632; Down</button>
<button class="podman-btn podman-btn-primary" id="compose-action-up">&#9654; Up</button>
</div>
<pre class="podman-yaml" id="compose-yaml"></pre>
</div>
</div>
</div>
</section>
<!-- ============================= SETTINGS ============================= -->
<section class="podman-panel" id="podman-panel-settings">
<div class="podman-grid">
<div class="podman-card">
<div class="podman-card-head"><h2>Storage</h2></div>
<div class="podman-field-row">
<label for="settings-storage-path">Storage path</label>
<div>
<input type="text" id="settings-storage-path" class="mono">
<div class="hint">Cache pool or dedicated disk — never a path under /mnt/user (FUSE).</div>
</div>
</div>
<div class="podman-field-row">
<label for="settings-storage-size">podman.img size</label>
<div><input type="number" id="settings-storage-size" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">GB</span></div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Autostart &amp; Lifecycle</h2></div>
<div class="podman-field-row">
<label for="settings-enabled">Start podman on array start</label>
<div><input type="checkbox" id="settings-enabled"></div>
</div>
<div class="podman-field-row">
<label for="settings-stop-timeout">Container stop timeout</label>
<div><input type="number" id="settings-stop-timeout" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">seconds</span></div>
</div>
<div class="podman-field-row">
<label>Autostart order</label>
<div class="podman-table-wrap">
<table>
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
<tbody id="autostart-tbody"></tbody>
</table>
</div>
</div>
<div class="podman-field-row">
<label></label>
<div><button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button></div>
</div>
</div>
<div class="podman-card">
<div class="podman-card-head"><h2>Installed Packages</h2></div>
<div class="podman-field-row">
<label>Versions</label>
<div class="hint mono" id="settings-package-versions" style="max-width:none;">—</div>
</div>
</div>
</div>
</section>
</main>
</div>
<script src="/plugins/podman/javascript/app.js"></script>
<script src="/plugins/podman/javascript/dashboard.js"></script>
<script src="/plugins/podman/javascript/containers.js"></script>
<script src="/plugins/podman/javascript/pods.js"></script>
<script src="/plugins/podman/javascript/images.js"></script>
<script src="/plugins/podman/javascript/volumes.js"></script>
<script src="/plugins/podman/javascript/networks.js"></script>
<script src="/plugins/podman/javascript/logs.js"></script>
<script src="/plugins/podman/javascript/terminal.js"></script>
<script src="/plugins/podman/javascript/compose.js"></script>
<script src="/plugins/podman/javascript/settings.js"></script>
+179
View File
@@ -0,0 +1,179 @@
<?php
/**
* ajax/compose.php
*
* Backs the Compose panel — and is the ONE deliberate exception to
* "talk to podman.sock, never shell out" in this entire WebUI.
*
* Why: libpod's REST API has no endpoint that understands
* docker-compose.yml/compose.yaml at all. The closest thing,
* POST /libpod/kube/play, takes Kubernetes YAML, not Compose YAML, and
* correctly translating arbitrary Compose files into Kube manifests is a
* substantial project of its own (this is exactly what `podman compose` /
* `podman-compose` already do). Reimplementing that translation from
* scratch here — instead of using the `podman compose` CLI, which IS the
* project's own supported way to run Compose files — would be more
* fragile, not more "API-native": there is no API to be native to for
* this one feature. So this file, and only this file, runs the
* `podman compose` CLI via proc_open(), with strict input validation
* (project names are matched against a fixed pattern, never interpolated
* into a shell string) rather than passing user input through a shell.
*
* Compose projects live under $bootDir/compose/<project>/compose.yaml —
* see docs/ARCHITECTURE.md; this mirrors how autostart/networks/backups
* are all rooted under /boot/config/plugins/podman/ for the same
* boot-persistence reasons.
*
* Actions (?action=...):
* list GET -> known projects with up/down status
* get GET (&project=...) -> raw compose.yaml content
* up POST {"project": "..."}
* down POST {"project": "..."}
* pull POST {"project": "..."}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$composeDir = $podmanConfig->bootDir . '/compose';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(compose_list($composeDir));
break;
case 'get':
$project = (string) ($_GET['project'] ?? '');
podman_json_response(['yaml' => compose_read($composeDir, $project)]);
break;
case 'up':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d']));
break;
case 'down':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['down']));
break;
case 'pull':
podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['pull']));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @param array<string,mixed> $body */
function require_project(array $body): string
{
$project = (string) ($body['project'] ?? '');
if (!is_valid_project_name($project)) {
podman_json_error('Missing or invalid project name', 400);
}
return $project;
}
/**
* Project names come from the user (a form field when creating a new
* compose project, or a value round-tripped from list()). Restricting
* them to a fixed safe character set here — BEFORE they're ever used to
* build a filesystem path or a command argument — is what makes it safe
* to pass them to proc_open() at all.
*/
function is_valid_project_name(string $name): bool
{
return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
}
/** @return array<int,array<string,mixed>> */
function compose_list(string $composeDir): array
{
if (!is_dir($composeDir)) {
return [];
}
$projects = [];
foreach (scandir($composeDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..' || !is_valid_project_name($entry)) {
continue;
}
$yamlPath = $composeDir . '/' . $entry . '/compose.yaml';
if (!is_file($yamlPath)) {
continue;
}
$projects[] = [
'name' => $entry,
'path' => $yamlPath,
'status' => compose_status($composeDir, $entry),
];
}
usort($projects, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $projects;
}
/** Best-effort "up"/"down" status via `podman compose ps`; degrades to "unknown" rather than failing the whole list. */
function compose_status(string $composeDir, string $project): string
{
$result = run_compose_command($composeDir, $project, ['ps', '--format', 'json'], 5);
if ($result['exitCode'] !== 0) {
return 'unknown';
}
$decoded = json_decode($result['output'], true);
return (is_array($decoded) && count($decoded) > 0) ? 'up' : 'down';
}
function compose_read(string $composeDir, string $project): string
{
if (!is_valid_project_name($project)) {
podman_json_error('Invalid project name', 400);
}
$path = $composeDir . '/' . $project . '/compose.yaml';
$content = is_file($path) ? file_get_contents($path) : false;
if ($content === false) {
podman_json_error("compose.yaml not found for project '{$project}'", 404);
}
return $content;
}
/** @return array<string,mixed> */
function compose_run(string $composeDir, string $project, array $subcommand): array
{
$result = run_compose_command($composeDir, $project, $subcommand, 300);
if ($result['exitCode'] !== 0) {
podman_json_error("podman compose " . implode(' ', $subcommand) . " failed:\n" . $result['output'], 502);
}
return ['output' => $result['output']];
}
/**
* Runs `podman compose -f <project>/compose.yaml <subcommand...>` via
* proc_open with an argv array (never a shell string — proc_open with an
* array argument bypasses the shell entirely, so there is no injection
* surface even though $project has already been validated above too).
*
* @param array<int,string> $subcommand
* @return array{exitCode:int,output:string}
*/
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
{
$yamlPath = $composeDir . '/' . $project . '/compose.yaml';
$argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand);
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project);
if (!is_resource($process)) {
return ['exitCode' => 127, 'output' => 'Could not start podman compose process'];
}
stream_set_timeout($pipes[1], $timeoutSeconds);
$stdout = stream_get_contents($pipes[1]) ?: '';
$stderr = stream_get_contents($pipes[2]) ?: '';
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
return ['exitCode' => $exitCode, 'output' => trim($stdout . $stderr)];
}
+132
View File
@@ -0,0 +1,132 @@
<?php
/**
* ajax/containers.php
*
* Backs the Containers panel (and the container rows nested inside the
* Pods panel — see ajax/pods.php). All actions go through PodmanClient,
* i.e. the real libpod REST API over podman.sock; nothing here shells out.
*
* Actions (?action=...):
* list GET -> normalized array of containers for the table view
* inspect GET (&id=...) -> raw inspect JSON, for a detail dialog
* start POST {"id": "..."}
* stop POST {"id": "...", "timeout": 10}
* restart POST {"id": "...", "timeout": 10}
* remove POST {"id": "...", "force": false}
* logs GET (&id=...&tail=200) -> plain text
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(containers_list($client));
break;
case 'inspect':
$id = (string) ($_GET['id'] ?? '');
if ($id === '') {
podman_json_error('Missing id', 400);
}
podman_json_response($client->inspectContainer($id));
break;
case 'logs':
$id = (string) ($_GET['id'] ?? '');
if ($id === '') {
podman_json_error('Missing id', 400);
}
$tail = (int) ($_GET['tail'] ?? 200);
podman_json_response(['text' => $client->containerLogs($id, $tail)]);
break;
case 'start':
$body = podman_read_json_body();
$client->startContainer(require_id($body));
podman_json_response(['status' => 'started']);
break;
case 'stop':
$body = podman_read_json_body();
$client->stopContainer(require_id($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
podman_json_response(['status' => 'stopped']);
break;
case 'restart':
$body = podman_read_json_body();
$client->restartContainer(require_id($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
podman_json_response(['status' => 'restarted']);
break;
case 'remove':
$body = podman_read_json_body();
$client->removeContainer(require_id($body), (bool) ($body['force'] ?? false));
podman_json_response(['status' => 'removed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @param array<string,mixed> $body */
function require_id(array $body): string
{
$id = (string) ($body['id'] ?? '');
if ($id === '') {
podman_json_error('Missing id in request body', 400);
}
return $id;
}
/**
* Normalizes libpod's /containers/json entries into exactly what the
* Containers table (javascript/containers.js) renders — keeping this
* shaping logic server-side means the frontend never has to know libpod's
* raw field names/quirks (e.g. Names is an array, State vs Status, etc).
*
* @return array<int,array<string,mixed>>
*/
function containers_list(PodmanClient $client): array
{
$raw = $client->listContainers(true);
$out = [];
foreach ($raw as $c) {
$names = $c['Names'] ?? [];
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
$ports = [];
foreach (($c['Ports'] ?? []) as $p) {
if (isset($p['host_port'], $p['container_port'])) {
$ports[] = "{$p['host_port']}:{$p['container_port']}/" . ($p['protocol'] ?? 'tcp');
} elseif (isset($p['container_port'])) {
$ports[] = "{$p['container_port']}/" . ($p['protocol'] ?? 'tcp');
}
}
$startedAt = podman_parse_time($c['StartedAt'] ?? null);
$state = strtolower((string) ($c['State'] ?? 'unknown'));
$out[] = [
'id' => (string) ($c['Id'] ?? ''),
'shortId' => podman_short_id((string) ($c['Id'] ?? '')),
'name' => $name,
'image' => (string) ($c['Image'] ?? ''),
'state' => $state,
'status' => (string) ($c['Status'] ?? ''),
'health' => $c['Health']['Status'] ?? null,
'ports' => $ports,
'pod' => $c['Pod'] ?? null,
'podName' => $c['PodName'] ?? null,
'uptimeSeconds' => ($state === 'running' && $startedAt !== null) ? (time() - $startedAt) : null,
'createdAt' => podman_parse_time($c['Created'] ?? null),
];
}
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
+67
View File
@@ -0,0 +1,67 @@
<?php
/**
* ajax/exec.php
*
* Backs the Terminal panel — and this is the one panel where "exclusively
* via podman system service, no shell hacks" needs an honest caveat
* spelled out rather than silently glossed over:
*
* libpod's real exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) is used here — PodmanClient::execRun() never
* shells out to the `podman` binary. But that API's interactive mode works
* by HTTP connection hijacking: the HTTP connection is upgraded into a raw
* bidirectional byte stream for the lifetime of the shell session. That
* model assumes a long-lived process holding the socket open on both ends
* (an actual terminal emulator, or a WebSocket bridge) — it does not fit
* PHP-FPM's request/response lifecycle, where each AJAX call is a fresh,
* independent, short-lived process with no memory of any previous one.
*
* Rather than fake interactivity with something that would break on the
* first multi-line prompt, `sudo`, or interactive editor, this endpoint
* offers a deliberately simpler, honest contract: one command in, its
* complete output back, using Tty=true so output reads like a real
* terminal (colors, prompts-in-output, etc. survive) but with no
* persistent shell state (`cd` does not carry over between calls — see
* the "cwd" parameter below, which javascript/terminal.js tracks
* client-side and resends every time instead).
*
* A true interactive PTY (arrow-key history, tab completion, vim, ...)
* would need a WebSocket-capable process sitting between the browser and
* podman.sock — out of scope for this PHP/AJAX stack; tracked as a
* follow-up rather than implemented as a shell-out workaround.
*
* Actions (?action=...):
* run POST {"id": "...", "cmd": "ls -la", "cwd": "/config"}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'run':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
$commandLine = (string) ($body['cmd'] ?? '');
$cwd = (string) ($body['cwd'] ?? '');
if ($id === '' || trim($commandLine) === '') {
podman_json_error('Missing id or cmd in request body', 400);
}
// The command line is run through the container's own shell
// (sh -c) so the user can type ordinary shell syntax (pipes,
// globs, env vars) in the terminal box, exactly like a real
// shell prompt would accept — still one real exec API call, just
// with /bin/sh as the interpreter instead of us parsing shell
// syntax ourselves in PHP.
$output = $client->execRun($id, ['/bin/sh', '-c', $commandLine], $cwd);
podman_json_response(['output' => $output]);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
+88
View File
@@ -0,0 +1,88 @@
<?php
/**
* ajax/images.php
*
* Backs the Images panel.
*
* Actions (?action=...):
* list GET -> normalized image list
* pull POST {"reference": "docker.io/library/postgres:16"}
* remove POST {"id": "...", "force": false}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(images_list($client));
break;
case 'pull':
$body = podman_read_json_body();
$reference = (string) ($body['reference'] ?? '');
if ($reference === '') {
podman_json_error('Missing reference in request body', 400);
}
podman_json_response($client->pullImage($reference));
break;
case 'remove':
$body = podman_read_json_body();
$id = (string) ($body['id'] ?? '');
if ($id === '') {
podman_json_error('Missing id in request body', 400);
}
$client->removeImage($id, (bool) ($body['force'] ?? false));
podman_json_response(['status' => 'removed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @return array<int,array<string,mixed>> */
function images_list(PodmanClient $client): array
{
$raw = $client->listImages();
// In-use counts let the frontend show "0" (safe to remove) vs a
// positive count, without a separate round trip per image.
$usageCounts = [];
foreach ($client->listContainers(true) as $c) {
$imageId = (string) ($c['ImageID'] ?? '');
if ($imageId !== '') {
$usageCounts[$imageId] = ($usageCounts[$imageId] ?? 0) + 1;
}
}
$out = [];
foreach ($raw as $img) {
$id = (string) ($img['Id'] ?? '');
$repoTags = $img['RepoTags'] ?? [];
$repository = '<none>';
$tag = '<none>';
if (is_array($repoTags) && count($repoTags) > 0 && is_string($repoTags[0]) && str_contains($repoTags[0], ':')) {
[$repository, $tag] = explode(':', $repoTags[0], 2);
}
$out[] = [
'id' => $id,
'shortId' => podman_short_id($id),
'repository' => $repository,
'tag' => $tag,
'sizeBytes' => (int) ($img['Size'] ?? 0),
'sizeFormatted' => podman_format_bytes((int) ($img['Size'] ?? 0)),
// Unlike containers' Created/StartedAt (RFC3339 strings), libpod
// reports image Created as a Unix timestamp integer directly.
'createdAt' => isset($img['Created']) ? (int) $img['Created'] : null,
'usedBy' => $usageCounts[$id] ?? 0,
];
}
usort($out, static fn($a, $b) => strcmp($a['repository'], $b['repository']));
return $out;
}
+93
View File
@@ -0,0 +1,93 @@
<?php
/**
* ajax/networks.php
*
* Backs the Networks panel. See docs/ARCHITECTURE.md section 8 for why
* networks created here are netavark-backed and deliberately isolated
* from Docker's own docker0/custom-network space.
*
* Actions (?action=...):
* list GET -> normalized network list with subnet/gateway/usage
* create POST {"name": "...", "driver": "bridge", "subnet": "...", "gateway": "..."}
* remove POST {"name": "...", "force": false}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(networks_list($client));
break;
case 'create':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
podman_json_response($client->createNetwork(
$name,
(string) ($body['driver'] ?? 'bridge'),
isset($body['subnet']) ? (string) $body['subnet'] : null,
isset($body['gateway']) ? (string) $body['gateway'] : null
));
break;
case 'remove':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
// podman0, the default bridge, refuses removal API-side — no
// special-casing needed here, PodmanApiException surfaces podman's
// own rejection message as-is.
$client->removeNetwork($name, (bool) ($body['force'] ?? false));
podman_json_response(['status' => 'removed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @return array<int,array<string,mixed>> */
function networks_list(PodmanClient $client): array
{
$raw = $client->listNetworks();
$usageCounts = [];
foreach ($client->listContainers(true) as $c) {
$nets = $c['Networks'] ?? [];
if (is_array($nets)) {
foreach ($nets as $netName) {
if (is_string($netName)) {
$usageCounts[$netName] = ($usageCounts[$netName] ?? 0) + 1;
}
}
}
}
$out = [];
foreach ($raw as $n) {
$name = (string) ($n['name'] ?? '');
$subnets = $n['subnets'] ?? [];
$subnet = is_array($subnets) && count($subnets) > 0 ? (string) ($subnets[0]['subnet'] ?? '') : '';
$gateway = is_array($subnets) && count($subnets) > 0 ? (string) ($subnets[0]['gateway'] ?? '') : '';
$out[] = [
'name' => $name,
'driver' => (string) ($n['driver'] ?? 'bridge'),
'subnet' => $subnet,
'gateway' => $gateway,
'isDefault' => $name === 'podman',
'containers' => $usageCounts[$name] ?? 0,
];
}
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
+103
View File
@@ -0,0 +1,103 @@
<?php
/**
* ajax/pods.php
*
* Backs the Pods panel. A pod is rendered as its own card with the member
* containers nested inside (see javascript/pods.js) — listPods() gives us
* the membership, and we cross-reference the container list for per-member
* status/image/ports rather than issuing one inspect call per container.
*
* Actions (?action=...):
* list GET -> pods with nested container summaries
* start POST {"name": "..."}
* stop POST {"name": "...", "timeout": 10}
* remove POST {"name": "...", "force": false}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(pods_list($client));
break;
case 'start':
$body = podman_read_json_body();
$client->startPod(require_name($body));
podman_json_response(['status' => 'started']);
break;
case 'stop':
$body = podman_read_json_body();
$client->stopPod(require_name($body), (int) ($body['timeout'] ?? $podmanConfig->stopTimeoutSeconds));
podman_json_response(['status' => 'stopped']);
break;
case 'remove':
$body = podman_read_json_body();
$client->removePod(require_name($body), (bool) ($body['force'] ?? false));
podman_json_response(['status' => 'removed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @param array<string,mixed> $body */
function require_name(array $body): string
{
$name = (string) ($body['name'] ?? '');
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
return $name;
}
/** @return array<int,array<string,mixed>> */
function pods_list(PodmanClient $client): array
{
$pods = $client->listPods();
$containersByPod = [];
foreach (containers_grouped_by_pod($client) as $podId => $members) {
$containersByPod[$podId] = $members;
}
$out = [];
foreach ($pods as $p) {
$id = (string) ($p['Id'] ?? '');
$out[] = [
'id' => $id,
'name' => (string) ($p['Name'] ?? ''),
'status' => strtolower((string) ($p['Status'] ?? 'unknown')),
'containersTotal' => (int) ($p['NumContainers'] ?? count($containersByPod[$id] ?? [])),
'infraId' => $p['InfraId'] ?? null,
'members' => $containersByPod[$id] ?? [],
];
}
return $out;
}
/** @return array<string,array<int,array<string,mixed>>> keyed by pod id */
function containers_grouped_by_pod(PodmanClient $client): array
{
$grouped = [];
foreach ($client->listContainers(true) as $c) {
$podId = $c['Pod'] ?? null;
if (!is_string($podId) || $podId === '') {
continue;
}
$names = $c['Names'] ?? [];
$name = is_array($names) && count($names) > 0 ? ltrim((string) $names[0], '/') : podman_short_id((string) ($c['Id'] ?? ''));
$grouped[$podId][] = [
'id' => (string) ($c['Id'] ?? ''),
'name' => $name,
'image' => (string) ($c['Image'] ?? ''),
'state' => strtolower((string) ($c['State'] ?? 'unknown')),
];
}
return $grouped;
}
+159
View File
@@ -0,0 +1,159 @@
<?php
/**
* ajax/settings.php
*
* Backs the Settings panel. Unlike every other ajax/*.php file, this one
* is NOT primarily a PodmanClient consumer — plugin settings (podman.cfg,
* the autostart list, installed package versions) are unraid-podman's own
* data on /boot, not a libpod-managed resource, so there is no API to call
* here in the first place. Writes go straight to the same files
* plugin/sbin/podman-config.sh and podman-autostart.sh read, using the
* same paths (see include/Config.php, which mirrors podman-common.sh's
* path constants).
*
* Actions (?action=...):
* get GET -> current settings + autostart list + package versions
* save POST {"storagePath": "...", "storageImageSizeGb": 20,
* "enabled": true, "stopTimeoutSeconds": 10}
* autostart_save POST {"names": ["postgres", "nextcloud", ...]}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'get':
podman_json_response(settings_get($podmanConfig));
break;
case 'save':
$body = podman_read_json_body();
settings_save($podmanConfig, $body);
podman_json_response(['status' => 'saved']);
break;
case 'autostart_save':
$body = podman_read_json_body();
$names = $body['names'] ?? null;
if (!is_array($names)) {
podman_json_error('Missing names array in request body', 400);
}
autostart_save($podmanConfig, $names);
podman_json_response(['status' => 'saved']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @return array<string,mixed> */
function settings_get(PodmanConfig $config): array
{
return [
'storagePath' => $config->storagePath,
'storageImageSizeGb' => $config->storageImageSizeGb,
'enabled' => $config->enabled,
'stopTimeoutSeconds' => $config->stopTimeoutSeconds,
'autostart' => autostart_read($config),
'packageVersions' => installed_package_versions(),
];
}
/** @param array<string,mixed> $input */
function settings_save(PodmanConfig $config, array $input): void
{
$storagePath = isset($input['storagePath']) ? (string) $input['storagePath'] : $config->storagePath;
if (!storage_path_is_safe($storagePath)) {
podman_json_error(
"storagePath ({$storagePath}) is under /mnt/user (FUSE/shfs). " .
'The overlay storage driver needs a real mounted filesystem — use a cache pool or a specific disk path instead.',
400
);
}
$lines = [
'# Rewritten by the unraid-podman WebUI (ajax/settings.php).',
'# Applies on the next "rc.podman restart" — see plugin/rc.d/rc.podman.',
'STORAGE_PATH="' . $storagePath . '"',
'STORAGE_IMAGE_SIZE_GB="' . (int) ($input['storageImageSizeGb'] ?? $config->storageImageSizeGb) . '"',
'PODMAN_ENABLED="' . ((bool) ($input['enabled'] ?? $config->enabled) ? 'yes' : 'no') . '"',
'STOP_TIMEOUT="' . (int) ($input['stopTimeoutSeconds'] ?? $config->stopTimeoutSeconds) . '"',
'CONFIG_SCHEMA_VERSION="1"',
'',
];
$target = $config->bootDir . '/podman.cfg';
if (file_put_contents($target, implode("\n", $lines), LOCK_EX) === false) {
podman_json_error("Could not write {$target} — check permissions on /boot/config/plugins/podman/", 500);
}
}
/**
* Mirrors plugin/sbin/podman-common.sh's podman_storage_path_is_safe() —
* kept in sync deliberately (both reject the same /mnt/user prefix, for
* the same reason) rather than shelling out to the bash version, since
* this is a one-line string check, not worth a process spawn for.
*/
function storage_path_is_safe(string $path): bool
{
return $path !== '/mnt/user' && !str_starts_with($path, '/mnt/user/');
}
/** @return array<int,string> */
function autostart_read(PodmanConfig $config): array
{
if (!is_readable($config->autostartFile)) {
return [];
}
$lines = file($config->autostartFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
$names = [];
foreach ($lines as $line) {
$line = trim(preg_replace('/#.*$/', '', $line) ?? '');
if ($line !== '') {
$names[] = $line;
}
}
return $names;
}
/** @param array<int,mixed> $names */
function autostart_save(PodmanConfig $config, array $names): void
{
$lines = array_map(static fn($n) => (string) $n, $names);
$content = implode("\n", $lines) . (count($lines) > 0 ? "\n" : '');
if (file_put_contents($config->autostartFile, $content, LOCK_EX) === false) {
podman_json_error("Could not write {$config->autostartFile}", 500);
}
}
/**
* 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;
}
+76
View File
@@ -0,0 +1,76 @@
<?php
/**
* ajax/system.php
*
* Backs the Dashboard panel's summary tiles and the service-status line
* shown in the page header — aggregates across several libpod endpoints
* into the one shape javascript/dashboard.js needs, so the frontend
* doesn't have to make (and wait on) five separate round trips.
*
* Actions (?action=...):
* summary GET -> counts, storage usage, engine version, ping status
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'summary':
podman_json_response(system_summary($client, $podmanConfig));
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @return array<string,mixed> */
function system_summary(PodmanClient $client, PodmanConfig $config): array
{
if (!$client->ping()) {
return [
'reachable' => false,
'socketPath' => $config->socketPath,
];
}
$containers = $client->listContainers(true);
$running = 0;
foreach ($containers as $c) {
if (strtolower((string) ($c['State'] ?? '')) === 'running') {
$running++;
}
}
$pods = $client->listPods();
$images = $client->listImages();
$volumes = $client->listVolumes();
$networks = $client->listNetworks();
$info = $client->info();
$df = $client->systemDf();
$imagesSize = 0;
foreach (($df['Images'] ?? []) as $img) {
$imagesSize += (int) ($img['Size'] ?? 0);
}
return [
'reachable' => true,
'socketPath' => $config->socketPath,
'podmanVersion' => $info['Version']['Version'] ?? null,
'containers' => [
'total' => count($containers),
'running' => $running,
],
'pods' => count($pods),
'images' => count($images),
'volumes' => count($volumes),
'networks' => count($networks),
'storage' => [
'imagesSizeBytes' => $imagesSize,
'imagesSizeFormatted' => podman_format_bytes($imagesSize),
],
];
}
+79
View File
@@ -0,0 +1,79 @@
<?php
/**
* ajax/volumes.php
*
* Backs the Volumes panel. Bind-mounted appdata (e.g. /mnt/user/appdata/...)
* intentionally does not appear here — only Podman-managed named volumes
* do, since bind mounts aren't a libpod-managed resource at all (see
* docs/ARCHITECTURE.md section 9).
*
* Actions (?action=...):
* list GET -> normalized volume list, with usedBy counts
* create POST {"name": "...", "driver": "local"}
* remove POST {"name": "...", "force": false}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(volumes_list($client));
break;
case 'create':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local')));
break;
case 'remove':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if ($name === '') {
podman_json_error('Missing name in request body', 400);
}
$client->removeVolume($name, (bool) ($body['force'] ?? false));
podman_json_response(['status' => 'removed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/** @return array<int,array<string,mixed>> */
function volumes_list(PodmanClient $client): array
{
$raw = $client->listVolumes();
$usageCounts = [];
foreach ($client->listContainers(true) as $c) {
foreach (($c['Mounts'] ?? []) as $mount) {
$volName = $mount['Name'] ?? null;
if (is_string($volName) && $volName !== '') {
$usageCounts[$volName] = ($usageCounts[$volName] ?? 0) + 1;
}
}
}
$out = [];
foreach ($raw as $v) {
$name = (string) ($v['Name'] ?? '');
$out[] = [
'name' => $name,
'driver' => (string) ($v['Driver'] ?? 'local'),
'mountpoint' => (string) ($v['Mountpoint'] ?? ''),
'createdAt' => podman_parse_time($v['CreatedAt'] ?? null),
'usedBy' => $usageCounts[$name] ?? 0,
];
}
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
+106
View File
@@ -0,0 +1,106 @@
<?php
/**
* Config.php
*
* Reads unraid-podman's own settings so the WebUI and the shell-based
* plugin scripts under plugin/sbin/ agree on where everything lives —
* this file is the PHP-side counterpart of plugin/sbin/podman-common.sh's
* podman_load_cfg() function, and intentionally mirrors its defaults and
* paths exactly (see that script for the shell equivalent).
*/
declare(strict_types=1);
final class PodmanConfig
{
public string $storagePath;
public int $storageImageSizeGb;
public bool $enabled;
public int $stopTimeoutSeconds;
public string $socketPath;
public string $bootDir;
public string $autostartFile;
public string $autostartDelayFile;
private function __construct()
{
// Defaults mirror podman-common.sh's podman_load_cfg() defaults.
$this->bootDir = '/boot/config/plugins/podman';
$this->storagePath = '/mnt/cache/system/podman';
$this->storageImageSizeGb = 20;
$this->enabled = true;
$this->stopTimeoutSeconds = 10;
$this->socketPath = '/var/run/podman/podman.sock';
$this->autostartFile = $this->bootDir . '/autostart';
$this->autostartDelayFile = $this->bootDir . '/autostart-delay';
}
public static function load(): self
{
$cfg = new self();
$cfgFile = $cfg->bootDir . '/podman.cfg';
if (is_readable($cfgFile)) {
$values = self::parseShellStyleFile($cfgFile);
if (isset($values['STORAGE_PATH'])) {
$cfg->storagePath = $values['STORAGE_PATH'];
}
if (isset($values['STORAGE_IMAGE_SIZE_GB'])) {
$cfg->storageImageSizeGb = (int) $values['STORAGE_IMAGE_SIZE_GB'];
}
if (isset($values['PODMAN_ENABLED'])) {
$cfg->enabled = strtolower($values['PODMAN_ENABLED']) === 'yes';
}
if (isset($values['STOP_TIMEOUT'])) {
$cfg->stopTimeoutSeconds = (int) $values['STOP_TIMEOUT'];
}
}
return $cfg;
}
/**
* Parses the simple `KEY="value"` / `KEY=value` shell-sourceable format
* used by podman.cfg (see config/podman.cfg.example) WITHOUT executing
* it as shell — this file is read by an unprivileged PHP-FPM worker,
* so treating it as data rather than sourcing it is a deliberate
* safety boundary, not just a convenience.
*
* @return array<string,string>
*/
private static function parseShellStyleFile(string $path): array
{
$result = [];
$lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if ($lines === false) {
return $result;
}
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || str_starts_with($line, '#')) {
continue;
}
if (!preg_match('/^([A-Z_][A-Z0-9_]*)=(.*)$/', $line, $m)) {
continue;
}
[$_, $key, $value] = $m;
$value = trim($value);
// Strip one layer of matching quotes, if present.
if (strlen($value) >= 2 && (
($value[0] === '"' && str_ends_with($value, '"')) ||
($value[0] === "'" && str_ends_with($value, "'"))
)) {
$value = substr($value, 1, -1);
}
$result[$key] = $value;
}
return $result;
}
public function newClient(): PodmanClient
{
return new PodmanClient($this->socketPath);
}
}
@@ -0,0 +1,404 @@
<?php
/**
* PodmanClient.php
*
* Thin PHP client for the Podman libpod REST API, spoken exclusively over
* the local Unix socket that plugin/rc.d/rc.podman starts
* (`podman system service`, see docs/ARCHITECTURE.md section 18.1). Every
* AJAX endpoint under webui/plugins/podman/ajax/ goes through this class —
* none of them shell out to the `podman` binary. The one deliberate,
* documented exception to "talk to the API, not the CLI" is Compose
* support (see ajax/compose.php), because compose has no REST equivalent
* in libpod at all, not because it was more convenient to shell out.
*
* Design notes:
* - One cURL handle per request (kept simple; this is a low-traffic
* admin UI, not a high-throughput proxy — a persistent handle pool
* would be premature).
* - Every method returns a plain PHP array/bool decoded from the API's
* JSON response, or throws PodmanApiException on a non-2xx response
* or transport failure, so callers can use a single try/catch instead
* of checking a mixed-shape return value after every call.
* - No business logic lives here — this class only knows how to speak
* the libpod API. Formatting, filtering, and aggregation belong in the
* ajax/*.php endpoints that use it (see e.g. ajax/system.php for
* dashboard aggregation).
*/
declare(strict_types=1);
final class PodmanApiException extends \RuntimeException
{
public int $httpStatus;
public function __construct(string $message, int $httpStatus = 0, ?\Throwable $previous = null)
{
parent::__construct($message, 0, $previous);
$this->httpStatus = $httpStatus;
}
}
final class PodmanClient
{
/** libpod REST API version this client targets. */
private const API_VERSION = 'v4.0.0';
private string $socketPath;
private int $timeoutSeconds;
public function __construct(string $socketPath, int $timeoutSeconds = 15)
{
$this->socketPath = $socketPath;
$this->timeoutSeconds = $timeoutSeconds;
}
// -------------------------------------------------------------------
// System
// -------------------------------------------------------------------
/** GET /info — engine + host information (used by the Dashboard/Settings panels). */
public function info(): array
{
return $this->request('GET', '/info');
}
/** GET /system/df — image/container/volume disk usage summary. */
public function systemDf(): array
{
return $this->request('GET', '/system/df');
}
/** Quick reachability check — used by ajax/system.php's status endpoint. */
public function ping(): bool
{
try {
$this->request('GET', '/_ping', [], true);
return true;
} catch (PodmanApiException $e) {
return false;
}
}
// -------------------------------------------------------------------
// Containers
// -------------------------------------------------------------------
/** GET /containers/json — list containers. $all=true includes stopped ones. */
public function listContainers(bool $all = true): array
{
return $this->request('GET', '/containers/json', ['all' => $all ? 'true' : 'false']);
}
/** GET /containers/{id}/json — full inspect data for one container. */
public function inspectContainer(string $id): array
{
return $this->request('GET', '/containers/' . rawurlencode($id) . '/json');
}
/** GET /containers/{id}/stats?stream=false — one-shot CPU/memory snapshot. */
public function containerStats(string $id): array
{
return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']);
}
public function startContainer(string $id): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
}
public function stopContainer(string $id, int $timeoutSeconds = 10): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/stop', ['t' => (string) $timeoutSeconds], true);
}
public function restartContainer(string $id, int $timeoutSeconds = 10): void
{
$this->request('POST', '/containers/' . rawurlencode($id) . '/restart', ['t' => (string) $timeoutSeconds], true);
}
public function removeContainer(string $id, bool $force = false): void
{
$this->request('DELETE', '/containers/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
/**
* GET /containers/{id}/logs — returns the raw (already de-multiplexed
* where possible) log text. Podman's non-TTY log stream uses the same
* 8-byte-frame-header multiplexing as `attach`; we strip those frame
* headers in demuxStream() so callers just get plain text lines.
*/
public function containerLogs(string $id, int $tail = 200, bool $timestamps = true): string
{
$query = [
'stdout' => 'true',
'stderr' => 'true',
'tail' => (string) $tail,
'timestamps' => $timestamps ? 'true' : 'false',
];
$raw = $this->requestRaw('GET', '/containers/' . rawurlencode($id) . '/logs', $query);
return self::demuxStream($raw);
}
// -------------------------------------------------------------------
// Exec — see webui/plugins/podman/ajax/exec.php for the important
// caveat: this implements one-shot "run a command, return its output"
// semantics over the exec API, not a true interactive PTY (which would
// require a persistent bidirectional connection this stack doesn't
// have — see that file's header comment for the full explanation).
// -------------------------------------------------------------------
/**
* Creates and immediately runs one command inside a container via the
* real libpod exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) and returns its combined stdout+stderr output.
* Tty=true is used deliberately so the response is a plain byte stream
* with no frame-header demultiplexing needed (see containerLogs() for
* the non-TTY case, which does need it).
*/
public function execRun(string $containerId, array $cmd, string $workingDir = ''): string
{
$createBody = [
'AttachStdin' => false,
'AttachStdout' => true,
'AttachStderr' => true,
'Tty' => true,
'Cmd' => $cmd,
];
if ($workingDir !== '') {
$createBody['WorkingDir'] = $workingDir;
}
$created = $this->request('POST', '/containers/' . rawurlencode($containerId) . '/exec', [], false, $createBody);
$execId = $created['Id'] ?? null;
if (!is_string($execId) || $execId === '') {
throw new PodmanApiException('exec create response did not include an Id');
}
$output = $this->requestRaw('POST', '/exec/' . rawurlencode($execId) . '/start', [], [
'Detach' => false,
'Tty' => true,
]);
return $output;
}
// -------------------------------------------------------------------
// Pods
// -------------------------------------------------------------------
public function listPods(): array
{
return $this->request('GET', '/pods/json');
}
public function inspectPod(string $name): array
{
return $this->request('GET', '/pods/' . rawurlencode($name) . '/json');
}
public function startPod(string $name): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/start', [], true);
}
public function stopPod(string $name, int $timeoutSeconds = 10): void
{
$this->request('POST', '/pods/' . rawurlencode($name) . '/stop', ['t' => (string) $timeoutSeconds], true);
}
public function removePod(string $name, bool $force = false): void
{
$this->request('DELETE', '/pods/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
}
// -------------------------------------------------------------------
// Images
// -------------------------------------------------------------------
public function listImages(): array
{
return $this->request('GET', '/images/json');
}
/** POST /images/pull — pulls (or updates) an image by reference, e.g. "docker.io/library/postgres:16". */
public function pullImage(string $reference): array
{
return $this->request('POST', '/images/pull', ['reference' => $reference]);
}
public function removeImage(string $id, bool $force = false): void
{
$this->request('DELETE', '/images/' . rawurlencode($id), ['force' => $force ? 'true' : 'false'], true);
}
// -------------------------------------------------------------------
// Volumes
// -------------------------------------------------------------------
public function listVolumes(): array
{
return $this->request('GET', '/volumes/json');
}
public function createVolume(string $name, string $driver = 'local'): array
{
return $this->request('POST', '/volumes/create', [], false, ['Name' => $name, 'Driver' => $driver]);
}
public function removeVolume(string $name, bool $force = false): void
{
$this->request('DELETE', '/volumes/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
}
// -------------------------------------------------------------------
// Networks
// -------------------------------------------------------------------
public function listNetworks(): array
{
return $this->request('GET', '/networks/json');
}
public function createNetwork(string $name, string $driver, ?string $subnet = null, ?string $gateway = null): array
{
$body = ['name' => $name, 'driver' => $driver];
if ($subnet !== null) {
$body['subnets'] = [array_filter(['subnet' => $subnet, 'gateway' => $gateway])];
}
return $this->request('POST', '/networks/create', [], false, $body);
}
public function removeNetwork(string $name, bool $force = false): void
{
$this->request('DELETE', '/networks/' . rawurlencode($name), ['force' => $force ? 'true' : 'false'], true);
}
// -------------------------------------------------------------------
// Internals
// -------------------------------------------------------------------
/**
* Issues a request and JSON-decodes the response body.
*
* @param array<string,string> $query
* @param bool $expectEmptyBody set true for endpoints that reply 204/200 with no/irrelevant body
* @param array<mixed>|null $jsonBody request body to send as JSON, for POST/PUT endpoints that take one
* @return array<mixed>
*/
private function request(string $method, string $path, array $query = [], bool $expectEmptyBody = false, ?array $jsonBody = null): array
{
$raw = $this->requestRaw($method, $path, $query, $jsonBody);
if ($expectEmptyBody || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
throw new PodmanApiException('Expected a JSON object/array response from ' . $path);
}
return $decoded;
}
/**
* Issues a request and returns the raw response body as a string,
* without JSON decoding — used for endpoints whose response isn't
* JSON (logs, exec start) and internally by request().
*
* @param array<string,string> $query
* @param array<mixed>|null $jsonBody
*/
private function requestRaw(string $method, string $path, array $query = [], ?array $jsonBody = null): string
{
$url = 'http://d/' . self::API_VERSION . '/libpod' . $path;
if (!empty($query)) {
$url .= '?' . http_build_query($query);
}
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_UNIX_SOCKET_PATH => $this->socketPath,
CURLOPT_URL => $url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $this->timeoutSeconds,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
if ($jsonBody !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($jsonBody));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json', 'Accept: application/json']);
}
$body = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($errno !== 0) {
throw new PodmanApiException(
"Could not reach podman API socket ({$this->socketPath}): {$error}. Is rc.podman running?",
0
);
}
if ($status >= 400) {
$detail = self::extractErrorMessage($body ?: '');
throw new PodmanApiException("Podman API {$method} {$path} failed ({$status}): {$detail}", $status);
}
return $body === false ? '' : $body;
}
/** Best-effort extraction of libpod's {"cause":...,"message":...} error body shape. */
private static function extractErrorMessage(string $body): string
{
$decoded = json_decode($body, true);
if (is_array($decoded) && isset($decoded['message']) && is_string($decoded['message'])) {
return $decoded['message'];
}
return $body !== '' ? $body : '(no response body)';
}
/**
* Strips Docker/Podman's attach-stream frame headers from a
* non-TTY multiplexed stdout/stderr stream. Each frame is an 8-byte
* header — [stream type (1 byte), 0, 0, 0, big-endian uint32 length]
* — followed by that many bytes of payload. Stream type 1 = stdout,
* 2 = stderr; both are concatenated here since the UI just needs
* readable log text, not separated channels.
*/
private static function demuxStream(string $raw): string
{
if ($raw === '') {
return '';
}
// If the stream doesn't start with a recognizable frame header,
// assume it's already plain text (e.g. a TTY-attached container's
// logs, which libpod does not frame) and return it as-is.
$firstByte = ord($raw[0]);
if ($firstByte > 2) {
return $raw;
}
$out = '';
$offset = 0;
$len = strlen($raw);
while ($offset + 8 <= $len) {
$header = substr($raw, $offset, 8);
$unpacked = unpack('Ctype/C3pad/Nsize', $header);
if ($unpacked === false) {
break;
}
$frameLen = $unpacked['size'];
$offset += 8;
if ($offset + $frameLen > $len) {
// Truncated final frame — take what's left and stop.
$out .= substr($raw, $offset);
break;
}
$out .= substr($raw, $offset, $frameLen);
$offset += $frameLen;
}
return $out;
}
}
@@ -0,0 +1,37 @@
<?php
/**
* bootstrap.php
*
* Required by every file under webui/plugins/podman/ajax/ as its very
* first line. Centralizes the three things every endpoint would otherwise
* repeat: loading the other include/ modules, turning any PodmanApiException
* into the same JSON error envelope helpers.php's podman_json_error()
* produces (so the frontend never has to special-case "the socket was
* unreachable" vs. "podman returned a 404" vs. "something else threw"),
* and constructing a ready-to-use PodmanClient from the on-disk config.
*
* Usage, at the top of an ajax/*.php file:
* require __DIR__ . '/../include/bootstrap.php';
* // $client (PodmanClient) and $podmanConfig (PodmanConfig) are now set.
*/
declare(strict_types=1);
require_once __DIR__ . '/PodmanClient.php';
require_once __DIR__ . '/Config.php';
require_once __DIR__ . '/helpers.php';
set_exception_handler(static function (\Throwable $e): void {
if ($e instanceof PodmanApiException) {
// A 0 status means "couldn't even reach the socket" (transport
// failure) rather than an HTTP error podman itself returned —
// surfaced as 503 (Service Unavailable) since that's the more
// accurate signal to the frontend than a generic 500.
$status = $e->httpStatus > 0 ? $e->httpStatus : 503;
podman_json_error($e->getMessage(), $status);
}
podman_json_error('Internal error: ' . $e->getMessage(), 500);
});
$podmanConfig = PodmanConfig::load();
$client = $podmanConfig->newClient();
+107
View File
@@ -0,0 +1,107 @@
<?php
/**
* helpers.php
*
* Small, stateless formatting helpers shared by the ajax/*.php endpoints.
* Kept separate from PodmanClient (which only knows the API) and from the
* endpoints themselves (which only know their one resource), so the same
* "format 1610612736 bytes as 1.5 GB" logic isn't duplicated across
* images.php, volumes.php, and system.php.
*/
declare(strict_types=1);
function podman_format_bytes(int $bytes): string
{
if ($bytes <= 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = (int) floor(log($bytes, 1024));
$i = min($i, count($units) - 1);
$value = $bytes / (1024 ** $i);
return sprintf($value >= 100 || $i === 0 ? '%.0f %s' : '%.1f %s', $value, $units[$i]);
}
/** Formats a duration in seconds as a compact "14d 6h" / "6h 12m" / "38m" style string. */
function podman_format_duration(int $seconds): string
{
if ($seconds < 60) {
return $seconds . 's';
}
$days = intdiv($seconds, 86400);
$hours = intdiv($seconds % 86400, 3600);
$minutes = intdiv($seconds % 3600, 60);
if ($days > 0) {
return "{$days}d {$hours}h";
}
if ($hours > 0) {
return "{$hours}h {$minutes}m";
}
return "{$minutes}m";
}
/** Converts a libpod RFC3339 timestamp (as found in inspect output) to a Unix timestamp, or null if unparsable. */
function podman_parse_time(?string $rfc3339): ?int
{
if ($rfc3339 === null || $rfc3339 === '' || str_starts_with($rfc3339, '0001-01-01')) {
return null;
}
$ts = strtotime($rfc3339);
return $ts === false ? null : $ts;
}
/**
* Shortens a full image/container ID to the 12-character form Docker/
* Podman CLIs conventionally display, matching what users expect to see
* (and copy-paste into `podman inspect <id>`, which accepts short IDs).
*/
function podman_short_id(string $id): string
{
// Some APIs prefix with "sha256:" for image IDs.
$id = str_starts_with($id, 'sha256:') ? substr($id, 7) : $id;
return substr($id, 0, 12);
}
/**
* Sends a JSON response and terminates the request — every ajax/*.php
* endpoint's single exit point, so response shape (envelope with "ok" and
* either "data" or "error") is consistent for the frontend's shared AJAX
* helper (javascript/app.js's request() function) to rely on.
*/
function podman_json_response(mixed $data, int $httpStatus = 200): never
{
http_response_code($httpStatus);
header('Content-Type: application/json');
echo json_encode(['ok' => $httpStatus < 400, 'data' => $data], JSON_UNESCAPED_SLASHES);
exit;
}
function podman_json_error(string $message, int $httpStatus = 500): never
{
http_response_code($httpStatus);
header('Content-Type: application/json');
echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_SLASHES);
exit;
}
/**
* Reads and JSON-decodes the request body for POST/DELETE actions that
* take parameters (e.g. {"id": "..."}), with a friendly error on
* malformed input instead of a fatal error deep inside an endpoint.
*
* @return array<string,mixed>
*/
function podman_read_json_body(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
podman_json_error('Request body must be a JSON object', 400);
}
return $decoded;
}
+186
View File
@@ -0,0 +1,186 @@
/**
* javascript/app.js
*
* Shared runtime for the Podman plugin page: the AJAX helper every panel
* module uses to talk to webui/plugins/podman/ajax/*.php, small DOM
* utilities to avoid repeating the same escaping/formatting logic in ten
* places, and the sub-tab router that shows/hides panels and lazily
* initializes each one's module the first time it's opened.
*
* Loaded first (before any panel module) — see Podman.page.
*/
window.Podman = (function () {
'use strict';
const BASE = '/plugins/podman/ajax/';
/**
* Calls one ajax/<file>.php?action=<action> endpoint and resolves with
* response.data, or rejects with an Error carrying the server's message
* — every ajax/*.php endpoint replies with the same {ok, data|error}
* envelope (see include/helpers.php's podman_json_response/_error), so
* this one function is the only place that envelope shape is known.
*
* @param {string} file e.g. "containers"
* @param {string} action e.g. "list"
* @param {('GET'|'POST')} method
* @param {object|null} body sent as JSON for POST
* @param {object} query extra query-string params (e.g. {id: "..."})
*/
function call(file, action, method, body, query) {
const params = new URLSearchParams(Object.assign({ action: action }, query || {}));
const url = BASE + file + '.php?' + params.toString();
const opts = { method: method, headers: {} };
if (body !== undefined && body !== null) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
return fetch(url, opts)
.then(function (res) {
return res.json().then(function (envelope) {
if (!envelope.ok) {
throw new Error(envelope.error || ('Request failed (' + res.status + ')'));
}
return envelope.data;
});
});
}
function get(file, action, query) {
return call(file, action, 'GET', null, query);
}
function post(file, action, body) {
return call(file, action, 'POST', body || {}, {});
}
// --- DOM helpers ---------------------------------------------------------
function escapeHtml(value) {
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function el(id) {
return document.getElementById(id);
}
function formatBytes(bytes) {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / Math.pow(1024, i);
return (value >= 100 || i === 0 ? value.toFixed(0) : value.toFixed(1)) + ' ' + units[i];
}
function formatDuration(seconds) {
if (seconds == null) return '—';
if (seconds < 60) return seconds + 's';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return days + 'd ' + hours + 'h';
if (hours > 0) return hours + 'h ' + minutes + 'm';
return minutes + 'm';
}
function formatRelativeTime(unixSeconds) {
if (!unixSeconds) return '—';
const diff = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
}
/** Status string (from libpod's container "State") -> chip color class. */
function stateChipClass(state) {
switch (state) {
case 'running': return 'podman-chip-good';
case 'paused': return 'podman-chip-warn';
case 'exited':
case 'created': return 'podman-chip-neutral';
default: return 'podman-chip-bad';
}
}
function loadingRow(colspan, label) {
return '<tr><td colspan="' + colspan + '" class="podman-loading">' + escapeHtml(label || 'Loading…') + '</td></tr>';
}
function errorRow(colspan, message) {
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
}
// --- Panel router ----------------------------------------------------------
const panelModules = {};
const initialized = {};
/** Called by each panel's own JS file (e.g. containers.js) to register itself. */
function registerPanel(name, module) {
panelModules[name] = module;
}
function activatePanel(name) {
document.querySelectorAll('.podman-subnav button').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.panel === name);
});
document.querySelectorAll('.podman-panel').forEach(function (panel) {
panel.classList.toggle('active', panel.id === 'podman-panel-' + name);
});
const module = panelModules[name];
if (!module) return;
if (!initialized[name]) {
initialized[name] = true;
if (typeof module.init === 'function') module.init();
} else if (typeof module.refresh === 'function') {
module.refresh();
}
}
function boot() {
const subnav = document.querySelector('.podman-subnav');
if (!subnav) return;
subnav.addEventListener('click', function (e) {
const btn = e.target.closest('button[data-panel]');
if (btn) activatePanel(btn.dataset.panel);
});
const refreshBtn = el('podman-refresh-all');
if (refreshBtn) {
refreshBtn.addEventListener('click', function () {
const active = document.querySelector('.podman-subnav button.active');
if (active) activatePanel(active.dataset.panel);
});
}
// Activate whichever panel is marked active in the initial HTML
// (Dashboard, by default — see Podman.page).
const initial = document.querySelector('.podman-subnav button.active');
activatePanel(initial ? initial.dataset.panel : 'dashboard');
}
document.addEventListener('DOMContentLoaded', boot);
return {
get: get,
post: post,
escapeHtml: escapeHtml,
el: el,
formatBytes: formatBytes,
formatDuration: formatDuration,
formatRelativeTime: formatRelativeTime,
stateChipClass: stateChipClass,
loadingRow: loadingRow,
errorRow: errorRow,
registerPanel: registerPanel,
activatePanel: activatePanel,
};
})();
@@ -0,0 +1,83 @@
/**
* javascript/compose.js
*
* Compose panel: project list + read-only YAML view + up/down/pull,
* backed by ajax/compose.php. See that file's header comment — this is
* the one panel whose backend shells out to the `podman compose` CLI,
* because no REST equivalent for Compose exists in libpod.
*/
(function () {
'use strict';
const P = window.Podman;
let projects = [];
let selected = null;
function statusChip(status) {
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn');
return '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
}
function renderSidebar() {
P.el('compose-sidebar').innerHTML = projects.map(function (p) {
return '<div class="podman-compose-proj' + (p.name === selected ? ' active' : '') + '" data-name="' + P.escapeHtml(p.name) + '">' +
'<div class="name" style="display:flex; justify-content:space-between; gap:8px;">' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '</div>' +
'<div class="path">' + P.escapeHtml(p.path) + '</div>' +
'</div>';
}).join('') || '<div class="podman-empty-note">No compose projects under /boot/config/plugins/podman/compose/</div>';
}
function loadYaml(name) {
P.el('compose-title').textContent = name + ' / compose.yaml';
P.el('compose-yaml').textContent = 'Loading…';
return P.get('compose', 'get', { project: name }).then(function (data) {
P.el('compose-yaml').textContent = data.yaml;
}).catch(function (err) {
P.el('compose-yaml').textContent = 'Error: ' + err.message;
});
}
function selectProject(name) {
selected = name;
renderSidebar();
loadYaml(name);
}
function loadProjects() {
return P.get('compose', 'list').then(function (data) {
projects = data;
if (!selected && projects.length > 0) selected = projects[0].name;
renderSidebar();
if (selected) loadYaml(selected);
}).catch(function (err) {
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
});
}
function runAction(action) {
if (!selected) return;
const btn = P.el('compose-action-' + action);
btn.disabled = true;
P.post('compose', action, { project: selected }).then(function (data) {
alert((data.output || 'Done.').slice(0, 2000));
return loadProjects();
}).catch(function (err) {
alert('podman compose ' + action + ' failed: ' + err.message);
}).finally(function () {
btn.disabled = false;
});
}
function init() {
P.el('compose-sidebar').addEventListener('click', function (e) {
const item = e.target.closest('.podman-compose-proj[data-name]');
if (item) selectProject(item.dataset.name);
});
P.el('compose-action-up').addEventListener('click', function () { runAction('up'); });
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); });
return loadProjects();
}
P.registerPanel('compose', { init: init, refresh: loadProjects });
})();
@@ -0,0 +1,125 @@
/**
* javascript/containers.js
*
* Containers panel: table of all containers with lifecycle actions
* (start/stop/restart/remove), backed entirely by ajax/containers.php.
*/
(function () {
'use strict';
const P = window.Podman;
let allContainers = [];
let filter = 'all';
let searchTerm = '';
function iconLabel(name) {
return P.escapeHtml(name.slice(0, 2).toUpperCase());
}
function rowHtml(c) {
const cpuMem = c.state === 'running'
? '<span class="podman-row-sub">running</span>'
: '<span class="podman-row-sub">&mdash;</span>';
return '' +
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
'<td><div class="podman-row-name"><span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</div></td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
'<td>' + cpuMem + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' +
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</td>' +
'<td class="podman-actions">' + actionButtons(c) + '</td>' +
'</tr>';
}
function actionButtons(c) {
if (c.state === 'running') {
return '' +
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">&#8635;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">&#9632;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove" disabled>&#128465;</button>';
}
return '' +
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove">&#128465;</button>';
}
function applyFilters() {
return allContainers.filter(function (c) {
if (filter === 'running' && c.state !== 'running') return false;
if (filter === 'stopped' && c.state === 'running') return false;
if (searchTerm && c.name.toLowerCase().indexOf(searchTerm) === -1 && c.image.toLowerCase().indexOf(searchTerm) === -1) return false;
return true;
});
}
function renderTable() {
const tbody = P.el('containers-tbody');
const visible = applyFilters();
tbody.innerHTML = visible.length
? visible.map(rowHtml).join('')
: '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
}
function renderCounts() {
const running = allContainers.filter(function (c) { return c.state === 'running'; }).length;
P.el('containers-count-all').textContent = 'All ' + allContainers.length;
P.el('containers-count-running').textContent = 'Running ' + running;
P.el('containers-count-stopped').textContent = 'Stopped ' + (allContainers.length - running);
}
function load() {
const tbody = P.el('containers-tbody');
tbody.innerHTML = P.loadingRow(7);
return P.get('containers', 'list').then(function (data) {
allContainers = data;
renderCounts();
renderTable();
}).catch(function (err) {
tbody.innerHTML = P.errorRow(7, err.message);
});
}
function handleAction(id, action, btn) {
const doIt = function (extra) {
btn.disabled = true;
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
alert('Action failed: ' + err.message);
btn.disabled = false;
});
};
if (action === 'remove') {
if (!confirm('Remove this container? This does not remove its volumes.')) return;
doIt({ force: true });
} else {
doIt({});
}
}
function init() {
P.el('containers-search').addEventListener('input', function (e) {
searchTerm = e.target.value.trim().toLowerCase();
renderTable();
});
document.querySelectorAll('#containers-filterset button').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('#containers-filterset button').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
filter = btn.dataset.filter;
renderTable();
});
});
P.el('containers-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const row = btn.closest('tr');
handleAction(row.dataset.id, btn.dataset.action, btn);
});
return load();
}
P.registerPanel('containers', { init: init, refresh: load });
})();
@@ -0,0 +1,52 @@
/**
* javascript/dashboard.js
*
* Dashboard panel: summary stat tiles fed by ajax/system.php?action=summary.
* The Activity list and the CPU/Memory sparkline in the mockup were
* illustrative sample data with no backing API (libpod has no "recent
* events for a container fleet" convenience endpoint beyond raw
* /events streaming, which is a separate follow-up — see the note
* rendered in place of it below) — rather than fake data pretending to be
* live, this real implementation shows what's genuinely available now
* (the summary counts) and a clear placeholder for what needs the events
* stream, so nobody mistakes a mock for a working feature.
*/
(function () {
'use strict';
const P = window.Podman;
function render(summary) {
if (!summary.reachable) {
P.el('podman-panel-dashboard').innerHTML =
'<div class="podman-card"><div class="podman-error">' +
'Cannot reach the Podman API socket (' + P.escapeHtml(summary.socketPath) + '). ' +
'Is rc.podman running? Try <code>rc.podman status</code> from a terminal.' +
'</div></div>';
return;
}
P.el('stat-running').textContent = summary.containers.running;
P.el('stat-running-total').textContent = '/ ' + summary.containers.total;
P.el('stat-pods').textContent = summary.pods;
P.el('stat-images').textContent = summary.images;
P.el('stat-volumes').textContent = summary.volumes;
P.el('stat-networks').textContent = summary.networks;
P.el('stat-images-size').textContent = summary.storage.imagesSizeFormatted;
const meta = P.el('podman-header-meta');
if (meta) {
meta.innerHTML =
'<span class="dot-good">&#9679;</span> podman.sock connected' +
(summary.podmanVersion ? ' &middot; v' + P.escapeHtml(summary.podmanVersion) : '') +
' &middot; ' + summary.containers.running + ' of ' + summary.containers.total + ' containers running';
}
}
function load() {
return P.get('system', 'summary').then(render).catch(function (err) {
P.el('podman-panel-dashboard').innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
});
}
P.registerPanel('dashboard', { init: load, refresh: load });
})();
+69
View File
@@ -0,0 +1,69 @@
/**
* javascript/images.js
*
* Images panel: table + a "Pull Image" action, backed by ajax/images.php.
*/
(function () {
'use strict';
const P = window.Podman;
let images = [];
function rowHtml(img) {
const created = img.createdAt ? P.formatRelativeTime(img.createdAt) : '&mdash;';
return '' +
'<tr data-id="' + P.escapeHtml(img.id) + '">' +
'<td>' + P.escapeHtml(img.repository) + '</td>' +
'<td class="mono">' + P.escapeHtml(img.tag) + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(img.shortId) + '</td>' +
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
'<td class="tnum">' + created + '</td>' +
'<td class="tnum">' + img.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'</tr>';
}
function render() {
const tbody = P.el('images-tbody');
tbody.innerHTML = images.length
? images.map(rowHtml).join('')
: '<tr><td colspan="7" class="podman-empty-note">No images.</td></tr>';
}
function load() {
const tbody = P.el('images-tbody');
tbody.innerHTML = P.loadingRow(7);
return P.get('images', 'list').then(function (data) {
images = data;
render();
}).catch(function (err) {
tbody.innerHTML = P.errorRow(7, err.message);
});
}
function init() {
P.el('images-pull-btn').addEventListener('click', function () {
const reference = prompt('Image to pull (e.g. docker.io/library/postgres:16):');
if (!reference) return;
P.post('images', 'pull', { reference: reference }).then(load).catch(function (err) {
alert('Pull failed: ' + err.message);
});
});
P.el('images-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
if (!btn || btn.disabled) return;
const id = btn.closest('tr').dataset.id;
if (!confirm('Remove this image?')) return;
btn.disabled = true;
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
btn.disabled = false;
});
});
return load();
}
P.registerPanel('images', { init: init, refresh: load });
})();
+102
View File
@@ -0,0 +1,102 @@
/**
* javascript/logs.js
*
* Logs panel: container selector sidebar + log pane, backed by
* ajax/containers.php?action=logs. "Follow" polls on an interval rather
* than opening a persistent stream — see javascript/terminal.js for the
* same underlying constraint (PHP-FPM's request lifecycle) applied to a
* different panel.
*/
(function () {
'use strict';
const P = window.Podman;
let containers = [];
let selectedId = null;
let follow = true;
let pollHandle = null;
function levelClass(line) {
if (/\berror\b/i.test(line)) return 'lvl-error';
if (/\bwarn(ing)?\b/i.test(line)) return 'lvl-warn';
return '';
}
function renderLog(text) {
const pane = P.el('log-pane');
const atBottom = pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 20;
const lines = text.split('\n').filter(function (l) { return l.length > 0; });
pane.innerHTML = lines.map(function (line) {
const cls = levelClass(line);
return '<div class="l' + (cls ? ' ' + cls : '') + '">' + P.escapeHtml(line) + '</div>';
}).join('') || '<div class="l podman-row-sub">(no output)</div>';
if (atBottom) pane.scrollTop = pane.scrollHeight;
}
function loadLogs() {
if (!selectedId) return Promise.resolve();
return P.get('containers', 'logs', { id: selectedId, tail: 300 }).then(function (data) {
renderLog(data.text);
}).catch(function (err) {
P.el('log-pane').innerHTML = '<div class="l podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function renderSidebar() {
const side = P.el('logs-sidebar');
side.innerHTML = containers.map(function (c) {
return '<div class="item' + (c.id === selectedId ? ' active' : '') + '" data-id="' + P.escapeHtml(c.id) + '">' +
'<span class="podman-chip ' + P.stateChipClass(c.state) + '" style="padding:2px 6px;"><span class="d"></span></span> ' +
P.escapeHtml(c.name) + '</div>';
}).join('');
}
function selectContainer(id) {
selectedId = id;
renderSidebar();
loadLogs();
}
function setPolling(enabled) {
follow = enabled;
if (pollHandle) clearInterval(pollHandle);
if (follow) {
pollHandle = setInterval(loadLogs, 4000);
}
}
function init() {
P.el('logs-sidebar').addEventListener('click', function (e) {
const item = e.target.closest('.item[data-id]');
if (item) selectContainer(item.dataset.id);
});
document.querySelectorAll('#logs-follow-toggle button').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('#logs-follow-toggle button').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
setPolling(btn.dataset.follow === 'true');
});
});
P.el('logs-filter').addEventListener('input', function (e) {
const term = e.target.value.toLowerCase();
P.el('log-pane').querySelectorAll('.l').forEach(function (line) {
line.style.display = term === '' || line.textContent.toLowerCase().includes(term) ? '' : 'none';
});
});
return P.get('containers', 'list').then(function (data) {
containers = data;
if (containers.length > 0) {
selectedId = containers[0].id;
}
renderSidebar();
setPolling(true);
return loadLogs();
}).catch(function (err) {
P.el('logs-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
});
}
P.registerPanel('logs', { init: init, refresh: loadLogs });
})();
@@ -0,0 +1,72 @@
/**
* javascript/networks.js
*
* Networks panel: table + create/remove, backed by ajax/networks.php.
*/
(function () {
'use strict';
const P = window.Podman;
let networks = [];
function rowHtml(n) {
const nameCell = n.isDefault
? '<strong>' + P.escapeHtml(n.name) + '</strong> <span class="podman-row-sub">(default)</span>'
: P.escapeHtml(n.name);
const removeDisabled = n.isDefault || n.containers > 0;
return '' +
'<tr data-name="' + P.escapeHtml(n.name) + '">' +
'<td>' + nameCell + '</td>' +
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(n.driver) + '</span></td>' +
'<td class="mono">' + P.escapeHtml(n.subnet || '&mdash;') + '</td>' +
'<td class="mono">' + P.escapeHtml(n.gateway || '&mdash;') + '</td>' +
'<td class="tnum">' + n.containers + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></td>' +
'</tr>';
}
function render() {
const tbody = P.el('networks-tbody');
tbody.innerHTML = networks.length
? networks.map(rowHtml).join('')
: '<tr><td colspan="6" class="podman-empty-note">No networks.</td></tr>';
}
function load() {
const tbody = P.el('networks-tbody');
tbody.innerHTML = P.loadingRow(6);
return P.get('networks', 'list').then(function (data) {
networks = data;
render();
}).catch(function (err) {
tbody.innerHTML = P.errorRow(6, err.message);
});
}
function init() {
P.el('networks-create-btn').addEventListener('click', function () {
const name = prompt('New network name:');
if (!name) return;
const subnet = prompt('Subnet (optional, e.g. 10.89.2.0/24):') || undefined;
P.post('networks', 'create', { name: name, driver: 'bridge', subnet: subnet }).then(load).catch(function (err) {
alert('Create failed: ' + err.message);
});
});
P.el('networks-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
if (!btn || btn.disabled) return;
const name = btn.closest('tr').dataset.name;
if (!confirm('Remove network "' + name + '"?')) return;
btn.disabled = true;
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
btn.disabled = false;
});
});
return load();
}
P.registerPanel('networks', { init: init, refresh: load });
})();
+50
View File
@@ -0,0 +1,50 @@
/**
* javascript/pods.js
*
* Pods panel: one card per pod with its member containers nested inside,
* backed by ajax/pods.php (which itself cross-references containers.php's
* data server-side — see that file for why).
*/
(function () {
'use strict';
const P = window.Podman;
function memberRow(m) {
return '' +
'<tr>' +
'<td>' + P.escapeHtml(m.name) + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(m.image) + '</td>' +
'<td><span class="podman-chip ' + P.stateChipClass(m.state) + '"><span class="d"></span>' + P.escapeHtml(m.state) + '</span></td>' +
'</tr>';
}
function podCard(pod) {
const members = pod.members.length
? pod.members.map(memberRow).join('')
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
return '' +
'<div class="podman-pod-card">' +
'<div class="podman-pod-head">' +
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
'</div>' +
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
'<tbody>' + members + '</tbody></table></div>' +
'</div>';
}
function load() {
const container = P.el('podman-panel-pods');
return P.get('pods', 'list').then(function (pods) {
container.innerHTML = pods.length
? pods.map(podCard).join('')
: '<div class="podman-card"><div class="podman-empty-note">No pods yet.</div></div>';
}).catch(function (err) {
container.innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
});
}
P.registerPanel('pods', { init: load, refresh: load });
})();
@@ -0,0 +1,95 @@
/**
* javascript/settings.js
*
* Settings panel: reads/writes unraid-podman's own configuration via
* ajax/settings.php (podman.cfg + the autostart list) — not a
* PodmanClient/libpod concern, see that file's header comment.
*/
(function () {
'use strict';
const P = window.Podman;
let autostartNames = [];
function renderAutostart() {
const tbody = P.el('autostart-tbody');
tbody.innerHTML = autostartNames.length
? autostartNames.map(function (name, i) {
return '<tr data-index="' + i + '">' +
'<td class="tnum">' + (i + 1) + '</td>' +
'<td>' + P.escapeHtml(name) + '</td>' +
'<td class="podman-actions">' +
'<button class="podman-btn podman-btn-icon" data-action="up"' + (i === 0 ? ' disabled' : '') + ' title="Move up">&#8593;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">&#8595;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">&#128465;</button>' +
'</td></tr>';
}).join('')
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
}
function saveAutostart() {
return P.post('settings', 'autostart_save', { names: autostartNames }).catch(function (err) {
alert('Could not save autostart order: ' + err.message);
});
}
function fillForm(settings) {
P.el('settings-storage-path').value = settings.storagePath;
P.el('settings-storage-size').value = settings.storageImageSizeGb;
P.el('settings-enabled').checked = settings.enabled;
P.el('settings-stop-timeout').value = settings.stopTimeoutSeconds;
autostartNames = settings.autostart.slice();
renderAutostart();
const versions = settings.packageVersions || {};
const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS'];
P.el('settings-package-versions').textContent = order
.map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); })
.join(' · ');
}
function load() {
return P.get('settings', 'get').then(fillForm).catch(function (err) {
alert('Could not load settings: ' + err.message);
});
}
function save() {
const body = {
storagePath: P.el('settings-storage-path').value.trim(),
storageImageSizeGb: parseInt(P.el('settings-storage-size').value, 10) || 20,
enabled: P.el('settings-enabled').checked,
stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10,
};
return P.post('settings', 'save', body).then(function () {
alert('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.');
}).catch(function (err) {
alert('Save failed: ' + err.message);
});
}
function init() {
P.el('settings-save-btn').addEventListener('click', save);
P.el('autostart-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const i = parseInt(btn.closest('tr').dataset.index, 10);
const action = btn.dataset.action;
if (action === 'remove') {
autostartNames.splice(i, 1);
} else if (action === 'up' && i > 0) {
[autostartNames[i - 1], autostartNames[i]] = [autostartNames[i], autostartNames[i - 1]];
} else if (action === 'down' && i < autostartNames.length - 1) {
[autostartNames[i + 1], autostartNames[i]] = [autostartNames[i], autostartNames[i + 1]];
}
renderAutostart();
saveAutostart();
});
return load();
}
P.registerPanel('settings', { init: init, refresh: load });
})();
@@ -0,0 +1,89 @@
/**
* javascript/terminal.js
*
* Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that
* file's header comment for the full, honest explanation of why this is
* "type a command, see its output" rather than a true interactive PTY —
* the short version is that libpod's interactive exec needs a persistent
* bidirectional connection this PHP/AJAX stack doesn't have, and faking
* interactivity on top of that would break the moment a user ran anything
* that expects a real terminal (vim, an interactive prompt, etc).
*
* `cd` is handled client-side: this module tracks a per-session `cwd` and
* passes it as the exec's working directory on every call, so at least
* directory navigation feels persistent even though nothing else is.
*/
(function () {
'use strict';
const P = window.Podman;
let cwd = '/';
let containerId = null;
function appendLine(html) {
const out = P.el('term-output');
const div = document.createElement('div');
div.innerHTML = html;
out.appendChild(div);
out.scrollTop = out.scrollHeight;
}
function promptHtml() {
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$';
}
function runCommand(cmd) {
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd));
// `cd <dir>` is intercepted client-side (see file header) rather than
// sent as a real command, since a one-shot exec has no way to report
// "the working directory changed" back to us otherwise.
const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/);
if (cdMatch) {
cwd = cdMatch[1].startsWith('/') ? cdMatch[1] : (cwd.replace(/\/$/, '') + '/' + cdMatch[1]);
return Promise.resolve();
}
return P.post('exec', 'run', { id: containerId, cmd: cmd, cwd: cwd }).then(function (data) {
if (data.output) appendLine('<span class="mono">' + P.escapeHtml(data.output).replace(/\n/g, '<br>') + '</span>');
}).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
});
}
function populateContainerSelect(containers) {
const select = P.el('term-container-select');
select.innerHTML = containers
.filter(function (c) { return c.state === 'running'; })
.map(function (c) { return '<option value="' + P.escapeHtml(c.id) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('');
containerId = select.value || null;
}
function init() {
const input = P.el('term-input');
P.el('term-container-select').addEventListener('change', function (e) {
containerId = e.target.value;
cwd = '/';
P.el('term-output').innerHTML = '';
});
input.addEventListener('keydown', function (e) {
if (e.key !== 'Enter') return;
const cmd = input.value;
input.value = '';
if (!containerId) {
appendLine('<span style="color:#ef6470;">No running container selected.</span>');
return;
}
if (cmd.trim() === '') return;
runCommand(cmd);
});
return P.get('containers', 'list').then(populateContainerSelect).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
});
}
P.registerPanel('terminal', { init: init });
})();
@@ -0,0 +1,68 @@
/**
* javascript/volumes.js
*
* Volumes panel: named-volume table + create/remove, backed by
* ajax/volumes.php. Bind mounts are deliberately not shown here — see
* that file's header comment.
*/
(function () {
'use strict';
const P = window.Podman;
let volumes = [];
function rowHtml(v) {
return '' +
'<tr data-name="' + P.escapeHtml(v.name) + '">' +
'<td>' + P.escapeHtml(v.name) + '</td>' +
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(v.mountpoint) + '</td>' +
'<td class="tnum">' + v.usedBy + '</td>' +
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></td>' +
'</tr>';
}
function render() {
const tbody = P.el('volumes-tbody');
tbody.innerHTML = volumes.length
? volumes.map(rowHtml).join('')
: '<tr><td colspan="5" class="podman-empty-note">No named volumes.</td></tr>';
}
function load() {
const tbody = P.el('volumes-tbody');
tbody.innerHTML = P.loadingRow(5);
return P.get('volumes', 'list').then(function (data) {
volumes = data;
render();
}).catch(function (err) {
tbody.innerHTML = P.errorRow(5, err.message);
});
}
function init() {
P.el('volumes-create-btn').addEventListener('click', function () {
const name = prompt('New volume name:');
if (!name) return;
P.post('volumes', 'create', { name: name }).then(load).catch(function (err) {
alert('Create failed: ' + err.message);
});
});
P.el('volumes-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action="remove"]');
if (!btn || btn.disabled) return;
const name = btn.closest('tr').dataset.name;
if (!confirm('Remove volume "' + name + '"? This deletes its data.')) return;
btn.disabled = true;
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
alert('Remove failed: ' + err.message);
btn.disabled = false;
});
});
return load();
}
P.registerPanel('volumes', { init: init, refresh: load });
})();
+225
View File
@@ -0,0 +1,225 @@
/**
* styles/podman.css
*
* Design tokens and component styles for the Podman plugin page. Ported
* directly from the approved mockup (webui/mockups/prototype.html) so the
* real implementation is visually identical to what was reviewed — the
* masthead/main-nav chrome from that mockup is NOT included here, since
* Podman.page renders inside Unraid's own real chrome, not a recreation
* of it.
*
* Token strategy: custom properties on :root, redefined under
* prefers-color-scheme (OS default) and under [data-theme] (explicit
* override, e.g. from Unraid's own theme setting) — component rules below
* only ever reference the tokens, never hardcode a color, so both themes
* stay in sync automatically. See docs/ARCHITECTURE.md section 18.
*/
.podman-plugin {
--bg: #eef0f2; --surface: #ffffff; --surface-2: #f4f5f7; --surface-3: #e9ebee;
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20, 22, 26, .06), 0 4px 12px rgba(20, 22, 26, .05);
--font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace;
font-family: var(--font-ui);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
.podman-plugin {
--bg: #15171b; --surface: #1c1f24; --surface-2: #23262c; --surface-3: #2b2f36;
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
}
:root[data-theme="dark"] .podman-plugin {
--bg: #15171b; --surface: #1c1f24; --surface-2: #23262c; --surface-3: #2b2f36;
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
}
:root[data-theme="light"] .podman-plugin {
--bg: #eef0f2; --surface: #ffffff; --surface-2: #f4f5f7; --surface-3: #e9ebee;
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05);
}
.podman-plugin * { box-sizing: border-box; }
.podman-plugin h1, .podman-plugin h2, .podman-plugin h3 { text-wrap: balance; margin: 0; font-weight: 600; }
.podman-plugin .tnum { font-variant-numeric: tabular-nums; }
.podman-plugin .mono { font-family: var(--font-mono); }
.podman-plugin :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
.podman-pagehead {
display: flex; align-items: flex-end; justify-content: space-between;
gap: 16px; flex-wrap: wrap; padding: 4px 0 0;
}
.podman-pagehead .titlewrap { display: flex; align-items: center; gap: 12px; }
.podman-pagehead .icon {
width: 38px; height: 38px; border-radius: 9px; background: var(--surface-3);
display: grid; place-items: center; color: var(--accent); border: 1px solid var(--border);
}
.podman-pagehead h1 { font-size: 21px; }
.podman-pagehead .meta { font-size: 12.5px; color: var(--text-dim); margin-top: 2px; }
.podman-pagehead .meta .dot-good { color: var(--good); }
.podman-pagehead .meta .dot-bad { color: var(--bad); }
.podman-btn {
appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text);
padding: 8px 14px; border-radius: 7px; font-size: 13px; font-weight: 600; cursor: pointer;
display: inline-flex; align-items: center; gap: 6px; transition: border-color .12s, background .12s;
font-family: var(--font-ui);
}
.podman-btn:hover { border-color: var(--text-faint); }
.podman-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); }
.podman-btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
.podman-btn-danger { color: var(--bad); }
.podman-btn-danger:hover { border-color: var(--bad); }
.podman-btn-icon { padding: 6px 8px; }
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
.podman-subnav {
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
overflow-x: auto; list-style: none;
}
.podman-subnav button {
appearance: none; background: none; border: none; border-bottom: 2px solid transparent;
color: var(--text-dim); padding: 10px 12px; font-size: 13px; font-weight: 600; cursor: pointer;
white-space: nowrap; display: flex; align-items: center; gap: 6px; font-family: var(--font-ui);
}
.podman-subnav button:hover { color: var(--text); }
.podman-subnav button.active { color: var(--accent-strong); border-bottom-color: var(--accent); }
.podman-subnav button .n { font-size: 10.5px; padding: 1px 5px; border-radius: 8px; background: var(--surface-3); color: var(--text-dim); }
.podman-main { padding: 20px 0 48px; }
.podman-panel { display: none; }
.podman-panel.active { display: block; }
.podman-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: var(--shadow); }
.podman-card-pad { padding: 16px 18px; }
.podman-card-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid var(--border); }
.podman-card-head h2 { font-size: 14.5px; }
.podman-card-head .sub { font-size: 12px; color: var(--text-faint); font-weight: 500; }
.podman-grid { display: grid; gap: 14px; }
.podman-stat-grid { grid-template-columns: repeat(6, 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); } }
.podman-stat { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; box-shadow: var(--shadow); }
.podman-stat .label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); font-weight: 700; }
.podman-stat .value { font-size: 24px; font-weight: 700; margin-top: 6px; }
.podman-stat .value small { font-size: 13px; color: var(--text-dim); font-weight: 600; }
.podman-stat .bar { height: 5px; border-radius: 3px; background: var(--surface-3); margin-top: 10px; overflow: hidden; }
.podman-stat .bar > span { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
.podman-chip {
display: inline-flex; align-items: center; gap: 5px; padding: 3px 9px; border-radius: 100px;
font-size: 11.5px; font-weight: 700; letter-spacing: .01em; white-space: nowrap;
}
.podman-chip .d { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
.podman-chip-good { background: var(--good-bg); color: var(--good); }
.podman-chip-warn { background: var(--warn-bg); color: var(--warn); }
.podman-chip-bad { background: var(--bad-bg); color: var(--bad); }
.podman-chip-neutral { background: var(--neutral-bg); color: var(--neutral); }
.podman-plugin table { width: 100%; border-collapse: collapse; font-size: 13px; }
.podman-plugin thead th {
text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .04em;
color: var(--text-faint); font-weight: 700; padding: 10px 18px; border-bottom: 1px solid var(--border);
}
.podman-plugin tbody td { padding: 11px 18px; border-bottom: 1px solid var(--border); vertical-align: middle; }
.podman-plugin tbody tr:last-child td { border-bottom: none; }
.podman-plugin tbody tr:hover { background: var(--surface-2); }
.podman-table-wrap { overflow-x: auto; }
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
.podman-row-name .ico {
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
}
.podman-row-sub { font-size: 11.5px; color: var(--text-faint); font-weight: 500; margin-top: 1px; }
.podman-actions { display: flex; gap: 4px; justify-content: flex-end; }
.podman-usage-mini { display: flex; align-items: center; gap: 8px; min-width: 110px; }
.podman-usage-mini .track { flex: 1; height: 5px; border-radius: 3px; background: var(--surface-3); overflow: hidden; }
.podman-usage-mini .track > span { display: block; height: 100%; background: var(--accent); }
.podman-usage-mini .num { font-size: 11.5px; color: var(--text-dim); width: 34px; text-align: right; }
.podman-toolbar { display: flex; align-items: center; gap: 10px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.podman-search { flex: 1; min-width: 180px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 11px; font-size: 13px; color: var(--text); font-family: var(--font-ui); }
.podman-search::placeholder { color: var(--text-faint); }
.podman-two-col { display: grid; grid-template-columns: 1.3fr 1fr; gap: 14px; align-items: start; }
@media (max-width: 920px) { .podman-two-col { grid-template-columns: 1fr; } }
.podman-activity-list { list-style: none; margin: 0; padding: 6px 0; }
.podman-activity-list li { display: flex; gap: 10px; padding: 9px 18px; border-bottom: 1px solid var(--border); font-size: 12.5px; }
.podman-activity-list li:last-child { border-bottom: none; }
.podman-activity-list .dot { width: 7px; height: 7px; border-radius: 50%; margin-top: 5px; flex: none; }
.podman-activity-list .when { color: var(--text-faint); font-size: 11px; margin-top: 1px; }
.podman-empty-note { padding: 28px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
.podman-pod-card { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 14px; background: var(--surface); box-shadow: var(--shadow); }
.podman-pod-head { display: flex; align-items: center; gap: 10px; padding: 13px 16px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
.podman-logs-layout { display: grid; grid-template-columns: 200px 1fr; min-height: 460px; }
@media (max-width: 760px) { .podman-logs-layout { grid-template-columns: 1fr; } }
.podman-logs-side { border-right: 1px solid var(--border); }
.podman-logs-side .item { padding: 10px 14px; font-size: 12.5px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 8px; }
.podman-logs-side .item.active { background: var(--surface-2); font-weight: 700; box-shadow: inset 2px 0 0 var(--accent); }
.podman-log-pane {
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.3px;
padding: 14px 16px; height: 400px; overflow-y: auto; line-height: 1.65;
}
.podman-log-pane .l { white-space: pre-wrap; word-break: break-word; }
.podman-log-pane .ts { color: #6b7280; }
.podman-log-pane .lvl-warn { color: #e0b23d; }
.podman-log-pane .lvl-error { color: #ef6470; }
.podman-term { background: #0f1114; color: #d7dbe0; font-family: var(--font-mono); font-size: 12.6px; border-radius: 8px; padding: 14px 16px; height: 380px; overflow-y: auto; line-height: 1.7; }
.podman-term .prompt { color: #4cc785; }
.podman-term .path { color: #6fb2f5; }
.podman-term-input {
width: 100%; margin-top: 10px; background: #0f1114; color: #d7dbe0; border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; font-family: var(--font-mono); font-size: 12.6px;
}
.podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; }
@media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } }
.podman-compose-side { border-right: 1px solid var(--border); }
.podman-compose-proj { padding: 12px 14px; border-bottom: 1px solid var(--border); cursor: pointer; }
.podman-compose-proj.active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); }
.podman-compose-proj .path { font-size: 11px; color: var(--text-faint); margin-top: 2px; font-family: var(--font-mono); }
.podman-yaml { background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.4px; padding: 16px 18px; height: 420px; overflow: auto; line-height: 1.7; white-space: pre-wrap; }
.podman-field-row { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); align-items: start; }
.podman-field-row:last-child { border-bottom: none; }
.podman-field-row label { font-weight: 600; font-size: 13px; }
.podman-field-row .hint { font-size: 11.5px; color: var(--text-faint); margin-top: 4px; max-width: 46ch; }
.podman-field-row input[type="text"], .podman-field-row input[type="number"], .podman-field-row select {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
font-size: 13px; color: var(--text); width: 100%; max-width: 340px; font-family: var(--font-ui);
}
.podman-danger-card { border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
.podman-danger-card .podman-card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); }
.podman-danger-card .podman-card-head h2 { color: var(--bad); }
.podman-badge-update { font-size: 10px; font-weight: 700; color: var(--accent-strong); background: color-mix(in srgb, var(--accent) 16%, transparent); padding: 2px 7px; border-radius: 100px; margin-left: 8px; }
.podman-loading, .podman-error { padding: 32px 18px; text-align: center; color: var(--text-faint); font-size: 13px; }
.podman-error { color: var(--bad); }