Docker: add a Compose stack manager (Containers | Stacks toggle): list stacks in a stacks folder (default /opt/stacks) plus projects found via compose ls, create/edit compose.yaml and .env in an editor (validated with compose config), deploy/stop/restart/down/pull/logs/delete in terminal tabs, clone stacks from Git and git pull + redeploy, app templates in Portainer format (default Lissy93/portainer-templates: container templates are converted to compose, stack templates loaded from GitHub), "Set up folder" makes the stacks folder writable for the SSH user

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 14:58:50 +02:00
co-authored by Claude Opus 5.5
parent 0b43182019
commit 6efa74aa7e
8 changed files with 528 additions and 6 deletions
+7
View File
@@ -13,6 +13,7 @@ const { FirewallManager } = require('../main/firewall');
const { NetworkConfigManager } = require('../main/network'); const { NetworkConfigManager } = require('../main/network');
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)
@@ -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: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));
+50
View File
@@ -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.', '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',
+75
View File
@@ -0,0 +1,75 @@
// App-Vorlagen im Portainer-Format (v2/v3), z. B. https://github.com/Lissy93/portainer-templates.
// Container-Vorlagen (type 1) werden in eine compose.yaml umgewandelt; Stack-Vorlagen (type 2/3) laden die
// Compose-Datei aus dem angegebenen GitHub-Repository. Umgebungsvariablen landen mit Standardwerten in der .env.
const DEFAULT_URL = 'https://raw.githubusercontent.com/Lissy93/portainer-templates/main/templates.json';
let cache = { url: '', at: 0, list: [] };
const y = (v) => JSON.stringify(String(v)); // JSON-Strings sind gültiges YAML
const slug = (s) => String(s || 'app').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^[-_]+|[-_]+$/g, '').slice(0, 40) || 'app';
async function list(url = DEFAULT_URL) {
url = url || DEFAULT_URL;
if (cache.url === url && Date.now() - cache.at < 3600e3) return cache.list;
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const j = await res.json();
const items = (Array.isArray(j) ? j : j.templates || []).filter((t) => t && t.title && (t.image || t.repository?.stackfile));
const listed = items.map((t, i) => ({
i, title: t.title, description: String(t.description || '').slice(0, 400), categories: t.categories || [],
kind: t.image ? 'container' : 'stack', note: t.note || '', t,
}));
cache = { url, at: Date.now(), list: listed };
return listed;
}
// Für die Oberfläche ohne Rohdaten
const summary = (l) => l.map(({ t: _t, ...rest }) => rest);
function envFile(env) {
return (env || []).filter((e) => e?.name).map((e) => {
const def = e.select ? (e.select.find((x) => x.default) || e.select[0] || {}).value : e.default;
const hint = [e.label && e.label !== e.name ? e.label : '', e.description || '', e.select ? `(${e.select.map((x) => x.value).join(' | ')})` : ''].filter(Boolean).join(' – ');
return `${hint ? `# ${hint.replace(/\n/g, ' ')}\n` : ''}${e.name}=${def ?? ''}`;
}).join('\n');
}
function containerCompose(t, name) {
const L = ['services:', ` ${name}:`, ` image: ${y(t.image)}`, ` container_name: ${y(name)}`, ` restart: ${y(t.restart_policy || 'unless-stopped')}`];
if (t.hostname) L.push(` hostname: ${y(t.hostname)}`);
if (t.command) L.push(` command: ${y(t.command)}`);
if (t.privileged) L.push(' privileged: true');
if (t.interactive) L.push(' stdin_open: true', ' tty: true');
if (t.network === 'host') L.push(' network_mode: host');
if (t.ports?.length && t.network !== 'host') L.push(' ports:', ...t.ports.map((p) => ` - ${y(p)}`));
if (t.env?.length) L.push(' environment:', ...t.env.filter((e) => e?.name).map((e) => ` - ${y(`${e.name}=\${${e.name}}`)}`));
if (t.volumes?.length) {
L.push(' volumes:', ...t.volumes.filter((v) => v?.container).map((v) => {
const host = v.bind ? v.bind.replace(/^\/portainer\/Files\/AppData\/[^/]+\/?/, './data/') : `./data${v.container}`;
return ` - ${y(`${host.replace(/\/$/, '') || './data'}:${v.container}${v.readonly ? ':ro' : ''}`)}`;
}));
}
if (t.labels?.length) L.push(' labels:', ...t.labels.filter((l) => l?.name).map((l) => ` ${y(l.name)}: ${y(l.value ?? '')}`));
return L.join('\n') + '\n';
}
// Liefert { name, compose, env, note }
async function build(index, url) {
const l = await list(url);
const item = l.find((x) => x.i === index);
if (!item) throw new Error('Template not found');
const t = item.t;
const name = slug(t.name || t.title);
let compose;
if (t.image) compose = containerCompose(t, name);
else {
const m = /^https:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?\/?$/.exec(t.repository.url || '');
if (!m) throw new Error(`Only GitHub repositories are supported (${t.repository.url}). Use “Clone from Git” instead.`);
const res = await fetch(`https://raw.githubusercontent.com/${m[1]}/${m[2]}/HEAD/${t.repository.stackfile.replace(/^\/+/, '')}`);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${t.repository.stackfile}`);
compose = await res.text();
}
return { name, compose, env: envFile(t.env), note: String(t.note || '').replace(/<[^>]+>/g, '') };
}
module.exports = { list: async (url) => summary(await list(url)), build, DEFAULT_URL };
+133
View File
@@ -8,6 +8,12 @@ const ACTIONS = { start: 'start', stop: 'stop', restart: 'restart', remove: 'rm
const LIST_FMT = "'{{.ID}}\\t{{.Names}}\\t{{.Image}}\\t{{.State}}\\t{{.Status}}\\t{{.Ports}}'"; const 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,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'`; 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) { close(id) {
const s = this.sessions.get(id); const s = this.sessions.get(id);
if (!s) return; if (!s) return;
+7
View File
@@ -17,6 +17,7 @@ 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 applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale()); const applyLanguage = () => i18n.setLanguage(store.get().settings.language, app.getLocale());
let win; 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: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) => {
+2
View File
@@ -39,6 +39,8 @@ const DEFAULTS = {
updatePrerelease: false, updatePrerelease: false,
autoLock: 0, autoLock: 0,
language: 'auto', language: 'auto',
dockerStacksDir: '/opt/stacks',
dockerTemplatesUrl: '',
}, },
}; };
+231 -6
View File
@@ -1069,6 +1069,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 +1132,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, 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, rdpCard, dockerCard, importCard, dataCard, updCard, keysCard);
} }
// ============================================================ App-Sperre // ============================================================ App-Sperre
@@ -2086,14 +2095,25 @@ 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-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 +2148,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 +2161,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 +2214,206 @@ 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 {}
}
// 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(() => {}); }
} }
+23
View File
@@ -336,6 +336,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); }