Compare commits

...
16 Commits
Author SHA1 Message Date
MrBlakeandClaude Opus 5.5 6b02dc078a Version 0.18.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:12:10 +02:00
MrBlake ecaf579ccd Version 0.18.0 2026-10-01 22:44:35 +02:00
MrBlakeandClaude Opus 5.5 4176cc0a01 Support assistant across all tabs: global panel (title bar button and in terminal, Docker, firewall and network tabs) that uses the active tab as context (terminal output, container/stack view, compose editor with masked .env values, firewall rules, network config); chats are stored in the vault and can be switched, started anew and deleted; stop button while answering
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:44:35 +02:00
MrBlake 20506ee889 Version 0.17.1 2026-10-01 22:12:38 +02:00
MrBlakeandClaude Opus 5.5 6ebdc5126f Docker stacks: show .env variables when editing a stack whose .env was written via sudo (root-owned, mode 600); reading now retries with sudo instead of silently showing an empty .env, which also deleted the file on the next save
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 22:12:37 +02:00
MrBlake 08c6d65e29 Version 0.17.0 2026-10-01 21:42:02 +02:00
MrBlakeandClaude Opus 5.5 8c8dc0d4c0 Add support assistant via Ollama: chat panel in terminal tabs with the latest terminal output as context, 'explain last error', copy/insert suggested commands (never auto-executed), Ollama server/model in settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 21:41:56 +02:00
MrBlake a279ef88b2 Version 0.16.0 2026-09-27 14:58:50 +02:00
MrBlakeandClaude Opus 5.5 6efa74aa7e Docker: add a Compose stack manager (Containers | Stacks toggle): list stacks in a stacks folder (default /opt/stacks) plus projects found via compose ls, create/edit compose.yaml and .env in an editor (validated with compose config), deploy/stop/restart/down/pull/logs/delete in terminal tabs, clone stacks from Git and git pull + redeploy, app templates in Portainer format (default Lissy93/portainer-templates: container templates are converted to compose, stack templates loaded from GitHub), "Set up folder" makes the stacks folder writable for the SSH user
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:58:50 +02:00
MrBlake 0b43182019 Version 0.15.0 2026-09-27 14:45:31 +02:00
MrBlakeandClaude Opus 5.5 93d7130bfa Backup: export everything (hosts, groups, keys, passwords, snippets, forwards, VPNs, known hosts, history, settings), optionally encrypted with a password (scrypt + AES-256-GCM); import asks for the password, can merge or replace existing data and optionally restore settings; old backups still import; shared module for desktop, web and Android (src/core/backup.js)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:45:31 +02:00
MrBlakeandClaude Opus 5.5 408b4bd9d0 Snippets: add built-in templates (collapsed "Templates" section in the snippets view and the terminal snippet panel): authorize an SSH key from the keychain for a user, install the QEMU guest agent, set/remove an APT cache server (apt-cacher-ng), system update; parameters are asked for before running
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:37:31 +02:00
MrBlakeandClaude Opus 5.5 7d4991c99c App lock: unlock with security keys via the built-in CTAP2 client on Linux too (Chromium returned no PRF result, so adding a YubiKey failed with "does not support hmac-secret"); updates: show only one update dialog at a time (startup and manual check stacked two)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:22:34 +02:00
MrBlake 10acdaeb0b Version 0.13.2 2026-09-27 14:09:14 +02:00
MrBlakeandClaude Opus 5.5 daff240aa6 App lock: fix registering security keys with a FIDO2 PIN on Linux (Electron has no WebAuthn PIN prompt, so Chromium failed with NotAllowedError after touching the key); register via a built-in CTAP2 client over hidraw with PIN entry in the key window
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:53:38 +02:00
MrBlakeandClaude Opus 5.5 66d4350221 README: add update instructions for the Docker web version
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:06:14 +02:00
18 changed files with 1584 additions and 41 deletions
+16 -1
View File
@@ -62,7 +62,7 @@ RDP, SFTP, port forwarding and VPN are only available in the desktop app.
- **Known hosts**: MrTerm warns you if a server's host key changes
- **History** of recent connections
- **Import** from `~/.ssh/config` and from **Devolutions Remote Desktop Manager** (`.rdm`/XML, JSON or CSV)
- **Backup** export and import
- **Backup** of everything (hosts, keys, passwords, snippets, forwards, VPNs, known hosts, settings), optionally encrypted with a password (scrypt + AES-256-GCM); import can merge or replace
- **Web version** for your own server (Docker), usable in any browser. See [Web version (Docker)](#web-version-docker)
- **LAN sync**: keep several MrTerm devices in sync over your local network, end-to-end encrypted and without a server
- **7 app themes** (Midnight, Navy, Nord, Dracula, Catppuccin, Forest, Light) plus a custom accent color
@@ -101,6 +101,21 @@ Then open `http://<your-server>:8080` and create the administrator account. As a
- **Data** lives in the `mrterm-data` volume (`/data` in the container). Back it up regularly. A restored vault still needs its user's password to open.
- **SSH connections** start from the server, so the server must be able to reach your hosts.
### Updating the web version
Run these commands in the folder that contains your `docker-compose.yml`:
```bash
docker compose build --pull --no-cache
docker compose up -d
```
This fetches the latest MrTerm from the repository, rebuilds the image and restarts the container. Your users and vaults stay in the `mrterm-data` volume and are kept. Everyone has to sign in again after the restart.
To check which version is running, look at the bottom of the sign-in page. If a release note mentions changes to `docker-compose.yml`, download it again first (`curl -O …` as above) and copy over your own changes, such as ports or `TRUST_PROXY`.
To remove images left over from earlier builds: `docker image prune`.
## Getting started
1. Click **New host**, enter the address, username and password or key, and click **Save**.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mrterm",
"version": "0.13.1",
"version": "0.18.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mrterm",
"version": "0.13.1",
"version": "0.18.1",
"license": "MIT",
"dependencies": {
"@xterm/addon-fit": "^0.11.0",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "mrterm",
"productName": "MrTerm",
"version": "0.13.1",
"version": "0.18.1",
"description": "Moderner SSH-, SFTP- und RDP-Client",
"main": "src/main/main.js",
"author": "MrBlake",
@@ -53,6 +53,7 @@
"directories": {
"buildResources": "build"
},
"beforePack": "scripts/ensure-koffi.js",
"asarUnpack": [
"node_modules/koffi/**",
"node_modules/@koromix/**"
+19
View File
@@ -0,0 +1,19 @@
// electron-builder beforePack: stellt sicher, dass die native koffi-Binary der Zielplattform in node_modules liegt.
// npm installiert nur die Binary der Build-Plattform – beim Cross-Build (z. B. Windows-Paket auf Linux) fehlt sie sonst,
// und RDP im Tab fällt mit "koffi nicht verfügbar" auf ein externes Fenster zurück.
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const { Arch } = require('electron-builder');
module.exports = async (context) => {
const platform = context.electronPlatformName;
const arch = Arch[context.arch];
const pkg = `@koromix/koffi-${platform}-${arch}`;
const root = path.join(__dirname, '..');
if (fs.existsSync(path.join(root, 'node_modules', pkg))) return;
const { version } = require(path.join(root, 'node_modules', 'koffi', 'package.json'));
console.log(` • koffi-Binary für ${platform}-${arch} fehlt – installiere ${pkg}@${version}`);
execSync(`npm install --no-save --force ${pkg}@${version}`, { cwd: root, stdio: 'inherit' });
if (!fs.existsSync(path.join(root, 'node_modules', pkg))) throw new Error(`${pkg} konnte nicht installiert werden`);
};
+22 -5
View File
@@ -11,7 +11,10 @@ const { SshManager } = require('../main/ssh');
const { DockerManager } = require('../main/docker');
const { FirewallManager } = require('../main/firewall');
const { NetworkConfigManager } = require('../main/network');
const { AiAssistant } = require('../main/ai');
const i18n = require('../i18n');
const backup = require('./backup');
const composeTemplates = require('../main/compose-templates');
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
@@ -32,6 +35,7 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(ssh);
const ai = new AiAssistant(store, send);
let sync = null;
if (withSync) {
const { SyncService } = require('../main/sync');
@@ -107,11 +111,12 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
});
handle('vault:settings', (s) => { store.setSettings(s); if ('language' in s) onLanguage(s.language); });
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
handle('vault:exportData', () => publicData());
handle('vault:importData', (data) => {
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'])
for (const item of data?.[col] || []) store.upsert(col, item);
return true;
handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version }));
handle('backup:inspect', (content) => backup.inspectBackup(content));
handle('backup:import', (content, password, opts) => {
const r = backup.importBackup(store, content, password, opts);
if (opts?.settings) onLanguage(store.get().settings.language);
return r;
});
// entries: [{ folder: ['A','B'], host?: {...} }] – wie am Desktop (Import aus Remote Desktop Manager)
handle('vault:bulkImport', (entries) => {
@@ -182,6 +187,12 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
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));
handle('docker:stacks', (id, dir) => docker.stacks(id, dir));
handle('docker:stackRead', (id, file) => docker.readStack(id, file));
handle('docker:stackSave', (id, data) => docker.saveStack(id, data));
handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts));
handle('docker:templates', (url) => composeTemplates.list(url));
handle('docker:template', (index, url) => composeTemplates.build(index, url));
handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id)));
handle('firewall:list', (id, backend) => firewall.list(id, backend));
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
@@ -197,6 +208,11 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
handle('network:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
handle('network:close', (id) => network.close(id));
// ---------- Support-Assistent (Ollama) ----------
handle('ai:models', (url) => ai.models(url));
handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context));
handle('ai:stop', (id) => ai.stop(id));
// ---------- Synchronisation ----------
if (sync) {
handle('sync:status', () => sync.status());
@@ -212,6 +228,7 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
function close() {
ssh.closeAll?.();
ai.stopAll();
sync?.stop();
for (const r of pending.values()) r(null);
pending.clear();
+99
View File
@@ -0,0 +1,99 @@
// Vollständiges Backup (Desktop, Web, Android): alle Collections, bekannte Hosts, Verlauf und Einstellungen.
// Optional mit Passwort verschlüsselt: scrypt (N=2^16, r=8, p=1) → AES-256-GCM.
// Gerätebezogenes (LAN-Sync-Kopplungen, Löschvermerke, App-Sperre) wird nicht exportiert.
const crypto = require('crypto');
const FORMAT = 'mrterm-backup';
const COLLECTIONS = ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'];
const KDF = { N: 1 << 16, r: 8, p: 1 };
const scrypt = (password, salt, k) => crypto.scryptSync(String(password).normalize('NFC'), salt, 32, { ...k, maxmem: 256 * 1024 * 1024 });
function snapshot(store, { settings = true } = {}) {
const d = store.get();
const out = { knownHosts: d.knownHosts || {}, history: d.history || [] };
for (const c of COLLECTIONS) out[c] = d[c] || [];
if (settings) out.settings = d.settings;
return out;
}
// Liefert den Dateiinhalt (JSON-Text)
function createBackup(store, { password = '', settings = true, version = '' } = {}) {
const data = snapshot(store, { settings });
const head = { format: FORMAT, version: 2, app: version, createdAt: new Date().toISOString() };
if (!password) return JSON.stringify({ ...head, encrypted: false, data }, null, 2);
const salt = crypto.randomBytes(16);
const key = scrypt(password, salt, KDF);
const iv = crypto.randomBytes(12);
const c = crypto.createCipheriv('aes-256-gcm', key, iv);
c.setAAD(Buffer.from(FORMAT));
const ct = Buffer.concat([c.update(JSON.stringify(data)), c.final()]);
return JSON.stringify({
...head, encrypted: true,
kdf: { name: 'scrypt', salt: salt.toString('base64'), ...KDF },
cipher: { name: 'aes-256-gcm', iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64') },
ct: ct.toString('base64'),
}, null, 2);
}
function parse(content) {
let j;
try { j = JSON.parse(String(content).replace(/^/, '')); } catch { throw new Error('INVALID'); }
if (!j || typeof j !== 'object') throw new Error('INVALID');
// Altes Format (bis 0.14): unverschlüsseltes JSON mit den Collections auf oberster Ebene
if (j.format !== FORMAT) {
if (!COLLECTIONS.some((c) => Array.isArray(j[c]))) throw new Error('INVALID');
return { encrypted: false, data: j, createdAt: null, app: '' };
}
return { encrypted: !!j.encrypted, raw: j, data: j.encrypted ? null : j.data, createdAt: j.createdAt, app: j.app };
}
const counts = (data) => Object.fromEntries([...COLLECTIONS.map((c) => [c, (data[c] || []).length]), ['knownHosts', Object.keys(data.knownHosts || {}).length], ['settings', data.settings ? 1 : 0]]);
// Vorabinfo für die Oberfläche (ohne Passwort)
function inspectBackup(content) {
const b = parse(content);
return { encrypted: b.encrypted, createdAt: b.createdAt, app: b.app, counts: b.data ? counts(b.data) : null };
}
function decrypt(content, password) {
const b = parse(content);
if (!b.encrypted) return b.data;
if (!password) throw new Error('PASSWORD_REQUIRED');
const { kdf, cipher, ct } = b.raw;
const key = scrypt(password, Buffer.from(kdf.salt, 'base64'), { N: kdf.N, r: kdf.r, p: kdf.p });
try {
const d = crypto.createDecipheriv('aes-256-gcm', key, Buffer.from(cipher.iv, 'base64'));
d.setAAD(Buffer.from(FORMAT));
d.setAuthTag(Buffer.from(cipher.tag, 'base64'));
return JSON.parse(Buffer.concat([d.update(Buffer.from(ct, 'base64')), d.final()]).toString('utf8'));
} catch { throw new Error('WRONG_PASSWORD'); }
}
// opts: { replace: vorhandene Einträge entfernen, die nicht im Backup sind; settings: Einstellungen übernehmen }
function importBackup(store, content, password, { replace = false, settings = false } = {}) {
const data = decrypt(content, password);
const d = store.get();
store.muted = true;
try {
for (const c of COLLECTIONS) {
const items = Array.isArray(data[c]) ? data[c].filter((x) => x && typeof x === 'object') : [];
if (replace) {
const keep = new Set(items.map((x) => x.id).filter(Boolean));
for (const x of [...(d[c] || [])]) if (!keep.has(x.id)) store.remove(c, x.id);
}
for (const item of items) store.upsert(c, { ...item });
}
if (data.knownHosts && typeof data.knownHosts === 'object') d.knownHosts = replace ? { ...data.knownHosts } : { ...d.knownHosts, ...data.knownHosts };
if (Array.isArray(data.history)) {
const seen = new Set();
d.history = [...(replace ? [] : d.history || []), ...data.history].filter((h) => h && !seen.has(h.hostId) && seen.add(h.hostId)).slice(0, 30);
}
if (settings && data.settings && typeof data.settings === 'object') d.settings = { ...d.settings, ...data.settings };
} finally {
store.muted = false;
}
store.save();
return counts(data);
}
module.exports = { createBackup, inspectBackup, importBackup };
+116
View File
@@ -151,6 +151,35 @@
'Send Enter automatically': 'Enter automatisch senden',
'Command is missing.': 'Befehl fehlt.',
'No open terminal': 'Kein offenes Terminal',
// Support-Assistent
'Delete chat': 'Chat löschen',
'Delete chat?': 'Chat löschen?',
'Saved chats': 'Gespeicherte Chats',
'Context:': 'Kontext:',
'Vault (no context)': 'Tresor (kein Kontext)',
'Explain this tab': 'Diesen Tab erklären',
'Look at the active tab, explain what you see and point out problems.': 'Sieh dir den aktiven Tab an, erkläre, was du siehst, und weise auf Probleme hin.',
'The content of the active tab is sent to the Ollama server.': 'Der Inhalt des aktiven Tabs wird an den Ollama-Server gesendet.',
'Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.': 'Frag nach einem Fehler oder lass dir erklären, was du siehst. Der Inhalt des aktiven Tabs wird als Kontext mitgesendet.',
'Copied – no terminal tab is active': 'Kopiert – kein Terminal-Tab aktiv',
'Helps with errors in the Linux console, Docker, firewall and network via a local <b>Ollama</b> instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault.': 'Hilft bei Fehlern in Linux-Konsole, Docker, Firewall und Netzwerk über eine lokale <b>Ollama</b>-Instanz. Öffnen über den Chat-Button in der Titelleiste oder im Tab. Der Inhalt des aktiven Tabs wird als Kontext an den Ollama-Server gesendet. Chats werden im Tresor gespeichert.',
'Support assistant (Ollama)': 'Support-Assistent (Ollama)',
'Assistant': 'Assistent',
'Support assistant': 'Support-Assistent',
'New chat': 'Neuer Chat',
'Include terminal output': 'Terminalausgabe mitsenden',
'Describe the problem … (Enter = send)': 'Problem beschreiben … (Enter = senden)',
'Explain last error': 'Letzten Fehler erklären',
'Send': 'Senden',
'Explain the last error in the terminal output and how to fix it.': 'Erkläre den letzten Fehler in der Terminalausgabe und wie ich ihn behebe.',
'Please select an Ollama model in the settings first.': 'Bitte zuerst in den Einstellungen ein Ollama-Modell auswählen.',
'Insert into terminal (not executed)': 'Ins Terminal einfügen (wird nicht ausgeführt)',
'Insert': 'Einfügen',
'Ollama server': 'Ollama-Server',
'Model': 'Modell',
'– select –': '– auswählen –',
'No models installed (ollama pull …)': 'Keine Modelle installiert (ollama pull …)',
'Load models': 'Modelle laden',
'No snippets yet.': 'Noch keine Snippets.',
// Port Forwarding
@@ -351,6 +380,93 @@
'Touch your security key to unlock MrTerm.': 'Berühre deinen Sicherheitsschlüssel, um MrTerm zu entsperren.',
'Security key prompt was cancelled or timed out.': 'Die Abfrage des Sicherheitsschlüssels wurde abgebrochen oder ist abgelaufen.',
'This security key does not support the hmac-secret/PRF extension.': 'Dieser Sicherheitsschlüssel unterstützt die hmac-secret/PRF-Erweiterung nicht.',
'This file is not a MrTerm backup.': 'Diese Datei ist kein MrTerm-Backup.',
'This backup is encrypted. Please enter the password.': 'Dieses Backup ist verschlüsselt. Bitte gib das Passwort ein.',
'Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.': 'Exportiert alle Hosts, Gruppen, SSH-Keys, Passwörter, Snippets, Portweiterleitungen, VPNs, bekannten Hosts und Einstellungen.',
'Recommended. Without a password, keys and passwords are stored in plain text in the file.': 'Empfohlen. Ohne Passwort stehen Schlüssel und Passwörter im Klartext in der Datei.',
'Include settings': 'Einstellungen einschließen',
'Export': 'Exportieren',
'Export without password?': 'Ohne Passwort exportieren?',
'Private keys and passwords will be readable by anyone who gets the file.': 'Private Schlüssel und Passwörter sind dann für jeden lesbar, der die Datei erhält.',
'{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs': '{hosts} Hosts, {keys} Schlüssel, {snippets} Snippets, {vpns} VPNs',
'Contents are encrypted.': 'Inhalt ist verschlüsselt.',
'Import settings': 'Einstellungen übernehmen',
'Replace existing data (entries not contained in the backup are deleted)': 'Vorhandene Daten ersetzen (Einträge, die nicht im Backup sind, werden gelöscht)',
'Replace existing data?': 'Vorhandene Daten ersetzen?',
'Hosts, keys, snippets and other entries that are not in the backup will be deleted.': 'Hosts, Schlüssel, Snippets und andere Einträge, die nicht im Backup sind, werden gelöscht.',
'Replace': 'Ersetzen',
'Import complete: {hosts} hosts, {keys} keys, {snippets} snippets': 'Import abgeschlossen: {hosts} Hosts, {keys} Schlüssel, {snippets} Snippets',
'Stacks folder': 'Stack-Ordner',
'Folder on the host in which each stack gets its own subfolder': 'Ordner auf dem Host, in dem jeder Stack einen eigenen Unterordner bekommt',
'Template source': 'Vorlagen-Quelle',
'URL of a templates.json in Portainer format. Empty = Lissy93/portainer-templates.': 'URL einer templates.json im Portainer-Format. Leer = Lissy93/portainer-templates.',
'Containers': 'Container',
'Folder': 'Ordner',
'Change folder': 'Ordner ändern',
'Creates the folder with sudo and makes it writable for {user}.': 'Legt den Ordner per sudo an und macht ihn für {user} beschreibbar.',
'Set up folder': 'Ordner einrichten',
'From Git': 'Aus Git',
'New stack': 'Neuer Stack',
'Compose file': 'Compose-Datei',
'Found via docker compose ls, outside the stacks folder': 'Über docker compose ls gefunden, außerhalb des Stack-Ordners',
'external': 'extern',
'not deployed': 'nicht gestartet',
'no compose file found': 'keine Compose-Datei gefunden',
'Deploy (up -d)': 'Starten (up -d)',
'No matches.': 'Keine Treffer.',
'No stacks yet. Create one, clone it from Git or start from a template.': 'Noch keine Stacks. Lege einen an, klone ihn aus Git oder starte mit einer Vorlage.',
'Update images (pull + up)': 'Images aktualisieren (pull + up)',
'Git pull + redeploy': 'Git pull + neu starten',
'Down (remove containers)': 'Down (Container entfernen)',
'Stack from Git': 'Stack aus Git',
'Repository URL': 'Repository-URL',
'Stack name': 'Stack-Name',
'Branch (optional)': 'Branch (optional)',
'Compose file in the repository (optional)': 'Compose-Datei im Repository (optional)',
'If empty, the first compose file found is used.': 'Leer = die erste gefundene Compose-Datei wird verwendet.',
'Deploy right after cloning': 'Nach dem Klonen direkt starten',
'The repository is cloned on the host into the stacks folder. Git asks for credentials in the terminal if needed; for private repos an SSH deploy key or a token in the URL also works.': 'Das Repository wird auf dem Host in den Stack-Ordner geklont. Git fragt Zugangsdaten bei Bedarf im Terminal ab; für private Repos funktioniert auch ein SSH-Deploy-Key oder ein Token in der URL.',
'Clone': 'Klonen',
'App templates': 'App-Vorlagen',
'Search templates …': 'Vorlagen durchsuchen …',
'Source: {url} (Portainer template format, changeable in the settings).': 'Quelle: {url} (Portainer-Vorlagenformat, in den Einstellungen änderbar).',
'All categories': 'Alle Kategorien',
'Delete stack?': 'Stack löschen?',
'The stack “{name}” is stopped (down) and its folder {dir} is deleted.': 'Der Stack „{name}“ wird gestoppt (down) und sein Ordner {dir} gelöscht.',
'Also delete named volumes (data!)': 'Auch benannte Volumes löschen (Daten!)',
'Remove containers?': 'Container entfernen?',
'docker compose down stops and removes the containers of “{name}”. Volumes and files are kept.': 'docker compose down stoppt und entfernt die Container von „{name}“. Volumes und Dateien bleiben erhalten.',
'Down': 'Down',
'Back': 'Zurück',
'Save & deploy': 'Speichern & starten',
'(variables, used as ${NAME} in the compose file)': '(Variablen, in der Compose-Datei als ${NAME} nutzbar)',
'Saved, but the compose file has an error: {msg}': 'Gespeichert, aber die Compose-Datei enthält einen Fehler: {msg}',
'Docker Compose was not found on this host (docker compose plugin or docker-compose).': 'Docker Compose wurde auf diesem Host nicht gefunden (docker-compose-Plugin oder docker-compose).',
'Could not read {file}': '{file} konnte nicht gelesen werden',
'Stack names may only contain lowercase letters, digits, - and _.': 'Stack-Namen dürfen nur Kleinbuchstaben, Ziffern, - und _ enthalten.',
'A stack named “{name}” already exists.': 'Ein Stack namens „{name}“ existiert bereits.',
'No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.': 'Keine Schreibrechte in {dir}. Nutze „Ordner einrichten“ oder speichere das Host-Passwort, damit MrTerm sudo verwenden kann.',
'Templates': 'Vorlagen',
'Run': 'Ausführen',
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
'Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.': 'Trägt einen öffentlichen Schlüssel aus dem Schlüsselbund in ~/.ssh/authorized_keys des Benutzers ein.',
'Key': 'Schlüssel',
'Public key is missing.': 'Öffentlicher Schlüssel fehlt.',
'User is missing.': 'Benutzer fehlt.',
'Install QEMU guest agent': 'QEMU Guest Agent installieren',
'Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).': 'Installiert und startet qemu-guest-agent (apt, dnf, yum, pacman, zypper oder apk).',
'Use APT cache server': 'APT-Cache-Server verwenden',
'Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).': 'Leitet APT-Downloads über einen Cache-Proxy wie apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).',
'Proxy URL': 'Proxy-URL',
'Invalid URL.': 'Ungültige URL.',
'Remove APT cache server': 'APT-Cache-Server entfernen',
'Removes the APT proxy setting again.': 'Entfernt die APT-Proxy-Einstellung wieder.',
'Updates all packages with the system package manager.': 'Aktualisiert alle Pakete mit dem Paketmanager des Systems.',
'Connecting to security key …': 'Verbinde mit dem Sicherheitsschlüssel …',
'Enter the PIN of your security key.': 'Gib die PIN deines Sicherheitsschlüssels ein.',
'Wrong PIN.': 'Falsche PIN.',
'{n} attempts left.': 'Noch {n} Versuche.',
'The PIN of this security key is blocked. Remove and reinsert the key, or reset it.': 'Die PIN dieses Sicherheitsschlüssels ist gesperrt. Ziehe den Schlüssel ab und stecke ihn neu ein oder setze ihn zurück.',
// VPN
'— No VPN —': '— Kein VPN —',
+93
View File
@@ -0,0 +1,93 @@
// Support-Assistent über eine Ollama-Instanz (lokal oder im Netz).
// Antworten werden gestreamt: send('ai:event', chatId, 'data' | 'done' | 'error', payload)
const http = require('http');
const https = require('https');
const SYSTEM_PROMPT = `You are the MrTerm support assistant, an experienced Linux system administrator.
The user manages Linux servers (typically Ubuntu, Debian or Proxmox) with MrTerm: SSH terminals, Docker containers
and compose stacks, firewall (UFW/iptables), network configuration and SFTP.
You may receive the content of the user's active MrTerm tab as context (terminal output, container list, compose file,
firewall rules, …). Your job:
- Explain errors briefly and clearly, name the most likely cause.
- Suggest concrete, copy-pasteable shell commands in \`\`\`bash code blocks, one step at a time.
- Warn explicitly before destructive commands (rm -rf, dd, mkfs, firewall changes that may lock out SSH, network changes).
- If information is missing, say which command would provide it.
Answer in the language of the user's question. Be concise.`;
function request(base, path, { method = 'GET', body, signal } = {}) {
const url = new URL(path, base.endsWith('/') ? base : base + '/');
const lib = url.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = lib.request(url, { method, headers: body ? { 'Content-Type': 'application/json' } : {}, signal, timeout: 15000 }, (res) => {
if (res.statusCode >= 400) {
let t = ''; res.on('data', (c) => (t += c));
res.on('end', () => { let m = t; try { m = JSON.parse(t).error || t; } catch {} reject(new Error(`Ollama: ${m || res.statusCode}`)); });
return;
}
resolve(res);
});
req.on('timeout', () => req.destroy(new Error('Ollama: timeout')));
req.on('error', (e) => reject(e.code === 'ECONNREFUSED' ? new Error(`Ollama not reachable at ${base}`) : e));
if (body) req.write(JSON.stringify(body));
req.end();
});
}
class AiAssistant {
constructor(store, send) {
this.store = store;
this.send = send;
this.running = new Map();
}
get cfg() {
const s = this.store.get().settings;
return { url: s.aiUrl || 'http://localhost:11434', model: s.aiModel || '' };
}
async models(url) {
const res = await request(url || this.cfg.url, 'api/tags');
let t = ''; for await (const c of res) t += c;
return (JSON.parse(t).models || []).map((m) => m.name);
}
// messages: [{ role: 'user' | 'assistant', content }], context: letzte Terminalausgabe
async chat(chatId, messages, context = '') {
const { url, model } = this.cfg;
if (!model) throw new Error('No Ollama model selected (Settings → Support assistant).');
this.stop(chatId);
const ctrl = new AbortController();
this.running.set(chatId, ctrl);
const sys = SYSTEM_PROMPT + (context ? `\n\nContent of the active tab:\n\`\`\`\n${context.slice(-12000)}\n\`\`\`` : '');
(async () => {
try {
const res = await request(url, 'api/chat', { method: 'POST', signal: ctrl.signal, body: { model, stream: true, messages: [{ role: 'system', content: sys }, ...messages] } });
res.setTimeout(0);
let buf = '';
for await (const chunk of res) {
buf += chunk;
let i;
while ((i = buf.indexOf('\n')) >= 0) {
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
if (!line) continue;
const m = JSON.parse(line);
if (m.error) throw new Error(`Ollama: ${m.error}`);
if (m.message?.content) this.send('ai:event', chatId, 'data', m.message.content);
}
}
this.send('ai:event', chatId, 'done');
} catch (e) {
if (ctrl.signal.aborted) this.send('ai:event', chatId, 'done');
else this.send('ai:event', chatId, 'error', e.message || String(e));
} finally {
if (this.running.get(chatId) === ctrl) this.running.delete(chatId);
}
})();
return true;
}
stop(chatId) { this.running.get(chatId)?.abort(); this.running.delete(chatId); }
stopAll() { for (const c of this.running.values()) c.abort(); this.running.clear(); }
}
module.exports = { AiAssistant };
+75
View File
@@ -0,0 +1,75 @@
// App-Vorlagen im Portainer-Format (v2/v3), z. B. https://github.com/Lissy93/portainer-templates.
// Container-Vorlagen (type 1) werden in eine compose.yaml umgewandelt; Stack-Vorlagen (type 2/3) laden die
// Compose-Datei aus dem angegebenen GitHub-Repository. Umgebungsvariablen landen mit Standardwerten in der .env.
const DEFAULT_URL = 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json';
let cache = { url: '', at: 0, list: [] };
const y = (v) => JSON.stringify(String(v)); // JSON-Strings sind gültiges YAML
const slug = (s) => String(s || 'app').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 40) || 'app';
async function list(url = DEFAULT_URL) {
url = url || DEFAULT_URL;
if (cache.url === url && Date.now() - cache.at < 3600e3) return cache.list;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const j = await res.json();
const items = (Array.isArray(j) ? j : j.templates || []).filter((t) => t && t.title && (t.image || t.repository?.stackfile));
const listed = items.map((t, i) => ({
i, title: t.title, description: String(t.description || '').slice(0, 400), categories: t.categories || [],
kind: t.image ? 'container' : 'stack', note: t.note || '', t,
}));
cache = { url, at: Date.now(), list: listed };
return listed;
}
// Für die Oberfläche ohne Rohdaten
const summary = (l) => l.map(({ t: _t, ...rest }) => rest);
function envFile(env) {
return (env || []).filter((e) => e?.name).map((e) => {
const def = e.select ? (e.select.find((x) => x.default) || e.select[0] || {}).value : e.default;
const hint = [e.label && e.label !== e.name ? e.label : '', e.description || '', e.select ? `(${e.select.map((x) => x.value).join(' | ')})` : ''].filter(Boolean).join(' – ');
return `${hint ? `# ${hint.replace(/\n/g, ' ')}\n` : ''}${e.name}=${def ?? ''}`;
}).join('\n');
}
function containerCompose(t, name) {
const L = ['services:', ` ${name}:`, ` image: ${y(t.image)}`, ` container_name: ${y(name)}`, ` restart: ${y(t.restart_policy || 'unless-stopped')}`];
if (t.hostname) L.push(` hostname: ${y(t.hostname)}`);
if (t.command) L.push(` command: ${y(t.command)}`);
if (t.privileged) L.push(' privileged: true');
if (t.interactive) L.push(' stdin_open: true', ' tty: true');
if (t.network === 'host') L.push(' network_mode: host');
if (t.ports?.length && t.network !== 'host') L.push(' ports:', ...t.ports.map((p) => ` - ${y(p)}`));
if (t.env?.length) L.push(' environment:', ...t.env.filter((e) => e?.name).map((e) => ` - ${y(`${e.name}=\${${e.name}}`)}`));
if (t.volumes?.length) {
L.push(' volumes:', ...t.volumes.filter((v) => v?.container).map((v) => {
const host = v.bind ? v.bind.replace(/^\/portainer\/Files\/AppData\/[^/]+\/?/, './data/') : `./data${v.container}`;
return ` - ${y(`${host.replace(/\/$/, '') || './data'}:${v.container}${v.readonly ? ':ro' : ''}`)}`;
}));
}
if (t.labels?.length) L.push(' labels:', ...t.labels.filter((l) => l?.name).map((l) => ` ${y(l.name)}: ${y(l.value ?? '')}`));
return L.join('\n') + '\n';
}
// Liefert { name, compose, env, note }
async function build(index, url) {
const l = await list(url);
const item = l.find((x) => x.i === index);
if (!item) throw new Error('Template not found');
const t = item.t;
const name = slug(t.name || t.title);
let compose;
if (t.image) compose = containerCompose(t, name);
else {
const m = /^https:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?\/?$/.exec(t.repository.url || '');
if (!m) throw new Error(`Only GitHub repositories are supported (${t.repository.url}). Use “Clone from Git” instead.`);
const res = await fetch(`https://raw.githubusercontent.com/${m[1]}/${m[2]}/HEAD/${t.repository.stackfile.replace(/^\/+/, '')}`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${t.repository.stackfile}`);
compose = await res.text();
}
return { name, compose, env: envFile(t.env), note: String(t.note || '').replace(/<[^>]+>/g, '') };
}
module.exports = { list: async (url) => summary(await list(url)), build, DEFAULT_URL };
+257
View File
@@ -0,0 +1,257 @@
// Minimaler CTAP2-Client über Linux-hidraw (ohne native Module).
// Nötig, weil Chromium/Electron keine PIN-Eingabe für WebAuthn hat: Schlüssel mit gesetzter FIDO2-PIN
// (z. B. YubiKey 5 ohne makeCredUvNotRqd) verlangen die PIN beim Registrieren, und Chromium bricht dann
// nach dem Berühren mit NotAllowedError ab. Hier läuft makeCredential direkt, mit PIN-Token (Protokoll 1).
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// ---- CBOR (nur was CTAP2 braucht; Map-Schlüssel kanonisch sortiert) ----
function head(major, n) {
if (n < 24) return Buffer.from([(major << 5) | n]);
if (n < 0x100) return Buffer.from([(major << 5) | 24, n]);
if (n < 0x10000) { const b = Buffer.alloc(3); b[0] = (major << 5) | 25; b.writeUInt16BE(n, 1); return b; }
const b = Buffer.alloc(5); b[0] = (major << 5) | 26; b.writeUInt32BE(n, 1); return b;
}
function enc(v) {
if (typeof v === 'number') return v >= 0 ? head(0, v) : head(1, -1 - v);
if (typeof v === 'boolean') return Buffer.from([v ? 0xf5 : 0xf4]);
if (typeof v === 'string') { const s = Buffer.from(v, 'utf8'); return Buffer.concat([head(3, s.length), s]); }
if (Buffer.isBuffer(v) || v instanceof Uint8Array) return Buffer.concat([head(2, v.length), Buffer.from(v)]);
if (Array.isArray(v)) return Buffer.concat([head(4, v.length), ...v.map(enc)]);
if (v instanceof Map) {
const items = [...v].map(([k, x]) => [enc(k), enc(x)])
.sort(([a], [b]) => a.length - b.length || Buffer.compare(a, b));
return Buffer.concat([head(5, items.length), ...items.flat()]);
}
throw new Error('CBOR: unsupported value');
}
function dec(buf, pos = 0) {
const ib = buf[pos++], major = ib >> 5, ai = ib & 31;
let n = ai;
if (ai === 24) n = buf[pos++];
else if (ai === 25) { n = buf.readUInt16BE(pos); pos += 2; }
else if (ai === 26) { n = buf.readUInt32BE(pos); pos += 4; }
else if (ai === 27) { n = Number(buf.readBigUInt64BE(pos)); pos += 8; }
switch (major) {
case 0: return [n, pos];
case 1: return [-1 - n, pos];
case 2: return [buf.subarray(pos, pos + n), pos + n];
case 3: return [buf.toString('utf8', pos, pos + n), pos + n];
case 4: { const a = []; for (let i = 0; i < n; i++) { let x; [x, pos] = dec(buf, pos); a.push(x); } return [a, pos]; }
case 5: { const m = new Map(); for (let i = 0; i < n; i++) { let k, x; [k, pos] = dec(buf, pos); [x, pos] = dec(buf, pos); m.set(k, x); } return [m, pos]; }
case 6: return dec(buf, pos);
default: return [ai === 20 ? false : ai === 21 ? true : null, pos];
}
}
// ---- CTAPHID ----
const CMD = { CBOR: 0x10, INIT: 0x06, CANCEL: 0x11, KEEPALIVE: 0x3b, ERROR: 0x3f };
class CtapError extends Error {
constructor(code) { super(`CTAP2 error 0x${code.toString(16).padStart(2, '0')}`); this.code = code; }
}
function findDevice() {
const base = '/sys/class/hidraw';
let names = [];
try { names = fs.readdirSync(base); } catch { return null; }
for (const n of names) {
try {
const rd = fs.readFileSync(path.join(base, n, 'device', 'report_descriptor'));
if (rd.includes(Buffer.from([0x06, 0xd0, 0xf1]))) return '/dev/' + n; // Usage Page 0xF1D0 (FIDO)
} catch { /* ignorieren */ }
}
return null;
}
class Device {
static open() {
const p = findDevice();
if (!p) return null;
const d = new Device();
d.fd = fs.openSync(p, 'r+');
d.cid = Buffer.from([0xff, 0xff, 0xff, 0xff]);
return d;
}
close() {
this.closed = true;
try { fs.closeSync(this.fd); } catch { /* ignorieren */ }
}
// Nur lesen, während eine Antwort erwartet wird – so hängt nach close() kein blockierender Read im Threadpool
readPacket() {
const b = Buffer.alloc(64);
return new Promise((resolve, reject) => fs.read(this.fd, b, 0, 64, null, (e, n) => (e ? reject(e) : resolve(b.subarray(0, n)))));
}
write(cmd, data) {
const pkt = (b) => { const r = Buffer.alloc(65); b.copy(r, 1); fs.writeSync(this.fd, r); };
const first = Buffer.alloc(64);
this.cid.copy(first, 0); first[4] = 0x80 | cmd; first.writeUInt16BE(data.length, 5);
data.copy(first, 7, 0, 57); pkt(first);
for (let off = 57, seq = 0; off < data.length; off += 59, seq++) {
const c = Buffer.alloc(64);
this.cid.copy(c, 0); c[4] = seq; data.copy(c, 5, off, off + 59); pkt(c);
}
}
async transact(cmd, data) {
this.write(cmd, data);
for (;;) {
const r = await this.readPacket();
if (!r.subarray(0, 4).equals(this.cid)) continue;
const rcmd = r[4] & 0x7f;
if (rcmd === CMD.KEEPALIVE) continue;
const len = r.readUInt16BE(5);
const parts = [r.subarray(7)];
let got = r.length - 7;
while (got < len) { const c = await this.readPacket(); parts.push(c.subarray(5)); got += c.length - 5; }
const body = Buffer.concat(parts).subarray(0, len);
if (rcmd === CMD.ERROR) throw new Error(`CTAPHID error 0x${body[0].toString(16)}`);
return body;
}
}
async init() {
const nonce = crypto.randomBytes(8);
for (;;) {
const r = await this.transact(CMD.INIT, nonce);
if (r.subarray(0, 8).equals(nonce)) { this.cid = Buffer.from(r.subarray(8, 12)); return; }
}
}
cancel() { try { if (!this.closed) this.write(CMD.CANCEL, Buffer.alloc(0)); } catch { /* ignorieren */ } }
async cbor(cmd, params) {
const r = await this.transact(CMD.CBOR, Buffer.concat([Buffer.from([cmd]), params ? enc(params) : Buffer.alloc(0)]));
if (r[0] !== 0) throw new CtapError(r[0]);
return r.length > 1 ? dec(r, 1)[0] : new Map();
}
}
// ---- PIN-Protokoll 1 ----
const aes = (mode, key, data) => {
const c = mode === 'enc' ? crypto.createCipheriv('aes-256-cbc', key, Buffer.alloc(16)) : crypto.createDecipheriv('aes-256-cbc', key, Buffer.alloc(16));
c.setAutoPadding(false);
return Buffer.concat([c.update(data), c.final()]);
};
async function sharedSecret(dev) {
const ka = (await dev.cbor(0x06, new Map([[1, 1], [2, 2]]))).get(1);
const ecdh = crypto.createECDH('prime256v1');
ecdh.generateKeys();
const z = ecdh.computeSecret(Buffer.concat([Buffer.from([4]), ka.get(-2), ka.get(-3)]));
const pub = ecdh.getPublicKey();
const platformKey = new Map([[1, 2], [3, -25], [-1, 1], [-2, pub.subarray(1, 33)], [-3, pub.subarray(33, 65)]]);
return { key: crypto.createHash('sha256').update(z).digest(), platformKey };
}
async function pinToken(dev, pin) {
const { key, platformKey } = await sharedSecret(dev);
const pinHash = crypto.createHash('sha256').update(pin, 'utf8').digest().subarray(0, 16);
const r = await dev.cbor(0x06, new Map([[1, 1], [2, 5], [3, platformKey], [6, aes('enc', key, pinHash)]]));
return aes('dec', key, r.get(2));
}
async function pinRetries(dev) {
try { return (await dev.cbor(0x06, new Map([[1, 1], [2, 1]]))).get(3); } catch { return undefined; }
}
// Status-Codes, die für "abgebrochen / nicht bestätigt / Zeit abgelaufen" stehen
const CANCEL_CODES = new Set([0x27, 0x2d, 0x2f, 0x3a]);
const PIN = { INVALID: 0x31, BLOCKED: 0x32, AUTH_BLOCKED: 0x34 };
// Registriert ein nicht-residentes Credential mit hmac-secret für rpId "localhost" (kompatibel zur
// WebAuthn-PRF-Abfrage in fido.js). ui: { askPin(retries, wrong) -> Promise<string|null>, touch() }
// Liefert die Credential-ID als Buffer, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
async function makeCredential(ui, { timeoutMs = 60000 } = {}) {
let dev;
try { dev = Device.open(); } catch { return null; }
if (!dev) return null;
let timer;
const abort = () => dev.cancel();
try {
await dev.init();
const info = await dev.cbor(0x04);
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
if (!(info.get(2) || []).includes('hmac-secret')) throw new CtapError(-1);
const opts = info.get(4) || new Map();
const params = new Map([
[2, new Map([['id', 'localhost'], ['name', 'MrTerm']])],
[3, new Map([['id', crypto.randomBytes(16)], ['name', 'MrTerm'], ['displayName', 'MrTerm']])],
[4, [-7, -8, -257].map((alg) => new Map([['alg', alg], ['type', 'public-key']]))],
[6, new Map([['hmac-secret', true]])],
]);
const cdh = crypto.randomBytes(32);
params.set(1, cdh);
if (opts.get('clientPin') === true && opts.get('makeCredUvNotRqd') !== true) {
let wrong = false;
for (;;) {
const pin = await ui.askPin(await pinRetries(dev), wrong);
if (pin == null) throw new CtapError(0x2d);
try {
const tok = await pinToken(dev, pin);
params.set(8, crypto.createHmac('sha256', tok).update(cdh).digest().subarray(0, 16));
params.set(9, 1);
break;
} catch (e) {
if (e.code === PIN.INVALID) { wrong = true; continue; }
throw e;
}
}
}
ui.touch(abort);
timer = setTimeout(abort, timeoutMs);
const r = await dev.cbor(0x01, params);
const authData = r.get(2);
const idLen = authData.readUInt16BE(53);
return Buffer.from(authData.subarray(55, 55 + idLen));
} finally {
clearTimeout(timer);
dev.close();
}
}
// Entspricht WebAuthn-PRF (evalByCredential) über hmac-secret, ohne PIN (wie Chromium mit
// userVerification "discouraged"). creds: [{ credId: Buffer, salt: Buffer }] – salt ist der rohe PRF-Eingabewert.
// Liefert { credId: Buffer, secret: Buffer(32) }, oder null, wenn kein FIDO2-Gerät per hidraw erreichbar ist.
async function getHmacSecret(ui, creds, { timeoutMs = 60000 } = {}) {
let dev;
try { dev = Device.open(); } catch { return null; }
if (!dev) return null;
let timer;
try {
await dev.init();
const info = await dev.cbor(0x04);
if (!(info.get(1) || []).some((v) => String(v).startsWith('FIDO_2'))) return null;
const rpId = 'localhost';
const desc = (c) => new Map([['id', c.credId], ['type', 'public-key']]);
// Welches Credential liegt auf diesem Schlüssel? (Vorabprüfung ohne Berühren, up=false)
let cred = null;
for (const c of creds) {
try {
await dev.cbor(0x02, new Map([[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(c)]], [5, new Map([['up', false]])]]));
cred = c; break;
} catch (e) { if (e.code !== 0x2e) throw e; }
}
if (!cred) throw new CtapError(0x2e);
const { key, platformKey } = await sharedSecret(dev);
const salt = crypto.createHash('sha256').update(Buffer.concat([Buffer.from('WebAuthn PRF\0', 'latin1'), cred.salt])).digest();
const saltEnc = aes('enc', key, salt);
const saltAuth = crypto.createHmac('sha256', key).update(saltEnc).digest().subarray(0, 16);
ui.touch(() => dev.cancel());
timer = setTimeout(() => dev.cancel(), timeoutMs);
const r = await dev.cbor(0x02, new Map([
[1, rpId], [2, crypto.randomBytes(32)], [3, [desc(cred)]],
[4, new Map([['hmac-secret', new Map([[1, platformKey], [2, saltEnc], [3, saltAuth]])]])],
]));
const authData = r.get(2);
if (!(authData[32] & 0x80)) throw new CtapError(-1);
const ext = dec(authData, 37)[0].get('hmac-secret');
if (!ext) throw new CtapError(-1);
return { credId: Buffer.from(cred.credId), secret: aes('dec', key, ext).subarray(0, 32) };
} finally {
clearTimeout(timer);
dev.close();
}
}
module.exports = { makeCredential, getHmacSecret, CtapError, CANCEL_CODES, PIN };
+136
View File
@@ -8,6 +8,12 @@ const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm
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 q = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
const STACK_NAME = /^[a-z0-9][a-z0-9_-]*$/;
const COMPOSE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
const absPath = (p) => { if (typeof p !== 'string' || !p.startsWith('/') || /[\n\0]/.test(p) || p.split('/').includes('..')) throw new Error('Invalid path'); return p; };
const dirname = (p) => p.replace(/\/[^/]*$/, '') || '/';
const b64 = (t) => Buffer.from(String(t), 'utf8').toString('base64');
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
function execOn(conn, cmd, stdin) {
@@ -102,6 +108,136 @@ class DockerManager {
return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`;
}
// ---------- Compose-Stacks ----------
// Stacks liegen je in einem Unterordner von stacksDir (wie Dockge); zusätzlich werden per `compose ls`
// gefundene Projekte außerhalb davon angezeigt. Dateien werden ohne sudo geschrieben, sonst per sudo mit Host-Passwort.
async composeCmd(s) {
if (s.compose) return s.compose;
const cands = s.runtime === 'podman' ? ['podman compose', 'podman-compose'] : ['docker compose', 'docker-compose'];
for (const c of cands) {
const r = await execOn(s.conn, `${c} version >/dev/null 2>&1`);
if (!r.code) return (s.compose = c);
}
throw new Error(i18n.t('Docker Compose was not found on this host (docker compose plugin or docker-compose).'));
}
// Shell-Skript ausführen, bei fehlender Berechtigung per sudo (nur mit gespeichertem Passwort)
async sh(s, script) {
let r = await execOn(s.conn, `sh -c ${q(script)}`);
if (r.code && /permission denied|operation not permitted/i.test(r.err) && s.host.password) {
r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.host.password}\n`);
}
return r;
}
async stacks(id, stacksDir) {
const s = this.get(id);
const dir = absPath(stacksDir);
const compose = await this.composeCmd(s);
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
const scan = `d=${q(dir)}; [ -d "$d" ] && echo "#exists"; [ -w "$d" ] && echo "#writable"; for p in "$d"/*/; do [ -d "$p" ] || continue; `
+ `f=""; for c in ${COMPOSE_NAMES.join(' ')}; do [ -f "$p$c" ] && { f="$p$c"; break; }; done; `
+ `[ -n "$f" ] || f=$(find "$p" -maxdepth 3 \\( ${find} \\) 2>/dev/null | head -n1); `
+ `g=""; [ -d "$p.git" ] && g="git:$(git -C "$p" remote get-url origin 2>/dev/null)"; `
+ `printf '%s\\t%s\\t%s\\n' "$(basename "$p")" "$f" "$g"; done`;
const [r, ls] = await Promise.all([execOn(s.conn, `sh -c ${q(scan)}`), this.runCompose(s, compose, 'ls -a --format json')]);
const lines = r.out.split('\n').filter(Boolean);
let running = [];
try { running = JSON.parse(ls.out.trim() || '[]'); } catch { /* podman-compose kennt kein ls */ }
const status = new Map(running.map((x) => [x.Name, { status: x.Status, files: String(x.ConfigFiles || '').split(',').filter(Boolean) }]));
const list = lines.filter((l) => !l.startsWith('#')).map((l) => {
const [name, file, git] = l.split('\t');
return { name, dir: `${dir.replace(/\/$/, '')}/${name}`, file: file ? file.replace(/\/\//g, '/') : '', git: git ? git.slice(4) || true : false, managed: true, status: status.get(name)?.status || '' };
});
for (const [name, st] of status) {
if (list.some((x) => x.name === name) || !st.files[0]) continue;
list.push({ name, dir: dirname(st.files[0]), file: st.files[0], git: false, managed: false, status: st.status });
}
return { dir, exists: lines.includes('#exists'), writable: lines.includes('#writable'), compose, sudo: s.sudo, stacks: list.sort((a, b) => a.name.localeCompare(b.name)) };
}
runCompose(s, compose, args) {
const sudo = s.sudo ? "sudo -S -p '' " : '';
return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.host.password || ''}\n` : '');
}
async readStack(id, file) {
const s = this.get(id);
const f = absPath(file);
// .env wird mit umask 077 geschrieben – beim Speichern per sudo gehört sie root. Dann scheitert cat mit
// „Permission denied“ und sh() wiederholt das Lesen per sudo (vorher wurde der Fehler verschluckt).
const env = q(dirname(f) + '/.env');
const r = await this.sh(s, `cat ${q(f)} && printf '\\n\\0' && { [ ! -e ${env} ] || cat ${env}; }`);
if (r.code) throw new Error(lastLine(r.err) || i18n.t('Could not read {file}', { file: f }));
const i = r.out.lastIndexOf('\n\0');
return { compose: r.out.slice(0, i), env: r.out.slice(i + 2) };
}
// { stacksDir, name, file?, compose, env } – ohne file wird ein neuer Stack angelegt. Liefert { file, warning }
async saveStack(id, { stacksDir, name, file, compose, env }) {
const s = this.get(id);
let f;
if (file) f = absPath(file);
else {
if (!STACK_NAME.test(name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
f = `${absPath(stacksDir).replace(/\/$/, '')}/${name}/compose.yaml`;
}
const d = dirname(f);
const envFile = `${d}/.env`;
const script = `${file ? '' : `[ -e ${q(d)} ] && { echo "exists" >&2; exit 3; }; `}mkdir -p ${q(d)} && echo ${b64(compose)} | base64 -d > ${q(f)} && `
+ (String(env || '').trim() ? `(umask 077; echo ${b64(env.endsWith('\n') ? env : env + '\n')} | base64 -d > ${q(envFile)})` : `rm -f ${q(envFile)}`);
const r = await this.sh(s, script);
if (r.code === 3) throw new Error(i18n.t('A stack named “{name}” already exists.', { name }));
if (r.code) {
if (/permission denied/i.test(r.err)) throw new Error(i18n.t('No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.', { dir: d }));
throw new Error(lastLine(r.err) || 'Error');
}
const compose2 = await this.composeCmd(s);
const v = await this.runCompose(s, compose2, `--project-directory ${q(d)} -f ${q(f)} config -q`);
return { file: f, warning: v.code ? lastLine(v.err) || 'compose config failed' : '' };
}
// Befehl für einen Terminal-Tab (Ausgabe live; sudo/git fragen dort selbst nach Passwörtern)
async stackCommand(id, action, st, opts = {}) {
const s = this.get(id);
const compose = `${s.sudo ? 'sudo ' : ''}${await this.composeCmd(s)}`;
const S = (dir) => `S=$([ -w ${q(dir)} ] || echo sudo); `;
if (action === 'setup') {
const d = absPath(st.dir);
return `sudo mkdir -p ${q(d)} && sudo chown "$(id -u):$(id -g)" ${q(d)} && echo OK: ${q(d)}`;
}
if (action === 'clone') {
if (!STACK_NAME.test(st.name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.'));
if (!/^(https?:\/\/|git@|ssh:\/\/)\S+$/.test(opts.url || '')) throw new Error(i18n.t('Invalid repository URL: {url}', { url: opts.url || '' }));
const parent = absPath(st.stacksDir).replace(/\/$/, '');
const d = `${parent}/${st.name}`;
const sub = opts.path ? absPath('/' + opts.path.replace(/^\/+/, '')).slice(1) : '';
const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o ');
const locate = sub ? `f=${q(`${d}/${sub}`)}` : `f=$(find ${q(d)} -maxdepth 3 \\( ${find} \\) | head -n1)`;
return `${S(parent)}command -v git >/dev/null || { echo "git is not installed"; exit 1; }; $S git clone ${opts.branch ? `--branch ${q(opts.branch)} ` : ''}${q(opts.url)} ${q(d)} && `
+ `{ [ "$S" ] && $S chown -R "$(id -u):$(id -g)" ${q(d)}; true; } && ${locate} && [ -f "$f" ] && echo "Compose: $f"`
+ (opts.deploy ? ` && cd "$(dirname "$f")" && ${compose} -p ${q(st.name)} up -d` : '');
}
const f = absPath(st.file);
const cd = `cd ${q(dirname(f))} && `;
const p = `${compose} -p ${q(st.name)} -f ${q(f)}`;
switch (action) {
case 'up': return `${cd}${p} up -d --remove-orphans`;
case 'stop': return `${cd}${p} stop`;
case 'restart': return `${cd}${p} restart`;
case 'down': return `${cd}${p} down`;
case 'pull': return `${cd}${p} pull && ${p} up -d --remove-orphans`;
case 'logs': return `${cd}${p} logs -f --tail 200`;
case 'gitpull': return `${cd}${S(dirname(f))}$S git pull --ff-only && ${p} up -d --remove-orphans`;
case 'delete': {
if (!st.managed) throw new Error('Only stacks in the stacks folder can be deleted');
const d = absPath(st.dir);
return `${cd}${p} down${opts.volumes ? ' -v' : ''}; cd / && ${S(dirname(d))}$S rm -rf ${q(d)} && echo "Deleted: ${d.replace(/"/g, '')}"`;
}
default: throw new Error('Invalid action');
}
}
close(id) {
const s = this.sessions.get(id);
if (!s) return;
+87 -8
View File
@@ -4,8 +4,11 @@
// abgefangen wird (kein echter Server). rpId ist damit immer "localhost".
// Aus dem Schlüssel wird per PRF-Erweiterung (CTAP hmac-secret) ein geheimer Wert abgeleitet, der den
// Datenschlüssel des Vaults verpackt. userVerification "discouraged": Berühren genügt (Electron hat keine PIN-Eingabe).
// Registrieren läuft unter Linux direkt per CTAP2 (ctap2.js), weil Electron keine PIN-Abfrage kennt und
// Schlüssel mit gesetzter PIN sonst nach dem Berühren mit NotAllowedError scheitern.
const { BrowserWindow, session } = require('electron');
const i18n = require('../i18n');
const ctap2 = require('./ctap2');
const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
html,body{margin:0;height:100%;background:#1a1d27;color:#e6e8ef;font:14px system-ui,sans-serif;-webkit-app-region:drag}
@@ -13,7 +16,10 @@ const PAGE = `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
.ic{font-size:34px} #t{max-width:340px;line-height:1.4}
button{-webkit-app-region:no-drag;background:#2a2f40;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 16px;font:inherit;cursor:pointer}
button:hover{background:#343a4f}
</style></head><body><div class="ic">🔑</div><div id="t"></div><button id="c"></button></body></html>`;
form{display:flex;gap:8px;-webkit-app-region:no-drag} form[hidden]{display:none}
input{background:#10131b;color:#e6e8ef;border:1px solid #3a4054;border-radius:8px;padding:7px 10px;font:inherit;width:170px}
.row{display:flex;gap:8px}
</style></head><body><div class="ic">🔑</div><div id="t"></div><form id="f" hidden><input id="p" type="password" autocomplete="off"><button id="ok"></button></form><div class="row"><button id="c"></button></div></body></html>`;
let fidoSession;
function getSession() {
@@ -29,17 +35,23 @@ const HELPERS = `
const unb64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0));
`;
async function ceremony(parent, text, script) {
async function openWindow(parent, text) {
const w = new BrowserWindow({
parent, modal: !!parent, width: 420, height: 210, frame: false, resizable: false, show: false,
parent, modal: !!parent, width: 420, height: 230, frame: false, resizable: false, show: false,
backgroundColor: '#1a1d27', webPreferences: { session: getSession(), contextIsolation: true, sandbox: true },
});
await w.loadURL('http://localhost/');
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
document.getElementById('ok').textContent = ${JSON.stringify(i18n.t('OK'))};
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
w.show();
w.focus();
return w;
}
async function ceremony(parent, text, script) {
const w = await openWindow(parent, text);
try {
await w.loadURL('http://localhost/');
await w.webContents.executeJavaScript(`document.getElementById('t').textContent = ${JSON.stringify(text)};
const c = document.getElementById('c'); c.textContent = ${JSON.stringify(i18n.t('Cancel'))}; c.onclick = () => window.close(); 0;`);
w.show();
w.focus();
const closed = new Promise((resolve) => w.once('closed', () => resolve({ error: 'cancelled' })));
const r = await Promise.race([w.webContents.executeJavaScript(`(async () => { try { ${HELPERS} ${script} } catch (e) { return { error: e.name + ': ' + e.message }; } })()`, true), closed]);
if (r?.error === 'cancelled' || /NotAllowedError|AbortError/.test(r?.error || '')) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
@@ -50,8 +62,71 @@ async function ceremony(parent, text, script) {
}
}
// Registrierung direkt per CTAP2 (Linux). Liefert die Credential-ID (base64url) oder null, wenn kein
// FIDO2-Gerät per hidraw erreichbar ist (dann übernimmt Chromium).
async function registerNative(parent) {
const w = await openWindow(parent, i18n.t('Connecting to security key …'));
let abort = () => {};
let cancelled = false;
w.once('closed', () => { cancelled = true; abort(); });
const js = (code) => (w.isDestroyed() ? Promise.resolve(null) : w.webContents.executeJavaScript(code).catch(() => null));
const setText = (t) => js(`document.getElementById('t').textContent = ${JSON.stringify(t)}; 0;`);
const ui = {
async askPin(retries, wrong) {
let t = i18n.t('Enter the PIN of your security key.');
if (wrong) t = i18n.t('Wrong PIN.') + ' ' + t;
if (retries != null) t += ' ' + i18n.t('{n} attempts left.', { n: retries });
await setText(t);
const closed = new Promise((resolve) => w.once('closed', () => resolve(null)));
return Promise.race([closed, js(`new Promise((resolve) => {
const f = document.getElementById('f'), p = document.getElementById('p');
f.hidden = false; p.value = ''; p.focus();
f.onsubmit = (e) => { e.preventDefault(); f.hidden = true; resolve(p.value); };
})`)]);
},
touch(a) { abort = a; if (cancelled) a(); setText(i18n.t('Touch your security key to register it.')); },
};
try {
const id = await ctap2.makeCredential(ui);
return id && b64url(id);
} catch (e) {
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
if (e.code === ctap2.PIN.BLOCKED || e.code === ctap2.PIN.AUTH_BLOCKED) throw new Error(i18n.t('The PIN of this security key is blocked. Remove and reinsert the key, or reset it.'));
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
throw e;
} finally {
if (!w.isDestroyed()) w.destroy();
}
}
// PRF-Abfrage direkt per CTAP2 (Linux); null, wenn kein FIDO2-Gerät per hidraw erreichbar ist
async function deriveNative(parent, creds, text) {
const w = await openWindow(parent, text || i18n.t('Touch your security key to unlock MrTerm.'));
let abort = () => {};
let cancelled = false;
w.once('closed', () => { cancelled = true; abort(); });
try {
const r = await ctap2.getHmacSecret({ touch(a) { abort = a; if (cancelled) a(); } },
creds.map((c) => ({ credId: Buffer.from(c.credId, 'base64url'), salt: Buffer.from(c.prfSalt, 'base64url') })));
return r && { credId: b64url(r.credId), secret: r.secret };
} catch (e) {
if (cancelled || ctap2.CANCEL_CODES.has(e.code)) throw new Error(i18n.t('Security key prompt was cancelled or timed out.'));
if (e.code === 0x2e) throw new Error(i18n.t('Unknown security key.'));
if (e.code === -1) throw new Error(i18n.t('This security key does not support the hmac-secret/PRF extension.'));
throw e;
} finally {
if (!w.isDestroyed()) w.destroy();
}
}
const b64url = (buf) => Buffer.from(buf).toString('base64url');
// Neuen Schlüssel registrieren; liefert die Credential-ID (base64url)
async function register(parent) {
if (process.platform === 'linux') {
const id = await registerNative(parent);
if (id) return id;
}
const r = await ceremony(parent, i18n.t('Touch your security key to register it.'), `
const cred = await navigator.credentials.create({ publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
@@ -69,6 +144,10 @@ async function register(parent) {
// PRF-Wert für einen der Schlüssel abfragen. creds: [{ credId, prfSalt }] (base64url)
// Liefert { credId, secret: Buffer(32) }
async function derive(parent, creds, text) {
if (process.platform === 'linux') {
const r = await deriveNative(parent, creds, text);
if (r) return r;
}
const r = await ceremony(parent, text || i18n.t('Touch your security key to unlock MrTerm.'), `
const creds = ${JSON.stringify(creds)};
const evalByCredential = Object.fromEntries(creds.map((c) => [c.credId, { first: unb64(c.prfSalt) }]));
+26 -11
View File
@@ -16,6 +16,9 @@ const { DockerManager } = require('./docker');
const { FirewallManager } = require('./firewall');
const { NetworkConfigManager } = require('./network');
const { SyncService } = require('./sync');
const backup = require('../core/backup');
const composeTemplates = require('./compose-templates');
const { AiAssistant } = require('./ai');
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win;
@@ -75,6 +78,7 @@ const vpn = new VpnManager(store, app.getPath('userData'));
const docker = new DockerManager(ssh);
const firewall = new FirewallManager(ssh);
const network = new NetworkConfigManager(ssh);
const ai = new AiAssistant(store, send);
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
const pairReplies = new Map();
@@ -232,6 +236,12 @@ 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));
handle('docker:stacks', (id, dir) => docker.stacks(id, dir));
handle('docker:stackRead', (id, file) => docker.readStack(id, file));
handle('docker:stackSave', (id, data) => docker.saveStack(id, data));
handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts));
handle('docker:templates', (url) => composeTemplates.list(url));
handle('docker:template', (index, url) => composeTemplates.build(index, url));
// ---------- Firewall ----------
handle('firewall:open', async (id, hostRef) => {
@@ -270,6 +280,11 @@ handle('sync:share', (sel) => sync.setShare(sel));
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
handle('sync:probe', (address) => sync.probe(address));
// ---------- Support-Assistent (Ollama) ----------
handle('ai:models', (url) => ai.models(url));
handle('ai:chat', (id, messages, context) => ai.chat(id, messages, context));
handle('ai:stop', (id) => ai.stop(id));
// ---------- VPN ----------
handle('vpn:status', () => vpn.status());
handle('vpn:up', (id) => vpn.up(id));
@@ -280,19 +295,19 @@ handle('vault:settings', (s) => {
if ('rdpEmbed' in s) fs.writeFileSync(launchFile, JSON.stringify({ ...readLaunch(), x11: s.rdpEmbed !== false }));
});
handle('vault:forgetHost', (id) => store.forgetKnownHost(id));
handle('vault:export', async () => {
const r = await dialog.showSaveDialog(win, { defaultPath: 'mrterm-backup.json', filters: [{ name: 'JSON', extensions: ['json'] }] });
if (r.canceled) return false;
fs.writeFileSync(r.filePath, JSON.stringify(publicData(), null, 2), { mode: 0o600 });
return r.filePath;
// Backup: vollständig, optional per Passwort verschlüsselt (src/core/backup.js)
handle('backup:export', (opts) => backup.createBackup(store, { ...opts, version: app.getVersion() }));
handle('backup:inspect', (content) => backup.inspectBackup(content));
handle('backup:import', (content, password, opts) => {
const r = backup.importBackup(store, content, password, opts);
if (opts?.settings) applyLanguage();
return r;
});
handle('vault:import', async () => {
const r = await dialog.showOpenDialog(win, { filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'] });
handle('backup:saveFile', async (content, name) => {
const r = await dialog.showSaveDialog(win, { defaultPath: name, filters: [{ name: 'MrTerm Backup', extensions: ['json'] }] });
if (r.canceled) return false;
const data = JSON.parse(fs.readFileSync(r.filePaths[0], 'utf8'));
for (const col of ['groups', 'hosts', 'keys', 'snippets', 'forwards', 'vpns'])
for (const item of data[col] || []) store.upsert(col, item);
return true;
fs.writeFileSync(r.filePath, content, { mode: 0o600 });
return r.filePath;
});
// Import aus ~/.ssh/config
+6
View File
@@ -16,6 +16,8 @@ const DEFAULTS = {
vpns: [],
knownHosts: {},
history: [],
// Chats des Support-Assistenten (nur lokal, nicht synchronisiert)
aiChats: [],
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
tombstones: [],
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
@@ -39,6 +41,10 @@ const DEFAULTS = {
updatePrerelease: false,
autoLock: 0,
language: 'auto',
dockerStacksDir: '/opt/stacks',
dockerTemplatesUrl: '',
aiUrl: 'http://localhost:11434',
aiModel: '',
},
};
+571 -11
View File
@@ -46,6 +46,7 @@ const ICONS = {
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>',
wall: '<svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="1"/><path d="M3 9.3h18M3 14.7h18M9 4v5.3M15 4v5.3M6 9.3v5.4M12 9.3v5.4M18 9.3v5.4M9 14.7V20M15 14.7V20"/></svg>',
network: '<svg viewBox="0 0 24 24"><rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/></svg>',
ai: '<svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></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'];
@@ -150,6 +151,7 @@ async function reload() {
S.vault = await call('vault:get');
I18N.setLanguage(settings().language, navigator.language);
applyStatic();
initAi();
const b = $('#vaultBadge');
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`;
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
@@ -658,7 +660,7 @@ function viewSnippets(page) {
page.append(toolbar(T('Search snippets …'), [{ label: T('New snippet'), icon: 'plus', cls: 'primary', run: () => editSnippet() }]));
const c = h('<div class="content"></div>'); page.append(c);
const list = S.vault.snippets.filter((s) => matches(s.label, s.command));
if (!list.length) return c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet()));
if (!list.length) { c.append(emptyState('code', T('No snippets'), T('Save frequently used commands and send them to a terminal with one click.'), T('New snippet'), () => editSnippet())); c.append(templateList()); return; }
const l = h('<div class="list"></div>');
for (const s of list) {
const card = h(`<div class="card"><div class="avatar" style="background:var(--icon-bg);color:var(--green)">${ICONS.code}</div><div class="meta"><div class="title">${esc(s.label)}</div><div class="sub mono">${esc(s.command.split('\n')[0])}${s.command.includes('\n') ? ' …' : ''}</div></div><div class="actions"><button data-a="run" title="${T('Run in active terminal')}">${ICONS.play}</button><button data-a="copy" title="${T('Copy')}">${ICONS.copy}</button><button data-a="del" title="${T('Delete')}">${ICONS.trash}</button></div></div>`);
@@ -671,7 +673,7 @@ function viewSnippets(page) {
};
l.append(card);
}
c.append(l);
c.append(l, templateList());
}
function editSnippet(s = {}) {
const form = openDrawer(s.id ? T('Edit snippet') : T('New snippet'), [
@@ -685,6 +687,86 @@ function editSnippet(s = {}) {
reload();
});
}
// Eingebaute Vorlagen (nicht im Vault). Parameter werden vor dem Ausführen abgefragt; build() liefert eine Zeile.
// $S = sudo, außer man ist bereits root.
const shq = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`;
const SUDO = 'S=$([ "$(id -u)" = 0 ] || echo sudo);';
const SNIPPET_TEMPLATES = [
{
label: () => T('Authorize SSH key for a user'),
desc: () => T('Adds a public key from the keychain to ~/.ssh/authorized_keys of the user.'),
params: () => [
S.vault.keys.some((k) => k.publicKey)
? field(T('Key'), 'key', '', { type: 'select', options: S.vault.keys.filter((k) => k.publicKey).map((k) => [k.id, k.label]) })
: field('Public Key', 'pub', '', { type: 'textarea', placeholder: 'ssh-ed25519 AAAA…' }),
field(T('User'), 'user', 'root'),
],
build: (v) => {
const pub = (v.key ? S.vault.keys.find((k) => k.id === v.key)?.publicKey : v.pub || '').trim();
if (!pub) throw new Error(T('Public key is missing.'));
if (!v.user.trim()) throw new Error(T('User is missing.'));
return `${SUDO} u=${shq(v.user.trim())}; k=${shq(pub)}; d="$(getent passwd "$u" | cut -d: -f6)/.ssh"; `
+ `$S install -d -m 700 -o "$u" -g "$(id -gn "$u")" "$d" && { $S grep -qxF "$k" "$d/authorized_keys" 2>/dev/null || echo "$k" | $S tee -a "$d/authorized_keys" >/dev/null; } `
+ `&& $S chown "$u": "$d/authorized_keys" && $S chmod 600 "$d/authorized_keys" && echo "OK: $d/authorized_keys"`;
},
},
{
label: () => T('Install QEMU guest agent'),
desc: () => T('Installs and starts qemu-guest-agent (apt, dnf, yum, pacman, zypper or apk).'),
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get install -y qemu-guest-agent; `
+ 'elif command -v dnf >/dev/null; then $S dnf install -y qemu-guest-agent; elif command -v yum >/dev/null; then $S yum install -y qemu-guest-agent; '
+ 'elif command -v pacman >/dev/null; then $S pacman -S --needed --noconfirm qemu-guest-agent; elif command -v zypper >/dev/null; then $S zypper -n install qemu-guest-agent; '
+ 'elif command -v apk >/dev/null; then $S apk add qemu-guest-agent && $S rc-update add qemu-guest-agent && $S rc-service qemu-guest-agent start; fi; '
+ 'command -v systemctl >/dev/null && { $S systemctl enable --now qemu-guest-agent 2>/dev/null || $S systemctl start qemu-guest-agent; }; '
+ 'command -v systemctl >/dev/null && systemctl is-active qemu-guest-agent',
},
{
label: () => T('Use APT cache server'),
desc: () => T('Routes APT downloads through a cache proxy such as apt-cacher-ng (/etc/apt/apt.conf.d/00proxy).'),
params: () => [field(T('Proxy URL'), 'url', 'http://', { placeholder: 'http://192.168.1.10:3142' })],
build: (v) => {
const url = v.url.trim();
if (!/^https?:\/\/[^\s/]+/.test(url)) throw new Error(T('Invalid URL.'));
return `${SUDO} echo ${shq(`Acquire::http::Proxy "${url}";`)} | $S tee /etc/apt/apt.conf.d/00proxy && $S apt-get update`;
},
},
{
label: () => T('Remove APT cache server'),
desc: () => T('Removes the APT proxy setting again.'),
build: () => `${SUDO} $S rm -f /etc/apt/apt.conf.d/00proxy && $S apt-get update`,
},
{
label: () => T('System update'),
desc: () => T('Updates all packages with the system package manager.'),
build: () => `${SUDO} if command -v apt-get >/dev/null; then $S apt-get update && $S apt-get -y full-upgrade; elif command -v dnf >/dev/null; then $S dnf -y upgrade; `
+ 'elif command -v pacman >/dev/null; then $S pacman -Syu --noconfirm; elif command -v zypper >/dev/null; then $S zypper -n update; elif command -v apk >/dev/null; then $S apk upgrade --update; fi',
},
];
// Fragt die Parameter einer Vorlage ab und schickt den Befehl ins Terminal
async function runTemplate(t, session) {
const params = t.params ? t.params() : [];
let v = {};
if (params.length) {
const body = `<p style="margin-top:0;color:var(--muted)">${esc(t.desc())}</p>${params.map((el) => el.outerHTML).join('')}`;
v = await modal({ title: t.label(), body, buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Run'), value: 'form', cls: 'primary' }] });
if (!v) return;
}
let command;
try { command = t.build(v); } catch (e) { return toast(e.message, 'error'); }
runSnippet({ command, autoRun: true }, session);
}
function templateList(session) {
const d = h(`<details class="snip-tpl"><summary>${T('Templates')}</summary><div class="tpl-items"></div></details>`);
for (const t of SNIPPET_TEMPLATES) {
const it = h(`<div class="it"><div>${esc(t.label())}</div><div class="hint">${esc(t.desc())}</div></div>`);
it.onclick = () => runTemplate(t, session);
$('.tpl-items', d).append(it);
}
return d;
}
function runSnippet(s, session) {
const t = session || S.tabs.find((x) => x.id === S.active && x.kind === 'ssh') || [...S.tabs].reverse().find((x) => x.kind === 'ssh');
if (!t) return toast(T('No open terminal'), 'error');
@@ -971,6 +1053,28 @@ async function viewSettings(page) {
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
});
// ---- Support-Assistent (Ollama)
const aiCard = h(`<div class="settings-card"><h3>${T('Support assistant')}</h3><p style="color:var(--muted);margin-top:0">${T('Helps with errors in the Linux console, Docker, firewall and network via a local <b>Ollama</b> instance. Open it with the chat button in the title bar or in a tab. The content of the active tab is sent to the Ollama server as context. Chats are stored in the vault.')}</p></div>`);
const aiUrl = field(T('Ollama server'), 'aiUrl', st.aiUrl || 'http://localhost:11434', { placeholder: 'http://localhost:11434' });
const aiModel = field(T('Model'), 'aiModel', st.aiModel, { type: 'select', options: [[st.aiModel || '', st.aiModel || T('– select –')]] });
const loadModels = async () => {
const sel = $('select', aiModel);
try {
const list = await api.call('ai:models', $('input', aiUrl).value.trim());
sel.innerHTML = '';
if (!list.length) sel.append(new Option(T('No models installed (ollama pull …)'), ''));
list.forEach((m) => sel.append(new Option(m, m)));
sel.value = list.includes(st.aiModel) ? st.aiModel : list[0] || '';
if (sel.value !== (settings().aiModel || '')) save({ aiModel: sel.value });
} catch (e) { toast(e.message, 'error'); }
};
const aiReload = h(`<button class="btn">${ICONS.refresh}${T('Load models')}</button>`);
aiReload.onclick = loadModels;
$('input', aiUrl).onchange = () => save({ aiUrl: $('input', aiUrl).value.trim() }).then(loadModels);
$('select', aiModel).onchange = () => save({ aiModel: $('select', aiModel).value });
aiCard.append(aiUrl, aiModel, aiReload);
if (st.aiModel) loadModels();
const rdpCard = h(`<div class="settings-card"><h3>RDP</h3><div class="hint rdp-info" style="color:var(--muted);margin-bottom:12px">${T('Checking …')}</div></div>`);
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
emb.onchange = async () => {
@@ -989,11 +1093,20 @@ async function viewSettings(page) {
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
}).catch(() => {});
const dockerCard = h(`<div class="settings-card"><h3>Docker Compose</h3></div>`);
const dockerFields = h('<div></div>');
dockerFields.append(
field(T('Stacks folder'), 'dockerStacksDir', st.dockerStacksDir || '/opt/stacks', { hint: T('Folder on the host in which each stack gets its own subfolder') }),
field(T('Template source'), 'dockerTemplatesUrl', st.dockerTemplatesUrl || '', { placeholder: 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json', hint: T('URL of a templates.json in Portainer format. Empty = Lissy93/portainer-templates.') }),
);
dockerFields.addEventListener('change', () => save(formValues(dockerFields)));
dockerCard.append(dockerFields);
const dataCard = h(`<div class="settings-card"><h3>${T('Data')}</h3><p style="color:var(--muted);margin-top:0">${WEB ? T('Your vault is stored on the server, encrypted with a key that only your login password can unlock.') : S.vault.encrypted ? T('The vault is encrypted with the operating system keyring (Windows DPAPI / libsecret or KWallet).') : T('The vault is not encrypted because no keyring is available. On Arch/CachyOS: install <code>gnome-keyring</code> or <code>kwallet</code>.')}</p><div class="row" style="flex-wrap:wrap"></div></div>`);
const btns = [
...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]),
[T('Export backup'), async () => { const p = await call('vault:export'); if (p) toast(T('Exported to {path}', { path: p }), 'ok'); }],
[T('Import backup'), async () => { if (await call('vault:import')) { toast(T('Import complete'), 'ok'); reload(); } }],
[T('Export backup'), exportBackup],
[T('Import backup'), importBackup],
];
btns.forEach(([l, fn]) => { const b = h(`<button class="btn">${esc(l)}</button>`); b.onclick = fn; $('.row', dataCard).append(b); });
@@ -1043,8 +1156,8 @@ async function viewSettings(page) {
<span>${C}+<kbd>Shift</kbd>+<kbd>F</kbd></span><span>${T('Search in terminal')}</span>
<span>${C}+<kbd>+/−/0</kbd></span><span>${T('Font size')}</span>
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, importCard, dataCard, keysCard);
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, aiCard, dockerCard, importCard, dataCard, keysCard);
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, aiCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard);
}
// ============================================================ App-Sperre
@@ -1336,13 +1449,75 @@ async function importRdm() {
$('[data-view=hosts]').click();
}
// ============================================================ Backup
const BACKUP_ERRORS = () => ({ INVALID: T('This file is not a MrTerm backup.'), WRONG_PASSWORD: T('Wrong password.'), PASSWORD_REQUIRED: T('This backup is encrypted. Please enter the password.') });
const backupError = (e) => BACKUP_ERRORS()[e.message] || e.message;
async function exportBackup() {
let err = '';
for (;;) {
const r = await modal({
title: T('Export backup'),
body: `<p style="margin-top:0;color:var(--muted)">${T('Exports all hosts, groups, SSH keys, passwords, snippets, port forwards, VPNs, known hosts and settings.')}</p>
${err ? `<p style="color:var(--red)">${esc(err)}</p>` : ''}
${field(T('Password'), 'pw', '', { type: 'password', hint: T('Recommended. Without a password, keys and passwords are stored in plain text in the file.') }).outerHTML}
${field(T('Repeat password'), 'pw2', '', { type: 'password' }).outerHTML}
${check(T('Include settings'), 'settings', true).outerHTML}`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Export'), value: 'form', cls: 'primary' }],
});
if (!r) return;
if (r.pw !== r.pw2) { err = T('The passwords do not match.'); continue; }
if (r.pw && r.pw.length < 8) { err = T('The password must be at least 8 characters long.'); continue; }
if (!r.pw && !(await confirmBox(T('Export without password?'), T('Private keys and passwords will be readable by anyone who gets the file.'), T('Export')))) continue;
try {
const content = await api.call('backup:export', { password: r.pw, settings: r.settings });
const name = `mrterm-backup-${new Date().toISOString().slice(0, 10)}${r.pw ? '-encrypted' : ''}.json`;
const p = await call('backup:saveFile', content, name);
if (p) toast(T('Exported to {path}', { path: p }), 'ok');
} catch (e) { toast(backupError(e), 'error'); }
return;
}
}
async function importBackup() {
const f = await call('import:pickFile', 'MrTerm Backup', ['json']);
if (!f) return;
let info;
try { info = await api.call('backup:inspect', f.content); } catch (e) { return toast(backupError(e), 'error'); }
const c = info.counts;
const summary = c ? T('{hosts} hosts, {keys} keys, {snippets} snippets, {vpns} VPNs', c) : T('Contents are encrypted.');
let err = '';
for (;;) {
const r = await modal({
title: T('Import backup'),
body: `<p style="margin-top:0;color:var(--muted)">${esc(f.name)}${info.createdAt ? ' · ' + esc(new Date(info.createdAt).toLocaleString()) : ''}<br>${esc(summary)}</p>
${err ? `<p style="color:var(--red)">${esc(err)}</p>` : ''}
${info.encrypted ? field(T('Password'), 'pw', '', { type: 'password' }).outerHTML : ''}
${check(T('Import settings'), 'settings', false).outerHTML}
${check(T('Replace existing data (entries not contained in the backup are deleted)'), 'replace', false).outerHTML}`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Import'), value: 'form', cls: 'primary' }],
});
if (!r) return;
if (r.replace && !(await confirmBox(T('Replace existing data?'), T('Hosts, keys, snippets and other entries that are not in the backup will be deleted.'), T('Replace')))) continue;
try {
const n = await api.call('backup:import', f.content, r.pw || '', { replace: r.replace, settings: r.settings });
toast(T('Import complete: {hosts} hosts, {keys} keys, {snippets} snippets', n), 'ok');
reload();
return;
} catch (e) { err = backupError(e); }
}
}
// ============================================================ Updates
let updateInfo = null;
let updateDialogOpen = false; // Auto-Prüfung beim Start und manuelle Prüfung sollen keine zwei Dialoge stapeln
async function showUpdate(info) {
updateInfo = info;
const b = $('#updateBadge');
b.style.display = '';
b.querySelector('span').textContent = `Update ${info.version}`;
if (updateDialogOpen) return;
updateDialogOpen = true;
const r = await modal({
title: T('MrTerm {v} is available', { v: info.version }),
text: T('Installed: {v}', { v: info.current }) + (info.asset ? ' · ' + T('Package: {name} ({size})', { name: info.asset.name, size: fmtSize(info.asset.size) }) : ''),
@@ -1350,7 +1525,7 @@ async function showUpdate(info) {
${info.kind === 'dev' ? `<p>${T('Development mode: please update via <code>git pull</code>.')}</p>` : !info.asset ? `<p>${T('The release contains no package for this system.')}</p>` : ''}
<div class="upd-prog" style="display:none"><div class="transfer" style="padding:6px 0"><span class="nm">${T('Downloading …')}</span><span class="pct"></span><div class="bar"><i></i></div></div></div>`,
buttons: [{ label: T('Later'), value: false, cls: 'ghost' }, { label: T('Release page'), value: 'web', cls: '' }, ...(info.asset && info.kind !== 'dev' ? [{ label: T('Install now'), value: true, cls: 'primary' }] : [])],
});
}).finally(() => { updateDialogOpen = false; });
if (r === 'web') return api.call('shell:open', info.url);
if (r !== true) return;
toast(T('Downloading update …'));
@@ -1394,6 +1569,7 @@ function activateTab(id) {
});
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
renderTree();
if ($('#aiPanel').classList.contains('open')) aiUpdateCtx();
}
function closeTab(id) {
const i = S.tabs.findIndex((t) => t.id === id);
@@ -1418,6 +1594,7 @@ class TerminalSession {
<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="firewall" title="${T('Firewall')}">${ICONS.wall}${T('Firewall')}</button><button class="btn ghost sm" data-a="network" title="${T('Network')}">${ICONS.network}${T('Network')}</button>`}
<button class="btn ghost sm" data-a="snip" title="Snippets">${ICONS.code}Snippets</button>
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}${T('Assistant')}</button>
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
</div>
<div class="term-wrap"><div class="term"></div>
@@ -1501,6 +1678,14 @@ class TerminalSession {
it.onclick = () => runSnippet(s, this);
box.append(it);
});
box.append(templateList(this));
}
// Letzte Zeilen des Terminals als Kontext für den Assistenten
aiContext(lines = 120) {
const b = this.term.buffer.active, out = [];
for (let i = Math.max(0, b.length - lines); i < b.length; i++) out.push(b.getLine(i)?.translateToString(true) ?? '');
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}
toggleFind(open) {
@@ -1577,8 +1762,158 @@ class TerminalSession {
}
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
}
// Minimales Markdown für Assistenten-Antworten: Codeblöcke (mit Kopieren/Einfügen), Inline-Code, Fett
function aiMarkdown(md) {
return md.split(/```/).map((part, i) => {
if (i % 2) {
const code = part.replace(/^[\w-]*\n/, '');
return `<div class="code"><div class="acts"><button class="btn ghost sm" data-cmd="copy">${T('Copy')}</button><button class="btn ghost sm" data-cmd="paste" title="${T('Insert into terminal (not executed)')}">${T('Insert')}</button></div><pre><code>${esc(code)}</code></pre></div>`;
}
return esc(part).replace(/`([^`\n]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>').replace(/\n/g, '<br>');
}).join('');
}
function openTerminal(host) { return new TerminalSession(host); }
// ============================================================ Support-Assistent (tabübergreifend, Chats im Tresor)
const AI = { chat: null, unsub: null, busy: false };
const aiChats = () => (S.vault.aiChats ||= []);
const activeSession = () => S.tabs.find((t) => t.id === S.active);
function aiToggle(open) {
const p = $('#aiPanel'), o = open ?? !p.classList.contains('open');
p.classList.toggle('open', o);
if (o) { aiRender(); $('textarea', p).focus(); }
}
// Kontext aus dem aktiven Tab: Terminalausgabe, Docker-/Firewall-/Netzwerkansicht …
function aiContext() {
const t = activeSession();
if (!t) return '';
const host = t.host ? `, host ${t.host.username ? t.host.username + '@' : ''}${t.host.address}` : '';
const body = String((t.aiContext ? t.aiContext() : t.el.innerText) || '').trim();
return body ? `Active MrTerm tab: ${t.title} (${t.kind}${host})\n\n${body.slice(-12000)}` : '';
}
function aiUpdateCtx() {
const t = activeSession();
$('#aiPanel .ctx-name').textContent = t ? t.title : T('Vault (no context)');
}
function aiRender() {
const p = $('#aiPanel'), sel = $('.chats', p), box = $('.msgs', p);
sel.innerHTML = '';
sel.append(new Option(T('New chat'), ''));
[...aiChats()].sort((a, b) => b.updatedAt - a.updatedAt).forEach((c) => sel.append(new Option(c.title, c.id)));
sel.value = AI.chat?.id && aiChats().includes(AI.chat) ? AI.chat.id : '';
box.innerHTML = '';
if (!AI.chat?.messages.length) box.append(h(`<div class="hint">${T('Ask about an error or let the assistant explain what you see. The content of the active tab is sent as context.')}</div>`));
for (const m of AI.chat?.messages || []) {
if (m.role === 'user') box.append(h(`<div class="m user">${m.tab ? `<small>${esc(m.tab)}</small>` : ''}${esc(m.content)}</div>`));
else { const d = h('<div class="m bot"></div>'); d.innerHTML = aiMarkdown(m.content); box.append(d); }
}
$('[data-ai=del]', p).disabled = !AI.chat?.id || !aiChats().includes(AI.chat);
aiUpdateCtx();
box.scrollTop = box.scrollHeight;
}
async function aiSave() {
const c = AI.chat;
if (!c) return;
if (!aiChats().includes(c)) aiChats().push(c);
c.updatedAt = Date.now();
try { await call('vault:upsert', 'aiChats', c); } catch {}
}
function aiSetBusy(b) {
AI.busy = b;
const btn = $('#aiPanel [type=submit]');
btn.textContent = b ? T('Stop') : T('Send');
btn.classList.toggle('danger', b);
}
async function aiAsk(text) {
const p = $('#aiPanel');
if (!p.classList.contains('open')) aiToggle(true);
if (AI.busy) return;
if (!settings().aiModel) { toast(T('Please select an Ollama model in the settings first.'), 'error'); return; }
if (!AI.chat) AI.chat = { id: uid(), title: text.replace(/\s+/g, ' ').slice(0, 60), messages: [] };
const chat = AI.chat, t = activeSession();
chat.messages.push({ role: 'user', content: text, tab: t?.title || '' });
aiRender();
const box = $('.msgs', p);
$('.hint', box)?.remove();
const out = h('<div class="m bot"><div class="spinner"></div></div>'); box.append(out);
box.scrollTop = box.scrollHeight;
const reply = { role: 'assistant', content: '' };
aiSetBusy(true);
const finish = async (err) => {
AI.unsub?.(); AI.unsub = null; aiSetBusy(false);
if (err) { out.classList.add('err'); out.textContent = err; chat.messages.pop(); }
else if (reply.content) { chat.messages.push(reply); await aiSave(); if (AI.chat === chat) aiRender(); }
else out.remove();
};
AI.unsub = api.on('ai:event', (cid, type, payload) => {
if (cid !== chat.id) return;
if (type === 'data') {
reply.content += payload;
if (AI.chat !== chat) return;
const stick = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
out.innerHTML = aiMarkdown(reply.content);
if (stick) box.scrollTop = box.scrollHeight;
} else finish(type === 'error' ? payload : null);
});
const ctx = $('[name=ctx]', p).checked ? aiContext() : '';
try { await api.call('ai:chat', chat.id, chat.messages.slice(-20).map(({ role, content }) => ({ role, content })), ctx); }
catch (e) { finish(e.message); }
}
function initAi() {
const p = $('#aiPanel');
p.innerHTML = `<div class="head"><span>${ICONS.ai}${T('Support assistant')}</span>
<span><button class="btn ghost sm" data-ai="new" title="${T('New chat')}">${ICONS.plus}</button><button class="btn ghost sm" data-ai="del" title="${T('Delete chat')}">${ICONS.trash}</button><button class="btn ghost sm" data-ai-toggle title="${T('Close')}">${ICONS.close}</button></span></div>
<div class="bar"><select class="chats" title="${T('Saved chats')}"></select></div>
<div class="msgs"></div>
<form class="ask"><label class="check" title="${T('The content of the active tab is sent to the Ollama server.')}"><input type="checkbox" name="ctx" checked/>${T('Context:')}&nbsp;<b class="ctx-name"></b></label>
<textarea rows="3" placeholder="${T('Describe the problem … (Enter = send)')}"></textarea>
<div class="row"><button type="button" class="btn sm" data-ai="explain">${T('Explain this tab')}</button><button class="btn primary sm" type="submit">${T('Send')}</button></div></form>`;
const ta = $('textarea', p);
$('form', p).onsubmit = (e) => {
e.preventDefault();
if (AI.busy) { api.call('ai:stop', AI.chat?.id); return; }
if (ta.value.trim()) { aiAsk(ta.value.trim()); ta.value = ''; }
};
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!AI.busy) $('form', p).requestSubmit(); } });
$('.chats', p).onchange = (e) => {
if (AI.busy) api.call('ai:stop', AI.chat?.id);
AI.chat = aiChats().find((c) => c.id === e.target.value) || null;
aiRender();
};
p.addEventListener('click', async (e) => {
const a = e.target.closest('[data-ai]')?.dataset.ai;
if (a === 'new') { if (AI.busy) api.call('ai:stop', AI.chat?.id); AI.chat = null; aiRender(); ta.focus(); }
if (a === 'del' && AI.chat && await confirmBox(T('Delete chat?'), `“${AI.chat.title}”`, T('Delete'))) {
if (AI.busy) api.call('ai:stop', AI.chat.id);
S.vault.aiChats = aiChats().filter((c) => c !== AI.chat);
await call('vault:remove', 'aiChats', AI.chat.id).catch(() => {});
AI.chat = null; aiRender();
}
if (a === 'explain') {
const t = activeSession();
aiAsk(t?.kind === 'ssh' ? T('Explain the last error in the terminal output and how to fix it.') : T('Look at the active tab, explain what you see and point out problems.'));
}
const b = e.target.closest('[data-cmd]');
if (!b) return;
const code = b.closest('.code').querySelector('code').textContent.replace(/\n+$/, '');
const t = activeSession();
// Nur einfügen, nicht ausführen – der Nutzer prüft und bestätigt mit Enter
if (b.dataset.cmd === 'paste' && t?.kind === 'ssh') { t.term.paste(code); t.term.focus(); }
else { api.call('clipboard:write', code); toast(b.dataset.cmd === 'paste' ? T('Copied – no terminal tab is active') : T('Copied')); }
});
if ($('#aiPanel').classList.contains('open')) aiRender();
if (AI.init) return;
AI.init = true;
document.addEventListener('click', (e) => { if (e.target.closest('[data-ai-toggle]')) aiToggle(); });
}
// ============================================================ RDP-Session (eingebettet im Tab)
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
@@ -1943,14 +2278,26 @@ class DockerSession {
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>
<div class="seg sm"><button class="active" data-v="containers">${T('Containers')}</button><button data-v="stacks">Stacks</button></div>
<label class="search sm">${ICONS.search}<input placeholder="${esc(T('Search …'))}"/></label>
<label class="check stopped-opt" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
<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); });
this.view = 'containers'; this.stackInfo = null; this.editing = null;
this.el.addEventListener('click', (e) => {
if (e.target.closest('[data-a=refresh]')) this.refresh(true);
const v = e.target.closest('.seg [data-v]')?.dataset.v;
if (v && v !== this.view) {
this.view = v; this.editing = null;
$$('.seg [data-v]', this.el).forEach((b) => b.classList.toggle('active', b.dataset.v === v));
$('.stopped-opt', this.el).style.display = v === 'containers' ? '' : 'none';
if (v === 'stacks') this.loadStacks(true); else this.draw();
}
});
$('[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);
@@ -1985,7 +2332,11 @@ class DockerSession {
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'); }
try {
this.apply(await api.call('docker:list', this.id));
if (withStats) this.loadStats();
if (this.view === 'stacks' && !this.editing) await this.loadStacks();
} catch (e) { if (withStats) toast(e.message, 'error'); }
this.busy = false;
}
@@ -1994,6 +2345,7 @@ class DockerSession {
}
draw() {
if (this.view === 'stacks') return this.editing ? null : this.drawStacks();
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))))
@@ -2046,6 +2398,212 @@ class DockerSession {
await this.refresh(true);
}
// ---------- Compose-Stacks
get stacksDir() { return settings().dockerStacksDir || '/opt/stacks'; }
async loadStacks(showErrors) {
try { this.stackInfo = await api.call('docker:stacks', this.id, this.stacksDir); this.stackError = ''; }
catch (e) { this.stackError = e.message; if (showErrors) this.stackInfo = null; }
if (this.view === 'stacks' && !this.editing) this.drawStacks();
}
drawStacks() {
const info = this.stackInfo;
if (!info) {
this.body.innerHTML = `<div class="pane-empty">${this.stackError ? `<div style="color:var(--red);max-width:520px;text-align:center">${esc(this.stackError)}</div>` : '<span class="spinner sm"></span>'}</div>`;
return;
}
const list = info.stacks.filter((x) => !this.filter || [x.name, x.file, typeof x.git === 'string' ? x.git : ''].some((f) => String(f).toLowerCase().includes(this.filter)));
const scroll = this.body.scrollTop;
const needSetup = !info.exists || !info.writable;
this.body.innerHTML = `<div class="stack-bar">
<span class="muted">${T('Folder')}: <code>${esc(info.dir)}</code></span>
<button class="btn ghost sm" data-s="dir" title="${T('Change folder')}">${ICONS.edit}</button>
${needSetup ? `<button class="btn sm" data-s="setup" title="${esc(T('Creates the folder with sudo and makes it writable for {user}.', { user: this.host.username || '$USER' }))}">${ICONS.newdir}${T('Set up folder')}</button>` : ''}
<span style="flex:1"></span>
<button class="btn sm" data-s="templates">${ICONS.download}${T('Templates')}</button>
<button class="btn sm" data-s="git">${ICONS.code}${T('From Git')}</button>
<button class="btn primary sm" data-s="new">${ICONS.plus}${T('New stack')}</button>
</div>
${list.length ? `<table class="docker-table"><thead><tr><th></th><th>${T('Name')}</th><th>${T('Status')}</th><th>${T('Compose file')}</th><th></th></tr></thead><tbody>
${list.map((x, i) => {
const on = /running/.test(x.status);
return `<tr data-i="${i}"><td><span class="dot ${on ? 'on' : x.status ? '' : 'none'}"></span></td>
<td class="name"><div><b>${esc(x.name)}${x.git ? ` <span class="badge" title="${esc(typeof x.git === 'string' ? x.git : 'git')}">git</span>` : ''}${x.managed ? '' : ` <span class="badge" title="${esc(T('Found via docker compose ls, outside the stacks folder'))}">${T('external')}</span>`}</b></div></td>
<td class="muted">${esc(x.status || T('not deployed'))}</td><td class="muted ports" title="${esc(x.file)}">${esc(x.file || T('no compose file found'))}</td>
<td class="acts">${x.file ? `
<button class="btn ghost sm" data-k="edit" title="${T('Edit')}">${ICONS.edit}</button>
<button class="btn ghost sm" data-k="up" title="${T('Deploy (up -d)')}">${ICONS.play}</button>
${on ? `<button class="btn ghost sm" data-k="restart" title="${T('Restart')}">${ICONS.refresh}</button><button class="btn ghost sm" data-k="stop" title="${T('Stop')}">${ICONS.stop}</button>` : ''}
<button class="btn ghost sm" data-k="logs" title="${T('Logs')}">${ICONS.logs}</button>` : ''}
${x.managed ? `<button class="btn ghost sm" data-k="delete" title="${T('Delete')}">${ICONS.trash}</button>` : ''}
</td></tr>`;
}).join('')}</tbody></table>`
: `<div class="pane-empty" style="height:auto;padding:60px 0"><div style="color:var(--muted)">${this.filter ? T('No matches.') : T('No stacks yet. Create one, clone it from Git or start from a template.')}</div></div>`}`;
this.body.scrollTop = scroll;
$$('[data-s]', this.body).forEach((b) => { b.onclick = () => this.stackBar(b.dataset.s); });
$$('tr[data-i]', this.body).forEach((tr) => {
const x = list[+tr.dataset.i];
tr.onclick = (e) => { const k = e.target.closest('[data-k]')?.dataset.k; if (k) this.stackAct(k, x); };
tr.ondblclick = (e) => { if (!e.target.closest('[data-k]') && x.file) this.stackAct('edit', x); };
tr.oncontextmenu = (e) => ctxMenu(e.clientX, e.clientY, [
...(x.file ? [
{ label: T('Edit'), icon: 'edit', run: () => this.stackAct('edit', x) },
{ label: T('Deploy (up -d)'), icon: 'play', run: () => this.stackAct('up', x) },
{ label: T('Update images (pull + up)'), icon: 'download', run: () => this.stackAct('pull', x) },
...(x.git ? [{ label: T('Git pull + redeploy'), icon: 'code', run: () => this.stackAct('gitpull', x) }] : []),
{ label: T('Restart'), icon: 'refresh', run: () => this.stackAct('restart', x) },
{ label: T('Stop'), icon: 'stop', run: () => this.stackAct('stop', x) },
{ label: T('Down (remove containers)'), icon: 'close', run: () => this.stackAct('down', x) },
{ label: T('Logs'), icon: 'logs', run: () => this.stackAct('logs', x) },
'-', { label: T('Copy path'), icon: 'copy', run: () => api.call('clipboard:write', x.file) },
] : []),
...(x.managed ? ['-', { label: T('Delete'), icon: 'trash', danger: true, run: () => this.stackAct('delete', x) }] : []),
]);
});
}
// Befehl in einem Terminal-Tab ausführen (Live-Ausgabe, sudo/git-Abfragen möglich)
async stackTerminal(action, st, opts, label) {
const cmd = await call('docker:stackCommand', this.id, action, st, opts);
openTerminal({ ...this.host, execCommand: cmd, label: `${label} · ${st.name || st.dir}` });
}
async stackBar(a) {
if (a === 'new') return this.editStack({ name: '', compose: 'services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n', env: '' });
if (a === 'dir') {
const d = await promptBox(T('Stacks folder'), T('Folder on the host in which each stack gets its own subfolder'), this.stacksDir);
if (!d || !d.startsWith('/')) return;
await call('vault:settings', { dockerStacksDir: d.replace(/\/+$/, '') || '/' });
S.vault.settings.dockerStacksDir = d.replace(/\/+$/, '') || '/';
return this.loadStacks(true);
}
if (a === 'setup') return this.stackTerminal('setup', { dir: this.stacksDir }, {}, T('Set up folder'));
if (a === 'git') {
const r = await modal({
title: T('Stack from Git'),
body: `${field(T('Repository URL'), 'url', '', { placeholder: 'https://github.com/user/repo.git' }).outerHTML}
${row(field(T('Stack name'), 'name', '', { placeholder: 'my-app' }), field(T('Branch (optional)'), 'branch', '')).outerHTML}
${field(T('Compose file in the repository (optional)'), 'path', '', { placeholder: 'docker-compose.yml', hint: T('If empty, the first compose file found is used.') }).outerHTML}
${check(T('Deploy right after cloning'), 'deploy', true).outerHTML}
<div class="hint" style="color:var(--muted);font-size:12px">${T('The repository is cloned on the host into the stacks folder. Git asks for credentials in the terminal if needed; for private repos an SSH deploy key or a token in the URL also works.')}</div>`,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Clone'), value: 'form', cls: 'primary' }],
});
if (!r) return;
const name = (r.name || r.url.split('/').pop().replace(/\.git$/, '')).toLowerCase().replace(/[^a-z0-9_-]+/g, '-');
return this.stackTerminal('clone', { name, stacksDir: this.stacksDir }, { url: r.url.trim(), branch: r.branch.trim(), path: r.path.trim(), deploy: r.deploy }, 'git clone').catch(() => {});
}
if (a === 'templates') return this.pickTemplate();
}
async pickTemplate() {
const tplUrl = settings().dockerTemplatesUrl || '';
const done = modal({
title: T('App templates'),
body: `<div class="tpl-top"><label class="search sm" style="flex:1">${ICONS.search}<input class="tpl-q" placeholder="${esc(T('Search templates …'))}"/></label><select class="tpl-cat"></select></div>
<div class="tpl-list"><div class="pane-empty" style="padding:40px 0"><span class="spinner sm"></span></div></div>
<div class="hint" style="color:var(--faint);font-size:11px;margin-top:6px">${T('Source: {url} (Portainer template format, changeable in the settings).', { url: esc(tplUrl || 'Lissy93/portainer-templates') })}</div>`,
buttons: [{ label: T('Close'), value: null, cls: 'ghost' }],
noEnter: true,
});
const root = $$('#modalRoot .modal-bg').pop();
$('.modal', root).classList.add('wide');
let list = [];
try { list = await api.call('docker:templates', tplUrl); } catch (e) { $('.tpl-list', root).innerHTML = `<div style="color:var(--red);padding:20px">${esc(e.message)}</div>`; return done; }
const cats = [...new Set(list.flatMap((t) => t.categories))].sort();
$('.tpl-cat', root).innerHTML = `<option value="">${T('All categories')}</option>${cats.map((c) => `<option>${esc(c)}</option>`).join('')}`;
const render = () => {
const q = $('.tpl-q', root).value.toLowerCase(), cat = $('.tpl-cat', root).value;
const f = list.filter((t) => (!cat || t.categories.includes(cat)) && (!q || `${t.title} ${t.description}`.toLowerCase().includes(q))).slice(0, 200);
$('.tpl-list', root).innerHTML = f.map((t) => `<div class="tpl-it" data-i="${t.i}"><div><b>${esc(t.title)}</b> <span class="badge">${t.kind === 'stack' ? 'Stack' : 'Container'}</span></div><div class="d">${esc(t.description)}</div></div>`).join('') || `<div style="color:var(--muted);padding:20px">${T('No matches.')}</div>`;
};
$('.tpl-q', root).oninput = render;
$('.tpl-cat', root).onchange = render;
$('.tpl-list', root).onclick = async (e) => {
const it = e.target.closest('.tpl-it');
if (!it) return;
it.style.opacity = '.5';
try {
const t = await api.call('docker:template', +it.dataset.i, tplUrl);
$('.mfoot .btn', root).click();
this.editStack({ name: t.name, compose: t.compose, env: t.env, note: t.note });
} catch (err) { it.style.opacity = ''; toast(err.message, 'error'); }
};
render();
$('.tpl-q', root).focus();
return done;
}
async stackAct(k, x) {
try {
if (k === 'edit') {
const r = await call('docker:stackRead', this.id, x.file);
return this.editStack({ ...x, ...r });
}
if (k === 'delete') {
const r = await modal({
title: T('Delete stack?'),
text: T('The stack “{name}” is stopped (down) and its folder {dir} is deleted.', { name: x.name, dir: x.dir }),
body: check(T('Also delete named volumes (data!)'), 'volumes', false).outerHTML,
buttons: [{ label: T('Cancel'), value: null, cls: 'ghost' }, { label: T('Delete'), value: 'form', cls: 'danger' }],
});
if (!r) return;
return await this.stackTerminal('delete', x, { volumes: r.volumes }, T('Delete'));
}
if (k === 'down' && !(await confirmBox(T('Remove containers?'), T('docker compose down stops and removes the containers of “{name}”. Volumes and files are kept.', { name: x.name }), T('Down')))) return;
const labels = { up: 'up', pull: 'pull', gitpull: 'git pull', restart: 'restart', stop: 'stop', down: 'down', logs: T('Logs') };
await this.stackTerminal(k, x, {}, labels[k] || k);
} catch {}
}
aiContext() {
if (!this.editing || !$('.se-compose', this.body)) return this.body.innerText;
const env = $('.se-env', this.body).value.replace(/^(\s*[^#=\s]+\s*=).*$/gm, '$1***');
return `Editing compose stack "${this.editing.name || ''}"\n\ncompose.yaml:\n${$('.se-compose', this.body).value}\n\n.env (values hidden):\n${env}`;
}
// Editor für compose.yaml und .env; st.file fehlt bei neuen Stacks
editStack(st) {
this.editing = st;
const isNew = !st.file;
this.body.innerHTML = `<div class="stack-editor">
<div class="se-head">
<button class="btn ghost sm" data-e="back">← ${T('Back')}</button>
${isNew ? `<input class="se-name" placeholder="${esc(T('Stack name'))}" value="${esc(st.name || '')}" spellcheck="false"/>` : `<b>${esc(st.name)}</b><span class="muted mono" style="font-size:12px">${esc(st.file)}</span>`}
<span style="flex:1"></span>
<button class="btn sm" data-e="save">${T('Save')}</button>
<button class="btn primary sm" data-e="deploy">${ICONS.play}${T('Save & deploy')}</button>
</div>
${st.note ? `<div class="se-note">${esc(st.note)}</div>` : ''}
<div class="se-grid">
<div class="se-col"><label>${isNew ? 'compose.yaml' : esc(st.file.split('/').pop())}</label><textarea class="se-compose mono" spellcheck="false"></textarea></div>
<div class="se-col env"><label>.env <span class="muted">${T('(variables, used as ${NAME} in the compose file)')}</span></label><textarea class="se-env mono" spellcheck="false" placeholder="KEY=value"></textarea></div>
</div></div>`;
const ta = $('.se-compose', this.body), env = $('.se-env', this.body);
ta.value = st.compose || ''; env.value = st.env || '';
for (const t of [ta, env]) t.addEventListener('keydown', (e) => {
if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); t.setRangeText(' ', t.selectionStart, t.selectionEnd, 'end'); }
if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(false); }
});
const save = async (deploy) => {
const name = isNew ? $('.se-name', this.body).value.trim() : st.name;
const btns = $$('[data-e]', this.body); btns.forEach((b) => { b.disabled = true; });
try {
const r = await call('docker:stackSave', this.id, { stacksDir: this.stacksDir, name, file: st.file, compose: ta.value, env: env.value });
if (r.warning) toast(T('Saved, but the compose file has an error: {msg}', { msg: r.warning }), 'error');
else toast(T('Saved'), 'ok');
Object.assign(st, { file: r.file, name, dir: r.file.replace(/\/[^/]*$/, ''), managed: true, compose: ta.value, env: env.value });
if (deploy && !r.warning) await this.stackTerminal('up', st, {}, 'up');
if (isNew || deploy) { this.editing = null; await this.loadStacks(true); return; }
} catch {}
btns.forEach((b) => { b.disabled = false; });
};
$('[data-e=back]', this.body).onclick = () => { this.editing = null; this.loadStacks(true); };
$('[data-e=save]', this.body).onclick = () => save(false);
$('[data-e=deploy]', this.body).onclick = () => save(true);
(isNew && !st.name ? $('.se-name', this.body) : ta).focus();
}
onShow() { if (this.containers.length) this.refresh(); }
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
}
@@ -2061,6 +2619,7 @@ class FirewallSession {
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<div class="seg sm backends"></div>
<button class="btn ghost sm" data-a="save" style="display:none" title="${esc(T('Save the current iptables rules so they survive a reboot'))}">${ICONS.download}${T('Save permanently')}</button>
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
</div>
@@ -2254,6 +2813,7 @@ class NetworkSession {
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="virtual"/>${T('Show virtual interfaces')}</label>
<button class="btn ghost sm" data-a="files">${ICONS.file}${T('Config files')}</button>
<button class="btn ghost sm" data-ai-toggle title="${T('Support assistant (Ollama)')}">${ICONS.ai}</button>
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
<button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
</div>
+2
View File
@@ -19,6 +19,7 @@
</div>
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
<div class="drag-fill"></div>
<button id="aiBtn" class="icon-btn" data-ai-toggle title="Support assistant (Ollama)" data-i18n-title="Support assistant (Ollama)"><svg viewBox="0 0 24 24"><path d="M4 5h16v11H9l-5 4z"/><path d="M9 10h.01M12 10h.01M15 10h.01"/></svg></button>
<button id="lockBtn" class="icon-btn lock-btn" style="display:none" title="Lock (Ctrl+Shift+L)" data-i18n-title="Lock (Ctrl+Shift+L)"><svg viewBox="0 0 24 24"><rect x="5" y="11" width="14" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></button>
<div class="win-ctrls">
<button id="winMin" title="Minimize" data-i18n-title="Minimize"><svg viewBox="0 0 12 12"><path d="M2 6h8"/></svg></button>
@@ -52,6 +53,7 @@
<div id="sessions"></div>
</main>
<aside id="aiPanel" class="ai-panel"></aside>
<aside id="drawer" class="drawer"></aside>
</div>
</div>
+54
View File
@@ -191,7 +191,38 @@ body.in-session #sidebar { display: none; }
.snip-panel .items { overflow: auto; padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.snip-panel .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
.snip-panel .it:hover { background: var(--card-hover); }
.snip-tpl { margin-top: 10px; }
.snip-tpl summary { cursor: pointer; color: var(--muted); font-weight: 600; padding: 6px 2px; user-select: none; }
.snip-tpl .tpl-items { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
.snip-tpl .it { background: var(--card); border-radius: 8px; padding: 9px 10px; cursor: pointer; }
.snip-tpl .it:hover { background: var(--card-hover); }
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
.snip-panel .it .mono { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 3px; }
/* Support-Assistent (rechts neben allen Tabs) */
.ai-panel { width: 400px; border-left: 1px solid var(--border); background: var(--bg-2); display: none; flex-direction: column; flex: none; min-height: 0; }
.ai-panel.open { display: flex; }
.ai-panel .head { padding: 8px 12px; font-weight: 600; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
.ai-panel .head > span:first-child { display: flex; align-items: center; gap: 8px; }
.ai-panel .head svg { width: 16px; height: 16px; }
.ai-panel .bar { padding: 8px 12px; border-bottom: 1px solid var(--border); }
.ai-panel .bar select { width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; color: inherit; }
.ai-panel .msgs { flex: 1; overflow: auto; padding: 10px; display: flex; flex-direction: column; gap: 8px; user-select: text; }
.ai-panel .msgs .hint { color: var(--muted); padding: 8px; }
.ai-panel .m { border-radius: 8px; padding: 8px 10px; line-height: 1.45; overflow-wrap: anywhere; }
.ai-panel .m.user { background: var(--accent); color: #fff; align-self: flex-end; max-width: 90%; white-space: pre-wrap; }
.ai-panel .m.user small { display: block; opacity: .75; font-size: 10px; margin-bottom: 2px; }
.ai-panel .m.bot { background: var(--card); }
.ai-panel .m.err { color: var(--red, #ff6b6b); }
.ai-panel .m code { font-family: var(--mono, monospace); font-size: 12px; background: var(--bg); padding: 1px 4px; border-radius: 4px; }
.ai-panel .code { margin: 6px 0; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; }
.ai-panel .code .acts { display: flex; justify-content: flex-end; gap: 2px; padding: 2px; border-bottom: 1px solid var(--border); }
.ai-panel .code pre { margin: 0; padding: 8px; overflow: auto; }
.ai-panel .code pre code { background: none; padding: 0; }
.ai-panel .ask { border-top: 1px solid var(--border); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
.ai-panel .ask .check { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: block; }
.ai-panel .ask textarea { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 6px 8px; resize: vertical; color: inherit; font: inherit; outline: none; }
.ai-panel .ask .row { justify-content: space-between; }
.ai-panel .spinner { width: 16px; height: 16px; }
.findbar { position: absolute; top: 8px; right: 24px; z-index: 4; display: none; gap: 4px; align-items: center; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 4px; box-shadow: 0 8px 24px #0008; }
.findbar.open { display: flex; }
.findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }
@@ -330,6 +361,29 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
/* Firewall */
.seg.sm button { padding: 4px 10px; font-size: 12px; }
.session.docker .sbar .seg { margin: 0 6px 0 0; flex: none; }
.docker-table .dot.none { background: transparent; border: 1px solid var(--faint); }
.docker-table .badge, .tpl-it .badge { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: var(--icon-bg); color: var(--muted); margin-left: 4px; vertical-align: middle; }
.stack-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; position: sticky; top: 0; background: var(--bg); z-index: 2; }
.stack-bar .muted { color: var(--muted); font-size: 12px; }
.stack-bar + .docker-table th { top: 45px; }
.stack-editor { display: flex; flex-direction: column; height: 100%; }
.stack-editor .se-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.stack-editor .se-name { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 5px 8px; width: 200px; font: inherit; }
.stack-editor .se-note { padding: 8px 12px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--border); max-height: 80px; overflow: auto; }
.stack-editor .se-grid { flex: 1; display: grid; grid-template-columns: 2fr 1fr; gap: 10px; padding: 10px; min-height: 0; }
.stack-editor .se-col { display: flex; flex-direction: column; min-height: 0; gap: 6px; }
.stack-editor .se-col label { font-size: 12px; color: var(--muted); font-weight: 600; }
.stack-editor textarea { flex: 1; resize: none; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 13px; line-height: 1.45; tab-size: 2; white-space: pre; min-height: 200px; }
@media (max-width: 800px) { .stack-editor .se-grid { grid-template-columns: 1fr; } }
.modal.wide { width: min(760px, 94vw); max-width: none; }
.tpl-top { display: flex; gap: 8px; margin-bottom: 8px; }
.tpl-top .search { margin: 0; }
.tpl-top select { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 0 8px; }
.tpl-list { max-height: 55vh; overflow: auto; display: flex; flex-direction: column; gap: 6px; }
.tpl-it { background: var(--card); border-radius: 8px; padding: 9px 11px; cursor: pointer; }
.tpl-it:hover { background: var(--card-hover); }
.tpl-it .d { color: var(--muted); font-size: 12px; margin-top: 3px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.fw-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.fw-def { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; margin-left: 8px; }
.fw-def select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--text); }
+1 -2
View File
@@ -79,8 +79,7 @@
throw new Error('Pasting from the menu needs HTTPS. Use Ctrl+Shift+V or Ctrl+V instead.');
},
'shell:open': (url) => { if (/^https?:\/\//i.test(url)) window.open(url, '_blank', 'noopener'); },
'vault:export': async () => { downloadText('mrterm-backup.json', JSON.stringify(await remote('vault:exportData', []), null, 2)); return 'mrterm-backup.json'; },
'vault:import': async () => { const f = await pickFile('.json,application/json'); if (!f) return false; return remote('vault:importData', [JSON.parse(f.content)]); },
'backup:saveFile': (content, name) => { downloadText(name, content); return name; },
'key:pickFile': async () => { const f = await pickFile(); return f ? { name: f.name, content: f.content, publicKey: '' } : null; },
'import:pickFile': async () => pickFile(),
'vault:importSshConfig': () => { throw new Error('Not available in the web version.'); },