Compare commits
11
Commits
0b43182019
...
v0.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51bd769d6e | ||
|
|
136e4b4e2c | ||
|
|
6b02dc078a | ||
|
|
ecaf579ccd | ||
|
|
4176cc0a01 | ||
|
|
20506ee889 | ||
|
|
6ebdc5126f | ||
|
|
08c6d65e29 | ||
|
|
8c8dc0d4c0 | ||
|
|
a279ef88b2 | ||
|
|
6efa74aa7e |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.15.0",
|
"version": "0.19.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"version": "0.15.0",
|
"version": "0.19.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
|||||||
+2
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mrterm",
|
"name": "mrterm",
|
||||||
"productName": "MrTerm",
|
"productName": "MrTerm",
|
||||||
"version": "0.15.0",
|
"version": "0.19.0",
|
||||||
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
"description": "Moderner SSH-, SFTP- und RDP-Client",
|
||||||
"main": "src/main/main.js",
|
"main": "src/main/main.js",
|
||||||
"author": "MrBlake",
|
"author": "MrBlake",
|
||||||
@@ -53,6 +53,7 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"buildResources": "build"
|
"buildResources": "build"
|
||||||
},
|
},
|
||||||
|
"beforePack": "scripts/ensure-koffi.js",
|
||||||
"asarUnpack": [
|
"asarUnpack": [
|
||||||
"node_modules/koffi/**",
|
"node_modules/koffi/**",
|
||||||
"node_modules/@koromix/**"
|
"node_modules/@koromix/**"
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
};
|
||||||
@@ -11,8 +11,10 @@ const { SshManager } = require('../main/ssh');
|
|||||||
const { DockerManager } = require('../main/docker');
|
const { DockerManager } = require('../main/docker');
|
||||||
const { FirewallManager } = require('../main/firewall');
|
const { FirewallManager } = require('../main/firewall');
|
||||||
const { NetworkConfigManager } = require('../main/network');
|
const { NetworkConfigManager } = require('../main/network');
|
||||||
|
const { AiAssistant } = require('../main/ai');
|
||||||
const i18n = require('../i18n');
|
const i18n = require('../i18n');
|
||||||
const backup = require('./backup');
|
const backup = require('./backup');
|
||||||
|
const composeTemplates = require('../main/compose-templates');
|
||||||
|
|
||||||
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
|
function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) {
|
||||||
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
|
// Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode)
|
||||||
@@ -33,6 +35,7 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
|
|||||||
const docker = new DockerManager(ssh);
|
const docker = new DockerManager(ssh);
|
||||||
const firewall = new FirewallManager(ssh);
|
const firewall = new FirewallManager(ssh);
|
||||||
const network = new NetworkConfigManager(ssh);
|
const network = new NetworkConfigManager(ssh);
|
||||||
|
const ai = new AiAssistant(store, send);
|
||||||
let sync = null;
|
let sync = null;
|
||||||
if (withSync) {
|
if (withSync) {
|
||||||
const { SyncService } = require('../main/sync');
|
const { SyncService } = require('../main/sync');
|
||||||
@@ -184,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:action', (id, action, cid) => docker.action(id, action, cid));
|
||||||
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||||
handle('docker:close', (id) => docker.close(id));
|
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:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id)));
|
||||||
handle('firewall:list', (id, backend) => firewall.list(id, backend));
|
handle('firewall:list', (id, backend) => firewall.list(id, backend));
|
||||||
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
|
handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args));
|
||||||
@@ -199,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:writeFile', (id, path, content, verify) => network.writeFile(id, path, content, verify));
|
||||||
handle('network:close', (id) => network.close(id));
|
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 ----------
|
// ---------- Synchronisation ----------
|
||||||
if (sync) {
|
if (sync) {
|
||||||
handle('sync:status', () => sync.status());
|
handle('sync:status', () => sync.status());
|
||||||
@@ -214,6 +228,7 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa
|
|||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
ssh.closeAll?.();
|
ssh.closeAll?.();
|
||||||
|
ai.stopAll();
|
||||||
sync?.stop();
|
sync?.stop();
|
||||||
for (const r of pending.values()) r(null);
|
for (const r of pending.values()) r(null);
|
||||||
pending.clear();
|
pending.clear();
|
||||||
|
|||||||
+80
@@ -151,6 +151,35 @@
|
|||||||
'Send Enter automatically': 'Enter automatisch senden',
|
'Send Enter automatically': 'Enter automatisch senden',
|
||||||
'Command is missing.': 'Befehl fehlt.',
|
'Command is missing.': 'Befehl fehlt.',
|
||||||
'No open terminal': 'Kein offenes Terminal',
|
'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.',
|
'No snippets yet.': 'Noch keine Snippets.',
|
||||||
|
|
||||||
// Port Forwarding
|
// Port Forwarding
|
||||||
@@ -277,6 +306,7 @@
|
|||||||
'Connecting to {host} … Login or certificate prompts may appear in a separate window.': 'Verbinde mit {host} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.',
|
'Connecting to {host} … Login or certificate prompts may appear in a separate window.': 'Verbinde mit {host} … Anmelde- oder Zertifikatsabfragen erscheinen ggf. als eigenes Fenster.',
|
||||||
'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.',
|
'The RDP window could not be embedded.': 'Das RDP-Fenster konnte nicht eingebettet werden.',
|
||||||
'RDP session ended.': 'RDP-Sitzung beendet.',
|
'RDP session ended.': 'RDP-Sitzung beendet.',
|
||||||
|
'Adjusting resolution …': 'Auflösung wird angepasst …',
|
||||||
'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).',
|
'RDP session ended (code {code}).': 'RDP-Sitzung beendet (Code {code}).',
|
||||||
'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter',
|
'Session continues in a separate window': 'Sitzung läuft als eigenes Fenster weiter',
|
||||||
|
|
||||||
@@ -367,6 +397,56 @@
|
|||||||
'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.',
|
'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',
|
'Replace': 'Ersetzen',
|
||||||
'Import complete: {hosts} hosts, {keys} keys, {snippets} snippets': 'Import abgeschlossen: {hosts} Hosts, {keys} Schlüssel, {snippets} Snippets',
|
'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',
|
'Templates': 'Vorlagen',
|
||||||
'Run': 'Ausführen',
|
'Run': 'Ausführen',
|
||||||
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
|
'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten',
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'";
|
||||||
const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'";
|
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 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);
|
const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s);
|
||||||
|
|
||||||
function execOn(conn, cmd, stdin) {
|
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'`;
|
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) {
|
close(id) {
|
||||||
const s = this.sessions.get(id);
|
const s = this.sessions.get(id);
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
|
|||||||
+39
-9
@@ -17,6 +17,8 @@ const { FirewallManager } = require('./firewall');
|
|||||||
const { NetworkConfigManager } = require('./network');
|
const { NetworkConfigManager } = require('./network');
|
||||||
const { SyncService } = require('./sync');
|
const { SyncService } = require('./sync');
|
||||||
const backup = require('../core/backup');
|
const backup = require('../core/backup');
|
||||||
|
const composeTemplates = require('./compose-templates');
|
||||||
|
const { AiAssistant } = require('./ai');
|
||||||
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
|
||||||
|
|
||||||
let win;
|
let win;
|
||||||
@@ -76,6 +78,7 @@ const vpn = new VpnManager(store, app.getPath('userData'));
|
|||||||
const docker = new DockerManager(ssh);
|
const docker = new DockerManager(ssh);
|
||||||
const firewall = new FirewallManager(ssh);
|
const firewall = new FirewallManager(ssh);
|
||||||
const network = new NetworkConfigManager(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
|
// LAN-Synchronisation; Vergleichscode beim Koppeln bestätigt der Nutzer in der Oberfläche
|
||||||
const pairReplies = new Map();
|
const pairReplies = new Map();
|
||||||
@@ -233,6 +236,12 @@ handle('docker:stats', (id) => docker.stats(id));
|
|||||||
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
handle('docker:action', (id, action, cid) => docker.action(id, action, cid));
|
||||||
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid));
|
||||||
handle('docker:close', (id) => docker.close(id));
|
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 ----------
|
// ---------- Firewall ----------
|
||||||
handle('firewall:open', async (id, hostRef) => {
|
handle('firewall:open', async (id, hostRef) => {
|
||||||
@@ -271,6 +280,11 @@ handle('sync:share', (sel) => sync.setShare(sel));
|
|||||||
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
handle('sync:shareItem', (c, id, yes) => sync.shareItem(c, id, yes));
|
||||||
handle('sync:probe', (address) => sync.probe(address));
|
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 ----------
|
// ---------- VPN ----------
|
||||||
handle('vpn:status', () => vpn.status());
|
handle('vpn:status', () => vpn.status());
|
||||||
handle('vpn:up', (id) => vpn.up(id));
|
handle('vpn:up', (id) => vpn.up(id));
|
||||||
@@ -459,30 +473,46 @@ handle('rdp:open', async (id, hostRef, bounds) => {
|
|||||||
const host = hostWithOverrides(hostRef);
|
const host = hostWithOverrides(hostRef);
|
||||||
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
if (host.id) store.addHistory({ hostId: host.id, at: Date.now() });
|
||||||
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
if (await vpn.ensureForHost(host)) send('vpn:changed');
|
||||||
const emb = createEmbed(win.getNativeWindowHandle());
|
const sess = { cancelled: false };
|
||||||
const sess = { emb, cancelled: false };
|
|
||||||
rdpSessions.set(id, sess);
|
rdpSessions.set(id, sess);
|
||||||
|
return startRdpEmbed(id, sess, host, bounds);
|
||||||
|
});
|
||||||
|
async function startRdpEmbed(id, sess, host, bounds, acceptCert = false) {
|
||||||
|
const emb = createEmbed(win.getNativeWindowHandle());
|
||||||
|
emb.acceptCert = acceptCert;
|
||||||
|
sess.emb = emb;
|
||||||
const size = { width: bounds.width, height: bounds.height };
|
const size = { width: bounds.width, height: bounds.height };
|
||||||
let proc;
|
let proc;
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const file = rdp.writeRdpFile(host, size);
|
const args = rdp.mstscArgs(host, size);
|
||||||
proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ rdpFile: file }))));
|
proc = await new Promise((resolve) => rdp.withWinCredentials(host, () => resolve(emb.start({ args }))));
|
||||||
} else {
|
} else {
|
||||||
proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password });
|
proc = emb.start({ client: rdp.linuxClient(null, true), args: rdp.freerdpArgs(host, size), password: host.password });
|
||||||
}
|
}
|
||||||
proc.on('error', (e) => send('rdp:event', id, 'exit', { code: -1, message: e.message }));
|
const current = () => rdpSessions.get(id) === sess && sess.emb === emb;
|
||||||
|
proc.on('error', (e) => { if (current()) send('rdp:event', id, 'exit', { code: -1, message: e.message }); });
|
||||||
proc.on('exit', (code) => {
|
proc.on('exit', (code) => {
|
||||||
|
if (!current()) return; // durch Neuverbindung ersetzt
|
||||||
const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || '';
|
const err = (emb.err || '').split('\n').filter((l) => /ERROR|error/.test(l)).slice(-1)[0] || '';
|
||||||
if (rdpSessions.get(id) === sess) send('rdp:event', id, 'exit', { code, message: err });
|
send('rdp:event', id, 'exit', { code, message: err });
|
||||||
rdpSessions.delete(id);
|
rdpSessions.delete(id);
|
||||||
});
|
});
|
||||||
const ok = await emb.attach(bounds, () => sess.cancelled);
|
// Server ohne dynamische Auflösung: mit neuer Größe neu verbinden (die Sitzung auf dem Server bleibt erhalten)
|
||||||
if (ok) {
|
emb.onResizeNeeded = (b) => {
|
||||||
|
if (!current()) return;
|
||||||
|
send('rdp:event', id, 'resizing');
|
||||||
|
emb.kill();
|
||||||
|
startRdpEmbed(id, sess, host, b, emb.certAccepted).then((ok) => {
|
||||||
|
if (!ok && rdpSessions.get(id) === sess && !sess.cancelled) send('rdp:event', id, 'exit', { code: -1, message: '' });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const ok = await emb.attach(bounds, () => sess.cancelled || !current());
|
||||||
|
if (ok && current()) {
|
||||||
if (sess.hidden) emb.hide();
|
if (sess.hidden) emb.hide();
|
||||||
send('rdp:event', id, 'attached');
|
send('rdp:event', id, 'attached');
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
});
|
}
|
||||||
ipcMain.on('rdp:bounds', (_e, id, b) => { try { rdpSessions.get(id)?.emb.setBounds(b); } catch {} });
|
ipcMain.on('rdp:bounds', (_e, id, b) => { try { rdpSessions.get(id)?.emb.setBounds(b); } catch {} });
|
||||||
ipcMain.on('rdp:show', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = false; try { s.emb.show(); s.emb.focus(); } catch {} } });
|
ipcMain.on('rdp:show', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = false; try { s.emb.show(); s.emb.focus(); } catch {} } });
|
||||||
ipcMain.on('rdp:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } });
|
ipcMain.on('rdp:hide', (_e, id) => { const s = rdpSessions.get(id); if (s) { s.hidden = true; try { s.emb.hide(); } catch {} } });
|
||||||
|
|||||||
+142
-43
@@ -1,5 +1,5 @@
|
|||||||
// Bettet externe RDP-Clients als Kindfenster in das MrTerm-Fenster ein (Anzeige im Tab).
|
// Bettet externe RDP-Clients als Kindfenster in das MrTerm-Fenster ein (Anzeige im Tab).
|
||||||
// Windows: mstsc.exe wird gestartet, sein Hauptfenster (TscShellContainerClass) per SetParent eingehängt.
|
// Windows: mstsc.exe wird gestartet, sein Sitzungsfenster (TscShellContainerClass) rahmenlos über den Tab gelegt.
|
||||||
// Linux: FreeRDP mit /parent-window:<XID>, Position/Größe/Sichtbarkeit über libX11 (MrTerm läuft dafür unter X11/XWayland).
|
// Linux: FreeRDP mit /parent-window:<XID>, Position/Größe/Sichtbarkeit über libX11 (MrTerm läuft dafür unter X11/XWayland).
|
||||||
// Alle Koordinaten sind physische Pixel relativ zum Client-Bereich des MrTerm-Fensters.
|
// Alle Koordinaten sind physische Pixel relativ zum Client-Bereich des MrTerm-Fensters.
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
@@ -12,13 +12,25 @@ const i18n = require('../i18n');
|
|||||||
// Chromium zeichnet per DirectComposition über alle nativen Kindfenster – ein per SetParent eingehängtes
|
// Chromium zeichnet per DirectComposition über alle nativen Kindfenster – ein per SetParent eingehängtes
|
||||||
// mstsc bliebe unsichtbar (schwarz). Stattdessen wird das Sitzungsfenster rahmenlos und als "owned window"
|
// mstsc bliebe unsichtbar (schwarz). Stattdessen wird das Sitzungsfenster rahmenlos und als "owned window"
|
||||||
// von MrTerm exakt über den Tab gelegt: Es liegt immer über MrTerm, minimiert sich mit und hat keinen Taskleisten-Eintrag.
|
// von MrTerm exakt über den Tab gelegt: Es liegt immer über MrTerm, minimiert sich mit und hat keinen Taskleisten-Eintrag.
|
||||||
|
// Mit "dynamic resolution" passt mstsc die Auflösung der Sitzung an, sobald sich die Fenstergröße ändert.
|
||||||
function winApi() {
|
function winApi() {
|
||||||
if (winApi.api) return winApi.api;
|
if (winApi.api) return winApi.api;
|
||||||
const u = koffi.load('user32.dll');
|
const u = koffi.load('user32.dll');
|
||||||
const EnumProc = koffi.proto('bool __stdcall MrTermEnumProc(intptr hwnd, intptr lParam)');
|
const EnumProc = koffi.proto('bool __stdcall MrTermEnumProc(intptr hwnd, intptr lParam)');
|
||||||
|
const WinEventProc = koffi.proto('void __stdcall MrTermWinEventProc(intptr hook, uint32 event, intptr hwnd, long idObject, long idChild, uint32 thread, uint32 time)');
|
||||||
const POINT = koffi.struct('MrTermPOINT', { x: 'int', y: 'int' });
|
const POINT = koffi.struct('MrTermPOINT', { x: 'int', y: 'int' });
|
||||||
|
const RECT = koffi.struct('MrTermRECT', { left: 'int', top: 'int', right: 'int', bottom: 'int' });
|
||||||
winApi.api = {
|
winApi.api = {
|
||||||
EnumWindows: u.func('bool __stdcall EnumWindows(MrTermEnumProc *cb, intptr lParam)'),
|
EnumWindows: u.func('bool __stdcall EnumWindows(MrTermEnumProc *cb, intptr lParam)'),
|
||||||
|
EnumChildWindows: u.func('bool __stdcall EnumChildWindows(intptr hwnd, MrTermEnumProc *cb, intptr lParam)'),
|
||||||
|
SetWinEventHook: u.func('intptr __stdcall SetWinEventHook(uint32 min, uint32 max, intptr mod, MrTermWinEventProc *cb, uint32 pid, uint32 tid, uint32 flags)'),
|
||||||
|
UnhookWinEvent: u.func('bool __stdcall UnhookWinEvent(intptr hook)'),
|
||||||
|
GetDlgItem: u.func('intptr __stdcall GetDlgItem(intptr hwnd, int id)'),
|
||||||
|
PostMessageW: u.func('bool __stdcall PostMessageW(intptr hwnd, uint32 msg, uintptr w, intptr l)'),
|
||||||
|
GetAncestor: u.func('intptr __stdcall GetAncestor(intptr hwnd, uint32 flags)'),
|
||||||
|
GetWindowRect: u.func('bool __stdcall GetWindowRect(intptr hwnd, _Out_ MrTermRECT *rect)'),
|
||||||
|
SetLayeredWindowAttributes: u.func('bool __stdcall SetLayeredWindowAttributes(intptr hwnd, uint32 key, uint8 alpha, uint32 flags)'),
|
||||||
|
WinEventProc,
|
||||||
GetWindowThreadProcessId: u.func('uint32 __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint32 *pid)'),
|
GetWindowThreadProcessId: u.func('uint32 __stdcall GetWindowThreadProcessId(intptr hwnd, _Out_ uint32 *pid)'),
|
||||||
GetClassNameW: u.func('int __stdcall GetClassNameW(intptr hwnd, void *buf, int max)'),
|
GetClassNameW: u.func('int __stdcall GetClassNameW(intptr hwnd, void *buf, int max)'),
|
||||||
IsWindowVisible: u.func('bool __stdcall IsWindowVisible(intptr hwnd)'),
|
IsWindowVisible: u.func('bool __stdcall IsWindowVisible(intptr hwnd)'),
|
||||||
@@ -34,58 +46,143 @@ function winApi() {
|
|||||||
return winApi.api;
|
return winApi.api;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findWindowsOfPid(pid, cls) {
|
const buf = Buffer.alloc(512);
|
||||||
const api = winApi();
|
const className = (hwnd) => buf.toString('utf16le', 0, winApi().GetClassNameW(hwnd, buf, 256) * 2);
|
||||||
const out = [];
|
function childClasses(hwnd) {
|
||||||
const buf = Buffer.alloc(512);
|
const out = new Set();
|
||||||
api.EnumWindows((hwnd) => {
|
winApi().EnumChildWindows(hwnd, (c) => { out.add(className(c)); return true; }, 0);
|
||||||
const p = [0];
|
|
||||||
api.GetWindowThreadProcessId(hwnd, p);
|
|
||||||
if (p[0] === pid && api.IsWindowVisible(hwnd)) {
|
|
||||||
const n = api.GetClassNameW(hwnd, buf, 256);
|
|
||||||
if (!cls || buf.toString('utf16le', 0, n * 2) === cls) out.push(hwnd);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}, 0);
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const GWL_STYLE = -16, GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8;
|
const GWL_STYLE = -16, GWL_EXSTYLE = -20, GWLP_HWNDPARENT = -8;
|
||||||
const WS_CHILD = 0x40000000, WS_POPUP = 0x80000000, WS_CAPTION = 0x00c00000, WS_THICKFRAME = 0x00040000;
|
const WS_CHILD = 0x40000000, WS_POPUP = 0x80000000, WS_CAPTION = 0x00c00000, WS_THICKFRAME = 0x00040000;
|
||||||
const WS_SYSMENU = 0x00080000, WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000;
|
const WS_SYSMENU = 0x00080000, WS_MINIMIZEBOX = 0x00020000, WS_MAXIMIZEBOX = 0x00010000;
|
||||||
const WS_EX_APPWINDOW = 0x00040000, WS_EX_TOOLWINDOW = 0x00000080;
|
const WS_EX_APPWINDOW = 0x00040000, WS_EX_TOOLWINDOW = 0x00000080, WS_EX_LAYERED = 0x00080000;
|
||||||
const FRAME = WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
|
const FRAME = WS_CAPTION | WS_THICKFRAME | WS_SYSMENU | WS_MINIMIZEBOX | WS_MAXIMIZEBOX;
|
||||||
const SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10, SWP_FRAMECHANGED = 0x20, SWP_SHOWWINDOW = 0x40;
|
const SWP_NOSIZE = 0x1, SWP_NOZORDER = 0x4, SWP_NOACTIVATE = 0x10, SWP_FRAMECHANGED = 0x20, SWP_SHOWWINDOW = 0x40;
|
||||||
const SW_HIDE = 0, SW_SHOWNOACTIVATE = 4, SW_SHOW = 5;
|
const SW_HIDE = 0, SW_SHOWNOACTIVATE = 4, SW_SHOW = 5;
|
||||||
|
const EVENT_OBJECT_CREATE = 0x8000, EVENT_OBJECT_SHOW = 0x8002, WINEVENT_OUTOFCONTEXT = 0, GA_ROOT = 2, LWA_ALPHA = 2;
|
||||||
|
const BM_CLICK = 0xf5, ID_CERT_YES = 14004, ID_CERT_VIEW = 13443; // Zertifikatswarnung von mstsc: "Ja", "Zertifikat anzeigen"
|
||||||
|
|
||||||
class WinEmbed {
|
class WinEmbed {
|
||||||
constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); this.cls = 'TscShellContainerClass'; this.visible = true; }
|
constructor(parentHandle) { this.parent = Number(parentHandle.readBigUInt64LE(0)); this.visible = true; }
|
||||||
|
|
||||||
start({ rdpFile }) {
|
// args: mstsc-Kommandozeile. Mit /v:… statt .rdp-Datei zeigt mstsc keine Sicherheitswarnung "Unbekannter Herausgeber".
|
||||||
this.proc = spawn('mstsc.exe', rdpFile ? [rdpFile] : [], { stdio: 'ignore' });
|
start({ args }) {
|
||||||
|
this.proc = spawn('mstsc.exe', args, { stdio: 'ignore' });
|
||||||
|
this.hookWindows();
|
||||||
|
this.proc.on('exit', () => this.unhook());
|
||||||
return this.proc;
|
return this.proc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wartet, bis mstsc sein Sitzungsfenster geöffnet hat (Anmelde-/Zertifikatsdialoge davor bleiben eigene Fenster)
|
// Fängt die Fenster von mstsc ab, sobald sie entstehen (läuft über die Nachrichtenschleife des Electron-Hauptthreads):
|
||||||
async attach(bounds, isCancelled) {
|
// Sitzungsfenster -> noch unsichtbar rahmenlos über den Tab legen, damit nie ein normales mstsc-Fenster aufblitzt
|
||||||
const t0 = Date.now();
|
// "Verbindung wird hergestellt" (Dialog mit Fortschrittsbalken) -> unsichtbar lassen, der Tab zeigt den Status selbst
|
||||||
while (Date.now() - t0 < 120000) {
|
// andere Dialoge (Zertifikat, Fehler) -> brauchen eine Entscheidung, daher mittig über dem Tab einblenden
|
||||||
if (isCancelled() || this.proc.exitCode !== null) return false;
|
hookWindows() {
|
||||||
const [hwnd] = findWindowsOfPid(this.proc.pid, this.cls);
|
const api = winApi();
|
||||||
if (hwnd) {
|
this.cb = koffi.register((_hook, event, hwnd, idObject, idChild) => {
|
||||||
const api = winApi();
|
try {
|
||||||
const style = (((Number(api.GetWindowLongPtrW(hwnd, GWL_STYLE)) >>> 0) & ~(FRAME | WS_CHILD)) | WS_POPUP) >>> 0;
|
if (idObject !== 0 || idChild !== 0 || Number(api.GetAncestor(hwnd, GA_ROOT)) !== Number(hwnd)) return;
|
||||||
api.SetWindowLongPtrW(hwnd, GWL_STYLE, style);
|
const cls = className(hwnd);
|
||||||
const ex = ((((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW) >>> 0);
|
if (cls === 'TscShellContainerClass') {
|
||||||
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex);
|
if (event === EVENT_OBJECT_CREATE || !this.hwnd) this.adopt(hwnd);
|
||||||
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent); // Besitzer = MrTerm
|
if (event === EVENT_OBJECT_SHOW) this.onSessionShown();
|
||||||
this.hwnd = hwnd;
|
} else if (cls === '#32770') {
|
||||||
this.setBounds(bounds);
|
const isCert = () => api.GetDlgItem(hwnd, ID_CERT_YES) && api.GetDlgItem(hwnd, ID_CERT_VIEW);
|
||||||
return true;
|
if (event === EVENT_OBJECT_CREATE) this.setAlpha(hwnd, 0);
|
||||||
}
|
else if (childClasses(hwnd).has('msctls_progress32')) api.ShowWindow(hwnd, SW_HIDE);
|
||||||
await new Promise((r) => setTimeout(r, 250));
|
else if (this.acceptCert && isCert()) api.PostMessageW(api.GetDlgItem(hwnd, ID_CERT_YES), BM_CLICK, 0, 0);
|
||||||
}
|
else { if (isCert()) this.certPrompted = true; this.presentDialog(hwnd); }
|
||||||
return false;
|
}
|
||||||
|
} catch { /* Fenster kann bereits wieder zu sein */ }
|
||||||
|
}, koffi.pointer(api.WinEventProc));
|
||||||
|
this.hook = api.SetWinEventHook(EVENT_OBJECT_CREATE, EVENT_OBJECT_SHOW, 0, this.cb, this.proc.pid, 0, WINEVENT_OUTOFCONTEXT);
|
||||||
|
}
|
||||||
|
|
||||||
|
unhook() {
|
||||||
|
if (this.hook) { winApi().UnhookWinEvent(this.hook); this.hook = null; }
|
||||||
|
if (this.cb) { koffi.unregister(this.cb); this.cb = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
setAlpha(hwnd, alpha) {
|
||||||
|
const api = winApi();
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) | WS_EX_LAYERED) >>> 0);
|
||||||
|
api.SetLayeredWindowAttributes(hwnd, 0, alpha, LWA_ALPHA);
|
||||||
|
}
|
||||||
|
|
||||||
|
presentDialog(hwnd) {
|
||||||
|
const api = winApi();
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent);
|
||||||
|
const r = {};
|
||||||
|
api.GetWindowRect(hwnd, r);
|
||||||
|
const b = this.bounds || { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
const pt = { x: b.x + Math.round((b.width - (r.right - r.left)) / 2), y: b.y + Math.round((b.height - (r.bottom - r.top)) / 2) };
|
||||||
|
api.ClientToScreen(this.parent, pt);
|
||||||
|
api.SetWindowPos(hwnd, 0, pt.x, pt.y, 0, 0, SWP_NOSIZE | SWP_NOZORDER);
|
||||||
|
this.setAlpha(hwnd, 255);
|
||||||
|
api.SetForegroundWindow(hwnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sitzungsfenster rahmenlos und ohne Taskleisten-Eintrag als "owned window" von MrTerm
|
||||||
|
adopt(hwnd) {
|
||||||
|
const api = winApi();
|
||||||
|
this.hwnd = hwnd;
|
||||||
|
const style = (((Number(api.GetWindowLongPtrW(hwnd, GWL_STYLE)) >>> 0) & ~(FRAME | WS_CHILD)) | WS_POPUP) >>> 0;
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWL_STYLE, style);
|
||||||
|
const ex = ((((Number(api.GetWindowLongPtrW(hwnd, GWL_EXSTYLE)) >>> 0) & ~WS_EX_APPWINDOW) | WS_EX_TOOLWINDOW) >>> 0);
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWL_EXSTYLE, ex);
|
||||||
|
api.SetWindowLongPtrW(hwnd, GWLP_HWNDPARENT, this.parent);
|
||||||
|
// Bis zur Anmeldung außerhalb des Bildschirms parken: mstsc zentriert seine Dialoge über diesem Fenster,
|
||||||
|
// so bleibt auch "Verbindung wird hergestellt" unsichtbar, bevor der Hook ihn ausblenden kann.
|
||||||
|
if (!this.connected) api.SetWindowPos(hwnd, 0, -32000, -32000, this.bounds?.width || 800, this.bounds?.height || 600, SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mstsc blendet das Sitzungsfenster erst nach erfolgreicher Anmeldung ein und setzt dabei Rahmen und Größe neu
|
||||||
|
onSessionShown() {
|
||||||
|
this.connected = true;
|
||||||
|
this.adopt(this.hwnd);
|
||||||
|
if (!this.visible) winApi().ShowWindow(this.hwnd, SW_HIDE);
|
||||||
|
this.setBounds();
|
||||||
|
this.detectFixedSize();
|
||||||
|
this.setBounds();
|
||||||
|
this.onConnected?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ohne dynamische Auflösung (Server zu alt, xrdp < 0.10 …) begrenzt mstsc das Fenster auf die Sitzungsgröße –
|
||||||
|
// erkennbar daran, dass es sich nicht vergrößern lässt. Dann hilft nur Neuverbinden mit der neuen Größe.
|
||||||
|
detectFixedSize() {
|
||||||
|
const api = winApi();
|
||||||
|
const r = {};
|
||||||
|
api.GetWindowRect(this.hwnd, r);
|
||||||
|
api.SetWindowPos(this.hwnd, 0, r.left, r.top, r.right - r.left + 32, r.bottom - r.top + 32, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||||
|
const g = {};
|
||||||
|
api.GetWindowRect(this.hwnd, g);
|
||||||
|
this.fixedSize = g.right - g.left < r.right - r.left + 32 ? { width: r.right - r.left, height: r.bottom - r.top } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zertifikat wurde für diese Sitzung bestätigt (Rückfrage gezeigt und danach verbunden) – gilt für Neuverbindungen beim Größenändern
|
||||||
|
get certAccepted() { return this.acceptCert || (this.certPrompted && this.connected); }
|
||||||
|
|
||||||
|
checkFixedSize() {
|
||||||
|
clearTimeout(this.resizeTimer);
|
||||||
|
const b = this.bounds;
|
||||||
|
if (!this.fixedSize || !this.visible || (Math.abs(b.width - this.fixedSize.width) < 8 && Math.abs(b.height - this.fixedSize.height) < 8)) return;
|
||||||
|
this.resizeTimer = setTimeout(() => this.onResizeNeeded?.(this.bounds), 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wartet, bis die Sitzung steht (Sitzungsfenster sichtbar)
|
||||||
|
attach(bounds, isCancelled) {
|
||||||
|
this.bounds = bounds;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const done = (ok) => { clearInterval(iv); this.onConnected = null; resolve(ok); };
|
||||||
|
this.onConnected = () => done(true);
|
||||||
|
const iv = setInterval(() => {
|
||||||
|
if (this.connected) done(true);
|
||||||
|
else if (isCancelled() || this.proc.exitCode !== null || Date.now() - t0 > 120000) done(false);
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// b: physische Pixel relativ zum Client-Bereich von MrTerm -> Bildschirmkoordinaten
|
// b: physische Pixel relativ zum Client-Bereich von MrTerm -> Bildschirmkoordinaten
|
||||||
@@ -96,15 +193,17 @@ class WinEmbed {
|
|||||||
const pt = { x: this.bounds.x, y: this.bounds.y };
|
const pt = { x: this.bounds.x, y: this.bounds.y };
|
||||||
api.ClientToScreen(this.parent, pt);
|
api.ClientToScreen(this.parent, pt);
|
||||||
api.SetWindowPos(this.hwnd, 0, pt.x, pt.y, Math.max(50, this.bounds.width), Math.max(50, this.bounds.height),
|
api.SetWindowPos(this.hwnd, 0, pt.x, pt.y, Math.max(50, this.bounds.width), Math.max(50, this.bounds.height),
|
||||||
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED | (this.visible ? SWP_SHOWWINDOW : 0));
|
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED | (this.visible && this.connected ? SWP_SHOWWINDOW : 0));
|
||||||
|
if (this.connected) this.checkFixedSize();
|
||||||
}
|
}
|
||||||
reposition() { this.setBounds(); }
|
reposition() { this.setBounds(); }
|
||||||
show() { this.visible = true; if (this.hwnd) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } }
|
show() { this.visible = true; if (this.hwnd && this.connected) { winApi().ShowWindow(this.hwnd, SW_SHOWNOACTIVATE); this.setBounds(); } }
|
||||||
hide() { this.visible = false; if (this.hwnd) winApi().ShowWindow(this.hwnd, SW_HIDE); }
|
hide() { this.visible = false; clearTimeout(this.resizeTimer); if (this.hwnd) winApi().ShowWindow(this.hwnd, SW_HIDE); }
|
||||||
focus() { if (this.hwnd && this.visible) winApi().SetForegroundWindow(this.hwnd); }
|
focus() { if (this.hwnd && this.visible) winApi().SetForegroundWindow(this.hwnd); }
|
||||||
// Aus MrTerm lösen und als normales Fenster weiterlaufen lassen
|
// Aus MrTerm lösen und als normales Fenster weiterlaufen lassen
|
||||||
detach() {
|
detach() {
|
||||||
if (!this.hwnd) return;
|
if (!this.hwnd) return;
|
||||||
|
this.unhook();
|
||||||
const api = winApi();
|
const api = winApi();
|
||||||
api.SetWindowLongPtrW(this.hwnd, GWLP_HWNDPARENT, 0);
|
api.SetWindowLongPtrW(this.hwnd, GWLP_HWNDPARENT, 0);
|
||||||
const style = ((((Number(api.GetWindowLongPtrW(this.hwnd, GWL_STYLE)) >>> 0) & ~WS_POPUP) | FRAME) >>> 0);
|
const style = ((((Number(api.GetWindowLongPtrW(this.hwnd, GWL_STYLE)) >>> 0) & ~WS_POPUP) | FRAME) >>> 0);
|
||||||
@@ -115,7 +214,7 @@ class WinEmbed {
|
|||||||
api.SetWindowPos(this.hwnd, 0, 80, 80, this.bounds?.width || 1280, this.bounds?.height || 800, SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW);
|
api.SetWindowPos(this.hwnd, 0, 80, 80, this.bounds?.width || 1280, this.bounds?.height || 800, SWP_NOZORDER | SWP_FRAMECHANGED | SWP_SHOWWINDOW);
|
||||||
this.hwnd = null;
|
this.hwnd = null;
|
||||||
}
|
}
|
||||||
kill() { try { this.proc?.kill(); } catch {} }
|
kill() { clearTimeout(this.resizeTimer); this.unhook(); try { this.proc?.kill(); } catch {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Linux (X11)
|
// ------------------------------------------------------------------ Linux (X11)
|
||||||
|
|||||||
+11
-1
@@ -63,6 +63,16 @@ function writeRdpFile(host, size) {
|
|||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mstsc-Kommandozeile für eingebettete Sitzungen. Seit 2026 zeigt mstsc bei jeder unsignierten .rdp-Datei eine
|
||||||
|
// Sicherheitswarnung – mit /v:… startet es ohne. Die übrigen Optionen kommen dann aus Default.rdp (Zwischenablage an,
|
||||||
|
// Audio lokal, keine Laufwerke, dynamische Auflösung); weicht der Host davon ab, geht es nur über eine .rdp-Datei.
|
||||||
|
function mstscArgs(host, size) {
|
||||||
|
const port = Number(host.port) || 3389;
|
||||||
|
const needsFile = host.rdpDrives || host.rdpClipboard === false || host.rdpAudio === false || host.rdpResize === 'scale';
|
||||||
|
if (needsFile) return [writeRdpFile(host, size)];
|
||||||
|
return [`/v:${port === 3389 ? host.address : `${host.address}:${port}`}`, `/w:${size.width}`, `/h:${size.height}`];
|
||||||
|
}
|
||||||
|
|
||||||
// Legt Anmeldedaten kurzzeitig im Windows-Anmeldeinformationsspeicher ab, damit mstsc ohne Rückfrage verbindet.
|
// Legt Anmeldedaten kurzzeitig im Windows-Anmeldeinformationsspeicher ab, damit mstsc ohne Rückfrage verbindet.
|
||||||
function withWinCredentials(host, run) {
|
function withWinCredentials(host, run) {
|
||||||
const user = userOf(host);
|
const user = userOf(host);
|
||||||
@@ -110,4 +120,4 @@ function launch(host, settings) {
|
|||||||
return { client, process: p, getErr: () => err };
|
return { client, process: p, getErr: () => err };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { detect, launch, linuxClient, writeRdpFile, withWinCredentials, freerdpArgs };
|
module.exports = { detect, launch, linuxClient, writeRdpFile, mstscArgs, withWinCredentials, freerdpArgs };
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const DEFAULTS = {
|
|||||||
vpns: [],
|
vpns: [],
|
||||||
knownHosts: {},
|
knownHosts: {},
|
||||||
history: [],
|
history: [],
|
||||||
|
// Chats des Support-Assistenten (nur lokal, nicht synchronisiert)
|
||||||
|
aiChats: [],
|
||||||
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
|
// Löschvermerke für die Synchronisation: { c: Collection, id, at }
|
||||||
tombstones: [],
|
tombstones: [],
|
||||||
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
|
// LAN-Synchronisation (gerätebezogen, wird selbst nicht synchronisiert)
|
||||||
@@ -39,6 +41,10 @@ const DEFAULTS = {
|
|||||||
updatePrerelease: false,
|
updatePrerelease: false,
|
||||||
autoLock: 0,
|
autoLock: 0,
|
||||||
language: 'auto',
|
language: 'auto',
|
||||||
|
dockerStacksDir: '/opt/stacks',
|
||||||
|
dockerTemplatesUrl: '',
|
||||||
|
aiUrl: 'http://localhost:11434',
|
||||||
|
aiModel: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+427
-6
@@ -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>',
|
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>',
|
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>',
|
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>',
|
logs: '<svg viewBox="0 0 24 24"><path d="M5 4h14v16H5zM8 8h8M8 12h8M8 16h5"/></svg>',
|
||||||
};
|
};
|
||||||
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
const COLORS = ['#6e7bff', '#3ecf8e', '#ff5f6d', '#ffb454', '#c792ea', '#56d6d6', '#ff79c6', '#8b91a5', '#4f9dff', '#e0a100'];
|
||||||
@@ -150,6 +151,7 @@ async function reload() {
|
|||||||
S.vault = await call('vault:get');
|
S.vault = await call('vault:get');
|
||||||
I18N.setLanguage(settings().language, navigator.language);
|
I18N.setLanguage(settings().language, navigator.language);
|
||||||
applyStatic();
|
applyStatic();
|
||||||
|
initAi();
|
||||||
const b = $('#vaultBadge');
|
const b = $('#vaultBadge');
|
||||||
b.innerHTML = S.vault.encrypted ? `${ICONS.lock}<span>${T('Vault encrypted')}</span>` : `${ICONS.unlock}<span>${T('Vault not encrypted')}</span>`;
|
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).');
|
b.title = S.vault.encrypted ? T('Data is encrypted with the system keyring.') : T('No system keyring available (e.g. install gnome-keyring/kwallet).');
|
||||||
@@ -1051,6 +1053,28 @@ async function viewSettings(page) {
|
|||||||
save({ ...v, fontSize: Number(v.fontSize) || 14, scrollback: Number(v.scrollback) || 10000, keepAlive: Number(v.keepAlive) || 0 });
|
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 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);
|
const emb = check(T('Show RDP connections as tabs in MrTerm'), 'rdpEmbed', st.rdpEmbed !== false);
|
||||||
emb.onchange = async () => {
|
emb.onchange = async () => {
|
||||||
@@ -1069,6 +1093,15 @@ async function viewSettings(page) {
|
|||||||
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
|
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
|
||||||
}).catch(() => {});
|
}).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 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 = [
|
const btns = [
|
||||||
...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]),
|
...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]),
|
||||||
@@ -1123,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>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>+/−/0</kbd></span><span>${T('Font size')}</span>
|
||||||
<span>${C}+<kbd>Shift</kbd>+<kbd>L</kbd></span><span>${T('Lock now')}</span></div></div>`);
|
<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);
|
if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, aiCard, dockerCard, importCard, dataCard, keysCard);
|
||||||
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard);
|
else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, aiCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================ App-Sperre
|
// ============================================================ App-Sperre
|
||||||
@@ -1536,6 +1569,7 @@ function activateTab(id) {
|
|||||||
});
|
});
|
||||||
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
$('.tab.active')[0]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||||
renderTree();
|
renderTree();
|
||||||
|
if ($('#aiPanel').classList.contains('open')) aiUpdateCtx();
|
||||||
}
|
}
|
||||||
function closeTab(id) {
|
function closeTab(id) {
|
||||||
const i = S.tabs.findIndex((t) => t.id === id);
|
const i = S.tabs.findIndex((t) => t.id === id);
|
||||||
@@ -1560,6 +1594,7 @@ class TerminalSession {
|
|||||||
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
<button class="btn ghost sm" data-a="sftp" title="${T('SFTP for this host')}">${ICONS.folder}SFTP</button>
|
||||||
${host.execCommand ? '' : `<button class="btn ghost sm" data-a="docker" title="${T('Docker containers')}">${ICONS.docker}Docker</button><button class="btn ghost sm" data-a="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>`}
|
${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-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>
|
<button class="btn ghost sm" data-a="find" title="${T('Search (Ctrl+Shift+F)')}">${ICONS.search}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="term-wrap"><div class="term"></div>
|
<div class="term-wrap"><div class="term"></div>
|
||||||
@@ -1646,6 +1681,13 @@ class TerminalSession {
|
|||||||
box.append(templateList(this));
|
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) {
|
toggleFind(open) {
|
||||||
const fb = $('.findbar', this.el);
|
const fb = $('.findbar', this.el);
|
||||||
const o = open ?? !fb.classList.contains('open');
|
const o = open ?? !fb.classList.contains('open');
|
||||||
@@ -1720,8 +1762,158 @@ class TerminalSession {
|
|||||||
}
|
}
|
||||||
dispose() { this.unsub(); this.ro.disconnect(); api.ssh.close(this.id); this.term.dispose(); }
|
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); }
|
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:')} <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)
|
// ============================================================ RDP-Session (eingebettet im Tab)
|
||||||
// Das RDP-Fenster ist ein natives Kindfenster und liegt immer über dem HTML.
|
// 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.
|
// Deshalb wird es ausgeblendet, solange ein Dialog, Menü oder die Befehlspalette offen ist.
|
||||||
@@ -1802,6 +1994,10 @@ class RdpSession {
|
|||||||
this.overlay?.remove(); this.overlay = null;
|
this.overlay?.remove(); this.overlay = null;
|
||||||
setTabState(this, 'on');
|
setTabState(this, 'on');
|
||||||
if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id);
|
if (this.isVisible()) { this.pushBounds(); api.rdp.show(this.id); } else api.rdp.hide(this.id);
|
||||||
|
} else if (type === 'resizing') {
|
||||||
|
this.attached = false;
|
||||||
|
setTabState(this, 'wait');
|
||||||
|
this.showOverlay(T('Adjusting resolution …'));
|
||||||
} else if (type === 'exit') {
|
} else if (type === 'exit') {
|
||||||
this.attached = false; this.exited = true;
|
this.attached = false; this.exited = true;
|
||||||
setTabState(this, payload?.code ? 'err' : '');
|
setTabState(this, payload?.code ? 'err' : '');
|
||||||
@@ -2086,14 +2282,26 @@ class DockerSession {
|
|||||||
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
|
this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true;
|
||||||
this.el = h(`<div class="session docker">
|
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>
|
<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>
|
<div class="seg sm"><button class="active" data-v="containers">${T('Containers')}</button><button data-v="stacks">Stacks</button></div>
|
||||||
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
|
<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>
|
<button class="btn ghost sm" data-a="refresh" title="${T('Refresh')}">${ICONS.refresh}</button>
|
||||||
</div>
|
</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>`);
|
<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);
|
this.body = $('.docker-body', this.el);
|
||||||
$('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); };
|
$('.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(); };
|
$('[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.')); } });
|
this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } });
|
||||||
addTab(this);
|
addTab(this);
|
||||||
@@ -2128,7 +2336,11 @@ class DockerSession {
|
|||||||
async refresh(withStats) {
|
async refresh(withStats) {
|
||||||
if (this.busy) return;
|
if (this.busy) return;
|
||||||
this.busy = true;
|
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;
|
this.busy = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2137,6 +2349,7 @@ class DockerSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
draw() {
|
draw() {
|
||||||
|
if (this.view === 'stacks') return this.editing ? null : this.drawStacks();
|
||||||
const running = (c) => c.state === 'running';
|
const running = (c) => c.state === 'running';
|
||||||
const list = this.containers
|
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))))
|
.filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter))))
|
||||||
@@ -2189,6 +2402,212 @@ class DockerSession {
|
|||||||
await this.refresh(true);
|
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(); }
|
onShow() { if (this.containers.length) this.refresh(); }
|
||||||
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
|
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
|
||||||
}
|
}
|
||||||
@@ -2204,6 +2623,7 @@ class FirewallSession {
|
|||||||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
<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>
|
<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-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 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>
|
<button class="btn primary sm" data-a="add" disabled>${ICONS.plus}${T('Add rule')}</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -2397,6 +2817,7 @@ class NetworkSession {
|
|||||||
<div class="sbar"><div class="info">${avatar(host, 22)}<span>${esc(hostSub(host))}</span><span class="rt"></span></div>
|
<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>
|
<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-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 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>
|
<button class="btn primary sm" data-a="new" disabled>${ICONS.plus}${T('New interface')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button id="newTabBtn" class="icon-btn" title="Quick Connect (Ctrl+Shift+K)" data-i18n-title="Quick Connect (Ctrl+Shift+K)">+</button>
|
<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>
|
<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>
|
<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">
|
<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>
|
<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>
|
<div id="sessions"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<aside id="aiPanel" class="ai-panel"></aside>
|
||||||
<aside id="drawer" class="drawer"></aside>
|
<aside id="drawer" class="drawer"></aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -198,6 +198,31 @@ body.in-session #sidebar { display: none; }
|
|||||||
.snip-tpl .it:hover { background: var(--card-hover); }
|
.snip-tpl .it:hover { background: var(--card-hover); }
|
||||||
.snip-tpl .hint { color: var(--muted); font-size: 12px; margin-top: 3px; }
|
.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; }
|
.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 { 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.open { display: flex; }
|
||||||
.findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }
|
.findbar input { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 5px 8px; outline: none; width: 200px; }
|
||||||
@@ -336,6 +361,29 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi
|
|||||||
|
|
||||||
/* Firewall */
|
/* Firewall */
|
||||||
.seg.sm button { padding: 4px 10px; font-size: 12px; }
|
.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-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 { 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); }
|
.fw-def select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--text); }
|
||||||
|
|||||||
Reference in New Issue
Block a user