Rework Terminal into a real live console; polish danger buttons and Settings
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:
@@ -184,12 +184,22 @@ function podman_asset_version(string $relPath): string
|
||||
<!-- ============================= TERMINAL ============================= -->
|
||||
<section class="podman-panel" id="podman-panel-terminal">
|
||||
<div class="podman-card">
|
||||
<div class="podman-card-head"><h2>Live Terminal</h2></div>
|
||||
<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 class="podman-term-launcher">
|
||||
<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">▶ Open Terminal</button>
|
||||
<button class="podman-btn podman-btn-ghost podman-btn-danger" id="term-disconnect-btn" disabled>■ 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 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>
|
||||
@@ -207,7 +217,7 @@ function podman_asset_version(string $relPath): string
|
||||
<div>
|
||||
<div class="podman-toolbar">
|
||||
<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">⬇ Pull</button>
|
||||
<button class="podman-btn" id="compose-action-down">■ Down</button>
|
||||
<button class="podman-btn podman-btn-primary" id="compose-action-up">▶ Up</button>
|
||||
@@ -221,9 +231,15 @@ function podman_asset_version(string $relPath): string
|
||||
|
||||
<!-- ============================= 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-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">
|
||||
<label for="settings-storage-path">Storage path</label>
|
||||
<div>
|
||||
@@ -233,40 +249,52 @@ function podman_asset_version(string $relPath): string
|
||||
</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 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 class="podman-card">
|
||||
<div class="podman-card-head"><h2>Autostart & Lifecycle</h2></div>
|
||||
<div class="podman-card-head">
|
||||
<div><h2>Autostart & Lifecycle</h2><div class="sub">What runs when the array starts, and how containers shut down.</div></div>
|
||||
</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>
|
||||
<label class="podman-switch">
|
||||
<input type="checkbox" id="settings-enabled"><span class="podman-switch-track"><span class="podman-switch-thumb"></span></span>
|
||||
</label>
|
||||
</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>
|
||||
<label for="settings-stop-timeout">Container stop timeout</label>
|
||||
<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 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 class="podman-card-head">
|
||||
<div><h2>Installed Packages</h2><div class="sub">Versions currently installed on this system.</div></div>
|
||||
</div>
|
||||
<div class="podman-card-pad">
|
||||
<div class="podman-version-chips" id="settings-package-versions">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,35 +3,49 @@
|
||||
* 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:
|
||||
* via podman system service, no shell hacks" needs an honest caveat spelled
|
||||
* 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
|
||||
* 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.
|
||||
* POST /exec/{id}/start) works by HTTP connection hijacking: the 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. An earlier version of this file worked around that by
|
||||
* 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
|
||||
* 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).
|
||||
* Unraid's own webGui already solves exactly this problem for its System
|
||||
* Terminal and for `docker exec` (see
|
||||
* /usr/local/emhttp/plugins/dynamix/include/OpenTerminal.php's 'docker'
|
||||
* case, and /etc/nginx/conf.d/locations.conf's "logterminal" location
|
||||
* block) — by spawning one `ttyd` instance per session, bound to a unix
|
||||
* socket under /var/tmp, wrapping the real interactive command; nginx then
|
||||
* proxies /logterminal/<name>/ to that socket with a WebSocket upgrade,
|
||||
* 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, ...)
|
||||
* 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.
|
||||
* This is the one place in the plugin that shells out to the `podman`
|
||||
* binary via proc invocation rather than the REST API — container names
|
||||
* are validated against a fixed safe pattern and passed through
|
||||
* escapeshellarg(), never concatenated into a shell string.
|
||||
*
|
||||
* 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);
|
||||
@@ -41,27 +55,103 @@ require __DIR__ . '/../include/bootstrap.php';
|
||||
$action = $_GET['action'] ?? '';
|
||||
|
||||
switch ($action) {
|
||||
case 'run':
|
||||
case 'open':
|
||||
$body = podman_read_json_body();
|
||||
$id = (string) ($body['id'] ?? '');
|
||||
$commandLine = (string) ($body['cmd'] ?? '');
|
||||
$cwd = (string) ($body['cwd'] ?? '');
|
||||
$name = (string) ($body['name'] ?? '');
|
||||
$shell = (string) ($body['shell'] ?? 'sh');
|
||||
|
||||
if ($id === '' || trim($commandLine) === '') {
|
||||
podman_json_error('Missing id or cmd in request body', 400);
|
||||
// Same character set libpod itself allows in container names —
|
||||
// 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
|
||||
// (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(open_terminal($name, $shell));
|
||||
break;
|
||||
|
||||
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;
|
||||
|
||||
default:
|
||||
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).
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
'<td class="tnum">' + img.usedBy + '</td>' +
|
||||
'<td class="podman-actions"><div class="podman-actions-row">' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="tag" title="Add tag">🏷</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"') + '>🗑</button></div></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
'<td class="mono">' + P.escapeHtml(n.subnet || '—') + '</td>' +
|
||||
'<td class="mono">' + P.escapeHtml(n.gateway || '—') + '</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">🗑</button></div></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
@@ -43,9 +43,12 @@
|
||||
|
||||
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(' · ');
|
||||
const chipsHtml = order.map(function (k) {
|
||||
const name = k.toLowerCase().replace(/_/g, '-');
|
||||
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() {
|
||||
|
||||
@@ -1,89 +1,97 @@
|
||||
/**
|
||||
* 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).
|
||||
* Terminal panel: opens a real, fully interactive terminal inline (as an
|
||||
* <iframe>, not a popup window) — the same mechanism Unraid's own webGui
|
||||
* uses for its System Terminal and for `docker exec` (see ajax/exec.php's
|
||||
* header comment for the full explanation). This module's own job is just:
|
||||
*
|
||||
* `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.
|
||||
* 1. Ask ajax/exec.php to spawn a ttyd instance wrapping
|
||||
* `podman exec -it <container> <shell>`, bound to a unix socket.
|
||||
* 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 () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let cwd = '/';
|
||||
let containerId = null;
|
||||
let openName = 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 populateContainerSelect(list) {
|
||||
const select = P.el('term-container-select');
|
||||
if (!select) return;
|
||||
const running = list.filter(function (c) { return c.state === 'running'; });
|
||||
select.innerHTML = running
|
||||
.map(function (c) { return '<option value="' + P.escapeHtml(c.name) + '">' + P.escapeHtml(c.name) + '</option>'; })
|
||||
.join('') || '<option value="">No running containers</option>';
|
||||
}
|
||||
|
||||
function promptHtml() {
|
||||
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$';
|
||||
function loadContainers() {
|
||||
return P.get('containers', 'list').then(populateContainerSelect);
|
||||
}
|
||||
|
||||
function runCommand(cmd) {
|
||||
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd));
|
||||
function resetFrame(message) {
|
||||
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
|
||||
// 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();
|
||||
}
|
||||
/** Best-effort: tells the backend to kill the ttyd/podman-exec session, if any is open. Never rejects. */
|
||||
function closeCurrent() {
|
||||
if (!openName) return Promise.resolve();
|
||||
const name = openName;
|
||||
return P.post('exec', 'close', { name: name }).catch(function () {});
|
||||
}
|
||||
|
||||
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>');
|
||||
function openLiveTerminal() {
|
||||
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) {
|
||||
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) {
|
||||
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 disconnect() {
|
||||
if (!openName) return;
|
||||
const btn = P.el('term-disconnect-btn');
|
||||
btn.disabled = true;
|
||||
closeCurrent().finally(function () {
|
||||
resetFrame('Disconnected. Pick a container and click "Open Terminal" to start a new session.');
|
||||
});
|
||||
}
|
||||
|
||||
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.el('term-open-btn').addEventListener('click', openLiveTerminal);
|
||||
P.el('term-disconnect-btn').addEventListener('click', disconnect);
|
||||
return loadContainers();
|
||||
}
|
||||
|
||||
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 });
|
||||
})();
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
|
||||
'<td class="mono podman-row-sub">' + pathCell + '</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"') + '>🗑</button></div></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
--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;
|
||||
--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);
|
||||
--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;
|
||||
@@ -35,7 +35,7 @@
|
||||
--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;
|
||||
--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);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@
|
||||
--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;
|
||||
--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);
|
||||
}
|
||||
:root[data-theme="light"] .podman-plugin {
|
||||
@@ -52,7 +52,7 @@
|
||||
--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;
|
||||
--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);
|
||||
}
|
||||
|
||||
@@ -76,11 +76,20 @@
|
||||
.podman-pagehead .meta .dot-good { color: var(--good); }
|
||||
.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 {
|
||||
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);
|
||||
font-family: var(--font-ui); margin: 0;
|
||||
}
|
||||
.podman-btn:hover { border-color: var(--text-faint); }
|
||||
/*
|
||||
@@ -123,11 +132,31 @@
|
||||
background: var(--surface-2) !important; border-color: transparent !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
|
||||
would otherwise win by rule order. */
|
||||
.podman-btn-ghost.podman-btn-danger { color: var(--bad) !important; }
|
||||
.podman-btn-ghost.podman-btn-danger:hover { background: var(--bad-bg) !important; color: var(--bad) !important; }
|
||||
/*
|
||||
* A ghost button can still carry danger intent (Disconnect, Delete,
|
||||
* template "Delete") — needs its own !important since .podman-btn-ghost's
|
||||
* color/background/border would otherwise win by rule order. Solid fill
|
||||
* 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 {
|
||||
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-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-term-launcher {
|
||||
display: flex; align-items: flex-end; gap: 16px; flex-wrap: wrap; margin-bottom: 14px;
|
||||
padding: 12px 14px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px;
|
||||
}
|
||||
.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; }
|
||||
@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);
|
||||
}
|
||||
.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 h2 { color: var(--bad); }
|
||||
|
||||
@@ -460,7 +545,8 @@
|
||||
font-family: var(--font-ui); width: 100%;
|
||||
}
|
||||
.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-sep { height: 1px; background: var(--border); margin: 4px 2px; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user