Compare commits

..
2 Commits
9 changed files with 325 additions and 10 deletions
+11
View File
@@ -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 - **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 - **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 - **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) - **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 - **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 - **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. `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 <user>`). If a password is saved for the host, MrTerm falls back to `sudo` automatically.
## VPN ## 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. 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "mrterm", "name": "mrterm",
"version": "0.6.0", "version": "0.7.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "mrterm", "name": "mrterm",
"version": "0.6.0", "version": "0.7.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "mrterm", "name": "mrterm",
"productName": "MrTerm", "productName": "MrTerm",
"version": "0.6.0", "version": "0.7.0",
"description": "Moderner SSH-, SFTP- und RDP-Client", "description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js", "main": "src/main/main.js",
"author": "MrBlake", "author": "MrBlake",
+24
View File
@@ -386,6 +386,30 @@
'timeout': 'Zeitüberschreitung', 'timeout': 'Zeitüberschreitung',
'Connecting VPN “{name}” …': 'Baue VPN „{name}“ auf …', '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 // Main-Prozess
'Host key has changed!': 'Host-Schlüssel hat sich geändert!', 'Host key has changed!': 'Host-Schlüssel hat sich geändert!',
'Unknown host': 'Unbekannter Host', 'Unknown host': 'Unbekannter Host',
+114
View File
@@ -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 };
+17 -1
View File
@@ -12,6 +12,7 @@ const { createEmbed, embedSupported } = require('./rdp-embed');
const i18n = require('../i18n'); const i18n = require('../i18n');
const fido = require('./fido'); const fido = require('./fido');
const { VpnManager } = require('./vpn'); const { VpnManager } = require('./vpn');
const { DockerManager } = require('./docker');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win; let win;
@@ -61,6 +62,7 @@ function askSecret(sessionId, req) {
const ssh = new SshManager(store, confirmHostKey, askSecret); const ssh = new SshManager(store, confirmHostKey, askSecret);
const updater = new Updater(store, send); const updater = new Updater(store, send);
const vpn = new VpnManager(store, app.getPath('userData')); const vpn = new VpnManager(store, app.getPath('userData'));
const docker = new DockerManager(ssh);
function createWindow() { function createWindow() {
win = new BrowserWindow({ win = new BrowserWindow({
@@ -194,6 +196,18 @@ handle('vault:remove', async (col, id) => {
return store.remove(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 ---------- // ---------- VPN ----------
handle('vpn:status', () => vpn.status()); handle('vpn:status', () => vpn.status());
handle('vpn:up', (id) => vpn.up(id)); handle('vpn:up', (id) => vpn.up(id));
@@ -288,12 +302,14 @@ function hostWithOverrides(hostOrId) {
if (!h) throw new Error(i18n.t('Host not found')); if (!h) throw new Error(i18n.t('Host not found'));
return h; 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 return hostOrId; // Quick-Connect: temporärer Host
} }
handle('ssh:open', async (sessionId, hostRef, size) => { handle('ssh:open', async (sessionId, hostRef, size) => {
const host = hostWithOverrides(hostRef); 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); const onEvent = (type, payload) => send('ssh:event', sessionId, type, payload);
if (await vpn.ensureForHost(host, (m) => onEvent('status', m))) send('vpn:changed'); 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)); await ssh.openShell(sessionId, host, size, (type, payload) => send('ssh:event', sessionId, type, payload));
+7 -3
View File
@@ -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; 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) => { 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); if (err) return rej(err);
sess.stream = stream; sess.stream = stream;
stream.on('data', (d) => send('data', d.toString('utf8'))); stream.on('data', (d) => send('data', d.toString('utf8')));
stream.stderr.on('data', (d) => send('data', d.toString('utf8'))); stream.stderr.on('data', (d) => send('data', d.toString('utf8')));
stream.on('close', () => conn.end()); stream.on('close', () => conn.end());
if (host.startupCommand) stream.write(host.startupCommand + '\n'); if (host.startupCommand && !host.execCommand) stream.write(host.startupCommand + '\n');
res(); 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);
}); });
} }
+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>', 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>', 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>', 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']; const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
function colorFor(h) { function colorFor(h) {
@@ -387,7 +389,7 @@ function hostMenu(hst) {
return [ return [
{ label: hst.protocol === 'rdp' ? T('Connect in tab') : T('Connect'), icon: 'play', run: () => connectHost(hst) }, { 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 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('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(); } }, { 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.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.onauxclick = (e) => { if (e.button === 1) closeTab(session.id); };
tab.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [ 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() }] : []), ...(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('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) }, '-', { label: T('Close'), icon: 'close', run: () => closeTab(session.id) },
@@ -1205,6 +1207,7 @@ class TerminalSession {
this.el = h(`<div class="session"> 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> <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> <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="snip" title="Snippets">${ICONS.code}Snippets</button>
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button> <button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
</div> </div>
@@ -1262,6 +1265,7 @@ class TerminalSession {
this.el.addEventListener('click', (e) => { this.el.addEventListener('click', (e) => {
const a = e.target.closest('[data-a]')?.dataset.a; const a = e.target.closest('[data-a]')?.dataset.a;
if (a === 'sftp') openSftp(this.host); 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 === 'snip') { $('.snip-panel', this.el).classList.toggle('open'); this.renderSnips(); this.doFit(); }
if (a === 'snipnew') editSnippet(); if (a === 'snipnew') editSnippet();
if (a === 'find') this.toggleFind(); if (a === 'find') this.toggleFind();
@@ -1331,7 +1335,7 @@ class TerminalSession {
this.showOverlay(T('Connecting to {host} …', { host: this.host.address })); this.showOverlay(T('Connecting to {host} …', { host: this.host.address }));
this.doFit(); this.doFit();
try { 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.alive = true;
this.hideOverlay(); this.hideOverlay();
setTabState(this, 'on'); setTabState(this, 'on');
@@ -1664,6 +1668,125 @@ class SftpSession {
} }
function openSftp(host) { return new SftpSession(host); } 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 // ============================================================ Command Palette / Quick Connect
function openPalette() { function openPalette() {
if ($('.palette')) return; if ($('.palette')) return;
@@ -1682,6 +1805,7 @@ function openPalette() {
hosts.slice(0, 12).forEach((x) => { hosts.slice(0, 12).forEach((x) => {
items.push({ grp: 'Hosts', icon: avatar(x), label: x.label || x.address, sub: hostSub(x), run: () => connectHost(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(--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) })); 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()]] [[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 */
.vpn-hosts { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; } .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; } .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); }