From e92f67ebba9c7d03a516e2d0b5ecc2c209d61092 Mon Sep 17 00:00:00 2001 From: magges Date: Sun, 12 Jul 2026 19:07:09 +0000 Subject: [PATCH] Make Compose panel editable: create/edit/delete projects The YAML view was read-only with no way to create a new project at all. Add save/remove AJAX actions (validated via a real `podman compose ... config` dry-run, written to a .new sibling and only renamed into place on success) and a New Project/Save/Delete UI backed by an editable textarea instead of a
. Also strip ANSI
escape codes from compose command output so podman's own provider
banner doesn't show as literal garbage in error alerts.

Co-Authored-By: Claude Sonnet 5 
---
 webui/plugins/podman/Podman.page           |  11 ++-
 webui/plugins/podman/ajax/compose.php      | 100 +++++++++++++++++++--
 webui/plugins/podman/javascript/compose.js |  96 ++++++++++++++++++--
 webui/plugins/podman/styles/podman.css     |  13 +++
 4 files changed, 203 insertions(+), 17 deletions(-)

diff --git a/webui/plugins/podman/Podman.page b/webui/plugins/podman/Podman.page
index 73e1c5f..020fc0e 100644
--- a/webui/plugins/podman/Podman.page
+++ b/webui/plugins/podman/Podman.page
@@ -198,15 +198,22 @@ function podman_asset_version(string $relPath): string
     
-
+
+
+ +
+
+
+ +
-

+            
           
diff --git a/webui/plugins/podman/ajax/compose.php b/webui/plugins/podman/ajax/compose.php index bde4c12..b7ea13e 100644 --- a/webui/plugins/podman/ajax/compose.php +++ b/webui/plugins/podman/ajax/compose.php @@ -27,6 +27,8 @@ * Actions (?action=...): * list GET -> known projects with up/down status * get GET (&project=...) -> raw compose.yaml content + * save POST {"project": "...", "yaml": "..."} -> creates or overwrites a project's compose.yaml + * remove POST {"project": "..."} -> `down` (best-effort) then deletes the project's directory * up POST {"project": "..."} * down POST {"project": "..."} * pull POST {"project": "..."} @@ -49,6 +51,15 @@ switch ($action) { podman_json_response(['yaml' => compose_read($composeDir, $project)]); break; + case 'save': + $body = podman_read_json_body(); + podman_json_response(compose_save($composeDir, require_project($body), (string) ($body['yaml'] ?? ''))); + break; + + case 'remove': + podman_json_response(compose_remove($composeDir, require_project(podman_read_json_body()))); + break; + case 'up': podman_json_response(compose_run($composeDir, require_project(podman_read_json_body()), ['up', '-d'])); break; @@ -136,6 +147,79 @@ function compose_status(string $composeDir, string $project): string return $running > 0 ? 'up' : 'down'; } +/** + * Creates a new project (directory doesn't exist yet) or overwrites an + * existing one's compose.yaml. Validated via the real tool — `podman + * compose ... config` parses and resolves the file, exiting non-zero with + * a specific line/column message on invalid YAML/schema (verified live) + * — rather than a hand-rolled YAML parser, since PHP has no YAML + * extension available here to begin with. Written to a *.new sibling + * file first and only renamed into place once validation passes, so a + * bad edit never corrupts a previously-working compose.yaml. + * + * @return array + */ +function compose_save(string $composeDir, string $project, string $yaml): array +{ + if (trim($yaml) === '') { + podman_json_error('compose.yaml content cannot be empty', 400); + } + + $projectDir = $composeDir . '/' . $project; + if (!is_dir($projectDir) && !mkdir($projectDir, 0755, true) && !is_dir($projectDir)) { + podman_json_error("Could not create project directory for '{$project}'", 500); + } + + $yamlPath = $projectDir . '/compose.yaml'; + $tmpName = 'compose.yaml.new'; + if (file_put_contents($projectDir . '/' . $tmpName, $yaml) === false) { + podman_json_error('Could not write compose.yaml', 500); + } + + $result = run_compose_command($composeDir, $project, ['config'], 30, $tmpName); + if ($result['exitCode'] !== 0) { + @unlink($projectDir . '/' . $tmpName); + podman_json_error("Invalid compose file:\n" . trim($result['output']), 400); + } + + if (!rename($projectDir . '/' . $tmpName, $yamlPath)) { + podman_json_error('Could not save compose.yaml', 500); + } + + return ['status' => 'saved']; +} + +/** + * Best-effort `down` (ignored if it fails — e.g. already down, or the + * file was mid-edit and invalid) so deleting a running project's files + * doesn't leave orphaned containers/networks behind, then deletes just + * that one project's own directory. $project is validated by + * require_project() before this is ever called, so $projectDir can't + * escape $composeDir. + * + * @return array + */ +function compose_remove(string $composeDir, string $project): array +{ + $projectDir = $composeDir . '/' . $project; + if (!is_dir($projectDir)) { + podman_json_error("Project '{$project}' not found", 404); + } + + run_compose_command($composeDir, $project, ['down'], 60); + + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($projectDir, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($it as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($projectDir); + + return ['status' => 'removed']; +} + function compose_read(string $composeDir, string $project): string { if (!is_valid_project_name($project)) { @@ -168,9 +252,9 @@ function compose_run(string $composeDir, string $project, array $subcommand): ar * @param array $subcommand * @return array{exitCode:int,stdout:string,output:string} */ -function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds): array +function run_compose_command(string $composeDir, string $project, array $subcommand, int $timeoutSeconds, string $yamlFile = 'compose.yaml'): array { - $yamlPath = $composeDir . '/' . $project . '/compose.yaml'; + $yamlPath = $composeDir . '/' . $project . '/' . $yamlFile; $argv = array_merge(['podman', 'compose', '-f', $yamlPath], $subcommand); $descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; @@ -187,8 +271,12 @@ function run_compose_command(string $composeDir, string $project, array $subcomm $exitCode = proc_close($process); // 'stdout' (raw) for callers that need to parse machine-readable - // output (e.g. compose_status()'s JSON); 'output' (combined, - // trimmed) for human-facing success/error messages, where seeing - // podman's own stderr banner/warnings is actually useful context. - return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($stdout . $stderr)]; + // output (e.g. compose_status()'s JSON); 'output' (combined, trimmed, + // ANSI-stripped) for human-facing success/error messages, where seeing + // podman's own stderr banner/warnings is actually useful context — + // just not the raw \x1b[4m/\x1b[0m escape codes wrapping it (found + // live: they showed up as literal garbage characters in the WebUI's + // error alerts). + $combined = preg_replace('/\x1b\[[0-9;]*m/', '', $stdout . $stderr) ?? ($stdout . $stderr); + return ['exitCode' => $exitCode, 'stdout' => $stdout, 'output' => trim($combined)]; } diff --git a/webui/plugins/podman/javascript/compose.js b/webui/plugins/podman/javascript/compose.js index 68f286e..ef31531 100644 --- a/webui/plugins/podman/javascript/compose.js +++ b/webui/plugins/podman/javascript/compose.js @@ -1,10 +1,10 @@ /** * javascript/compose.js * - * Compose panel: project list + read-only YAML view + up/down/pull, - * 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. + * 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'; @@ -12,6 +12,13 @@ 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 '' + P.escapeHtml(status) + ''; @@ -23,21 +30,32 @@ '
' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '
' + '
' + P.escapeHtml(p.path) + '
' + ''; - }).join('') || '
No compose projects under /boot/config/plugins/podman/compose/
'; + }).join('') || '
No compose projects yet — click "+ New Project".
'; + } + + // 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').textContent = 'Loading…'; + P.el('compose-yaml').value = 'Loading…'; return P.get('compose', 'get', { project: name }).then(function (data) { - P.el('compose-yaml').textContent = data.yaml; + P.el('compose-yaml').value = data.yaml; }).catch(function (err) { - P.el('compose-yaml').textContent = 'Error: ' + err.message; + P.el('compose-yaml').value = 'Error: ' + err.message; }); } function selectProject(name) { selected = name; + setToolbarEnabled(true); renderSidebar(); loadYaml(name); } @@ -45,9 +63,19 @@ 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) loadYaml(selected); + 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 = '
' + P.escapeHtml(err.message) + '
'; }); @@ -67,15 +95,65 @@ }); } + 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 () { + return loadProjects(); + }).catch(function (err) { + alert('Save failed: ' + err.message); + }).finally(function () { + btn.disabled = false; + }); + } + + function deleteProject() { + if (!selected) return; + if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return; + const btn = P.el('compose-action-delete'); + btn.disabled = true; + P.post('compose', 'remove', { project: selected }).then(function () { + selected = null; + return loadProjects(); + }).catch(function (err) { + alert('Delete failed: ' + err.message); + 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(); } diff --git a/webui/plugins/podman/styles/podman.css b/webui/plugins/podman/styles/podman.css index 7e71ae5..c932978 100644 --- a/webui/plugins/podman/styles/podman.css +++ b/webui/plugins/podman/styles/podman.css @@ -337,6 +337,19 @@ .podman-compose-proj.active { background: var(--surface-2); box-shadow: inset 2px 0 0 var(--accent); } .podman-compose-proj .path { font-size: 11px; color: var(--text-faint); margin-top: 2px; font-family: var(--font-mono); } .podman-yaml { background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 12.4px; padding: 16px 18px; height: 420px; overflow: auto; line-height: 1.7; white-space: pre-wrap; } +/* + * !important: this is now a real