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:
+231
-6
@@ -1069,6 +1069,15 @@ async function viewSettings(page) {
|
||||
if (!d.available) $('.rdp-info', rdpCard).style.color = 'var(--orange)';
|
||||
}).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 btns = [
|
||||
...(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>+/−/0</kbd></span><span>${T('Font size')}</span>
|
||||
<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);
|
||||
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(`<div class="session docker">
|
||||
<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>
|
||||
<label class="check" style="margin:0 6px"><input type="checkbox" data-a="stopped" checked/>${T('Show stopped')}</label>
|
||||
<div class="seg sm"><button class="active" data-v="containers">${T('Containers')}</button><button data-v="stacks">Stacks</button></div>
|
||||
<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>
|
||||
</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);
|
||||
$('.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 = `<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(); }
|
||||
dispose() { clearInterval(this.timer); this.unsub(); api.call('docker:close', this.id).catch(() => {}); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user