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 <pre>. 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 <noreply@anthropic.com>
This commit is contained in:
@@ -198,15 +198,22 @@ function podman_asset_version(string $relPath): string
|
||||
<section class="podman-panel" id="podman-panel-compose">
|
||||
<div class="podman-card">
|
||||
<div class="podman-compose-layout">
|
||||
<div class="podman-compose-side" id="compose-sidebar"></div>
|
||||
<div class="podman-compose-side">
|
||||
<div class="podman-toolbar" style="border-bottom:1px solid var(--border); padding:10px;">
|
||||
<button class="podman-btn podman-btn-primary" id="compose-new-btn" style="width:100%; justify-content:center;">+ New Project</button>
|
||||
</div>
|
||||
<div id="compose-sidebar"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="podman-toolbar">
|
||||
<strong id="compose-title" style="flex:1;">—</strong>
|
||||
<button class="podman-btn podman-btn-ghost" id="compose-action-delete">Delete</button>
|
||||
<button class="podman-btn" id="compose-action-pull">⬇ Pull</button>
|
||||
<button class="podman-btn" id="compose-action-down">■ Down</button>
|
||||
<button class="podman-btn podman-btn-primary" id="compose-action-up">▶ Up</button>
|
||||
<button class="podman-btn podman-btn-primary" id="compose-action-save">Save</button>
|
||||
</div>
|
||||
<pre class="podman-yaml" id="compose-yaml"></pre>
|
||||
<textarea class="podman-yaml podman-yaml-editor mono" id="compose-yaml" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<string,mixed>
|
||||
*/
|
||||
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<string,mixed>
|
||||
*/
|
||||
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<int,string> $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)];
|
||||
}
|
||||
|
||||
@@ -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 '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
|
||||
@@ -23,21 +30,32 @@
|
||||
'<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 under /boot/config/plugins/podman/compose/</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').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 = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <textarea>, not a read-only <pre> —
|
||||
* Unraid's own webGui/styles/default-base.css targets textarea the same
|
||||
* way it targets input[type="text"] (see .podman-search's comment for
|
||||
* the exact rule), forcing border-width:0/border-bottom-width:1px/
|
||||
* background:transparent/border-radius:0, which would otherwise make the
|
||||
* whole editor look like a barely-visible underline instead of an actual
|
||||
* text area.
|
||||
*/
|
||||
.podman-yaml-editor {
|
||||
display: block; width: 100%; box-sizing: border-box; resize: vertical;
|
||||
border: none !important; border-radius: 0 !important; outline: none;
|
||||
}
|
||||
|
||||
.podman-field-row { display: grid; grid-template-columns: 220px 1fr; gap: 16px; padding: 14px 18px; border-bottom: 1px solid var(--border); align-items: start; }
|
||||
.podman-field-row:last-child { border-bottom: none; }
|
||||
|
||||
Reference in New Issue
Block a user