Rework Terminal into a real live console; polish danger buttons and Settings
Build Packages / Build .txz packages (push) Failing after 8m53s
Lint / ShellCheck (push) Successful in 43s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Failing after 6s

Terminal panel now opens a genuinely interactive shell (ttyd bound to a
unix socket, proxied through Unraid's own /logterminal/ nginx location —
the same mechanism Unraid's own Docker "Console" button uses) instead of
one-shot exec calls, shown inline with a Disconnect action; bash is the
default shell. Container/shell selectors and action buttons are now
correctly bottom-aligned (root cause: Unraid's theme puts a 10px margin
on every <button>, never reset before).

Destructive actions (Disconnect, Compose/Template Delete, Volumes/Images/
Networks Remove) get a consistent, solid red treatment at rest instead of
only tinting on hover, via new --bad-strong/--bad-contrast tokens.

Settings panel restructured: a real save toolbar instead of a button
buried in an empty-label row, card subtitles, a toggle switch instead of
a bare checkbox, and installed-package versions shown as chips.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 21:19:47 +00:00
co-authored by Claude Sonnet 5
parent e92f67ebba
commit 46a8503498
9 changed files with 366 additions and 186 deletions
+55 -27
View File
@@ -184,12 +184,22 @@ function podman_asset_version(string $relPath): string
<!-- ============================= TERMINAL ============================= --> <!-- ============================= TERMINAL ============================= -->
<section class="podman-panel" id="podman-panel-terminal"> <section class="podman-panel" id="podman-panel-terminal">
<div class="podman-card"> <div class="podman-card">
<div class="podman-card-head"><h2>Live Terminal</h2></div>
<div class="podman-card-pad"> <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);"> <div class="podman-term-launcher">
Exec into: <select id="term-container-select"></select> <label>Container <select class="podman-term-select" id="term-container-select"></select></label>
<label>Shell
<select class="podman-term-select" id="term-shell-select">
<option value="bash" selected>bash</option>
<option value="sh">sh</option>
</select>
</label>
<button class="podman-btn podman-btn-primary" id="term-open-btn">&#9654; Open Terminal</button>
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="term-disconnect-btn" disabled>&#9632; Disconnect</button>
</div>
<div id="term-frame-wrap">
<p class="podman-empty-note">Pick a running container and click "Open Terminal" — the same live, fully interactive terminal Unraid's own Docker "Console" button opens (arrow-key history, tab completion, vim, etc. all work).</p>
</div> </div>
<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>
</div> </div>
</section> </section>
@@ -207,7 +217,7 @@ function podman_asset_version(string $relPath): string
<div> <div>
<div class="podman-toolbar"> <div class="podman-toolbar">
<strong id="compose-title" style="flex:1;">—</strong> <strong id="compose-title" style="flex:1;">—</strong>
<button class="podman-btn podman-btn-ghost" id="compose-action-delete">Delete</button> <button class="podman-btn podman-btn-ghost podman-btn-danger" id="compose-action-delete">Delete</button>
<button class="podman-btn" id="compose-action-pull">&#11015; Pull</button> <button class="podman-btn" id="compose-action-pull">&#11015; Pull</button>
<button class="podman-btn" id="compose-action-down">&#9632; Down</button> <button class="podman-btn" id="compose-action-down">&#9632; Down</button>
<button class="podman-btn podman-btn-primary" id="compose-action-up">&#9654; Up</button> <button class="podman-btn podman-btn-primary" id="compose-action-up">&#9654; Up</button>
@@ -221,9 +231,15 @@ function podman_asset_version(string $relPath): string
<!-- ============================= SETTINGS ============================= --> <!-- ============================= SETTINGS ============================= -->
<section class="podman-panel" id="podman-panel-settings"> <section class="podman-panel" id="podman-panel-settings">
<div class="podman-settings-actions">
<span class="hint" id="settings-save-hint">Changes to storage/enabled/timeout need <span class="mono">rc.podman restart</span> to take effect.</span>
<button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button>
</div>
<div class="podman-grid"> <div class="podman-grid">
<div class="podman-card"> <div class="podman-card">
<div class="podman-card-head"><h2>Storage</h2></div> <div class="podman-card-head">
<div><h2>Storage</h2><div class="sub">Where podman keeps images, containers and volumes on disk.</div></div>
</div>
<div class="podman-field-row"> <div class="podman-field-row">
<label for="settings-storage-path">Storage path</label> <label for="settings-storage-path">Storage path</label>
<div> <div>
@@ -233,40 +249,52 @@ function podman_asset_version(string $relPath): string
</div> </div>
<div class="podman-field-row"> <div class="podman-field-row">
<label for="settings-storage-size">podman.img size</label> <label for="settings-storage-size">podman.img size</label>
<div><input type="number" id="settings-storage-size" style="max-width:100px;"> <span style="font-size:12px;color:var(--text-dim);">GB</span></div> <div>
<div class="podman-input-suffix"><input type="number" id="settings-storage-size" min="1"> <span>GB</span></div>
<div class="hint">Overlay filesystem image size. Only applies the first time podman initializes storage at this path.</div>
</div>
</div> </div>
</div> </div>
<div class="podman-card"> <div class="podman-card">
<div class="podman-card-head"><h2>Autostart &amp; Lifecycle</h2></div> <div class="podman-card-head">
<div><h2>Autostart &amp; Lifecycle</h2><div class="sub">What runs when the array starts, and how containers shut down.</div></div>
</div>
<div class="podman-field-row"> <div class="podman-field-row">
<label for="settings-enabled">Start podman on array start</label> <label for="settings-enabled">Start podman on array start</label>
<div><input type="checkbox" id="settings-enabled"></div> <div>
</div> <label class="podman-switch">
<div class="podman-field-row"> <input type="checkbox" id="settings-enabled"><span class="podman-switch-track"><span class="podman-switch-thumb"></span></span>
<label for="settings-stop-timeout">Container stop timeout</label> </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> </div>
<div class="podman-field-row"> <div class="podman-field-row">
<label></label> <label for="settings-stop-timeout">Container stop timeout</label>
<div><button class="podman-btn podman-btn-primary" id="settings-save-btn">Save Settings</button></div> <div>
<div class="podman-input-suffix"><input type="number" id="settings-stop-timeout" min="0"> <span>seconds</span></div>
<div class="hint">Grace period before a stop/restart escalates to SIGKILL.</div>
</div>
</div>
<div class="podman-field-row">
<label>Autostart order</label>
<div>
<div class="podman-table-wrap">
<table>
<thead><tr><th>#</th><th>Container</th><th></th></tr></thead>
<tbody id="autostart-tbody"></tbody>
</table>
</div>
<div class="hint">Saved immediately on reorder/remove — no separate save step.</div>
</div>
</div> </div>
</div> </div>
<div class="podman-card"> <div class="podman-card">
<div class="podman-card-head"><h2>Installed Packages</h2></div> <div class="podman-card-head">
<div class="podman-field-row"> <div><h2>Installed Packages</h2><div class="sub">Versions currently installed on this system.</div></div>
<label>Versions</label> </div>
<div class="hint mono" id="settings-package-versions" style="max-width:none;">—</div> <div class="podman-card-pad">
<div class="podman-version-chips" id="settings-package-versions">—</div>
</div> </div>
</div> </div>
</div> </div>
+127 -37
View File
@@ -3,35 +3,49 @@
* ajax/exec.php * ajax/exec.php
* *
* Backs the Terminal panel — and this is the one panel where "exclusively * Backs the Terminal panel — and this is the one panel where "exclusively
* via podman system service, no shell hacks" needs an honest caveat * via podman system service, no shell hacks" needs an honest caveat spelled
* spelled out rather than silently glossed over: * out rather than silently glossed over (the same exception ajax/compose.php
* documents for the same underlying reason: some things have no REST
* equivalent).
* *
* libpod's real exec API (POST /containers/{id}/exec, then * libpod's real exec API (POST /containers/{id}/exec, then
* POST /exec/{id}/start) is used here — PodmanClient::execRun() never * POST /exec/{id}/start) works by HTTP connection hijacking: the connection
* shells out to the `podman` binary. But that API's interactive mode works * is upgraded into a raw bidirectional byte stream for the lifetime of the
* by HTTP connection hijacking: the HTTP connection is upgraded into a raw * shell session. That model assumes a long-lived process holding the socket
* bidirectional byte stream for the lifetime of the shell session. That * open on both ends (an actual terminal emulator, or a WebSocket bridge) —
* model assumes a long-lived process holding the socket open on both ends * it does not fit PHP-FPM's request/response lifecycle, where each AJAX call
* (an actual terminal emulator, or a WebSocket bridge) — it does not fit * is a fresh, independent, short-lived process with no memory of any
* PHP-FPM's request/response lifecycle, where each AJAX call is a fresh, * previous one. An earlier version of this file worked around that by
* independent, short-lived process with no memory of any previous one. * offering one-shot "run a command, see its output" exec calls — honest
* about not being a real terminal, but not what a user expects when they
* open a "Console" tab (no history, no vim, no persistent `cd`).
* *
* Rather than fake interactivity with something that would break on the * Unraid's own webGui already solves exactly this problem for its System
* first multi-line prompt, `sudo`, or interactive editor, this endpoint * Terminal and for `docker exec` (see
* offers a deliberately simpler, honest contract: one command in, its * /usr/local/emhttp/plugins/dynamix/include/OpenTerminal.php's 'docker'
* complete output back, using Tty=true so output reads like a real * case, and /etc/nginx/conf.d/locations.conf's "logterminal" location
* terminal (colors, prompts-in-output, etc. survive) but with no * block) — by spawning one `ttyd` instance per session, bound to a unix
* persistent shell state (`cd` does not carry over between calls — see * socket under /var/tmp, wrapping the real interactive command; nginx then
* the "cwd" parameter below, which javascript/terminal.js tracks * proxies /logterminal/<name>/ to that socket with a WebSocket upgrade,
* client-side and resends every time instead). * generically, for ANY name. That proxy rule is already installed and
* already generic — this endpoint reuses it exactly the same way Unraid's
* own docker integration does, just with `podman exec -it` instead of
* `docker exec -it` as the wrapped command. `ttyd-exec` itself is a small
* wrapper script Unraid ships system-wide (sources /etc/default/ttyd for
* common xterm.js options, then execs ttyd in the background) — not
* something this plugin needs to vendor.
* *
* A true interactive PTY (arrow-key history, tab completion, vim, ...) * This is the one place in the plugin that shells out to the `podman`
* would need a WebSocket-capable process sitting between the browser and * binary via proc invocation rather than the REST API — container names
* podman.sock — out of scope for this PHP/AJAX stack; tracked as a * are validated against a fixed safe pattern and passed through
* follow-up rather than implemented as a shell-out workaround. * escapeshellarg(), never concatenated into a shell string.
* *
* Actions (?action=...): * Actions (?action=...):
* run POST {"id": "...", "cmd": "ls -la", "cwd": "/config"} * open POST {"name": "...", "shell": "sh"|"bash"} -> {"sockName": "..."}
* Caller then points an iframe/window at /logterminal/<sockName>/.
* close POST {"name": "..."} -> {"status": "closed"}
* Kills the ttyd instance (and, via it, the `podman exec` it
* wraps) for that container, if one is running.
*/ */
declare(strict_types=1); declare(strict_types=1);
@@ -41,27 +55,103 @@ require __DIR__ . '/../include/bootstrap.php';
$action = $_GET['action'] ?? ''; $action = $_GET['action'] ?? '';
switch ($action) { switch ($action) {
case 'run': case 'open':
$body = podman_read_json_body(); $body = podman_read_json_body();
$id = (string) ($body['id'] ?? ''); $name = (string) ($body['name'] ?? '');
$commandLine = (string) ($body['cmd'] ?? ''); $shell = (string) ($body['shell'] ?? 'sh');
$cwd = (string) ($body['cwd'] ?? '');
if ($id === '' || trim($commandLine) === '') { // Same character set libpod itself allows in container names —
podman_json_error('Missing id or cmd in request body', 400); // rejecting anything else here (BEFORE it's ever used to build a
// socket path or shell command) is what makes escapeshellarg() on
// top of it a defense in depth rather than the only line of
// defense.
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
if (!in_array($shell, ['sh', 'bash'], true)) {
podman_json_error('Invalid shell', 400);
} }
// The command line is run through the container's own shell podman_json_response(open_terminal($name, $shell));
// (sh -c) so the user can type ordinary shell syntax (pipes, break;
// 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]); case 'close':
$body = podman_read_json_body();
$name = (string) ($body['name'] ?? '');
if (!preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name)) {
podman_json_error('Missing or invalid container name', 400);
}
close_terminal($name);
podman_json_response(['status' => 'closed']);
break; break;
default: default:
podman_json_error("Unknown action '{$action}'", 400); podman_json_error("Unknown action '{$action}'", 400);
} }
function sock_path_for(string $containerName): string
{
// "podman." prefix keeps this plugin's per-container sockets under
// /var/tmp from ever colliding with Unraid's own docker-exec sockets
// (/var/tmp/<name>.sock), which are named after the same container
// names a user might also give their podman containers.
return '/var/tmp/podman.' . $containerName . '.sock';
}
/**
* @return array<string,mixed>
*/
function open_terminal(string $containerName, string $shell): array
{
// Close out any previous session for this container first — sockets
// are named deterministically per-container (not per-open-call), so
// without this, re-opening the same container's terminal (or switching
// shells) would try to bind a second ttyd to the same path and leave
// the first one orphaned, still running, holding /dev resources for a
// client that will never come.
close_terminal($containerName);
$sockPath = sock_path_for($containerName);
// -s9: send SIGKILL to the wrapped command when the client disconnects
// (no orphaned `podman exec` process lingering after the window is
// closed). -o -m1: accept exactly one client, then exit instead of
// staying resident waiting for a next one — matching exactly the
// options Unraid's own OpenTerminal.php uses for `docker exec` (see
// that file's 'docker' case).
$cmd = sprintf(
'ttyd-exec -s9 -o -m1 -i %s podman exec -it %s %s',
escapeshellarg($sockPath),
escapeshellarg($containerName),
escapeshellarg($shell)
);
exec($cmd, $output, $exitCode);
if ($exitCode !== 0) {
podman_json_error('Could not start terminal session', 500);
}
return ['sockName' => 'podman.' . $containerName];
}
/**
* Kills the ttyd instance (if any) bound to this container's socket, and
* removes the socket file. Matched via `pgrep -f` against the socket path
* embedded in ttyd's own argv (the -i flag passed in open_terminal()) —
* that's a stable, unique needle since it includes the "podman." prefix
* and the validated container name. Killing ttyd itself (rather than
* just closing a client connection nothing is holding) tears down the
* `podman exec` child with it, same as closing a real terminal window
* would once a client was attached.
*/
function close_terminal(string $containerName): void
{
$sockPath = sock_path_for($containerName);
exec('pgrep -f ' . escapeshellarg($sockPath) . ' 2>/dev/null', $pids);
foreach ($pids as $pid) {
if (ctype_digit($pid)) {
exec('kill ' . escapeshellarg($pid) . ' 2>/dev/null');
}
}
@unlink($sockPath);
}
@@ -193,41 +193,6 @@ final class PodmanClient
// have — see that file's header comment for the full explanation). // 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 // Pods
// ------------------------------------------------------------------- // -------------------------------------------------------------------
+1 -1
View File
@@ -20,7 +20,7 @@
'<td class="tnum">' + img.usedBy + '</td>' + '<td class="tnum">' + img.usedBy + '</td>' +
'<td class="podman-actions"><div class="podman-actions-row">' + '<td class="podman-actions"><div class="podman-actions-row">' +
'<button class="podman-btn podman-btn-icon" data-action="tag" title="Add tag">&#127991;</button>' + '<button class="podman-btn podman-btn-icon" data-action="tag" title="Add tag">&#127991;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="remove"' + '<button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' + (img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>'; '</tr>';
} }
+1 -1
View File
@@ -20,7 +20,7 @@
'<td class="mono">' + P.escapeHtml(n.subnet || '&mdash;') + '</td>' + '<td class="mono">' + P.escapeHtml(n.subnet || '&mdash;') + '</td>' +
'<td class="mono">' + P.escapeHtml(n.gateway || '&mdash;') + '</td>' + '<td class="mono">' + P.escapeHtml(n.gateway || '&mdash;') + '</td>' +
'<td class="tnum">' + n.containers + '</td>' + '<td class="tnum">' + n.containers + '</td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon" data-action="remove"' + '<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></div></td>' + (removeDisabled ? ' disabled' : '') + ' title="Remove">&#128465;</button></div></td>' +
'</tr>'; '</tr>';
} }
+6 -3
View File
@@ -43,9 +43,12 @@
const versions = settings.packageVersions || {}; const versions = settings.packageVersions || {};
const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS']; const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS'];
P.el('settings-package-versions').textContent = order const chipsHtml = order.map(function (k) {
.map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); }) const name = k.toLowerCase().replace(/_/g, '-');
.join(' · '); const version = versions[k + '_INSTALLED_VERSION'];
return '<span class="podman-version-chip">' + P.escapeHtml(name) + ' <b>' + P.escapeHtml(version || '?') + '</b></span>';
}).join('');
P.el('settings-package-versions').innerHTML = chipsHtml || '<span class="podman-empty-note">No version manifest found.</span>';
} }
function load() { function load() {
+72 -64
View File
@@ -1,89 +1,97 @@
/** /**
* javascript/terminal.js * javascript/terminal.js
* *
* Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that * Terminal panel: opens a real, fully interactive terminal inline (as an
* file's header comment for the full, honest explanation of why this is * <iframe>, not a popup window) — the same mechanism Unraid's own webGui
* "type a command, see its output" rather than a true interactive PTY — * uses for its System Terminal and for `docker exec` (see ajax/exec.php's
* the short version is that libpod's interactive exec needs a persistent * header comment for the full explanation). This module's own job is just:
* 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 * 1. Ask ajax/exec.php to spawn a ttyd instance wrapping
* passes it as the exec's working directory on every call, so at least * `podman exec -it <container> <shell>`, bound to a unix socket.
* directory navigation feels persistent even though nothing else is. * 2. Point an <iframe> at /logterminal/<sockName>/ — nginx's own
* "logterminal" location block (already installed system-wide by
* Unraid, not something this plugin configures) proxies that,
* WebSocket upgrade included, straight to ttyd's socket.
* 3. Track which container's session (if any) is currently open, so
* "Disconnect" — or opening a different container/shell — can kill
* the right ttyd process server-side instead of just discarding the
* iframe and leaving it running.
*/ */
(function () { (function () {
'use strict'; 'use strict';
const P = window.Podman; const P = window.Podman;
let cwd = '/'; let openName = null;
let containerId = null;
function appendLine(html) { function populateContainerSelect(list) {
const out = P.el('term-output'); const select = P.el('term-container-select');
const div = document.createElement('div'); if (!select) return;
div.innerHTML = html; const running = list.filter(function (c) { return c.state === 'running'; });
out.appendChild(div); select.innerHTML = running
out.scrollTop = out.scrollHeight; .map(function (c) { return '<option value="' + P.escapeHtml(c.name) + '">' + P.escapeHtml(c.name) + '</option>'; })
.join('') || '<option value="">No running containers</option>';
} }
function promptHtml() { function loadContainers() {
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$'; return P.get('containers', 'list').then(populateContainerSelect);
} }
function runCommand(cmd) { function resetFrame(message) {
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd)); P.el('term-frame-wrap').innerHTML = '<p class="podman-empty-note">' + message + '</p>';
P.el('term-disconnect-btn').disabled = true;
openName = null;
}
// `cd <dir>` is intercepted client-side (see file header) rather than /** Best-effort: tells the backend to kill the ttyd/podman-exec session, if any is open. Never rejects. */
// sent as a real command, since a one-shot exec has no way to report function closeCurrent() {
// "the working directory changed" back to us otherwise. if (!openName) return Promise.resolve();
const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/); const name = openName;
if (cdMatch) { return P.post('exec', 'close', { name: name }).catch(function () {});
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) { function openLiveTerminal() {
if (data.output) appendLine('<span class="mono">' + P.escapeHtml(data.output).replace(/\n/g, '<br>') + '</span>'); const name = P.el('term-container-select').value;
if (!name) return;
const shell = P.el('term-shell-select').value;
const wrap = P.el('term-frame-wrap');
const btn = P.el('term-open-btn');
wrap.innerHTML = '<p class="podman-empty-note">Opening terminal…</p>';
btn.disabled = true;
closeCurrent().then(function () {
return P.post('exec', 'open', { name: name, shell: shell });
}).then(function (data) {
openName = name;
P.el('term-disconnect-btn').disabled = false;
// Matches the ~200ms delay Unraid's own openTerminal() uses between
// asking the backend to spawn ttyd and navigating to its socket —
// ttyd needs a brief moment to bind before nginx can proxy to it.
setTimeout(function () {
wrap.innerHTML = '<iframe class="podman-term-frame" src="/logterminal/' + encodeURIComponent(data.sockName) + '/"></iframe>';
}, 200);
}).catch(function (err) { }).catch(function (err) {
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>'); resetFrame('Could not open terminal: ' + P.escapeHtml(err.message));
}).finally(function () {
btn.disabled = false;
}); });
} }
function populateContainerSelect(containers) { function disconnect() {
const select = P.el('term-container-select'); if (!openName) return;
select.innerHTML = containers const btn = P.el('term-disconnect-btn');
.filter(function (c) { return c.state === 'running'; }) btn.disabled = true;
.map(function (c) { return '<option value="' + P.escapeHtml(c.id) + '">' + P.escapeHtml(c.name) + '</option>'; }) closeCurrent().finally(function () {
.join(''); resetFrame('Disconnected. Pick a container and click "Open Terminal" to start a new session.');
containerId = select.value || null; });
} }
function init() { function init() {
const input = P.el('term-input'); P.el('term-open-btn').addEventListener('click', openLiveTerminal);
P.el('term-disconnect-btn').addEventListener('click', disconnect);
P.el('term-container-select').addEventListener('change', function (e) { return loadContainers();
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 }); // refresh() only repopulates the container select — it must never touch
// #term-frame-wrap, or an already-open terminal would be torn down out
// from under the user just by switching tabs and back.
P.registerPanel('terminal', { init: init, refresh: loadContainers });
})(); })();
+1 -1
View File
@@ -24,7 +24,7 @@
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' + '<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
'<td class="mono podman-row-sub">' + pathCell + '</td>' + '<td class="mono podman-row-sub">' + pathCell + '</td>' +
'<td class="tnum">' + v.usedBy + '</td>' + '<td class="tnum">' + v.usedBy + '</td>' +
'<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon" data-action="remove"' + '<td class="podman-actions"><div class="podman-actions-row"><button class="podman-btn podman-btn-icon podman-btn-danger" data-action="remove"' +
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' + (v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>&#128465;</button></div></td>' +
'</tr>'; '</tr>';
} }
+103 -17
View File
@@ -20,7 +20,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1; --border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3; --accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6; --good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee; --bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20, 22, 26, .06), 0 4px 12px rgba(20, 22, 26, .05); --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-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-mono: ui-monospace, "SF Mono", "Cascadia Code", "Roboto Mono", Consolas, "Liberation Mono", monospace;
@@ -35,7 +35,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280; --border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002; --accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d; --good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36; --bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35); --shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
} }
} }
@@ -44,7 +44,7 @@
--border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280; --border: #34383f; --text: #e7e9ec; --text-dim: #9aa1ab; --text-faint: #6b7280;
--accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002; --accent: #ef7f3f; --accent-strong: #f6975f; --accent-contrast: #1a1002;
--good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d; --good: #4cc785; --good-bg: #123322; --warn: #e0b23d; --warn-bg: #3a2e0d;
--bad: #ef6470; --bad-bg: #3a1519; --neutral: #9aa1ab; --neutral-bg: #2b2f36; --bad: #ef6470; --bad-bg: #3a1519; --bad-strong: #f6838c; --bad-contrast: #2a0a0d; --neutral: #9aa1ab; --neutral-bg: #2b2f36;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35); --shadow: 0 1px 2px rgba(0,0,0,.3), 0 8px 24px rgba(0,0,0,.35);
} }
:root[data-theme="light"] .podman-plugin { :root[data-theme="light"] .podman-plugin {
@@ -52,7 +52,7 @@
--border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1; --border: #dde1e6; --text: #1c2024; --text-dim: #5b6572; --text-faint: #8a94a1;
--accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3; --accent: #d8541a; --accent-strong: #b8420f; --accent-contrast: #fff8f3;
--good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6; --good: #1a8f4c; --good-bg: #e4f6ea; --warn: #9a6b00; --warn-bg: #fdf1d6;
--bad: #c22b3a; --bad-bg: #fbe6e8; --neutral: #5b6572; --neutral-bg: #e9ebee; --bad: #c22b3a; --bad-bg: #fbe6e8; --bad-strong: #9c1f2c; --bad-contrast: #fff5f6; --neutral: #5b6572; --neutral-bg: #e9ebee;
--shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05); --shadow: 0 1px 2px rgba(20,22,26,.06), 0 4px 12px rgba(20,22,26,.05);
} }
@@ -76,11 +76,20 @@
.podman-pagehead .meta .dot-good { color: var(--good); } .podman-pagehead .meta .dot-good { color: var(--good); }
.podman-pagehead .meta .dot-bad { color: var(--bad); } .podman-pagehead .meta .dot-bad { color: var(--bad); }
/*
* margin: 0 — Unraid's own webGui theme applies a 10px top/bottom margin
* to plain <button> elements site-wide. Without resetting it, every
* .podman-btn carries an invisible 10px gap above and below its own box,
* which silently breaks flex cross-axis alignment anywhere a button sits
* next to a non-button sibling (e.g. align-items: flex-end next to a
* <select> — verified live: the button's margin, not its content, was
* what left it floating 10px above the dropdown it should line up with).
*/
.podman-btn { .podman-btn {
appearance: none; border: 1px solid var(--border); background: var(--surface); color: var(--text); 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; 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; display: inline-flex; align-items: center; gap: 6px; transition: border-color .12s, background .12s;
font-family: var(--font-ui); font-family: var(--font-ui); margin: 0;
} }
.podman-btn:hover { border-color: var(--text-faint); } .podman-btn:hover { border-color: var(--text-faint); }
/* /*
@@ -123,11 +132,31 @@
background: var(--surface-2) !important; border-color: transparent !important; background: var(--surface-2) !important; border-color: transparent !important;
color: var(--text) !important; box-shadow: none !important; color: var(--text) !important; box-shadow: none !important;
} }
/* A ghost button can still carry danger intent (e.g. a template's /*
"Delete") — needs its own !important since .podman-btn-ghost's color * A ghost button can still carry danger intent (Disconnect, Delete,
would otherwise win by rule order. */ * template "Delete") — needs its own !important since .podman-btn-ghost's
.podman-btn-ghost.podman-btn-danger { color: var(--bad) !important; } * color/background/border would otherwise win by rule order. Solid fill
.podman-btn-ghost.podman-btn-danger:hover { background: var(--bad-bg) !important; color: var(--bad) !important; } * at rest (not just a tint, and not just on hover) so it reads with the
* same weight as .podman-btn-primary, just in red instead of accent —
* a merely tinted/outlined button still read as "just another secondary
* action" per live feedback.
*/
.podman-btn-ghost.podman-btn-danger {
color: var(--bad-contrast) !important; background: var(--bad) !important; border-color: var(--bad) !important;
}
.podman-btn-ghost.podman-btn-danger:hover {
background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; color: var(--bad-contrast) !important;
}
.podman-btn-ghost.podman-btn-danger[disabled] {
color: var(--text-faint) !important; background: transparent !important; border-color: var(--border) !important;
}
/* Icon-only danger buttons (row "remove" trash icons) carry an emoji
glyph, not text — .podman-btn-danger's `color` alone doesn't recolor an
emoji, so these get the same solid red fill instead, at rest not just
on hover, so "destructive" reads at a glance across a whole table. */
.podman-btn-icon.podman-btn-danger { border-color: var(--bad) !important; background: var(--bad) !important; }
.podman-btn-icon.podman-btn-danger:hover { background: var(--bad-strong) !important; border-color: var(--bad-strong) !important; }
.podman-btn-icon.podman-btn-danger[disabled] { border-color: var(--border) !important; background: transparent !important; }
.podman-subnav { .podman-subnav {
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
@@ -322,13 +351,23 @@
.podman-log-pane .lvl-warn { color: #e0b23d; } .podman-log-pane .lvl-warn { color: #e0b23d; }
.podman-log-pane .lvl-error { color: #ef6470; } .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-launcher {
.podman-term .prompt { color: #4cc785; } display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
.podman-term .path { color: #6fb2f5; } padding: 12px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
.podman-term-input {
width: 100%; margin-top: 10px; background: #0f1114; color: #d7dbe0; border: 1px solid var(--border);
border-radius: 6px; padding: 8px 10px; font-family: var(--font-mono); font-size: 12.6px;
} }
.podman-term-launcher label { display: flex; flex-direction: column; gap: 4px; font-size: 12.5px; font-weight: 600; color: var(--text-dim); }
/*
* !important here for the same reason as .podman-search's: Unraid's own
* webGui/styles/default-base.css has `select:where(:not(.unapi *))` rules
* for background/border/padding that would otherwise still show through
* around this class's box-model properties.
*/
.podman-term-select {
min-width: 180px !important; padding: 7px 12px !important; font-size: 13px !important;
font-family: var(--font-mono) !important; color: var(--text) !important;
background: var(--surface-3) !important; border: 1px solid var(--accent) !important; border-radius: 6px !important;
}
.podman-term-frame { display: block; width: 100%; height: 480px; border: 1px solid var(--border); border-radius: 8px; background: #0f1114; }
.podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; } .podman-compose-layout { display: grid; grid-template-columns: 230px 1fr; min-height: 480px; }
@media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } } @media (max-width: 800px) { .podman-compose-layout { grid-template-columns: 1fr; } }
@@ -360,6 +399,52 @@
font-size: 13px; color: var(--text); width: 100%; max-width: 340px; font-family: var(--font-ui); 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 { border-color: color-mix(in srgb, var(--bad) 40%, var(--border)); }
/* Settings panel: a shared save action above all cards (Storage's and
Autostart & Lifecycle's fields save together in one call — see
settings.js's save() — so one button belongs above both, not buried in
either card, and definitely not in its own row with an empty label).
Framed as its own small bar (background/border), not bare text+button
floating at the top of the page. */
.podman-settings-actions {
display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 14px;
padding: 12px 16px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px;
}
.podman-settings-actions .hint { margin: 0; max-width: 52ch; color: var(--text-dim); }
.podman-card-head .sub { margin-top: 3px; }
/* Number field + unit label (GB, seconds) — the input itself stays compact
instead of stretching to .podman-field-row's normal 340px text-field width. */
.podman-input-suffix { display: flex; align-items: center; gap: 8px; }
.podman-input-suffix input[type="number"] { max-width: 100px; width: auto; }
.podman-input-suffix span { font-size: 12px; color: var(--text-dim); }
/*
* Toggle switch — a plain checkbox reads as a leftover form control next
* to everything else in this panel getting a designed treatment; this
* hides the native checkbox (still the real, accessible input driving
* state) and draws a track+thumb off its :checked state instead. Sized in
* em off the track's own font-size so it scales if that ever changes.
*/
.podman-switch { position: relative; display: inline-flex; align-items: center; cursor: pointer; font-size: 22px; }
.podman-switch input { position: absolute; opacity: 0; width: 1px; height: 1px; }
.podman-switch-track {
display: inline-block; width: 1.9em; height: 1.05em; border-radius: 999px;
background: var(--surface-3); border: 1px solid var(--border); transition: background .15s, border-color .15s;
}
.podman-switch-thumb {
display: block; width: 0.75em; height: 0.75em; margin: 0.13em; border-radius: 50%;
background: var(--text-faint); transition: transform .15s, background .15s;
}
.podman-switch input:checked + .podman-switch-track { background: var(--accent); border-color: var(--accent); }
.podman-switch input:checked + .podman-switch-track .podman-switch-thumb { background: var(--accent-contrast); transform: translateX(0.85em); }
.podman-switch input:focus-visible + .podman-switch-track { outline: 2px solid var(--accent); outline-offset: 2px; }
.podman-version-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.podman-version-chip {
font-size: 11.5px; font-family: var(--font-mono); background: var(--surface-3); color: var(--text-dim);
border: 1px solid var(--border); padding: 5px 11px; border-radius: 100px;
}
.podman-version-chip b { color: var(--text); font-weight: 600; margin-left: 5px; }
.podman-danger-card .podman-card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); } .podman-danger-card .podman-card-head { border-bottom-color: color-mix(in srgb, var(--bad) 30%, var(--border)); }
.podman-danger-card .podman-card-head h2 { color: var(--bad); } .podman-danger-card .podman-card-head h2 { color: var(--bad); }
@@ -460,7 +545,8 @@
font-family: var(--font-ui); width: 100%; font-family: var(--font-ui); width: 100%;
} }
.podman-context-menu button:hover { background: var(--surface-2); } .podman-context-menu button:hover { background: var(--surface-2); }
.podman-context-menu button.danger { color: var(--bad); } .podman-context-menu button.danger { color: var(--bad); background: var(--bad-bg); }
.podman-context-menu button.danger:hover { background: var(--bad); color: var(--bad-contrast); }
.podman-context-menu button[disabled] { opacity: .4; cursor: not-allowed; } .podman-context-menu button[disabled] { opacity: .4; cursor: not-allowed; }
.podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; } .podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }