Add Create Container UI and Templates (Unraid XML) feature

Lets users create containers from the WebUI (image/name/ports/volumes/
env/network mode/restart policy/privileged) instead of only managing
existing ones, and adds a Templates panel to save/reuse those configs
as Unraid-Docker-compatible template XML, including browsing and
importing the host's own existing Docker Manager templates directly.

Also fixes bugs found via live testing along the way: container names
with spaces/invalid characters now get a clear client- and server-side
error with a suggested fix instead of podman's raw API error, the New
Container modal's backdrop no longer renders transparent (was being
appended outside the .podman-plugin CSS scope), and modal buttons now
have real visual hierarchy (ghost/primary/danger) after Unraid's own
site-wide button theme was found to override plain single-class rules.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-12 13:02:50 +00:00
co-authored by Claude Sonnet 5
parent 151d93c12d
commit 45e27f8575
8 changed files with 1158 additions and 22 deletions
+385
View File
@@ -0,0 +1,385 @@
<?php
/**
* ajax/templates.php
*
* Backs the Templates panel — reusable container configs, saved and
* loaded as XML in the same schema Unraid's own Docker Manager uses for
* its Community Applications templates (<Container version="2"> with
* <Config Type="Port"|"Path"|"Variable"> entries). Deliberately the SAME
* schema, not a podman-specific one of our own: it's the one users and
* template authors already know, and it means a template exported here
* carries over the fields (image, ports, paths, variables, icon,
* category, overview) a Docker template would too, even though the two
* ecosystems' XML isn't fully interchangeable (this plugin's Config
* entries don't have every attribute dockerMan's does, e.g. no GPU/USB/
* device passthrough yet — see docs/ARCHITECTURE.md section 18).
*
* Stored at $bootDir/templates/<name>.xml — same boot-persistence
* reasoning as compose/autostart/networks (see ajax/compose.php).
*
* Actions (?action=...):
* list GET -> [{name, image, icon, category, overview}, ...]
* get GET (&name=...) -> full parsed config, for prefilling the
* Create Container form ("Use template")
* export GET (&name=...) -> {xml: "<raw XML text>"} for download
* save POST {"name": "...", "image": "...", "icon": "...", "category": "...",
* "overview": "...", "networkMode": "...", "privileged": false,
* "restartPolicy": "...", "ports": [...], "volumes": [...], "env": [...]}
* import POST {"xml": "<raw XML text>"} -> parses + saves as a new template
* list_local GET -> [{file, name, image, icon}, ...] from Unraid's own
* dockerMan template directories (real existing Docker
* templates the user already has — see
* local_dockerman_templates_list()), for a "browse local
* templates" picker instead of copy-pasting XML by hand
* import_local POST {"file": "gitea.xml"} -> imports one by filename
* (validated against the same directories list_local
* scanned, never an arbitrary path from the client)
* remove POST {"name": "..."}
*/
declare(strict_types=1);
require __DIR__ . '/../include/bootstrap.php';
$templatesDir = $podmanConfig->bootDir . '/templates';
$action = $_GET['action'] ?? '';
switch ($action) {
case 'list':
podman_json_response(templates_list($templatesDir));
break;
case 'get':
podman_json_response(template_read(require_template_path($templatesDir, (string) ($_GET['name'] ?? ''))));
break;
case 'export':
$path = require_template_path($templatesDir, (string) ($_GET['name'] ?? ''));
podman_json_response(['xml' => file_get_contents($path)]);
break;
case 'save':
$body = podman_read_json_body();
$name = trim((string) ($body['name'] ?? ''));
if (!is_valid_template_name($name)) {
podman_json_error('Template name must be non-empty and contain only letters, digits, "-", "_".', 400);
}
template_write($templatesDir, $name, $body);
podman_json_response(['status' => 'saved', 'name' => $name]);
break;
case 'import':
$body = podman_read_json_body();
$xml = (string) ($body['xml'] ?? '');
if (trim($xml) === '') {
podman_json_error('Missing xml in request body', 400);
}
$name = template_import($templatesDir, $xml);
podman_json_response(['status' => 'imported', 'name' => $name]);
break;
case 'list_local':
podman_json_response(local_dockerman_templates_list());
break;
case 'import_local':
$body = podman_read_json_body();
$file = (string) ($body['file'] ?? '');
$name = template_import($templatesDir, local_dockerman_template_read($file));
podman_json_response(['status' => 'imported', 'name' => $name]);
break;
case 'remove':
$body = podman_read_json_body();
$path = require_template_path($templatesDir, (string) ($body['name'] ?? ''));
unlink($path);
podman_json_response(['status' => 'removed']);
break;
default:
podman_json_error("Unknown action '{$action}'", 400);
}
/**
* Template names become filenames — restricted to a fixed safe character
* set BEFORE ever being used to build a filesystem path, the same
* pattern ajax/compose.php's is_valid_project_name() uses for the same
* reason.
*/
function is_valid_template_name(string $name): bool
{
return $name !== '' && preg_match('/^[a-zA-Z0-9_-]+$/', $name) === 1;
}
function require_template_path(string $templatesDir, string $name): string
{
if (!is_valid_template_name($name)) {
podman_json_error('Invalid template name', 400);
}
$path = $templatesDir . '/' . $name . '.xml';
if (!is_file($path)) {
podman_json_error("Template '{$name}' not found", 404);
}
return $path;
}
/** @return array<int,array<string,mixed>> */
function templates_list(string $templatesDir): array
{
if (!is_dir($templatesDir)) {
return [];
}
$out = [];
foreach (scandir($templatesDir) ?: [] as $entry) {
if (!str_ends_with($entry, '.xml')) {
continue;
}
$name = substr($entry, 0, -4);
if (!is_valid_template_name($name)) {
continue;
}
try {
$parsed = template_read($templatesDir . '/' . $entry);
} catch (\Throwable $e) {
continue; // a hand-edited/corrupt file shouldn't break the whole list
}
$out[] = [
'name' => $name,
'image' => $parsed['image'],
'icon' => $parsed['icon'],
'category' => $parsed['category'],
'overview' => $parsed['overview'],
];
}
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
/**
* Parses one template XML file into the same shape
* ajax/containers.php's build_container_spec() (Create Container form)
* consumes, so "Use template" can feed straight into that form/action.
*
* @return array<string,mixed>
*/
function template_read(string $path): array
{
$xml = @simplexml_load_file($path);
if ($xml === false) {
podman_json_error("Could not parse template XML: {$path}", 500);
}
$ports = [];
$volumes = [];
$env = [];
foreach ($xml->Config as $cfg) {
$attrs = $cfg->attributes();
$type = (string) ($attrs['Type'] ?? '');
$value = trim((string) $cfg);
$target = (string) ($attrs['Target'] ?? '');
if ($value === '') {
continue;
}
switch ($type) {
case 'Port':
$ports[] = [
'hostPort' => (int) $value,
'containerPort' => (int) ($target !== '' ? $target : $value),
'protocol' => (string) ($attrs['Mode'] ?? 'tcp') ?: 'tcp',
];
break;
case 'Path':
$volumes[] = [
'kind' => 'path',
'source' => $value,
'containerPath' => $target !== '' ? $target : $value,
];
break;
case 'Variable':
$env[] = ['key' => $target !== '' ? $target : (string) ($attrs['Name'] ?? ''), 'value' => $value];
break;
}
}
return [
'image' => (string) $xml->Repository,
'networkMode' => (string) ($xml->Network ?: 'bridge'),
'privileged' => strtolower((string) $xml->Privileged) === 'true',
'icon' => (string) $xml->Icon,
'category' => (string) $xml->Category,
'overview' => (string) $xml->Overview,
'ports' => $ports,
'volumes' => $volumes,
'env' => $env,
];
}
/**
* Writes a template XML file from the Create Container form's field
* shapes (same as build_container_spec() in ajax/containers.php takes)
* plus template-only metadata (icon/category/overview). Uses DOMDocument
* rather than string concatenation so every value is properly escaped —
* no risk of a "<" or "&" in an image name/description breaking the XML.
*
* @param array<string,mixed> $body
*/
function template_write(string $templatesDir, string $name, array $body): void
{
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
podman_json_error("Could not create {$templatesDir}", 500);
}
$doc = new \DOMDocument('1.0');
$doc->formatOutput = true;
$root = $doc->createElement('Container');
$root->setAttribute('version', '2');
$doc->appendChild($root);
$append = static function (string $tag, string $value) use ($doc, $root): void {
$root->appendChild($doc->createElement($tag, $value));
};
$append('Name', $name);
$append('Repository', trim((string) ($body['image'] ?? '')));
$append('Network', (string) ($body['networkMode'] ?? 'bridge'));
$append('Privileged', ($body['privileged'] ?? false) ? 'true' : 'false');
$append('Overview', (string) ($body['overview'] ?? ''));
$append('Category', (string) ($body['category'] ?? ''));
$append('Icon', (string) ($body['icon'] ?? ''));
foreach (($body['ports'] ?? []) as $row) {
$hostPort = (string) ($row['hostPort'] ?? '');
$containerPort = (string) ($row['containerPort'] ?? '');
if ($hostPort === '' || $containerPort === '') {
continue;
}
$cfg = $doc->createElement('Config', $hostPort);
$cfg->setAttribute('Name', 'Port ' . $containerPort);
$cfg->setAttribute('Target', $containerPort);
$cfg->setAttribute('Mode', (string) ($row['protocol'] ?? 'tcp'));
$cfg->setAttribute('Type', 'Port');
$root->appendChild($cfg);
}
foreach (($body['volumes'] ?? []) as $row) {
$source = (string) ($row['source'] ?? '');
$containerPath = (string) ($row['containerPath'] ?? '');
if ($source === '' || $containerPath === '' || ($row['kind'] ?? 'named') !== 'path') {
continue; // named (podman-managed) volumes aren't portable across hosts, so templates only capture host-path binds
}
$cfg = $doc->createElement('Config', $source);
$cfg->setAttribute('Name', basename($containerPath));
$cfg->setAttribute('Target', $containerPath);
$cfg->setAttribute('Mode', 'rw');
$cfg->setAttribute('Type', 'Path');
$root->appendChild($cfg);
}
foreach (($body['env'] ?? []) as $row) {
$key = (string) ($row['key'] ?? '');
if ($key === '') {
continue;
}
$cfg = $doc->createElement('Config', (string) ($row['value'] ?? ''));
$cfg->setAttribute('Name', $key);
$cfg->setAttribute('Target', $key);
$cfg->setAttribute('Type', 'Variable');
$root->appendChild($cfg);
}
if ($doc->save($templatesDir . '/' . $name . '.xml') === false) {
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
}
}
/**
* Imports a pasted/uploaded template XML (this plugin's own schema, or
* a plain Unraid dockerMan template — both use the same <Container>/
* <Config> shape). Validates it parses and has a usable <Name> before
* writing it under our own templates dir with a sanitized filename.
*/
function template_import(string $templatesDir, string $xmlText): string
{
$xml = @simplexml_load_string($xmlText);
if ($xml === false || $xml->getName() !== 'Container') {
podman_json_error('Not a valid template XML (expected a <Container> root element)', 400);
}
$rawName = trim((string) $xml->Name);
$name = preg_replace('/[^a-zA-Z0-9_-]/', '-', $rawName);
if (!is_valid_template_name((string) $name)) {
podman_json_error('Template XML has no usable <Name>', 400);
}
if (!is_dir($templatesDir) && !mkdir($templatesDir, 0755, true) && !is_dir($templatesDir)) {
podman_json_error("Could not create {$templatesDir}", 500);
}
if (file_put_contents($templatesDir . '/' . $name . '.xml', $xmlText, LOCK_EX) === false) {
podman_json_error("Could not write {$templatesDir}/{$name}.xml", 500);
}
return (string) $name;
}
/**
* Unraid's own Docker Manager plugin stores every template a user has
* ever saved/customized under templates-user/, plus a local cache of
* Community Applications' own catalog under templates-community/ (may
* be empty/absent if CA was never installed) — both in the same
* <Container>/<Config> XML shape this plugin reads. Read-only: this
* plugin never writes into dockerMan's own directories.
*
* @return array<int,string>
*/
function dockerman_template_dirs(): array
{
return array_values(array_filter([
'/boot/config/plugins/dockerMan/templates-user',
'/boot/config/plugins/dockerMan/templates-community',
], 'is_dir'));
}
/** @return array<int,array<string,string>> */
function local_dockerman_templates_list(): array
{
$out = [];
foreach (dockerman_template_dirs() as $dir) {
foreach (scandir($dir) ?: [] as $entry) {
if (!str_ends_with($entry, '.xml')) {
continue;
}
$xml = @simplexml_load_file($dir . '/' . $entry);
if ($xml === false) {
continue; // skip anything that doesn't parse rather than failing the whole list
}
$out[] = [
'file' => $entry,
'name' => (string) $xml->Name,
'image' => (string) $xml->Repository,
'icon' => (string) $xml->Icon,
];
}
}
usort($out, static fn($a, $b) => strcmp($a['name'], $b['name']));
return $out;
}
/**
* Reads one local dockerMan template's raw XML by filename. $file comes
* straight from client input — reduced to its basename and required to
* actually exist in one of dockerman_template_dirs() (the same list
* list_local scanned), never treated as an arbitrary path.
*/
function local_dockerman_template_read(string $file): string
{
$file = basename($file);
if (!str_ends_with($file, '.xml')) {
podman_json_error('Invalid template file', 400);
}
foreach (dockerman_template_dirs() as $dir) {
$path = $dir . '/' . $file;
if (is_file($path)) {
$content = file_get_contents($path);
if ($content !== false) {
return $content;
}
}
}
podman_json_error("Local template '{$file}' not found", 404);
}