diff --git a/src/core/backend.js b/src/core/backend.js index 9645922..343bcd3 100644 --- a/src/core/backend.js +++ b/src/core/backend.js @@ -13,6 +13,7 @@ const { FirewallManager } = require('../main/firewall'); const { NetworkConfigManager } = require('../main/network'); const i18n = require('../i18n'); const backup = require('./backup'); +const composeTemplates = require('../main/compose-templates'); function createBackend({ store, send, platform, version = '0.0.0', withSync = false, onLanguage = () => {} }) { // Rückfragen an die Oberfläche (Hostschlüssel, Passwörter, Kopplungscode) @@ -184,6 +185,12 @@ function createBackend({ store, send, platform, version = '0.0.0', withSync = fa handle('docker:action', (id, action, cid) => docker.action(id, action, cid)); handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid)); handle('docker:close', (id) => docker.close(id)); + handle('docker:stacks', (id, dir) => docker.stacks(id, dir)); + handle('docker:stackRead', (id, file) => docker.readStack(id, file)); + handle('docker:stackSave', (id, data) => docker.saveStack(id, data)); + handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts)); + handle('docker:templates', (url) => composeTemplates.list(url)); + handle('docker:template', (index, url) => composeTemplates.build(index, url)); handle('firewall:open', (id, ref) => firewall.open(id, hostWithOverrides(ref), () => send('firewall:closed', id))); handle('firewall:list', (id, backend) => firewall.list(id, backend)); handle('firewall:ufw', (id, op, args) => firewall.ufw(id, op, args)); diff --git a/src/i18n.js b/src/i18n.js index 731f697..e2014af 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -367,6 +367,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.', 'Replace': 'Ersetzen', 'Import complete: {hosts} hosts, {keys} keys, {snippets} snippets': 'Import abgeschlossen: {hosts} Hosts, {keys} Schlüssel, {snippets} Snippets', + 'Stacks folder': 'Stack-Ordner', + 'Folder on the host in which each stack gets its own subfolder': 'Ordner auf dem Host, in dem jeder Stack einen eigenen Unterordner bekommt', + 'Template source': 'Vorlagen-Quelle', + 'URL of a templates.json in Portainer format. Empty = Lissy93/portainer-templates.': 'URL einer templates.json im Portainer-Format. Leer = Lissy93/portainer-templates.', + 'Containers': 'Container', + 'Folder': 'Ordner', + 'Change folder': 'Ordner ändern', + 'Creates the folder with sudo and makes it writable for {user}.': 'Legt den Ordner per sudo an und macht ihn für {user} beschreibbar.', + 'Set up folder': 'Ordner einrichten', + 'From Git': 'Aus Git', + 'New stack': 'Neuer Stack', + 'Compose file': 'Compose-Datei', + 'Found via docker compose ls, outside the stacks folder': 'Über docker compose ls gefunden, außerhalb des Stack-Ordners', + 'external': 'extern', + 'not deployed': 'nicht gestartet', + 'no compose file found': 'keine Compose-Datei gefunden', + 'Deploy (up -d)': 'Starten (up -d)', + 'No matches.': 'Keine Treffer.', + 'No stacks yet. Create one, clone it from Git or start from a template.': 'Noch keine Stacks. Lege einen an, klone ihn aus Git oder starte mit einer Vorlage.', + 'Update images (pull + up)': 'Images aktualisieren (pull + up)', + 'Git pull + redeploy': 'Git pull + neu starten', + 'Down (remove containers)': 'Down (Container entfernen)', + 'Stack from Git': 'Stack aus Git', + 'Repository URL': 'Repository-URL', + 'Stack name': 'Stack-Name', + 'Branch (optional)': 'Branch (optional)', + 'Compose file in the repository (optional)': 'Compose-Datei im Repository (optional)', + 'If empty, the first compose file found is used.': 'Leer = die erste gefundene Compose-Datei wird verwendet.', + 'Deploy right after cloning': 'Nach dem Klonen direkt starten', + 'The repository is cloned on the host into the stacks folder. Git asks for credentials in the terminal if needed; for private repos an SSH deploy key or a token in the URL also works.': 'Das Repository wird auf dem Host in den Stack-Ordner geklont. Git fragt Zugangsdaten bei Bedarf im Terminal ab; für private Repos funktioniert auch ein SSH-Deploy-Key oder ein Token in der URL.', + 'Clone': 'Klonen', + 'App templates': 'App-Vorlagen', + 'Search templates …': 'Vorlagen durchsuchen …', + 'Source: {url} (Portainer template format, changeable in the settings).': 'Quelle: {url} (Portainer-Vorlagenformat, in den Einstellungen änderbar).', + 'All categories': 'Alle Kategorien', + 'Delete stack?': 'Stack löschen?', + 'The stack “{name}” is stopped (down) and its folder {dir} is deleted.': 'Der Stack „{name}“ wird gestoppt (down) und sein Ordner {dir} gelöscht.', + 'Also delete named volumes (data!)': 'Auch benannte Volumes löschen (Daten!)', + 'Remove containers?': 'Container entfernen?', + 'docker compose down stops and removes the containers of “{name}”. Volumes and files are kept.': 'docker compose down stoppt und entfernt die Container von „{name}“. Volumes und Dateien bleiben erhalten.', + 'Down': 'Down', + 'Back': 'Zurück', + 'Save & deploy': 'Speichern & starten', + '(variables, used as ${NAME} in the compose file)': '(Variablen, in der Compose-Datei als ${NAME} nutzbar)', + 'Saved, but the compose file has an error: {msg}': 'Gespeichert, aber die Compose-Datei enthält einen Fehler: {msg}', + 'Docker Compose was not found on this host (docker compose plugin or docker-compose).': 'Docker Compose wurde auf diesem Host nicht gefunden (docker-compose-Plugin oder docker-compose).', + 'Could not read {file}': '{file} konnte nicht gelesen werden', + 'Stack names may only contain lowercase letters, digits, - and _.': 'Stack-Namen dürfen nur Kleinbuchstaben, Ziffern, - und _ enthalten.', + 'A stack named “{name}” already exists.': 'Ein Stack namens „{name}“ existiert bereits.', + 'No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.': 'Keine Schreibrechte in {dir}. Nutze „Ordner einrichten“ oder speichere das Host-Passwort, damit MrTerm sudo verwenden kann.', 'Templates': 'Vorlagen', 'Run': 'Ausführen', 'Authorize SSH key for a user': 'SSH-Key für Benutzer freischalten', diff --git a/src/main/compose-templates.js b/src/main/compose-templates.js new file mode 100644 index 0000000..b987e02 --- /dev/null +++ b/src/main/compose-templates.js @@ -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 }; diff --git a/src/main/docker.js b/src/main/docker.js index cd327d0..4a1ff3d 100644 --- a/src/main/docker.js +++ b/src/main/docker.js @@ -8,6 +8,12 @@ const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'"; const STATS_FMT = "'{{.ID}}\\t{{.CPUPerc}}\\t{{.MemUsage}}'"; const lastLine = (s) => String(s || '').split('\n').map((l) => l.trim()).filter(Boolean).slice(-1)[0] || ''; +const q = (v) => `'${String(v).replace(/'/g, `'\\''`)}'`; +const STACK_NAME = /^[a-z0-9][a-z0-9_-]*$/; +const COMPOSE_NAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml']; +const absPath = (p) => { if (typeof p !== 'string' || !p.startsWith('/') || /[\n\0]/.test(p) || p.split('/').includes('..')) throw new Error('Invalid path'); return p; }; +const dirname = (p) => p.replace(/\/[^/]*$/, '') || '/'; +const b64 = (t) => Buffer.from(String(t), 'utf8').toString('base64'); const denied = (s) => /permission denied|connect to the docker daemon socket/i.test(s); function execOn(conn, cmd, stdin) { @@ -102,6 +108,133 @@ class DockerManager { return `${pre} exec -it ${cid} sh -c 'if command -v bash >/dev/null 2>&1; then exec bash; else exec sh; fi'`; } + // ---------- Compose-Stacks ---------- + // Stacks liegen je in einem Unterordner von stacksDir (wie Dockge); zusätzlich werden per `compose ls` + // gefundene Projekte außerhalb davon angezeigt. Dateien werden ohne sudo geschrieben, sonst per sudo mit Host-Passwort. + async composeCmd(s) { + if (s.compose) return s.compose; + const cands = s.runtime === 'podman' ? ['podman compose', 'podman-compose'] : ['docker compose', 'docker-compose']; + for (const c of cands) { + const r = await execOn(s.conn, `${c} version >/dev/null 2>&1`); + if (!r.code) return (s.compose = c); + } + throw new Error(i18n.t('Docker Compose was not found on this host (docker compose plugin or docker-compose).')); + } + + // Shell-Skript ausführen, bei fehlender Berechtigung per sudo (nur mit gespeichertem Passwort) + async sh(s, script) { + let r = await execOn(s.conn, `sh -c ${q(script)}`); + if (r.code && /permission denied|operation not permitted/i.test(r.err) && s.host.password) { + r = await execOn(s.conn, `sudo -S -p '' sh -c ${q(script)}`, `${s.host.password}\n`); + } + return r; + } + + async stacks(id, stacksDir) { + const s = this.get(id); + const dir = absPath(stacksDir); + const compose = await this.composeCmd(s); + const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o '); + const scan = `d=${q(dir)}; [ -d "$d" ] && echo "#exists"; [ -w "$d" ] && echo "#writable"; for p in "$d"/*/; do [ -d "$p" ] || continue; ` + + `f=""; for c in ${COMPOSE_NAMES.join(' ')}; do [ -f "$p$c" ] && { f="$p$c"; break; }; done; ` + + `[ -n "$f" ] || f=$(find "$p" -maxdepth 3 \\( ${find} \\) 2>/dev/null | head -n1); ` + + `g=""; [ -d "$p.git" ] && g="git:$(git -C "$p" remote get-url origin 2>/dev/null)"; ` + + `printf '%s\\t%s\\t%s\\n' "$(basename "$p")" "$f" "$g"; done`; + const [r, ls] = await Promise.all([execOn(s.conn, `sh -c ${q(scan)}`), this.runCompose(s, compose, 'ls -a --format json')]); + const lines = r.out.split('\n').filter(Boolean); + let running = []; + try { running = JSON.parse(ls.out.trim() || '[]'); } catch { /* podman-compose kennt kein ls */ } + const status = new Map(running.map((x) => [x.Name, { status: x.Status, files: String(x.ConfigFiles || '').split(',').filter(Boolean) }])); + const list = lines.filter((l) => !l.startsWith('#')).map((l) => { + const [name, file, git] = l.split('\t'); + return { name, dir: `${dir.replace(/\/$/, '')}/${name}`, file: file ? file.replace(/\/\//g, '/') : '', git: git ? git.slice(4) || true : false, managed: true, status: status.get(name)?.status || '' }; + }); + for (const [name, st] of status) { + if (list.some((x) => x.name === name) || !st.files[0]) continue; + list.push({ name, dir: dirname(st.files[0]), file: st.files[0], git: false, managed: false, status: st.status }); + } + return { dir, exists: lines.includes('#exists'), writable: lines.includes('#writable'), compose, sudo: s.sudo, stacks: list.sort((a, b) => a.name.localeCompare(b.name)) }; + } + + runCompose(s, compose, args) { + const sudo = s.sudo ? "sudo -S -p '' " : ''; + return execOn(s.conn, `${sudo}${compose} ${args}`, s.sudo ? `${s.host.password || ''}\n` : ''); + } + + async readStack(id, file) { + const s = this.get(id); + const f = absPath(file); + const r = await this.sh(s, `cat ${q(f)} && printf '\\n\\0' && { cat ${q(dirname(f) + '/.env')} 2>/dev/null || true; }`); + if (r.code) throw new Error(lastLine(r.err) || i18n.t('Could not read {file}', { file: f })); + const i = r.out.lastIndexOf('\n\0'); + return { compose: r.out.slice(0, i), env: r.out.slice(i + 2) }; + } + + // { stacksDir, name, file?, compose, env } – ohne file wird ein neuer Stack angelegt. Liefert { file, warning } + async saveStack(id, { stacksDir, name, file, compose, env }) { + const s = this.get(id); + let f; + if (file) f = absPath(file); + else { + if (!STACK_NAME.test(name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.')); + f = `${absPath(stacksDir).replace(/\/$/, '')}/${name}/compose.yaml`; + } + const d = dirname(f); + const envFile = `${d}/.env`; + const script = `${file ? '' : `[ -e ${q(d)} ] && { echo "exists" >&2; exit 3; }; `}mkdir -p ${q(d)} && echo ${b64(compose)} | base64 -d > ${q(f)} && ` + + (String(env || '').trim() ? `(umask 077; echo ${b64(env.endsWith('\n') ? env : env + '\n')} | base64 -d > ${q(envFile)})` : `rm -f ${q(envFile)}`); + const r = await this.sh(s, script); + if (r.code === 3) throw new Error(i18n.t('A stack named “{name}” already exists.', { name })); + if (r.code) { + if (/permission denied/i.test(r.err)) throw new Error(i18n.t('No write permission in {dir}. Use “Set up folder” or save the host password so MrTerm can use sudo.', { dir: d })); + throw new Error(lastLine(r.err) || 'Error'); + } + const compose2 = await this.composeCmd(s); + const v = await this.runCompose(s, compose2, `--project-directory ${q(d)} -f ${q(f)} config -q`); + return { file: f, warning: v.code ? lastLine(v.err) || 'compose config failed' : '' }; + } + + // Befehl für einen Terminal-Tab (Ausgabe live; sudo/git fragen dort selbst nach Passwörtern) + async stackCommand(id, action, st, opts = {}) { + const s = this.get(id); + const compose = `${s.sudo ? 'sudo ' : ''}${await this.composeCmd(s)}`; + const S = (dir) => `S=$([ -w ${q(dir)} ] || echo sudo); `; + if (action === 'setup') { + const d = absPath(st.dir); + return `sudo mkdir -p ${q(d)} && sudo chown "$(id -u):$(id -g)" ${q(d)} && echo OK: ${q(d)}`; + } + if (action === 'clone') { + if (!STACK_NAME.test(st.name || '')) throw new Error(i18n.t('Stack names may only contain lowercase letters, digits, - and _.')); + if (!/^(https?:\/\/|git@|ssh:\/\/)\S+$/.test(opts.url || '')) throw new Error(i18n.t('Invalid repository URL: {url}', { url: opts.url || '' })); + const parent = absPath(st.stacksDir).replace(/\/$/, ''); + const d = `${parent}/${st.name}`; + const sub = opts.path ? absPath('/' + opts.path.replace(/^\/+/, '')).slice(1) : ''; + const find = COMPOSE_NAMES.map((n) => `-name ${n}`).join(' -o '); + const locate = sub ? `f=${q(`${d}/${sub}`)}` : `f=$(find ${q(d)} -maxdepth 3 \\( ${find} \\) | head -n1)`; + return `${S(parent)}command -v git >/dev/null || { echo "git is not installed"; exit 1; }; $S git clone ${opts.branch ? `--branch ${q(opts.branch)} ` : ''}${q(opts.url)} ${q(d)} && ` + + `{ [ "$S" ] && $S chown -R "$(id -u):$(id -g)" ${q(d)}; true; } && ${locate} && [ -f "$f" ] && echo "Compose: $f"` + + (opts.deploy ? ` && cd "$(dirname "$f")" && ${compose} -p ${q(st.name)} up -d` : ''); + } + const f = absPath(st.file); + const cd = `cd ${q(dirname(f))} && `; + const p = `${compose} -p ${q(st.name)} -f ${q(f)}`; + switch (action) { + case 'up': return `${cd}${p} up -d --remove-orphans`; + case 'stop': return `${cd}${p} stop`; + case 'restart': return `${cd}${p} restart`; + case 'down': return `${cd}${p} down`; + case 'pull': return `${cd}${p} pull && ${p} up -d --remove-orphans`; + case 'logs': return `${cd}${p} logs -f --tail 200`; + case 'gitpull': return `${cd}${S(dirname(f))}$S git pull --ff-only && ${p} up -d --remove-orphans`; + case 'delete': { + if (!st.managed) throw new Error('Only stacks in the stacks folder can be deleted'); + const d = absPath(st.dir); + return `${cd}${p} down${opts.volumes ? ' -v' : ''}; cd / && ${S(dirname(d))}$S rm -rf ${q(d)} && echo "Deleted: ${d.replace(/"/g, '')}"`; + } + default: throw new Error('Invalid action'); + } + } + close(id) { const s = this.sessions.get(id); if (!s) return; diff --git a/src/main/main.js b/src/main/main.js index 73667b6..6b260b9 100644 --- a/src/main/main.js +++ b/src/main/main.js @@ -17,6 +17,7 @@ const { FirewallManager } = require('./firewall'); const { NetworkConfigManager } = require('./network'); const { SyncService } = require('./sync'); const backup = require('../core/backup'); +const composeTemplates = require('./compose-templates'); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); let win; @@ -233,6 +234,12 @@ handle('docker:stats', (id) => docker.stats(id)); handle('docker:action', (id, action, cid) => docker.action(id, action, cid)); handle('docker:command', (id, kind, cid) => docker.command(id, kind, cid)); handle('docker:close', (id) => docker.close(id)); +handle('docker:stacks', (id, dir) => docker.stacks(id, dir)); +handle('docker:stackRead', (id, file) => docker.readStack(id, file)); +handle('docker:stackSave', (id, data) => docker.saveStack(id, data)); +handle('docker:stackCommand', (id, action, st, opts) => docker.stackCommand(id, action, st, opts)); +handle('docker:templates', (url) => composeTemplates.list(url)); +handle('docker:template', (index, url) => composeTemplates.build(index, url)); // ---------- Firewall ---------- handle('firewall:open', async (id, hostRef) => { diff --git a/src/main/store.js b/src/main/store.js index 01a258a..cc69126 100644 --- a/src/main/store.js +++ b/src/main/store.js @@ -39,6 +39,8 @@ const DEFAULTS = { updatePrerelease: false, autoLock: 0, language: 'auto', + dockerStacksDir: '/opt/stacks', + dockerTemplatesUrl: '', }, }; diff --git a/src/renderer/app.js b/src/renderer/app.js index b18669e..015a0f0 100644 --- a/src/renderer/app.js +++ b/src/renderer/app.js @@ -1069,6 +1069,15 @@ async function viewSettings(page) { if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)'; }).catch(() => {}); + const dockerCard = h(`

Docker Compose

`); + const dockerFields = h('
'); + 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(`

${T('Data')}

${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 gnome-keyring or kwallet.')}

`); const btns = [ ...(WEB ? [] : [[T('Import ~/.ssh/config'), importSshConfig]]), @@ -1123,8 +1132,8 @@ async function viewSettings(page) { ${C}+Shift+F${T('Search in terminal')} ${C}++/−/0${T('Font size')} ${C}+Shift+L${T('Lock now')}`); - if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, importCard, dataCard, keysCard); - else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, importCard, dataCard, updCard, keysCard); + if (WEB) c.append(langCard, webAccountCard(), designCard, themeCard, termCard, dockerCard, importCard, dataCard, keysCard); + else c.append(langCard, secCard, syncCard, designCard, themeCard, termCard, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard); } // ============================================================ App-Sperre @@ -2086,14 +2095,25 @@ class DockerSession { this.containers = []; this.stats = {}; this.filter = ''; this.showStopped = true; this.el = h(`
${avatar(host, 22)}${esc(hostSub(host))}
- - +
+ +
${esc(T('Connecting to {host} …', { host: host.address }))}
`); this.body = $('.docker-body', this.el); $('.search input', this.el).oninput = (e) => { this.filter = e.target.value.toLowerCase(); this.draw(); }; - this.el.addEventListener('click', (e) => { if (e.target.closest('[data-a=refresh]')) this.refresh(true); }); + this.view = 'containers'; this.stackInfo = null; this.editing = null; + this.el.addEventListener('click', (e) => { + if (e.target.closest('[data-a=refresh]')) this.refresh(true); + const v = e.target.closest('.seg [data-v]')?.dataset.v; + if (v && v !== this.view) { + this.view = v; this.editing = null; + $$('.seg [data-v]', this.el).forEach((b) => b.classList.toggle('active', b.dataset.v === v)); + $('.stopped-opt', this.el).style.display = v === 'containers' ? '' : 'none'; + if (v === 'stacks') this.loadStacks(true); else this.draw(); + } + }); $('[data-a=stopped]', this.el).onchange = (e) => { this.showStopped = e.target.checked; this.draw(); }; this.unsub = api.on('docker:closed', (sid) => { if (sid === this.id) { setTabState(this, 'err'); this.error(T('Connection closed.')); } }); addTab(this); @@ -2128,7 +2148,11 @@ class DockerSession { async refresh(withStats) { if (this.busy) return; this.busy = true; - try { this.apply(await api.call('docker:list', this.id)); if (withStats) this.loadStats(); } catch (e) { if (withStats) toast(e.message, 'error'); } + try { + this.apply(await api.call('docker:list', this.id)); + if (withStats) this.loadStats(); + if (this.view === 'stacks' && !this.editing) await this.loadStacks(); + } catch (e) { if (withStats) toast(e.message, 'error'); } this.busy = false; } @@ -2137,6 +2161,7 @@ class DockerSession { } draw() { + if (this.view === 'stacks') return this.editing ? null : this.drawStacks(); const running = (c) => c.state === 'running'; const list = this.containers .filter((c) => (this.showStopped || running(c)) && (!this.filter || [c.name, c.image, c.id, c.ports].some((f) => String(f || '').toLowerCase().includes(this.filter)))) @@ -2189,6 +2214,206 @@ class DockerSession { await this.refresh(true); } + // ---------- Compose-Stacks + get stacksDir() { return settings().dockerStacksDir || '/opt/stacks'; } + + async loadStacks(showErrors) { + try { this.stackInfo = await api.call('docker:stacks', this.id, this.stacksDir); this.stackError = ''; } + catch (e) { this.stackError = e.message; if (showErrors) this.stackInfo = null; } + if (this.view === 'stacks' && !this.editing) this.drawStacks(); + } + + drawStacks() { + const info = this.stackInfo; + if (!info) { + this.body.innerHTML = `
${this.stackError ? `
${esc(this.stackError)}
` : ''}
`; + 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 = `
+ ${T('Folder')}: ${esc(info.dir)} + + ${needSetup ? `` : ''} + + + + +
+ ${list.length ? ` + ${list.map((x, i) => { + const on = /running/.test(x.status); + return ` + + + `; + }).join('')}
${T('Name')}${T('Status')}${T('Compose file')}
${esc(x.name)}${x.git ? ` git` : ''}${x.managed ? '' : ` ${T('external')}`}
${esc(x.status || T('not deployed'))}${esc(x.file || T('no compose file found'))}${x.file ? ` + + + ${on ? `` : ''} + ` : ''} + ${x.managed ? `` : ''} +
` + : `
${this.filter ? T('No matches.') : T('No stacks yet. Create one, clone it from Git or start from a template.')}
`}`; + 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} +
${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.')}
`, + 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: `
+
+
${T('Source: {url} (Portainer template format, changeable in the settings).', { url: esc(tplUrl || 'Lissy93/portainer-templates') })}
`, + 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 = `
${esc(e.message)}
`; return done; } + const cats = [...new Set(list.flatMap((t) => t.categories))].sort(); + $('.tpl-cat', root).innerHTML = `${cats.map((c) => ``).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) => `
${esc(t.title)} ${t.kind === 'stack' ? 'Stack' : 'Container'}
${esc(t.description)}
`).join('') || `
${T('No matches.')}
`; + }; + $('.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 {} + } + + // 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 = `
+
+ + ${isNew ? `` : `${esc(st.name)}${esc(st.file)}`} + + + +
+ ${st.note ? `
${esc(st.note)}
` : ''} +
+
+
+
`; + const ta = $('.se-compose', this.body), env = $('.se-env', this.body); + ta.value = st.compose || ''; env.value = st.env || ''; + for (const t of [ta, env]) t.addEventListener('keydown', (e) => { + if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); t.setRangeText(' ', t.selectionStart, t.selectionEnd, 'end'); } + if (e.key === 's' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); save(false); } + }); + const save = async (deploy) => { + const name = isNew ? $('.se-name', this.body).value.trim() : st.name; + const btns = $$('[data-e]', this.body); btns.forEach((b) => { b.disabled = true; }); + try { + const r = await call('docker:stackSave', this.id, { stacksDir: this.stacksDir, name, file: st.file, compose: ta.value, env: env.value }); + if (r.warning) toast(T('Saved, but the compose file has an error: {msg}', { msg: r.warning }), 'error'); + else toast(T('Saved'), 'ok'); + Object.assign(st, { file: r.file, name, dir: r.file.replace(/\/[^/]*$/, ''), managed: true, compose: ta.value, env: env.value }); + if (deploy && !r.warning) await this.stackTerminal('up', st, {}, 'up'); + if (isNew || deploy) { this.editing = null; await this.loadStacks(true); return; } + } catch {} + btns.forEach((b) => { b.disabled = false; }); + }; + $('[data-e=back]', this.body).onclick = () => { this.editing = null; this.loadStacks(true); }; + $('[data-e=save]', this.body).onclick = () => save(false); + $('[data-e=deploy]', this.body).onclick = () => save(true); + (isNew && !st.name ? $('.se-name', this.body) : ta).focus(); + } + onShow() { if (this.containers.length) this.refresh(); } dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); } } diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 55e4113..c5ff1e2 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -336,6 +336,29 @@ kbd { background: var(--card); border: 1px solid var(--border); border-bottom-wi /* Firewall */ .seg.sm button { padding: 4px 10px; font-size: 12px; } +.session.docker .sbar .seg { margin: 0 6px 0 0; flex: none; } +.docker-table .dot.none { background: transparent; border: 1px solid var(--faint); } +.docker-table .badge, .tpl-it .badge { font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 6px; background: var(--icon-bg); color: var(--muted); margin-left: 4px; vertical-align: middle; } +.stack-bar { display: flex; align-items: center; gap: 6px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; position: sticky; top: 0; background: var(--bg); z-index: 2; } +.stack-bar .muted { color: var(--muted); font-size: 12px; } +.stack-bar + .docker-table th { top: 45px; } +.stack-editor { display: flex; flex-direction: column; height: 100%; } +.stack-editor .se-head { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-bottom: 1px solid var(--border); flex-wrap: wrap; } +.stack-editor .se-name { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; color: var(--text); padding: 5px 8px; width: 200px; font: inherit; } +.stack-editor .se-note { padding: 8px 12px; color: var(--muted); font-size: 12px; border-bottom: 1px solid var(--border); max-height: 80px; overflow: auto; } +.stack-editor .se-grid { flex: 1; display: grid; grid-template-columns: 2fr 1fr; gap: 10px; padding: 10px; min-height: 0; } +.stack-editor .se-col { display: flex; flex-direction: column; min-height: 0; gap: 6px; } +.stack-editor .se-col label { font-size: 12px; color: var(--muted); font-weight: 600; } +.stack-editor textarea { flex: 1; resize: none; background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px; font-size: 13px; line-height: 1.45; tab-size: 2; white-space: pre; min-height: 200px; } +@media (max-width: 800px) { .stack-editor .se-grid { grid-template-columns: 1fr; } } +.modal.wide { width: min(760px, 94vw); max-width: none; } +.tpl-top { display: flex; gap: 8px; margin-bottom: 8px; } +.tpl-top .search { margin: 0; } +.tpl-top select { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 0 8px; } +.tpl-list { max-height: 55vh; overflow: auto; display: flex; flex-direction: column; gap: 6px; } +.tpl-it { background: var(--card); border-radius: 8px; padding: 9px 11px; cursor: pointer; } +.tpl-it:hover { background: var(--card-hover); } +.tpl-it .d { color: var(--muted); font-size: 12px; margin-top: 3px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .fw-head { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border); flex-wrap: wrap; } .fw-def { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; margin-left: 8px; } .fw-def select { background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; color: var(--text); }