Files
maggesandClaude Sonnet 5 7e2ed451e3
Lint / ShellCheck (push) Successful in 14s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 6s
Add Apps/Store tab, template WebUI/URL import, container RO volumes/device passthrough/run-as-user, and in-app confirm dialogs
Replaces every native confirm() with a shared P.confirm() modal (a hung
native dialog was found live to block the whole tab, including
auto-refresh, and once even double-confirmed an unrelated deletion).
Also fixes Edit Container silently resetting to Bridge/blanking the
Static IP for any container on a custom network, and a context menu
losing its anchor to a mid-read auto-refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:24:32 +00:00

173 lines
6.5 KiB
JavaScript

/**
* javascript/compose.js
*
* Compose panel: project list + an editable YAML view + save/up/down/pull/
* delete, backed by ajax/compose.php. See that file's header comment —
* this is the one panel whose backend shells out to the `podman compose`
* CLI, because no REST equivalent for Compose exists in libpod.
*/
(function () {
'use strict';
const P = window.Podman;
let projects = [];
let selected = null;
const STARTER_YAML =
'services:\n' +
' app:\n' +
' image: docker.io/library/nginx:alpine\n' +
' ports:\n' +
' - "8080:80"\n';
function statusChip(status) {
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn');
return '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
}
function renderSidebar() {
P.el('compose-sidebar').innerHTML = projects.map(function (p) {
return '<div class="podman-compose-proj' + (p.name === selected ? ' active' : '') + '" data-name="' + P.escapeHtml(p.name) + '">' +
'<div class="name" style="display:flex; justify-content:space-between; gap:8px;">' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '</div>' +
'<div class="path">' + P.escapeHtml(p.path) + '</div>' +
'</div>';
}).join('') || '<div class="podman-empty-note">No compose projects yet — click "+ New Project".</div>';
}
// Up/Down/Pull/Save/Delete all need an actual selected project to act on
// — disabled (rather than left clickable and erroring) whenever nothing
// is selected, e.g. right after deleting the last project.
function setToolbarEnabled(enabled) {
['compose-action-up', 'compose-action-down', 'compose-action-pull', 'compose-action-save', 'compose-action-delete'].forEach(function (id) {
P.el(id).disabled = !enabled;
});
P.el('compose-yaml').disabled = !enabled;
}
function loadYaml(name) {
P.el('compose-title').textContent = name + ' / compose.yaml';
P.el('compose-yaml').value = 'Loading…';
return P.get('compose', 'get', { project: name }).then(function (data) {
P.el('compose-yaml').value = data.yaml;
}).catch(function (err) {
P.el('compose-yaml').value = 'Error: ' + err.message;
});
}
function selectProject(name) {
selected = name;
setToolbarEnabled(true);
renderSidebar();
loadYaml(name);
}
function loadProjects() {
return P.get('compose', 'list').then(function (data) {
projects = data;
if (selected && !projects.some(function (p) { return p.name === selected; })) {
selected = null;
}
if (!selected && projects.length > 0) selected = projects[0].name;
renderSidebar();
if (selected) {
setToolbarEnabled(true);
loadYaml(selected);
} else {
setToolbarEnabled(false);
P.el('compose-title').textContent = '—';
P.el('compose-yaml').value = '';
}
}).catch(function (err) {
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
});
}
function runAction(action) {
if (!selected) return;
const btn = P.el('compose-action-' + action);
btn.disabled = true;
// `podman compose` output can run to many lines — a log modal (same
// pattern as Settings' service Start/Stop/Restart) fits that; a toast
// has to stay short and auto-dismiss.
const modal = P.openLogModal('podman compose ' + action + ' — ' + selected);
P.post('compose', action, { project: selected }).then(function (data) {
(data.output || 'Done.').split('\n').forEach(function (line) { modal.log(line); });
modal.done();
return loadProjects();
}).catch(function (err) {
modal.log('Failed: ' + err.message);
modal.done('Close');
}).finally(function () {
btn.disabled = false;
});
}
function saveYaml() {
if (!selected) return;
const btn = P.el('compose-action-save');
btn.disabled = true;
P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () {
P.toast('Saved ' + selected + '.', 'success');
return loadProjects();
}).catch(function (err) {
P.toast('Save failed: ' + err.message, 'error');
}).finally(function () {
btn.disabled = false;
});
}
function deleteProject() {
if (!selected) return;
P.confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
if (!ok) return;
const btn = P.el('compose-action-delete');
btn.disabled = true;
const name = selected;
P.post('compose', 'remove', { project: selected }).then(function () {
selected = null;
P.toast('Deleted ' + name + '.', 'success');
return loadProjects();
}).catch(function (err) {
P.toast('Delete failed: ' + err.message, 'error');
btn.disabled = false;
});
});
}
function openNewProjectModal() {
P.openFormModal({
title: 'New Compose Project',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Project name', required: true, placeholder: 'my-stack', hint: 'Letters, digits, "_", "-" only — no spaces.' },
],
onSubmit: function (values) {
if (!/^[a-zA-Z0-9_-]+$/.test(values.name)) {
return Promise.reject(new Error('Project name can only contain letters, digits, "_", "-" — no spaces.'));
}
return P.post('compose', 'save', { project: values.name, yaml: STARTER_YAML }).then(function () {
selected = values.name;
return loadProjects();
});
},
});
}
function init() {
P.el('compose-sidebar').addEventListener('click', function (e) {
const item = e.target.closest('.podman-compose-proj[data-name]');
if (item) selectProject(item.dataset.name);
});
P.el('compose-new-btn').addEventListener('click', openNewProjectModal);
P.el('compose-action-up').addEventListener('click', function () { runAction('up'); });
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); });
P.el('compose-action-save').addEventListener('click', saveYaml);
P.el('compose-action-delete').addEventListener('click', deleteProject);
setToolbarEnabled(false);
return loadProjects();
}
P.registerPanel('compose', { init: init, refresh: loadProjects });
})();