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
+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 };