Add Docker/Podman container management over SSH: container list with status, ports, CPU and memory; shell and live logs in terminal tabs; start, stop, restart and remove

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 22:11:26 +02:00
co-authored by Claude Opus 5.5
parent 039d89b9f7
commit 70a15eddbc
7 changed files with 322 additions and 7 deletions
+127 -3
View File
@@ -40,6 +40,8 @@ const ICONS = {
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>',
download: '<svg viewBox="0 0 24 24"><path d="M12 4v12M6 10l6 6 6-6M4 20h16"/></svg>',
upload: '<svg viewBox="0 0 24 24"><path d="M12 20V8M6 14l6-6 6 6M4 4h16"/></svg>',
docker: '<svg viewBox="0 0 24 24"><path d="M2 12h19c-.6 4.5-4 8-10 8-5 0-8-3-9-8z"/><path d="M5 12V9h3v3M8 12V9h3v3M11 12V9h3v3M8 9V6h3v3M21 12c.5-1.5 0-3-1-3.5"/></svg>',
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
};
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
function colorFor(h) {
@@ -387,7 +389,7 @@ function hostMenu(hst) {
return [
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) },
...(hst.protocol === 'rdp' ? [{ label: T('Open in separate window'), icon: 'screen', run: () => launchRdp(hst) }] : []),
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }] : []),
...(hst.protocol !== 'rdp' ? [{ label: T('Open SFTP'), icon: 'folder', run: () => openSftp(hst) }, { label: T('Docker containers'), icon: 'docker', run: () => openDocker(hst) }] : []),
'-',
{ label: T('Edit'), icon: 'edit', run: () => editHost(hst) },
{ label: T('Duplicate'), icon: 'dup', run: async () => { const { id, createdAt, updatedAt, ...rest } = hst; await call('vault:upsert', 'hosts', { ...rest, label: (hst.label || hst.address) + T(' (copy)') }); reload(); } },
@@ -1158,7 +1160,7 @@ function addTab(session) {
tab.onclick = (e) => { if (e.target.closest('.x')) return closeTab(session.id); activateTab(session.id); };
tab.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => (session.kind === 'sftp' ? openSftp(session.host) : openTerminal(session.host)) }] : []),
...(session.host ? [{ label: T('Duplicate'), icon: 'dup', run: () => ({ sftp: openSftp, docker: openDocker }[session.kind] || openTerminal)(session.host) }] : []),
...(session.kind === 'ssh' ? [{ label: T('Reconnect'), icon: 'refresh', run: () => session.reconnect() }] : []),
{ label: T('Rename'), icon: 'edit', run: async () => { const n = await promptBox(T('Rename tab'), T('Title'), session.title); if (n) { session.title = n; $('span:nth-child(2)', tab).textContent = n; } } },
'-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
@@ -1205,6 +1207,7 @@ class TerminalSession {
this.el = h(`<div class="session">
<div class="sbar"><div class="info">${avatar(host, 22).replace('<span class="proto">SSH</span>', '')}<span>${esc(hostSub(host))}</span></div>
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button>`}
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
</div>
@@ -1262,6 +1265,7 @@ class TerminalSession {
this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'sftp') openSftp(this.host);
if (a === 'docker') openDocker(this.host);
if (a === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
if (a === 'snipnew') editSnippet();
if (a === 'find') this.toggleFind();
@@ -1331,7 +1335,7 @@ class TerminalSession {
this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
this.doFit();
try {
await api.call('ssh:open', this.id, this.host.id || this.host, { cols: this.term.cols, rows: this.term.rows });
await api.call('ssh:open', this.id, hostRef(this.host), { cols: this.term.cols, rows: this.term.rows });
this.alive = true;
this.hideOverlay();
setTabState(this, 'on');
@@ -1664,6 +1668,125 @@ class SftpSession {
}
function openSftp(host) { return new SftpSession(host); }
// Gespeicherte Hosts per ID übergeben; Container-Shells mit Zusatzbefehl
const hostRef = (host) => (host.id ? (host.execCommand ? { ref: host.id, execCommand: host.execCommand, label: host.label } : host.id) : host);
// ============================================================ Docker (Container über SSH)
class DockerSession {
constructor(host) {
this.kind = 'docker'; this.id = uid(); this.host = host;
this.title = `Docker · ${host.label || host.address}`;
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
this.el = h(`<div class="session docker">
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search containers …'))}"/></label>
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
</div>
<div class="docker-body"><div class="pane-empty"><div class="spinner" style="width:30px;height:30px;border:3px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .9s linear infinite"></div><div>${esc(T('Connecting to {host} …', { host: host.address }))}</div></div></div></div>`);
this.body = $('.docker-body', this.el);
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); });
$('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); };
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
addTab(this);
this.open();
}
async open() {
try {
this.apply(await api.call('docker:open', this.id, hostRef(this.host)));
setTabState(this, 'on');
this.loadStats();
this.timer = setInterval(() => { if (S.active === this.id && !$('.modal-bg')) this.refresh(); }, 10000);
} catch (e) {
setTabState(this, 'err');
this.error(e.message);
}
}
error(msg) {
this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--red);max-width:520px;text-align:center">${esc(msg)}</div></div>`;
const b = h(`<button class="btn primary">${ICONS.refresh}${T('Try again')}</button>`);
b.onclick = () => { closeTab(this.id); openDocker(this.host); };
$('.pane-empty', this.body).append(b);
}
apply(r) {
this.containers = r.containers;
$('.rt', this.el).textContent = ` · ${r.runtime === 'podman' ? 'Podman' : 'Docker'}${r.sudo ? ' (sudo)' : ''}`;
this.draw();
}
async refresh(withStats) {
if (this.busy) return;
this.busy = true;
try { this.apply(await api.call('docker:list', this.id)); if (withStats) this.loadStats(); } catch (e) { if (withStats) toast(e.message, 'error'); }
this.busy = false;
}
async loadStats() {
try { this.stats = await api.call('docker:stats', this.id); this.draw(); } catch {}
}
draw() {
const running = (c) => c.state === 'running';
const list = this.containers
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
.sort((a, b) => (running(b) - running(a)) || a.name.localeCompare(b.name, undefined, { numeric: true }));
if (!this.containers.length) { this.body.innerHTML = `<div class="pane-empty"><div style="color:var(--muted)">${T('No containers on this host.')}</div></div>`; return; }
const scroll = this.body.scrollTop;
this.body.innerHTML = `<table class="docker-table"><thead><tr><th></th><th>${T('Name')}</th><th>${T('Status')}</th><th>${T('Ports')}</th><th class="num">CPU</th><th class="num">${T('Memory')}</th><th></th></tr></thead><tbody>
${list.map((c) => {
const st = this.stats[c.id];
const on = running(c);
return `<tr data-id="${esc(c.id)}"><td><span class="dot ${on ? 'on' : c.state === 'paused' || c.state === 'restarting' ? 'wait' : ''}"></span></td>
<td class="name"><div><b>${esc(c.name)}</b><span class="img">${esc(c.image)}</span></div></td>
<td class="muted">${esc(c.status)}</td><td class="muted ports" title="${esc(c.ports)}">${esc(c.ports)}</td>
<td class="num">${on && st ? esc(st.cpu) : ''}</td><td class="num">${on && st ? esc(st.mem.split('/')[0].trim()) : ''}</td>
<td class="acts">
${on ? `<button class="btn ghost sm" data-c="shell" title="${T('Open shell')}">${ICONS.term}</button>` : ''}
<button class="btn ghost sm" data-c="logs" title="${T('Logs')}">${ICONS.logs}</button>
${on ? `<button class="btn ghost sm" data-c="restart" title="${T('Restart')}">${ICONS.refresh}</button><button class="btn ghost sm" data-c="stop" title="${T('Stop')}">${ICONS.stop}</button>`
: `<button class="btn ghost sm" data-c="start" title="${T('Start')}">${ICONS.play}</button><button class="btn ghost sm" data-c="remove" title="${T('Delete')}">${ICONS.trash}</button>`}
</td></tr>`;
}).join('')}</tbody></table>`;
this.body.scrollTop = scroll;
$$('tr[data-id]', this.body).forEach((tr) => {
const c = this.containers.find((x) => x.id === tr.dataset.id);
tr.onclick = (e) => { const a = e.target.closest('[data-c]')?.dataset.c; if (a) this.act(a, c, e.target.closest('button')); };
tr.ondblclick = (e) => { if (!e.target.closest('[data-c]') && running(c)) this.act('shell', c); };
tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
...(running(c) ? [{ label: T('Open shell'), icon: 'term', run: () => this.act('shell', c) }] : []),
{ label: T('Logs'), icon: 'logs', run: () => this.act('logs', c) },
'-',
...(running(c) ? [{ label: T('Restart'), icon: 'refresh', run: () => this.act('restart', c) }, { label: T('Stop'), icon: 'stop', run: () => this.act('stop', c) }]
: [{ label: T('Start'), icon: 'play', run: () => this.act('start', c) }]),
{ label: T('Copy ID'), icon: 'copy', run: () => api.call('clipboard:write', c.id) },
'-',
{ label: T('Delete'), icon: 'trash', danger: true, run: () => this.act('remove', c) },
]);
});
}
async act(a, c, btn) {
if (a === 'shell' || a === 'logs') {
const cmd = await call('docker:command', this.id, a, c.id);
openTerminal({ ...this.host, execCommand: cmd, label: `${a === 'logs' ? T('Logs') : '🐳'} ${c.name}` });
return;
}
if (a === 'remove' && !(await confirmBox(T('Delete container?'), T('“{name}” will be removed including its writable layer. Volumes are kept.', { name: c.name })))) return;
if (a === 'stop' && !(await confirmBox(T('Stop container?'), c.name, T('Stop')))) return;
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner sm"></span>'; }
try { await call('docker:action', this.id, a, c.id); toast(T('{action}: {name}', { action: { start: T('Started'), stop: T('Stopped'), restart: T('Restarted'), remove: T('Deleted') }[a], name: c.name }), 'ok'); } catch {}
await this.refresh(true);
}
onShow() { if (this.containers.length) this.refresh(); }
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
}
function openDocker(host) { return new DockerSession(host); }
// ============================================================ Command Palette / Quick Connect
function openPalette() {
if ($('.palette')) return;
@@ -1682,6 +1805,7 @@ function openPalette() {
hosts.slice(0, 12).forEach((x) => {
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--folder)">${ICONS.folder}</div>`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) });
if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--accent-2)">${ICONS.docker}</div>`, label: `Docker: ${x.label || x.address}`, run: () => openDocker(x) });
});
S.vault.snippets.filter((s) => q && s.label.toLowerCase().includes(q)).slice(0, 5).forEach((s) => items.push({ grp: 'Snippets', icon: `<div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div>`, label: s.label, sub: s.command, run: () => runSnippet(s) }));
[[T('New host'), () => { activateTab('home'); editHost({}); }], [T('Generate key'), generateKey], [T('Settings'), () => $('[data-view=settings]').click()]]
+22
View File
@@ -364,3 +364,25 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
/* VPN */
.vpn-hosts { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; }
.spinner.sm { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin .9s linear infinite; margin-right: 6px; vertical-align: -2px; }
/* Docker */
.session.docker .sbar .search.sm { flex: 0 1 260px; height: 28px; margin: 0; }
.session.docker .rt { color: var(--faint); }
.docker-body { flex: 1; overflow: auto; position: relative; }
.docker-body .pane-empty { height: 100%; display: flex; flex-direction: column; gap: 14px; align-items: center; justify-content: center; }
.docker-table { width: 100%; border-collapse: collapse; }
.docker-table th { position: sticky; top: 0; background: var(--bg); text-align: left; font-size: 11px; color: var(--faint); font-weight: 600; padding: 8px 10px; text-transform: uppercase; letter-spacing: .4px; border-bottom: 1px solid var(--border); z-index: 1; }
.docker-table td { padding: 7px 10px; border-bottom: 1px solid var(--row-line); white-space: nowrap; vertical-align: middle; }
.docker-table tr:hover td { background: rgb(var(--tint) / 0.024); }
.docker-table td.name { width: 30%; max-width: 0; }
.docker-table td.name > div { display: flex; flex-direction: column; min-width: 0; }
.docker-table td.name b, .docker-table td.name .img { overflow: hidden; text-overflow: ellipsis; }
.docker-table td.name .img { color: var(--faint); font-size: 11px; }
.docker-table td.muted { color: var(--muted); font-size: 12px; }
.docker-table td.ports { max-width: 260px; overflow: hidden; text-overflow: ellipsis; }
.docker-table .num { text-align: right; font-variant-numeric: tabular-nums; color: var(--muted); }
.docker-table td.acts { text-align: right; }
.docker-table td.acts .btn { padding: 4px 6px; }
.docker-table .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: var(--faint); }
.docker-table .dot.on { background: var(--green); }
.docker-table .dot.wait { background: var(--orange); }