diff --git a/README.md b/README.md index 794906c..db88d22 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ sudo pacman -S freerdp gnome-keyring # use kwallet instead of gnome-keyring on - **SFTP**: two-pane file browser (local ↔ remote), drag & drop, recursive folders, rename, delete, chmod and progress display - **RDP in a tab** right inside MrTerm (Windows: `mstsc`, Linux: FreeRDP), or in a separate window if you prefer - **Keychain**: generate Ed25519/ECDSA/RSA keys, import existing ones and copy the public key +- **Docker & Podman**: list a host's containers, open a shell inside a container, follow logs, and start, stop, restart or remove containers, all over SSH - **Port forwarding**: local (-L), remote (-R) and dynamic/SOCKS5 (-D) - **VPN**: add WireGuard or OpenVPN configurations and assign them to hosts. MrTerm connects the VPN automatically when you open such a host - **Snippets**: save frequently used commands and send them to a terminal with one click @@ -67,6 +68,16 @@ For a quick one-off connection, press `Ctrl+Shift+K` and type `user@host` (or `r `Ctrl+W`, `Ctrl+K` and `Ctrl+T` still reach the terminal, so editors like nano work as usual. +## Docker + +Right-click an SSH host and choose **Docker containers**, or click **Docker** in the toolbar of an open terminal. MrTerm connects over SSH and shows all containers on that host, with status, ports, CPU and memory. + +- **Open shell**: opens a terminal tab inside the container (bash if available, otherwise sh). You can also double-click a running container. +- **Logs**: follows the container's logs live in a terminal tab. +- **Start, stop, restart, delete** from the row buttons or the right-click menu. + +Nothing needs to be installed on the server. MrTerm uses the `docker` command (or `podman` if Docker isn't installed). Your SSH user needs permission to run it, which usually means membership in the `docker` group (`sudo usermod -aG docker `). If a password is saved for the host, MrTerm falls back to `sudo` automatically. + ## VPN Under **VPN** in the sidebar you can add WireGuard (`.conf`) and OpenVPN (`.ovpn`) configurations. Paste them or load them from a file, then assign hosts, either in the VPN itself or through the *VPN* field of a host. diff --git a/src/i18n.js b/src/i18n.js index d2a8687..5b10eaf 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -386,6 +386,30 @@ 'timeout': 'Zeitüberschreitung', 'Connecting VPN “{name}” …': 'Baue VPN „{name}“ auf …', + // Docker + 'Docker containers': 'Docker-Container', + 'Search containers …': 'Container suchen …', + 'Show stopped': 'Gestoppte anzeigen', + 'Connection closed.': 'Verbindung beendet.', + 'No containers on this host.': 'Keine Container auf diesem Host.', + 'Ports': 'Ports', + 'Memory': 'Speicher', + 'Open shell': 'Shell öffnen', + 'Logs': 'Logs', + 'Restart': 'Neu starten', + 'Copy ID': 'ID kopieren', + 'Delete container?': 'Container löschen?', + '“{name}” will be removed including its writable layer. Volumes are kept.': '„{name}“ wird samt beschreibbarer Ebene entfernt. Volumes bleiben erhalten.', + 'Stop container?': 'Container stoppen?', + 'Started': 'Gestartet', + 'Stopped': 'Gestoppt', + 'Restarted': 'Neu gestartet', + 'Deleted': 'Gelöscht', + 'Docker session not found': 'Docker-Sitzung nicht gefunden', + 'Neither Docker nor Podman was found on this host.': 'Auf diesem Host wurde weder Docker noch Podman gefunden.', + 'No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.': 'Keine Berechtigung für {runtime}. Füge den Benutzer der Gruppe „docker“ hinzu (sudo usermod -aG docker {user}) oder hinterlege das Host-Passwort, damit MrTerm sudo verwenden kann.', + '{runtime} exited with code {code}': '{runtime} beendet mit Code {code}', + // Main-Prozess 'Host key has changed!': 'Host-Schlüssel hat sich geändert!', 'Unknown host': 'Unbekannter Host', diff --git a/src/main/docker.js b/src/main/docker.js new file mode 100644 index 0000000..7121c52 --- /dev/null +++ b/src/main/docker.js @@ -0,0 +1,114 @@ +// Docker/Podman auf entfernten Hosts über die bestehende SSH-Verbindung (CLI per exec). +// Kein Zugriff auf den Docker-Socket nötig: MrTerm führt `docker ps`, `docker start` … aus. +// Fehlt die Berechtigung (Benutzer nicht in der Gruppe "docker"), wird sudo mit dem gespeicherten Host-Passwort versucht. +const i18n = require('../i18n'); + +const SAFE_ID = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/; +const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm -f' }; +const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'"; +const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'"; +const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || ''; +const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s); + +function execOn(conn, cmd, stdin) { + return new Promise((resolve, reject) => { + conn.exec(cmd, (err, stream) => { + if (err) return reject(err); + let out = '', errOut = ''; + stream.on('data', (d) => { out += d; }); + stream.stderr.on('data', (d) => { errOut += d; }); + stream.on('close', (code) => resolve({ code: code ?? 0, out, err: errOut })); + stream.end(stdin ?? ''); + }); + }); +} + +class DockerManager { + constructor(ssh) { + this.ssh = ssh; + this.sessions = new Map(); // id -> { conn, jumps, host, runtime, sudo } + } + + async open(id, host, onClose) { + const { conn, jumps } = await this.ssh.connect(host, id); + this.sessions.set(id, { conn, jumps, host, runtime: null, sudo: false }); + conn.on('close', () => { if (this.sessions.has(id)) { this.close(id); onClose(); } }); + conn.on('error', () => {}); + return this.list(id); + } + + get(id) { + const s = this.sessions.get(id); + if (!s) throw new Error(i18n.t('Docker session not found')); + return s; + } + + run(s, args) { + const sudo = s.sudo ? "sudo -S -p '' " : ''; + return execOn(s.conn, `${sudo}${s.runtime} ${args}`, s.sudo ? `${s.host.password || ''}\n` : ''); + } + + async list(id) { + const s = this.get(id); + if (!s.runtime) { + const r = await execOn(s.conn, 'for c in docker podman; do if command -v $c >/dev/null 2>&1; then echo $c; exit 0; fi; done; exit 127'); + if (r.code) throw new Error(i18n.t('Neither Docker nor Podman was found on this host.')); + s.runtime = r.out.trim(); + } + let r = await this.run(s, `ps -a --format ${LIST_FMT}`); + if (r.code && denied(r.err) && !s.sudo) { + s.sudo = true; + r = await this.run(s, `ps -a --format ${LIST_FMT}`); + if (r.code) s.sudo = false; + } + if (r.code) { + if (denied(r.err) || /sudo/i.test(r.err)) { + throw new Error(i18n.t('No permission to use {runtime}. Add the user to the "docker" group (sudo usermod -aG docker {user}) or save the host password so MrTerm can use sudo.', { runtime: s.runtime, user: s.host.username || '$USER' })); + } + throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code })); + } + const containers = r.out.split('\n').filter(Boolean).map((l) => { + const [cid, name, image, state, status, ports] = l.split('\t'); + return { id: cid.slice(0, 12), name, image, state: (state || '').toLowerCase(), status, ports }; + }); + return { runtime: s.runtime, sudo: s.sudo, containers }; + } + + // CPU/RAM der laufenden Container (dauert ~2 s) + async stats(id) { + const s = this.get(id); + const r = await this.run(s, `stats --no-stream --format ${STATS_FMT}`); + if (r.code) return {}; + return Object.fromEntries(r.out.split('\n').filter(Boolean).map((l) => { + const [cid, cpu, mem] = l.split('\t'); + return [cid.slice(0, 12), { cpu, mem }]; + })); + } + + async action(id, action, cid) { + const s = this.get(id); + if (!ACTIONS[action] || !SAFE_ID.test(cid)) throw new Error('Invalid action'); + const r = await this.run(s, `${ACTIONS[action]} ${cid}`); + if (r.code) throw new Error(lastLine(r.err) || i18n.t('{runtime} exited with code {code}', { runtime: s.runtime, code: r.code })); + return true; + } + + // Befehl für einen Terminal-Tab (läuft mit PTY; sudo fragt dort ggf. selbst nach dem Passwort) + command(id, kind, cid) { + const s = this.get(id); + if (!SAFE_ID.test(cid)) throw new Error('Invalid container'); + const pre = `${s.sudo ? 'sudo ' : ''}${s.runtime}`; + if (kind === 'logs') return `${pre} logs -f --tail 500 ${cid}`; + return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`; + } + + close(id) { + const s = this.sessions.get(id); + if (!s) return; + this.sessions.delete(id); + try { s.conn.end(); } catch {} + s.jumps?.forEach((c) => { try { c.end(); } catch {} }); + } +} + +module.exports = { DockerManager }; diff --git a/src/main/main.js b/src/main/main.js index d19841a..6b88da8 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -12,6 +12,7 @@ const { createEmbed, embedSupported } = require('./rdp-embed'); const i18n = require('../i18n'); const fido = require('./fido'); const { VpnManager } = require('./vpn'); +const { DockerManager } = require('./docker'); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); let win; @@ -61,6 +62,7 @@ function askSecret(sessionId, req) { const ssh = new SshManager(store, confirmHostKey, askSecret); const updater = new Updater(store, send); const vpn = new VpnManager(store, app.getPath('userData')); +const docker = new DockerManager(ssh); function createWindow() { win = new BrowserWindow({ @@ -194,6 +196,18 @@ handle('vault:remove', async (col, id) => { return store.remove(col, id); }); +// ---------- Docker ---------- +handle('docker:open', async (id, hostRef) => { + const host = hostWithOverrides(hostRef); + if (await vpn.ensureForHost(host)) send('vpn:changed'); + return docker.open(id, host, () => send('docker:closed', id)); +}); +handle('docker:list', (id) => docker.list(id)); +handle('docker:stats', (id) => docker.stats(id)); +handle('docker:action', (id, action, cid) => docker.action(id, action, cid)); +handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid)); +handle('docker:close', (id) => docker.close(id)); + // ---------- VPN ---------- handle('vpn:status', () => vpn.status()); handle('vpn:up', (id) => vpn.up(id)); @@ -288,12 +302,14 @@ function hostWithOverrides(hostOrId) { if (!h) throw new Error(i18n.t('Host not found')); return h; } + // Gespeicherter Host mit Zusätzen, z. B. { ref, execCommand } für Container-Shells + if (hostOrId?.ref) return { ...hostWithOverrides(hostOrId.ref), execCommand: hostOrId.execCommand, label: hostOrId.label }; return hostOrId; // Quick-Connect: temporärer Host } handle('ssh:open', async (sessionId, hostRef, size) => { const host = hostWithOverrides(hostRef); - if (host.id) store.addHistory({ hostId: host.id, at: Date.now() }); + if (host.id && !host.execCommand) store.addHistory({ hostId: host.id, at: Date.now() }); const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload); if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed'); await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload)); diff --git a/src/main/ssh.js b/src/main/ssh.js index 8fa4842..7345245 100644 --- a/src/main/ssh.js +++ b/src/main/ssh.js @@ -137,15 +137,19 @@ class SshManager { const env = host.env ? Object.fromEntries(host.env.split('\n').filter(Boolean).map((l) => l.split('=').map((s) => s.trim()))) : undefined; await new Promise((res, rej) => { - conn.shell({ term: 'xterm-256color', cols, rows }, { env }, (err, stream) => { + const pty = { term: 'xterm-256color', cols, rows }; + const onStream = (err, stream) => { if (err) return rej(err); sess.stream = stream; stream.on('data', (d) => send('data', d.toString('utf8'))); stream.stderr.on('data', (d) => send('data', d.toString('utf8'))); stream.on('close', () => conn.end()); - if (host.startupCommand) stream.write(host.startupCommand + '\n'); + if (host.startupCommand && !host.execCommand) stream.write(host.startupCommand + '\n'); res(); - }); + }; + // execCommand: statt Login-Shell einen Befehl mit PTY starten (z. B. docker exec -it …) + if (host.execCommand) conn.exec(host.execCommand, { pty, env }, onStream); + else conn.shell(pty, { env }, onStream); }); } diff --git a/src/renderer/app.js b/src/renderer/app.js index 71c72e3..89ca3c2 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -40,6 +40,8 @@ const ICONS = { clock: '', download: '', upload: '', + docker: '', + logs: '', }; 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(`
${avatar(host, 22).replace('SSH', '')}${esc(hostSub(host))}
+ ${host.execCommand ? '' : ``}
@@ -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(`
+
${avatar(host, 22)}${esc(hostSub(host))}
+ + + +
+
${esc(T('Connecting to {host} …', { host: host.address }))}
`); + 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 = `
${esc(msg)}
`; + const b = h(``); + 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 = `
${T('No containers on this host.')}
`; return; } + const scroll = this.body.scrollTop; + this.body.innerHTML = ` + ${list.map((c) => { + const st = this.stats[c.id]; + const on = running(c); + return ` + + + + `; + }).join('')}
${T('Name')}${T('Status')}${T('Ports')}CPU${T('Memory')}
${esc(c.name)}${esc(c.image)}
${esc(c.status)}${esc(c.ports)}${on && st ? esc(st.cpu) : ''}${on && st ? esc(st.mem.split('/')[0].trim()) : ''} + ${on ? `` : ''} + + ${on ? `` + : ``} +
`; + 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 = ''; } + 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: `
${ICONS.folder}
`, label: `SFTP: ${x.label || x.address}`, run: () => openSftp(x) }); + if (x.protocol !== 'rdp' && q) items.push({ grp: 'Hosts', icon: `
${ICONS.docker}
`, 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: `
${ICONS.code}
`, 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()]] diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 155c29c..987f3b6 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -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); }