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:
@@ -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>';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user