Add catatonit/nftables/docker-compose packages, fix CSRF/streaming/storage bugs found by live testing
- Package #9-11: catatonit (pod infra init), nftables (netavark firewall backend), docker-compose (external compose provider for `podman compose`) — all vendored prebuilt binaries, versions.env pinned, propagated through build-packages.sh/release.sh/podman.plg/verify+update-packages.sh. - Fix WebUI: every POST action was silently failing (empty response body) because Unraid's own CSRF protection was never satisfied — app.js now sends the page's csrf_token as X-CSRF-Token. - Fix WebUI: PodmanClient::pullImage() assumed a single JSON response, but /images/pull actually streams newline-delimited JSON — every successful pull was throwing "Expected a JSON object/array response". - Fix WebUI: compose.php's up/down status detection had the same single-JSON-vs-NDJSON bug for `podman compose ps`, plus stderr was corrupting the parse. - Add cache-busting (?v=<mtime>) to Podman.page's script/style tags so a redeployed JS/CSS fix isn't served stale from browser cache. - Add a reusable modal dialog (app.js openFormModal) replacing prompt()/alert() for New Volume/Network/Pull Image. - Add host-path (bind-mount) support when creating a named volume. - Add Create Container (image, name, network mode incl. custom networks, ports, volumes, env, restart policy, privileged, start-after-create), auto-pulling the image on first use since /containers/create doesn't. All fixes verified live against a real podman system service and, where reachable, via the actual WebUI over the real socket — not just unit-level. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,8 +16,23 @@ Icon="podman"
|
||||
* page's markup mirrors (same structure, same CSS classes, real data
|
||||
* instead of static samples).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Cache-busts every static asset with its own on-disk mtime. Unraid's
|
||||
* webserver sends no explicit no-cache headers for /plugins/ static
|
||||
* files, so without this, browsers can keep serving a stale app.js/
|
||||
* podman.css for a long time after a plugin update — verified live: a
|
||||
* bugfix to app.js's CSRF handling silently kept failing in a real
|
||||
* browser after redeploy until this was added, even though the deployed
|
||||
* file on disk was byte-for-byte correct.
|
||||
*/
|
||||
function podman_asset_version(string $relPath): string
|
||||
{
|
||||
$full = __DIR__ . $relPath;
|
||||
return is_file($full) ? (string) filemtime($full) : '0';
|
||||
}
|
||||
?>
|
||||
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css">
|
||||
<link rel="stylesheet" type="text/css" href="/plugins/podman/styles/podman.css?v=<?=podman_asset_version('/styles/podman.css')?>">
|
||||
|
||||
<div class="podman-plugin">
|
||||
|
||||
@@ -73,6 +88,7 @@ Icon="podman"
|
||||
<button data-filter="running" id="containers-count-running">Running</button>
|
||||
<button data-filter="stopped" id="containers-count-stopped">Stopped</button>
|
||||
</div>
|
||||
<button class="podman-btn podman-btn-primary" id="containers-create-btn">+ New Container</button>
|
||||
</div>
|
||||
<div class="podman-table-wrap">
|
||||
<table>
|
||||
@@ -243,14 +259,12 @@ Icon="podman"
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/plugins/podman/javascript/app.js"></script>
|
||||
<script src="/plugins/podman/javascript/dashboard.js"></script>
|
||||
<script src="/plugins/podman/javascript/containers.js"></script>
|
||||
<script src="/plugins/podman/javascript/pods.js"></script>
|
||||
<script src="/plugins/podman/javascript/images.js"></script>
|
||||
<script src="/plugins/podman/javascript/volumes.js"></script>
|
||||
<script src="/plugins/podman/javascript/networks.js"></script>
|
||||
<script src="/plugins/podman/javascript/logs.js"></script>
|
||||
<script src="/plugins/podman/javascript/terminal.js"></script>
|
||||
<script src="/plugins/podman/javascript/compose.js"></script>
|
||||
<script src="/plugins/podman/javascript/settings.js"></script>
|
||||
<?php
|
||||
foreach ([
|
||||
'app', 'dashboard', 'containers', 'pods', 'images', 'volumes',
|
||||
'networks', 'logs', 'terminal', 'compose', 'settings',
|
||||
] as $podmanJsModule) {
|
||||
$podmanJsPath = "/javascript/{$podmanJsModule}.js";
|
||||
echo '<script src="/plugins/podman' . $podmanJsPath . '?v=' . podman_asset_version($podmanJsPath) . '"></script>' . "\n";
|
||||
}
|
||||
?>
|
||||
|
||||
@@ -121,8 +121,19 @@ function compose_status(string $composeDir, string $project): string
|
||||
if ($result['exitCode'] !== 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
$decoded = json_decode($result['output'], true);
|
||||
return (is_array($decoded) && count($decoded) > 0) ? 'up' : 'down';
|
||||
// `podman compose ps --format json` emits one JSON object PER LINE
|
||||
// (JSONL), not a single JSON array — decoding the whole blob in one
|
||||
// json_decode() call fails silently (-> null) as soon as a project has
|
||||
// more than one service (verified live with a 2-service project).
|
||||
// stdout only, too: the "external compose provider" banner goes to
|
||||
// stderr and would otherwise corrupt this either way.
|
||||
$running = 0;
|
||||
foreach (explode("\n", trim($result['stdout'])) as $line) {
|
||||
if (trim($line) !== '' && is_array(json_decode($line, true))) {
|
||||
$running++;
|
||||
}
|
||||
}
|
||||
return $running > 0 ? 'up' : 'down';
|
||||
}
|
||||
|
||||
function compose_read(string $composeDir, string $project): string
|
||||
@@ -155,7 +166,7 @@ function compose_run(string $composeDir, string $project, array $subcommand): ar
|
||||
* surface even though $project has already been validated above too).
|
||||
*
|
||||
* @param array<int,string> $subcommand
|
||||
* @return array{exitCode:int,output:string}
|
||||
* @return array{exitCode:int,stdout:string,output:string}
|
||||
*/
|
||||
function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array
|
||||
{
|
||||
@@ -165,7 +176,7 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
|
||||
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
|
||||
$process = proc_open($argv, $descriptors, $pipes, $composeDir . '/' . $project);
|
||||
if (!is_resource($process)) {
|
||||
return ['exitCode' => 127, 'output' => 'Could not start podman compose process'];
|
||||
return ['exitCode' => 127, 'stdout' => '', 'output' => 'Could not start podman compose process'];
|
||||
}
|
||||
|
||||
stream_set_timeout($pipes[1], $timeoutSeconds);
|
||||
@@ -175,5 +186,9 @@ function run_compose_command(string $composeDir, string $project, array $subcomm
|
||||
fclose($pipes[2]);
|
||||
$exitCode = proc_close($process);
|
||||
|
||||
return ['exitCode' => $exitCode, 'output' => trim($stdout . $stderr)];
|
||||
// 'stdout' (raw) for callers that need to parse machine-readable
|
||||
// output (e.g. compose_status()'s JSON); 'output' (combined,
|
||||
// trimmed) for human-facing success/error messages, where seeing
|
||||
// podman's own stderr banner/warnings is actually useful context.
|
||||
return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($stdout . $stderr)];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
* restart POST {"id": "...", "timeout": 10}
|
||||
* remove POST {"id": "...", "force": false}
|
||||
* logs GET (&id=...&tail=200) -> plain text
|
||||
* create POST {"image": "...", "name": "...", "networkMode": "bridge"|"host"|"none"|"<custom-network-name>",
|
||||
* "ports": [{"hostPort": 8080, "containerPort": 80, "protocol": "tcp"}],
|
||||
* "volumes": [{"kind": "named"|"path", "source": "myvol"|"/mnt/...", "containerPath": "/data"}],
|
||||
* "env": [{"key": "...", "value": "..."}], "restartPolicy": "no",
|
||||
* "privileged": false, "startAfterCreate": true}
|
||||
*/
|
||||
|
||||
declare(strict_types=1);
|
||||
@@ -68,10 +73,129 @@ switch ($action) {
|
||||
podman_json_response(['status' => 'removed']);
|
||||
break;
|
||||
|
||||
case 'create':
|
||||
$body = podman_read_json_body();
|
||||
$image = trim((string) ($body['image'] ?? ''));
|
||||
if ($image === '') {
|
||||
podman_json_error('Missing image in request body', 400);
|
||||
}
|
||||
$spec = build_container_spec($image, $body);
|
||||
// Unlike `podman run`, /containers/create does NOT auto-pull a
|
||||
// missing image — it fails outright with a 404 "no such image"
|
||||
// (found by live-testing the Create Container form against a
|
||||
// freshly-typed image reference that wasn't pulled yet). Retry
|
||||
// once after an explicit pull rather than always pulling
|
||||
// up-front, so re-creating with an image the user already has
|
||||
// stays fast and offline-friendly.
|
||||
try {
|
||||
$id = $client->createContainer($spec);
|
||||
} catch (PodmanApiException $e) {
|
||||
if ($e->httpStatus !== 404) {
|
||||
throw $e;
|
||||
}
|
||||
$client->pullImage($image);
|
||||
$id = $client->createContainer($spec);
|
||||
}
|
||||
if ($body['startAfterCreate'] ?? true) {
|
||||
$client->startContainer($id);
|
||||
}
|
||||
podman_json_response(['id' => $id, 'status' => ($body['startAfterCreate'] ?? true) ? 'started' : 'created']);
|
||||
break;
|
||||
|
||||
default:
|
||||
podman_json_error("Unknown action '{$action}'", 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a libpod SpecGenerator body (POST /containers/create) from the
|
||||
* WebUI's Create Container form fields. Field names/shapes here
|
||||
* (portmappings, netns, networks, mounts, volumes, restart_policy) were
|
||||
* verified live against a real podman system service — see
|
||||
* PodmanClient::createContainer()'s header comment.
|
||||
*
|
||||
* @param array<string,mixed> $body
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
function build_container_spec(string $image, array $body): array
|
||||
{
|
||||
$spec = ['image' => $image];
|
||||
|
||||
$name = trim((string) ($body['name'] ?? ''));
|
||||
if ($name !== '') {
|
||||
$spec['name'] = $name;
|
||||
}
|
||||
|
||||
$env = [];
|
||||
foreach (($body['env'] ?? []) as $row) {
|
||||
$key = trim((string) ($row['key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$env[$key] = (string) ($row['value'] ?? '');
|
||||
}
|
||||
}
|
||||
if ($env !== []) {
|
||||
$spec['env'] = $env;
|
||||
}
|
||||
|
||||
$ports = [];
|
||||
foreach (($body['ports'] ?? []) as $row) {
|
||||
$hostPort = (int) ($row['hostPort'] ?? 0);
|
||||
$containerPort = (int) ($row['containerPort'] ?? 0);
|
||||
if ($hostPort > 0 && $containerPort > 0) {
|
||||
$ports[] = [
|
||||
'host_ip' => '',
|
||||
'host_port' => $hostPort,
|
||||
'container_port' => $containerPort,
|
||||
'protocol' => (string) ($row['protocol'] ?? 'tcp'),
|
||||
];
|
||||
}
|
||||
}
|
||||
if ($ports !== []) {
|
||||
$spec['portmappings'] = $ports;
|
||||
}
|
||||
|
||||
$mounts = [];
|
||||
$volumes = [];
|
||||
foreach (($body['volumes'] ?? []) as $row) {
|
||||
$source = trim((string) ($row['source'] ?? ''));
|
||||
$containerPath = trim((string) ($row['containerPath'] ?? ''));
|
||||
if ($source === '' || $containerPath === '') {
|
||||
continue;
|
||||
}
|
||||
if (($row['kind'] ?? 'named') === 'path') {
|
||||
$mounts[] = ['destination' => $containerPath, 'type' => 'bind', 'source' => $source, 'options' => ['rbind']];
|
||||
} else {
|
||||
$volumes[] = ['name' => $source, 'dest' => $containerPath];
|
||||
}
|
||||
}
|
||||
if ($mounts !== []) {
|
||||
$spec['mounts'] = $mounts;
|
||||
}
|
||||
if ($volumes !== []) {
|
||||
$spec['volumes'] = $volumes;
|
||||
}
|
||||
|
||||
// "bridge"/"host"/"none" are podman's own reserved netns modes; any
|
||||
// other value is an existing custom podman network's name, attached
|
||||
// via the "networks" field instead (verified live: passing a
|
||||
// network name through "networks" attaches it without needing an
|
||||
// explicit netns mode at all).
|
||||
$networkMode = (string) ($body['networkMode'] ?? 'bridge');
|
||||
if (in_array($networkMode, ['bridge', 'host', 'none'], true)) {
|
||||
$spec['netns'] = ['nsmode' => $networkMode];
|
||||
} elseif ($networkMode !== '') {
|
||||
$spec['networks'] = [$networkMode => new \stdClass()];
|
||||
}
|
||||
|
||||
if (isset($body['restartPolicy']) && $body['restartPolicy'] !== '') {
|
||||
$spec['restart_policy'] = (string) $body['restartPolicy'];
|
||||
}
|
||||
if ($body['privileged'] ?? false) {
|
||||
$spec['privileged'] = true;
|
||||
}
|
||||
|
||||
return $spec;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $body */
|
||||
function require_id(array $body): string
|
||||
{
|
||||
|
||||
@@ -59,7 +59,10 @@ function system_summary(PodmanClient $client, PodmanConfig $config): array
|
||||
return [
|
||||
'reachable' => true,
|
||||
'socketPath' => $config->socketPath,
|
||||
'podmanVersion' => $info['Version']['Version'] ?? null,
|
||||
// libpod's /info nests the version block under lowercase "version"
|
||||
// (unlike most other libpod endpoints, which are PascalCase
|
||||
// throughout) — verified live against a real podman system service.
|
||||
'podmanVersion' => $info['version']['Version'] ?? null,
|
||||
'containers' => [
|
||||
'total' => count($containers),
|
||||
'running' => $running,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Actions (?action=...):
|
||||
* list GET -> normalized volume list, with usedBy counts
|
||||
* create POST {"name": "...", "driver": "local"}
|
||||
* create POST {"name": "...", "driver": "local", "path": "/mnt/cache/..." (optional)}
|
||||
* remove POST {"name": "...", "force": false}
|
||||
*/
|
||||
|
||||
@@ -30,7 +30,11 @@ switch ($action) {
|
||||
if ($name === '') {
|
||||
podman_json_error('Missing name in request body', 400);
|
||||
}
|
||||
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local')));
|
||||
$path = trim((string) ($body['path'] ?? ''));
|
||||
if ($path !== '' && !str_starts_with($path, '/')) {
|
||||
podman_json_error("Host path ({$path}) must be an absolute path.", 400);
|
||||
}
|
||||
podman_json_response($client->createVolume($name, (string) ($body['driver'] ?? 'local'), $path !== '' ? $path : null));
|
||||
break;
|
||||
|
||||
case 'remove':
|
||||
@@ -65,10 +69,19 @@ function volumes_list(PodmanClient $client): array
|
||||
$out = [];
|
||||
foreach ($raw as $v) {
|
||||
$name = (string) ($v['Name'] ?? '');
|
||||
$options = $v['Options'] ?? [];
|
||||
// A volume created with our "Host path" field carries
|
||||
// type=none,o=bind,device=<path> (see PodmanClient::createVolume)
|
||||
// — surfaced separately from 'mountpoint' (podman's own internal
|
||||
// storage path, which stays populated even for bind-backed
|
||||
// volumes) so the UI can show users the host path they actually
|
||||
// asked for.
|
||||
$hostPath = (is_array($options) && ($options['o'] ?? '') === 'bind') ? (string) ($options['device'] ?? '') : null;
|
||||
$out[] = [
|
||||
'name' => $name,
|
||||
'driver' => (string) ($v['Driver'] ?? 'local'),
|
||||
'mountpoint' => (string) ($v['Mountpoint'] ?? ''),
|
||||
'hostPath' => $hostPath,
|
||||
'createdAt' => podman_parse_time($v['CreatedAt'] ?? null),
|
||||
'usedBy' => $usageCounts[$name] ?? 0,
|
||||
];
|
||||
|
||||
@@ -101,6 +101,23 @@ final class PodmanClient
|
||||
return $this->request('GET', '/containers/' . rawurlencode($id) . '/stats', ['stream' => 'false']);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /containers/create — takes a libpod SpecGenerator body. Field
|
||||
* names/shapes below (image, name, command, env, portmappings,
|
||||
* netns, networks, mounts, volumes, restart_policy, privileged) were
|
||||
* verified live against a real podman system service, not assumed
|
||||
* from docs — see ajax/containers.php's create action, which builds
|
||||
* this array from the WebUI's Create Container form.
|
||||
*
|
||||
* @param array<string,mixed> $spec
|
||||
* @return string the new container's ID
|
||||
*/
|
||||
public function createContainer(array $spec): string
|
||||
{
|
||||
$result = $this->request('POST', '/containers/create', [], false, $spec);
|
||||
return (string) ($result['Id'] ?? '');
|
||||
}
|
||||
|
||||
public function startContainer(string $id): void
|
||||
{
|
||||
$this->request('POST', '/containers/' . rawurlencode($id) . '/start', [], true);
|
||||
@@ -220,10 +237,54 @@ final class PodmanClient
|
||||
return $this->request('GET', '/images/json');
|
||||
}
|
||||
|
||||
/** POST /images/pull — pulls (or updates) an image by reference, e.g. "docker.io/library/postgres:16". */
|
||||
/**
|
||||
* POST /images/pull — pulls (or updates) an image by reference, e.g.
|
||||
* "docker.io/library/postgres:16".
|
||||
*
|
||||
* Unlike virtually every other libpod endpoint, a successful pull's
|
||||
* response body is NOT one JSON document — it's newline-delimited
|
||||
* JSON, one progress object per line (verified live:
|
||||
* `{"status":"pulling","stream":"..."}` repeated, then a final
|
||||
* `{"status":"success","images":[...],"id":"..."}` line). Feeding
|
||||
* that whole blob through the normal single-document request() here
|
||||
* made json_decode() fail on every successful pull with "Expected a
|
||||
* JSON object/array response from /images/pull" — found by
|
||||
* live-testing a real pull through the WebUI's Images panel, not
|
||||
* from reading libpod's docs. An error that happens before any
|
||||
* image data is found (e.g. unknown reference) is unaffected: libpod
|
||||
* sends that as a normal single-JSON-object 4xx response, which
|
||||
* requestRaw()/request()'s existing status>=400 handling already
|
||||
* covers correctly.
|
||||
*/
|
||||
public function pullImage(string $reference): array
|
||||
{
|
||||
return $this->request('POST', '/images/pull', ['reference' => $reference]);
|
||||
$raw = $this->requestRaw('POST', '/images/pull', ['reference' => $reference]);
|
||||
|
||||
$last = null;
|
||||
foreach (explode("\n", trim($raw)) as $line) {
|
||||
$line = trim($line);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
$decoded = json_decode($line, true);
|
||||
if (!is_array($decoded)) {
|
||||
continue;
|
||||
}
|
||||
// A mid-stream error (pull started, then failed — e.g. the
|
||||
// connection dropped partway through a layer) is reported as
|
||||
// an {"error": "..."} line rather than an HTTP error status,
|
||||
// since headers/status are already committed by the time
|
||||
// libpod knows the pull failed.
|
||||
if (isset($decoded['error'])) {
|
||||
throw new PodmanApiException((string) $decoded['error'], 502);
|
||||
}
|
||||
$last = $decoded;
|
||||
}
|
||||
|
||||
if ($last === null) {
|
||||
throw new PodmanApiException('Expected a JSON object/array response from /images/pull');
|
||||
}
|
||||
return $last;
|
||||
}
|
||||
|
||||
public function removeImage(string $id, bool $force = false): void
|
||||
@@ -240,9 +301,21 @@ final class PodmanClient
|
||||
return $this->request('GET', '/volumes/json');
|
||||
}
|
||||
|
||||
public function createVolume(string $name, string $driver = 'local'): array
|
||||
/**
|
||||
* $hostPath, if given, binds the volume directly to an existing host
|
||||
* directory instead of a podman-managed one — the local driver's
|
||||
* `type=none,o=bind,device=<path>` option trio (same mechanism
|
||||
* `podman volume create --opt type=none --opt o=bind --opt device=...`
|
||||
* uses on the CLI). Verified live: a container mounting such a volume
|
||||
* reads/writes the host path directly, not an internal copy.
|
||||
*/
|
||||
public function createVolume(string $name, string $driver = 'local', ?string $hostPath = null): array
|
||||
{
|
||||
return $this->request('POST', '/volumes/create', [], false, ['Name' => $name, 'Driver' => $driver]);
|
||||
$body = ['Name' => $name, 'Driver' => $driver];
|
||||
if ($hostPath !== null && $hostPath !== '') {
|
||||
$body['Options'] = ['type' => 'none', 'device' => $hostPath, 'o' => 'bind'];
|
||||
}
|
||||
return $this->request('POST', '/volumes/create', [], false, $body);
|
||||
}
|
||||
|
||||
public function removeVolume(string $name, bool $force = false): void
|
||||
|
||||
@@ -36,6 +36,17 @@ window.Podman = (function () {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
// Unraid's own webGui/include/local_prepend.php (auto_prepend_file on
|
||||
// every PHP request, not something this plugin controls) kills any
|
||||
// POST request with no output at all unless it carries the page's
|
||||
// CSRF token — either as a "csrf_token" POST field or this header.
|
||||
// `csrf_token` itself is a global var HeadInlineJS.php sets on every
|
||||
// Unraid page before plugin JS loads (verified live: without this
|
||||
// header, every mutating action failed with "JSON.parse: unexpected
|
||||
// end of data", i.e. an empty response body from csrf_terminate()).
|
||||
if (method === 'POST' && typeof window.csrf_token === 'string') {
|
||||
opts.headers['X-CSRF-Token'] = window.csrf_token;
|
||||
}
|
||||
|
||||
return fetch(url, opts)
|
||||
.then(function (res) {
|
||||
@@ -115,6 +126,109 @@ window.Podman = (function () {
|
||||
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
||||
}
|
||||
|
||||
// --- Modal form dialog -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shows a small form modal in place of browser-native prompt()/confirm()
|
||||
* — needed for any action that takes more than one related value (e.g.
|
||||
* "New Volume" wants a name AND an optional host path together; chaining
|
||||
* prompt() calls for that is both bad UX and can't show both fields at
|
||||
* once, or offer a hint under the path field explaining what it does).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.title
|
||||
* @param {Array<{name:string, label:string, placeholder?:string, hint?:string, required?:boolean}>} opts.fields
|
||||
* @param {string} [opts.submitLabel]
|
||||
* @param {(values: Object<string,string>) => Promise<any>} opts.onSubmit
|
||||
* Called with {fieldName: value}. Rejecting keeps the modal open and
|
||||
* shows the error inline; resolving closes it.
|
||||
*/
|
||||
function openFormModal(opts) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'podman-modal-backdrop';
|
||||
|
||||
const fieldsHtml = opts.fields.map(function (f) {
|
||||
return '' +
|
||||
'<div class="podman-modal-field">' +
|
||||
'<label for="podman-modal-' + f.name + '">' + escapeHtml(f.label) + '</label>' +
|
||||
'<input type="text" id="podman-modal-' + f.name + '" name="' + f.name + '"' +
|
||||
(f.placeholder ? ' placeholder="' + escapeHtml(f.placeholder) + '"' : '') + '>' +
|
||||
(f.hint ? '<div class="hint">' + escapeHtml(f.hint) + '</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
backdrop.innerHTML = '' +
|
||||
'<div class="podman-modal" role="dialog" aria-modal="true">' +
|
||||
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title) + '</h3></div>' +
|
||||
'<form class="podman-modal-body">' + fieldsHtml + '</form>' +
|
||||
'<div class="podman-modal-actions">' +
|
||||
'<button type="button" class="podman-btn" data-role="cancel">Cancel</button>' +
|
||||
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' +
|
||||
escapeHtml(opts.submitLabel || 'Create') + '</button>' +
|
||||
'</div></div>';
|
||||
|
||||
// Appended inside .podman-plugin, not document.body: the --surface/
|
||||
// --border/etc. custom properties this modal's CSS relies on are
|
||||
// scoped to .podman-plugin (see podman.css's token strategy comment),
|
||||
// so a modal appended to body would resolve none of them — verified
|
||||
// live: the backdrop dimming and card background were both missing,
|
||||
// only the (inherited-from-body) text was visible. position:fixed
|
||||
// still overlays the full viewport regardless of this nesting.
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||
|
||||
const firstInput = backdrop.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
|
||||
function close() {
|
||||
backdrop.remove();
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const values = {};
|
||||
opts.fields.forEach(function (f) {
|
||||
values[f.name] = backdrop.querySelector('#podman-modal-' + f.name).value.trim();
|
||||
});
|
||||
for (const f of opts.fields) {
|
||||
if (f.required && !values[f.name]) {
|
||||
showError('"' + f.label + '" is required.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
Promise.resolve(opts.onSubmit(values)).then(close).catch(function (err) {
|
||||
submitBtn.disabled = false;
|
||||
showError(err.message || String(err));
|
||||
});
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
let box = backdrop.querySelector('.podman-modal-error');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.className = 'podman-modal-error';
|
||||
backdrop.querySelector('.podman-modal-body').appendChild(box);
|
||||
}
|
||||
box.textContent = message;
|
||||
}
|
||||
|
||||
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
|
||||
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
|
||||
backdrop.querySelector('form').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
});
|
||||
backdrop.addEventListener('click', function (e) {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
document.addEventListener('keydown', function onKey(e) {
|
||||
if (e.key === 'Escape') {
|
||||
close();
|
||||
document.removeEventListener('keydown', onKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Panel router ----------------------------------------------------------
|
||||
|
||||
const panelModules = {};
|
||||
@@ -180,6 +294,7 @@ window.Podman = (function () {
|
||||
stateChipClass: stateChipClass,
|
||||
loadingRow: loadingRow,
|
||||
errorRow: errorRow,
|
||||
openFormModal: openFormModal,
|
||||
registerPanel: registerPanel,
|
||||
activatePanel: activatePanel,
|
||||
};
|
||||
|
||||
@@ -80,6 +80,180 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- Create Container -----------------------------------------------------
|
||||
//
|
||||
// Purpose-built modal (not app.js's generic openFormModal, which only
|
||||
// supports flat text fields) — port/volume/env rows are dynamic
|
||||
// add/remove groups, and network needs a <select> populated from the
|
||||
// real network list, none of which fits the generic helper. Reuses its
|
||||
// .podman-modal-* CSS classes for visual consistency.
|
||||
|
||||
function portRowHtml() {
|
||||
return '' +
|
||||
'<div class="podman-row-group-item">' +
|
||||
'<input type="text" class="mono" data-field="hostPort" placeholder="Host port">' +
|
||||
'<span>→</span>' +
|
||||
'<input type="text" class="mono" data-field="containerPort" placeholder="Container port">' +
|
||||
'<select data-field="protocol"><option value="tcp">TCP</option><option value="udp">UDP</option></select>' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon" data-remove-row title="Remove">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function volumeRowHtml() {
|
||||
return '' +
|
||||
'<div class="podman-row-group-item">' +
|
||||
'<select data-field="kind"><option value="named">Volume</option><option value="path">Host path</option></select>' +
|
||||
'<input type="text" class="mono" data-field="source" placeholder="my-volume or /mnt/cache/...">' +
|
||||
'<span>→</span>' +
|
||||
'<input type="text" class="mono" data-field="containerPath" placeholder="/data">' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon" data-remove-row title="Remove">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function envRowHtml() {
|
||||
return '' +
|
||||
'<div class="podman-row-group-item">' +
|
||||
'<input type="text" class="mono" data-field="key" placeholder="KEY">' +
|
||||
'<span>=</span>' +
|
||||
'<input type="text" class="mono" data-field="value" placeholder="value">' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon" data-remove-row title="Remove">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function addRow(groupEl, rowHtmlFn) {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = rowHtmlFn();
|
||||
const row = div.firstElementChild;
|
||||
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
|
||||
groupEl.appendChild(row);
|
||||
}
|
||||
|
||||
function readRows(groupEl) {
|
||||
return Array.from(groupEl.children).map(function (row) {
|
||||
const values = {};
|
||||
row.querySelectorAll('[data-field]').forEach(function (input) {
|
||||
values[input.dataset.field] = input.value.trim();
|
||||
});
|
||||
return values;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateContainerModal() {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'podman-modal-backdrop';
|
||||
backdrop.innerHTML = '' +
|
||||
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
|
||||
'<div class="podman-modal-head"><h3>New Container</h3></div>' +
|
||||
'<form class="podman-modal-body">' +
|
||||
'<div class="podman-modal-field"><label>Image</label>' +
|
||||
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' +
|
||||
'<div class="podman-modal-field"><label>Name (optional)</label>' +
|
||||
'<input type="text" id="cc-name" placeholder="my-container"></div>' +
|
||||
'<div class="podman-modal-field"><label>Network</label>' +
|
||||
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
|
||||
'<option value="host">Host</option><option value="none">None</option></select></div>' +
|
||||
'<div class="podman-modal-field"><label>Port mappings</label>' +
|
||||
'<div class="podman-row-group" id="cc-ports"></div>' +
|
||||
'<button type="button" class="podman-btn" data-add="port">+ Add port</button></div>' +
|
||||
'<div class="podman-modal-field"><label>Volumes</label>' +
|
||||
'<div class="podman-row-group" id="cc-volumes"></div>' +
|
||||
'<button type="button" class="podman-btn" data-add="volume">+ Add volume</button></div>' +
|
||||
'<div class="podman-modal-field"><label>Environment variables</label>' +
|
||||
'<div class="podman-row-group" id="cc-env"></div>' +
|
||||
'<button type="button" class="podman-btn" data-add="env">+ Add variable</button></div>' +
|
||||
'<div class="podman-modal-field"><label>Restart policy</label>' +
|
||||
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
|
||||
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
|
||||
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
||||
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
|
||||
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
||||
'<input type="checkbox" id="cc-start" checked> Start after create</label></div>' +
|
||||
'</form>' +
|
||||
'<div class="podman-modal-actions">' +
|
||||
'<button type="button" class="podman-btn" data-role="cancel">Cancel</button>' +
|
||||
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
|
||||
'</div></div>';
|
||||
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||
|
||||
const portsGroup = backdrop.querySelector('#cc-ports');
|
||||
const volumesGroup = backdrop.querySelector('#cc-volumes');
|
||||
const envGroup = backdrop.querySelector('#cc-env');
|
||||
addRow(portsGroup, portRowHtml);
|
||||
addRow(volumesGroup, volumeRowHtml);
|
||||
addRow(envGroup, envRowHtml);
|
||||
|
||||
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup, portRowHtml); });
|
||||
backdrop.querySelector('[data-add="volume"]').addEventListener('click', function () { addRow(volumesGroup, volumeRowHtml); });
|
||||
backdrop.querySelector('[data-add="env"]').addEventListener('click', function () { addRow(envGroup, envRowHtml); });
|
||||
|
||||
// Populate the network dropdown with any existing custom (non-default)
|
||||
// podman networks, in addition to the built-in bridge/host/none modes
|
||||
// — best-effort: if the list call fails, the three built-ins still work.
|
||||
P.get('networks', 'list').then(function (networks) {
|
||||
const select = backdrop.querySelector('#cc-network');
|
||||
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = n.name;
|
||||
opt.textContent = n.name;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}).catch(function () { /* built-in modes still usable */ });
|
||||
|
||||
backdrop.querySelector('#cc-image').focus();
|
||||
|
||||
function close() { backdrop.remove(); }
|
||||
|
||||
function showError(message) {
|
||||
let box = backdrop.querySelector('.podman-modal-error');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.className = 'podman-modal-error';
|
||||
backdrop.querySelector('.podman-modal-body').appendChild(box);
|
||||
}
|
||||
box.textContent = message;
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const image = backdrop.querySelector('#cc-image').value.trim();
|
||||
if (!image) {
|
||||
showError('"Image" is required.');
|
||||
return;
|
||||
}
|
||||
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
|
||||
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
|
||||
const env = readRows(envGroup).filter(function (r) { return r.key; });
|
||||
|
||||
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
P.post('containers', 'create', {
|
||||
image: image,
|
||||
name: backdrop.querySelector('#cc-name').value.trim(),
|
||||
networkMode: backdrop.querySelector('#cc-network').value,
|
||||
ports: ports,
|
||||
volumes: volumes,
|
||||
env: env,
|
||||
restartPolicy: backdrop.querySelector('#cc-restart').value,
|
||||
privileged: backdrop.querySelector('#cc-privileged').checked,
|
||||
startAfterCreate: backdrop.querySelector('#cc-start').checked,
|
||||
}).then(function () {
|
||||
close();
|
||||
return load();
|
||||
}).catch(function (err) {
|
||||
submitBtn.disabled = false;
|
||||
showError(err.message);
|
||||
});
|
||||
}
|
||||
|
||||
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
|
||||
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
|
||||
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
|
||||
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
|
||||
document.addEventListener('keydown', function onKey(e) {
|
||||
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
|
||||
});
|
||||
}
|
||||
|
||||
function handleAction(id, action, btn) {
|
||||
const doIt = function (extra) {
|
||||
btn.disabled = true;
|
||||
@@ -97,6 +271,8 @@
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('containers-create-btn').addEventListener('click', openCreateContainerModal);
|
||||
|
||||
P.el('containers-search').addEventListener('input', function (e) {
|
||||
searchTerm = e.target.value.trim().toLowerCase();
|
||||
renderTable();
|
||||
|
||||
@@ -43,10 +43,15 @@
|
||||
|
||||
function init() {
|
||||
P.el('images-pull-btn').addEventListener('click', function () {
|
||||
const reference = prompt('Image to pull (e.g. docker.io/library/postgres:16):');
|
||||
if (!reference) return;
|
||||
P.post('images', 'pull', { reference: reference }).then(load).catch(function (err) {
|
||||
alert('Pull failed: ' + err.message);
|
||||
P.openFormModal({
|
||||
title: 'Pull Image',
|
||||
submitLabel: 'Pull',
|
||||
fields: [
|
||||
{ name: 'reference', label: 'Image reference', required: true, placeholder: 'docker.io/library/postgres:16' },
|
||||
],
|
||||
onSubmit: function (values) {
|
||||
return P.post('images', 'pull', { reference: values.reference }).then(load);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -45,11 +45,16 @@
|
||||
|
||||
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.openFormModal({
|
||||
title: 'New Network',
|
||||
submitLabel: 'Create',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Network name', required: true, placeholder: 'my-network' },
|
||||
{ name: 'subnet', label: 'Subnet (optional)', placeholder: '10.89.2.0/24' },
|
||||
],
|
||||
onSubmit: function (values) {
|
||||
return P.post('networks', 'create', { name: values.name, driver: 'bridge', subnet: values.subnet || undefined }).then(load);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
* javascript/volumes.js
|
||||
*
|
||||
* Volumes panel: named-volume table + create/remove, backed by
|
||||
* ajax/volumes.php. Bind mounts are deliberately not shown here — see
|
||||
* that file's header comment.
|
||||
* ajax/volumes.php. Container-level bind mounts (e.g. appdata under
|
||||
* /mnt/user/appdata/...) are deliberately not shown here, since they
|
||||
* aren't a libpod-managed resource at all — see that file's header
|
||||
* comment. A named volume created here WITH a host path (v.hostPath) IS
|
||||
* still a real, listed podman volume, just backed by that path instead
|
||||
* of podman's own internal storage — see PodmanClient::createVolume().
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -11,11 +15,14 @@
|
||||
let volumes = [];
|
||||
|
||||
function rowHtml(v) {
|
||||
const pathCell = v.hostPath
|
||||
? P.escapeHtml(v.hostPath) + ' <span class="podman-chip podman-chip-neutral" title="Bind-mounted to this host path">bind</span>'
|
||||
: P.escapeHtml(v.mountpoint);
|
||||
return '' +
|
||||
'<tr data-name="' + P.escapeHtml(v.name) + '">' +
|
||||
'<td>' + P.escapeHtml(v.name) + '</td>' +
|
||||
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(v.mountpoint) + '</td>' +
|
||||
'<td class="mono podman-row-sub">' + pathCell + '</td>' +
|
||||
'<td class="tnum">' + v.usedBy + '</td>' +
|
||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></td>' +
|
||||
@@ -42,10 +49,20 @@
|
||||
|
||||
function init() {
|
||||
P.el('volumes-create-btn').addEventListener('click', function () {
|
||||
const name = prompt('New volume name:');
|
||||
if (!name) return;
|
||||
P.post('volumes', 'create', { name: name }).then(load).catch(function (err) {
|
||||
alert('Create failed: ' + err.message);
|
||||
P.openFormModal({
|
||||
title: 'New Volume',
|
||||
submitLabel: 'Create',
|
||||
fields: [
|
||||
{ name: 'name', label: 'Volume name', required: true, placeholder: 'my-volume' },
|
||||
{
|
||||
name: 'path', label: 'Host path (optional)', placeholder: '/mnt/cache/appdata/my-volume',
|
||||
hint: 'Leave empty for a podman-managed volume. Set this to bind the volume ' +
|
||||
'directly to an existing directory on disk (e.g. a cache pool path) instead.',
|
||||
},
|
||||
],
|
||||
onSubmit: function (values) {
|
||||
return P.post('volumes', 'create', { name: values.name, path: values.path || undefined }).then(load);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -222,4 +222,52 @@
|
||||
.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; }
|
||||
|
||||
/**
|
||||
* Modal form dialog — replaces browser-native prompt()/confirm() for any
|
||||
* action that needs more than a single yes/no (e.g. "New Volume" needs a
|
||||
* name AND an optional host path together, which prompt() can't express
|
||||
* as one coherent form). See app.js's openFormModal().
|
||||
*/
|
||||
.podman-modal-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(15, 17, 20, .55); z-index: 1000;
|
||||
display: flex; align-items: center; justify-content: center; padding: 20px;
|
||||
}
|
||||
.podman-modal {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
|
||||
box-shadow: var(--shadow); width: 100%; max-width: 420px; max-height: calc(100vh - 40px);
|
||||
overflow-y: auto; color: var(--text); font-family: var(--font-ui);
|
||||
}
|
||||
.podman-modal-head { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.podman-modal-head h3 { font-size: 15px; }
|
||||
.podman-modal-body { padding: 16px 20px; display: grid; gap: 14px; }
|
||||
.podman-modal-field label { display: block; font-weight: 600; font-size: 12.5px; margin-bottom: 6px; }
|
||||
.podman-modal-field input[type="text"] {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
|
||||
font-size: 13px; color: var(--text); width: 100%; font-family: var(--font-ui);
|
||||
}
|
||||
.podman-modal-field .hint { font-size: 11.5px; color: var(--text-faint); margin-top: 4px; }
|
||||
.podman-modal-field select {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 10px;
|
||||
font-size: 13px; color: var(--text); font-family: var(--font-ui);
|
||||
}
|
||||
.podman-modal-checkbox label { display: flex; align-items: center; gap: 8px; font-weight: 600; font-size: 12.5px; margin-bottom: 0; }
|
||||
.podman-modal-error { font-size: 12.5px; color: var(--bad); background: var(--bad-bg); border-radius: 7px; padding: 8px 10px; }
|
||||
.podman-modal-actions { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.podman-error { color: var(--bad); }
|
||||
|
||||
/* Wider variant + repeatable row groups, for forms with more than 1-2 fields (e.g. Create Container). */
|
||||
.podman-modal-wide { max-width: 640px; }
|
||||
.podman-row-group { display: grid; gap: 8px; margin-bottom: 8px; }
|
||||
.podman-row-group-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.podman-row-group-item input[type="text"] {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px;
|
||||
font-size: 12.5px; color: var(--text); font-family: var(--font-mono); flex: 1; min-width: 0;
|
||||
}
|
||||
.podman-row-group-item select {
|
||||
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px;
|
||||
font-size: 12.5px; color: var(--text); font-family: var(--font-ui); flex: none;
|
||||
}
|
||||
.podman-row-group-item span { color: var(--text-faint); font-size: 12px; flex: none; }
|
||||
|
||||
Reference in New Issue
Block a user