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:
@@ -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 };
|
||||
Reference in New Issue
Block a user