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
+354
View File
@@ -0,0 +1,354 @@
# Unraid Podman Plugin Anforderungen an die WebUI
Du bist ein Senior UX Designer und Senior Full-Stack Entwickler mit Erfahrung in Unraid, Podman und Self-Hosting.
Entwirf eine moderne, performante und vollständig in Unraid integrierte WebUI für ein natives Podman-Plugin.
Die Oberfläche soll sich optisch und funktional wie ein offizieller Bestandteil von Unraid anfühlen und keine Fremdanwendung darstellen.
## Ziel
Die WebUI soll alle Funktionen bieten, die Administratoren für die Verwaltung von Podman benötigen, ohne auf die Kommandozeile angewiesen zu sein.
Die Bedienung soll sowohl für Einsteiger als auch für erfahrene Nutzer geeignet sein.
---
# Allgemeine Anforderungen
* Responsives Design
* Dark-Mode kompatibel
* Passend zum Unraid-Design
* Sehr schnelle Ladezeiten
* Modular aufgebaut
* Erweiterbar
* AJAX/WebSocket statt kompletter Seitenreloads
* Suchfunktion auf allen Listen
* Sortierung aller Tabellen
* Filtermöglichkeiten
* Mehrfachauswahl
* Kontextmenüs
* Bestätigungsdialoge
* Benachrichtigungen (Success, Warning, Error)
* Ladeanimationen
* Fortschrittsanzeigen
* Automatische Aktualisierung wichtiger Statusinformationen
* Mehrsprachigkeit vorbereiten
---
# Dashboard
Das Dashboard soll auf einen Blick den Zustand der gesamten Podman-Umgebung zeigen.
Anzeigen:
* Anzahl Container
* Laufende Container
* Gestoppte Container
* Pods
* Images
* Netzwerke
* Volumes
* CPU-Auslastung
* RAM-Auslastung
* Storage-Auslastung
* Schreib-/Lese-I/O
* Netzwerkdurchsatz
* Podman-Version
* Conmon-Version
* crun-Version
* Netavark-Version
* Plugin-Version
Zusätzlich:
* Letzte Ereignisse
* Fehler
* Warnungen
* Updates verfügbar
* Container mit Problemen
* Container ohne Healthcheck
* Nicht verwendete Images
* Nicht verwendete Volumes
* Nicht verwendete Netzwerke
---
# Containerverwaltung
Für jeden Container:
* Start
* Stop
* Restart
* Pause
* Resume
* Kill
* Löschen
* Umbenennen
* Duplizieren
* Exportieren
* Commit als Image
* Snapshot (falls unterstützt)
Informationen:
* Name
* Image
* Status
* Uptime
* CPU
* RAM
* PID
* IP-Adresse
* Netzwerk
* Ports
* Volumes
* Labels
* Environment
* Health Status
* Restart Policy
Tabs:
* Übersicht
* Logs
* Konsole (TTY)
* Ressourcen
* Netzwerke
* Volumes
* Environment
* Mounts
* Labels
* Events
* Inspect (JSON)
* Healthcheck-Verlauf
---
# Pod-Verwaltung
* Pod erstellen
* Pod löschen
* Container hinzufügen
* Container entfernen
* Pod starten
* Pod stoppen
* Neustarten
* Logs
* Portübersicht
* Netzwerkübersicht
---
# Image-Verwaltung
* Images anzeigen
* Pull
* Push
* Taggen
* Löschen
* Prune
* Registry auswählen
* Größe anzeigen
* Erstellungsdatum
* Layer anzeigen
* Historie anzeigen
* Sicherheitsinformationen (wenn verfügbar)
---
# Registry-Unterstützung
Unterstützung für:
* Docker Hub
* GitHub Container Registry
* Quay.io
* GitLab Registry
* Harbor
* Eigene Registries
Funktionen:
* Login
* Logout
* Zugangsdaten speichern
* TLS-Konfiguration
* Unsichere Registry optional aktivieren
---
# Netzwerke
* Netzwerke erstellen
* Löschen
* Bearbeiten
* Verbundene Container anzeigen
* Treiber anzeigen
* Subnetz
* Gateway
* DNS
* MTU
* VLAN (falls unterstützt)
---
# Volumes
* Erstellen
* Löschen
* Mountpunkte anzeigen
* Speicherverbrauch
* Zugeordnete Container
* Backup starten
* Wiederherstellen
* Exportieren
---
# Compose-Unterstützung
Unterstützung für:
* podman compose
Funktionen:
* Compose-Datei importieren
* Stack erstellen
* Start
* Stop
* Neustart
* Logs
* Bearbeiten
* Aktualisieren
* Löschen
* YAML-Editor mit Syntax-Highlighting
* Validierung vor dem Speichern
---
# Template-Unterstützung
Kompatibilität mit Unraid-Templates.
Unterstützung für:
* XML importieren
* XML exportieren
* Template erstellen
* Template bearbeiten
* Icons
* Kategorien
* Repository-Links
---
# Container-Erstellung
Wizard mit mehreren Schritten:
* Name
* Image auswählen
* Registry auswählen
* Ports
* Volumes
* Environment
* Labels
* Geräte
* GPU
* USB
* Netzwerk
* Ressourcen
* Healthcheck
* Restart Policy
* Zusammenfassung
* Validierung
* Container erzeugen
---
# Logs
* Live-Logs
* Suchfunktion
* Filter
* Zeitfilter
* Auto-Scroll
* Download
* Kopieren
* ANSI-Farben korrekt darstellen
---
# Konsole
Browser-Terminal mit:
* Vollbildmodus
* Kopieren/Einfügen
* Mehrere Sitzungen
* Größenanpassung
* UTF-8-Unterstützung
---
# Ressourcenverwaltung
Container-Limits bearbeiten:
* CPU
* RAM
* Swap
* PIDs
* Block-I/O
* Geräte
* HugePages (falls unterstützt)
Visualisierung:
* CPU
* RAM
* Netzwerk
* Festplatten-I/O
---
# Ereignisse
Live-Event-Ansicht:
* Container gestartet
* Container gestoppt
* Image geladen
* Fehler
* Netzwerkänderungen
* Volumeänderungen
Filter nach:
* Container
* Typ
* Zeitraum
* Schweregrad
---
# Einstellungen
Plugin-Einstellungen:
* Storage-Pfad
* API
* Socket
* Registry-Einstellungen
* Standardnetzwerk
* Standard-Runtime
* Logging
* Autostart
* Updates
* Debugmodus
---
+5 -1
View File
@@ -54,6 +54,7 @@ function podman_asset_version(string $relPath): string
<nav class="podman-subnav">
<button class="active" data-panel="dashboard">Dashboard</button>
<button data-panel="containers">Containers</button>
<button data-panel="templates">Templates</button>
<button data-panel="pods">Pods</button>
<button data-panel="images">Images</button>
<button data-panel="volumes">Volumes</button>
@@ -99,6 +100,9 @@ function podman_asset_version(string $relPath): string
</div>
</section>
<!-- ============================= TEMPLATES ============================= -->
<section class="podman-panel" id="podman-panel-templates"></section>
<!-- ============================= PODS ============================= -->
<section class="podman-panel" id="podman-panel-pods"></section>
@@ -261,7 +265,7 @@ function podman_asset_version(string $relPath): string
<?php
foreach ([
'app', 'dashboard', 'containers', 'pods', 'images', 'volumes',
'app', 'dashboard', 'containers', 'templates', 'pods', 'images', 'volumes',
'networks', 'logs', 'terminal', 'compose', 'settings',
] as $podmanJsModule) {
$podmanJsPath = "/javascript/{$podmanJsModule}.js";
+13
View File
@@ -154,6 +154,19 @@ function build_container_spec(string $image, array $body): array
$name = trim((string) ($body['name'] ?? ''));
if ($name !== '') {
// Same character set podman itself enforces (define.NameRegex in
// libpod) — validated here too so a space/invalid character gets
// a clear message instead of podman's raw "running container
// create option: names must match ...: invalid argument" (found
// live: a template-derived container name with a space in it hit
// exactly this).
if (preg_match('/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/', $name) !== 1) {
podman_json_error(
"Container name (\"{$name}\") can only contain letters, digits, \".\", \"_\", \"-\" — no spaces. Try \"" .
preg_replace('/[^a-zA-Z0-9_.-]+/', '-', $name) . '" instead.',
400
);
}
$spec['name'] = $name;
}
+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);
}
+1 -1
View File
@@ -162,7 +162,7 @@ window.Podman = (function () {
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title) + '</h3></div>' +
'<form class="podman-modal-body">' + fieldsHtml + '</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' +
escapeHtml(opts.submitLabel || 'Create') + '</button>' +
'</div></div>';
+90 -17
View File
@@ -251,11 +251,11 @@
function portRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<input type="text" class="mono" data-field="hostPort" placeholder="Host port">' +
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
'<span>&rarr;</span>' +
'<input type="text" class="mono" data-field="containerPort" placeholder="Container port">' +
'<input type="text" class="mono podman-input-narrow" data-field="containerPort" placeholder="Container port">' +
'<select data-field="protocol"><option value="tcp">TCP</option><option value="udp">UDP</option></select>' +
'<button type="button" class="podman-btn podman-btn-icon" data-remove-row title="Remove">&times;</button>' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
@@ -266,7 +266,7 @@
'<input type="text" class="mono" data-field="source" placeholder="my-volume or /mnt/cache/...">' +
'<span>&rarr;</span>' +
'<input type="text" class="mono" data-field="containerPath" placeholder="/data">' +
'<button type="button" class="podman-btn podman-btn-icon" data-remove-row title="Remove">&times;</button>' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
@@ -276,15 +276,20 @@
'<input type="text" class="mono" data-field="key" placeholder="KEY">' +
'<span>=</span>' +
'<input type="text" class="mono" data-field="value" placeholder="value">' +
'<button type="button" class="podman-btn podman-btn-icon" data-remove-row title="Remove">&times;</button>' +
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function addRow(groupEl, rowHtmlFn) {
function addRow(groupEl, rowHtmlFn, values) {
const div = document.createElement('div');
div.innerHTML = rowHtmlFn();
const row = div.firstElementChild;
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
if (values) {
row.querySelectorAll('[data-field]').forEach(function (input) {
if (values[input.dataset.field] !== undefined) input.value = values[input.dataset.field];
});
}
groupEl.appendChild(row);
}
@@ -298,7 +303,14 @@
});
}
function openCreateContainerModal() {
/**
* @param {object|null} prefill Optional template data (same shape
* ajax/templates.php's "get" action returns) to seed the form with —
* used by templates.js's "Use template" action. null/omitted opens a
* blank form, same as the toolbar's "+ New Container" button.
*/
function openCreateContainerModal(prefill) {
prefill = prefill || {};
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
@@ -308,19 +320,20 @@
'<div class="podman-modal-field"><label>Image</label>' +
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' +
'<div class="podman-modal-field"><label>Name (optional)</label>' +
'<input type="text" id="cc-name" placeholder="my-container"></div>' +
'<input type="text" id="cc-name" placeholder="my-container">' +
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
'<div class="podman-modal-field"><label>Network</label>' +
'<select id="cc-network"><option value="bridge">Bridge (default)</option>' +
'<option value="host">Host</option><option value="none">None</option></select></div>' +
'<div class="podman-modal-field"><label>Port mappings</label>' +
'<div class="podman-row-group" id="cc-ports"></div>' +
'<button type="button" class="podman-btn" data-add="port">+ Add port</button></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button></div>' +
'<div class="podman-modal-field"><label>Volumes</label>' +
'<div class="podman-row-group" id="cc-volumes"></div>' +
'<button type="button" class="podman-btn" data-add="volume">+ Add volume</button></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="volume">+ Add volume</button></div>' +
'<div class="podman-modal-field"><label>Environment variables</label>' +
'<div class="podman-row-group" id="cc-env"></div>' +
'<button type="button" class="podman-btn" data-add="env">+ Add variable</button></div>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-add="env">+ Add variable</button></div>' +
'<div class="podman-modal-field"><label>Restart policy</label>' +
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
@@ -328,20 +341,35 @@
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<input type="checkbox" id="cc-start" checked> Start after create</label></div>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<input type="checkbox" id="cc-save-template"> Save as template</label></div>' +
'<div class="podman-modal-field" id="cc-template-fields" style="display:none;">' +
'<label>Template name</label><input type="text" id="cc-template-name" placeholder="my-template">' +
'<label style="margin-top:10px;">Icon URL (optional)</label><input type="text" id="cc-template-icon" placeholder="https://...">' +
'<label style="margin-top:10px;">Category (optional)</label><input type="text" id="cc-template-category" placeholder="Databases:">' +
'<label style="margin-top:10px;">Description (optional)</label><input type="text" id="cc-template-overview" placeholder="What this template runs">' +
'</div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Create</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
const portsGroup = backdrop.querySelector('#cc-ports');
const volumesGroup = backdrop.querySelector('#cc-volumes');
const envGroup = backdrop.querySelector('#cc-env');
addRow(portsGroup, portRowHtml);
addRow(volumesGroup, volumeRowHtml);
addRow(envGroup, envRowHtml);
// A template may carry zero, one, or several rows of each kind — always
// leave at least one (blank) row so the user has somewhere to type,
// matching the blank-form behavior.
(prefill.ports && prefill.ports.length ? prefill.ports : [{}]).forEach(function (row) { addRow(portsGroup, portRowHtml, row); });
(prefill.volumes && prefill.volumes.length ? prefill.volumes : [{}]).forEach(function (row) { addRow(volumesGroup, volumeRowHtml, row); });
(prefill.env && prefill.env.length ? prefill.env : [{}]).forEach(function (row) { addRow(envGroup, envRowHtml, row); });
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup, portRowHtml); });
backdrop.querySelector('[data-add="volume"]').addEventListener('click', function () { addRow(volumesGroup, volumeRowHtml); });
@@ -362,6 +390,10 @@
backdrop.querySelector('#cc-image').focus();
backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) {
backdrop.querySelector('#cc-template-fields').style.display = e.target.checked ? '' : 'none';
});
function close() { backdrop.remove(); }
function showError(message) {
@@ -380,22 +412,58 @@
showError('"Image" is required.');
return;
}
const name = backdrop.querySelector('#cc-name').value.trim();
// Same character set podman itself enforces — checked here too so
// a typo (most commonly a space, e.g. copying a template's display
// name straight in) gets caught before a round trip to the server.
if (name && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
return;
}
const ports = readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
const env = readRows(envGroup).filter(function (r) { return r.key; });
const saveAsTemplate = backdrop.querySelector('#cc-save-template').checked;
const templateName = backdrop.querySelector('#cc-template-name').value.trim();
if (saveAsTemplate && !templateName) {
showError('"Template name" is required when "Save as template" is checked.');
return;
}
const networkMode = backdrop.querySelector('#cc-network').value;
const privileged = backdrop.querySelector('#cc-privileged').checked;
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('containers', 'create', {
image: image,
name: backdrop.querySelector('#cc-name').value.trim(),
networkMode: backdrop.querySelector('#cc-network').value,
networkMode: networkMode,
ports: ports,
volumes: volumes,
env: env,
restartPolicy: backdrop.querySelector('#cc-restart').value,
privileged: backdrop.querySelector('#cc-privileged').checked,
privileged: privileged,
startAfterCreate: backdrop.querySelector('#cc-start').checked,
}).then(function () {
// Best-effort: a template-save failure shouldn't undo or block
// the container that was just successfully created.
if (!saveAsTemplate) return null;
return P.post('templates', 'save', {
name: templateName,
image: image,
networkMode: networkMode,
privileged: privileged,
ports: ports,
volumes: volumes,
env: env,
icon: backdrop.querySelector('#cc-template-icon').value.trim(),
category: backdrop.querySelector('#cc-template-category').value.trim(),
overview: backdrop.querySelector('#cc-template-overview').value.trim(),
}).catch(function (err) {
alert('Container created, but saving the template failed: ' + err.message);
});
}).then(function () {
close();
return load();
@@ -471,5 +539,10 @@
return load();
}
// Exposed for templates.js's "Use template" action, which needs to open
// this same modal pre-filled — templates.js loads after containers.js
// (see Podman.page's script list), so this is already set by then.
P.openCreateContainerModal = openCreateContainerModal;
P.registerPanel('containers', { init: init, refresh: load });
})();
@@ -0,0 +1,219 @@
/**
* javascript/templates.js
*
* Templates panel: reusable container configs saved as XML (Unraid
* Docker-template-compatible schema — see ajax/templates.php's header
* comment for why). "Use template" hands off to containers.js's Create
* Container modal, pre-filled; templates are themselves created from
* that same modal's "Save as template" checkbox, not from here.
*/
(function () {
'use strict';
const P = window.Podman;
let allTemplates = [];
function iconHtml(t) {
if (t.icon) {
return '<img class="podman-template-icon" src="' + P.escapeHtml(t.icon) + '" alt="" loading="lazy" ' +
'onerror="this.replaceWith(Object.assign(document.createElement(\'div\'),{className:\'podman-template-icon podman-template-icon-fallback\',textContent:\'' +
P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '\'}))">';
}
return '<div class="podman-template-icon podman-template-icon-fallback">' + P.escapeHtml(t.name.slice(0, 1).toUpperCase()) + '</div>';
}
function cardHtml(t) {
const overview = t.overview && t.overview.length > 110 ? t.overview.slice(0, 107) + '…' : (t.overview || '');
return '' +
'<div class="podman-template-card" data-name="' + P.escapeHtml(t.name) + '">' +
iconHtml(t) +
'<div class="podman-template-body">' +
'<div class="podman-template-name">' + P.escapeHtml(t.name) + '</div>' +
'<div class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</div>' +
(t.category ? '<span class="podman-badge">' + P.escapeHtml(t.category) + '</span>' : '') +
(overview ? '<div class="podman-template-overview">' + P.escapeHtml(overview) + '</div>' : '') +
'</div>' +
'<div class="podman-template-actions">' +
'<button class="podman-btn podman-btn-primary" data-action="use">Use</button>' +
'<button class="podman-btn podman-btn-ghost" data-action="export">Export</button>' +
'<button class="podman-btn podman-btn-ghost podman-btn-danger" data-action="delete">Delete</button>' +
'</div></div>';
}
function render() {
const grid = P.el('templates-grid');
grid.innerHTML = allTemplates.length
? allTemplates.map(cardHtml).join('')
: '<div class="podman-empty-note">No templates yet — save one from the "New Container" form, or import an XML template.</div>';
}
function load() {
const container = P.el('podman-panel-templates');
if (!P.el('templates-grid')) {
container.innerHTML = '' +
'<div class="podman-card">' +
'<div class="podman-toolbar">' +
'<strong style="flex:1;">Reusable container configs</strong>' +
'<button class="podman-btn podman-btn-ghost" id="templates-import-btn">&#11014; Import Template</button>' +
'</div>' +
'<div class="podman-template-grid" id="templates-grid"></div>' +
'</div>';
P.el('templates-import-btn').addEventListener('click', openImportModal);
P.el('templates-grid').addEventListener('click', handleCardClick);
}
return P.get('templates', 'list').then(function (data) {
allTemplates = data;
render();
}).catch(function (err) {
P.el('templates-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function handleCardClick(e) {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const name = btn.closest('.podman-template-card').dataset.name;
if (btn.dataset.action === 'use') {
btn.disabled = true;
P.get('templates', 'get', { name: name }).then(function (config) {
btn.disabled = false;
P.openCreateContainerModal(config);
}).catch(function (err) {
btn.disabled = false;
alert('Could not load template: ' + err.message);
});
return;
}
if (btn.dataset.action === 'export') {
btn.disabled = true;
P.get('templates', 'export', { name: name }).then(function (data) {
btn.disabled = false;
const blob = new Blob([data.xml], { type: 'application/xml' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = name + '.xml';
a.click();
URL.revokeObjectURL(url);
}).catch(function (err) {
btn.disabled = false;
alert('Export failed: ' + err.message);
});
return;
}
if (btn.dataset.action === 'delete') {
if (!confirm('Delete template "' + name + '"? This does not affect any running containers.')) return;
btn.disabled = true;
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
btn.disabled = false;
alert('Delete failed: ' + err.message);
});
}
}
function openImportModal() {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>Import Template</h3></div>' +
'<form class="podman-modal-body">' +
'<div class="podman-modal-field"><label>Your existing Docker templates</label>' +
'<input type="text" id="ti-local-search" placeholder="Search by name…">' +
'<div class="podman-local-template-list" id="ti-local-list"><div class="podman-empty-note">Loading…</div></div>' +
'</div>' +
'<div class="podman-modal-field"><label>Or paste XML directly</label>' +
'<textarea id="ti-xml" rows="8" class="mono" placeholder="This plugin\'s own export format, or an Unraid/Community Applications Docker template." style="width:100%; resize:vertical;"></textarea></div>' +
'</form>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">Import pasted XML</button>' +
'</div></div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
let localTemplates = [];
function renderLocalList(filter) {
const list = backdrop.querySelector('#ti-local-list');
const visible = filter
? localTemplates.filter(function (t) { return t.name.toLowerCase().indexOf(filter) !== -1; })
: localTemplates;
if (!visible.length) {
list.innerHTML = '<div class="podman-empty-note">' + (localTemplates.length ? 'No match.' : 'None found.') + '</div>';
return;
}
list.innerHTML = visible.map(function (t) {
return '<div class="podman-local-template-item" data-file="' + P.escapeHtml(t.file) + '">' +
'<span class="podman-local-template-name">' + P.escapeHtml(t.name) + '</span>' +
'<span class="podman-row-sub mono">' + P.escapeHtml(t.image) + '</span>' +
'<button type="button" class="podman-btn podman-btn-ghost" data-action="import-local">Import</button>' +
'</div>';
}).join('');
}
P.get('templates', 'list_local').then(function (data) {
localTemplates = data;
renderLocalList('');
}).catch(function () {
backdrop.querySelector('#ti-local-list').innerHTML = '<div class="podman-empty-note">Could not read local templates.</div>';
});
backdrop.querySelector('#ti-local-search').addEventListener('input', function (e) {
renderLocalList(e.target.value.trim().toLowerCase());
});
backdrop.querySelector('#ti-local-list').addEventListener('click', function (e) {
const btn = e.target.closest('[data-action="import-local"]');
if (!btn) return;
const file = btn.closest('.podman-local-template-item').dataset.file;
btn.disabled = true;
P.post('templates', 'import_local', { file: file }).then(function () {
close();
return load();
}).catch(function (err) {
btn.disabled = false;
showError(err.message);
});
});
backdrop.querySelector('#ti-xml').focus();
function close() { backdrop.remove(); }
function showError(message) {
let box = backdrop.querySelector('.podman-modal-error');
if (!box) {
box = document.createElement('div');
box.className = 'podman-modal-error';
backdrop.querySelector('.podman-modal-body').appendChild(box);
}
box.textContent = message;
}
function submit() {
const xml = backdrop.querySelector('#ti-xml').value.trim();
if (!xml) {
showError('Paste a template XML first.');
return;
}
const submitBtn = backdrop.querySelector('[data-role="submit"]');
submitBtn.disabled = true;
P.post('templates', 'import', { xml: xml }).then(function () {
close();
return load();
}).catch(function (err) {
submitBtn.disabled = false;
showError(err.message);
});
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
});
}
P.registerPanel('templates', { init: load, refresh: load });
})();
+91 -3
View File
@@ -83,12 +83,51 @@
font-family: var(--font-ui);
}
.podman-btn:hover { border-color: var(--text-faint); }
.podman-btn-primary { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast); }
.podman-btn-primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
/*
* !important here for the same reason as .podman-btn-ghost below: Unraid's
* own webGUI theme applies a default border/hover treatment to every
* <button> that otherwise silently wins over this rule at rest — verified
* live via a screen recording: without !important, "Create" only ever
* looked filled while under the mouse (Unraid's generic hover glow, applied
* to literally any button), never at rest, making it indistinguishable
* from Cancel except by coincidence of cursor position.
*/
.podman-btn-primary {
background: var(--accent) !important; border-color: var(--accent) !important; color: var(--accent-contrast) !important;
}
.podman-btn-primary:hover {
background: var(--accent-strong) !important; border-color: var(--accent-strong) !important;
}
.podman-btn-danger { color: var(--bad); }
.podman-btn-danger:hover { border-color: var(--bad); }
.podman-btn-icon { padding: 6px 8px; }
.podman-btn[disabled] { opacity: .4; cursor: not-allowed; }
/*
* Secondary action (Cancel, "+ Add row") — every button previously shared
* the same bordered/accent-colored treatment as Create, so nothing in a
* form stood out as THE primary action (found from a live screenshot AND
* a screen recording: Cancel/Create/+Add/× all read as equally weighted,
* with the exact same hover glow even). Unraid's own webGUI applies a
* site-wide default border+hover-gradient to every <button>, at higher
* effective priority than a plain single-class selector here — verified
* live: a bare `.podman-btn-ghost { border-color: transparent }` was
* silently losing to it, on both the rest AND hover state. !important is
* the only reliable way to guarantee this specific, deliberate style
* wins regardless of what Unraid's base theme does elsewhere.
*/
.podman-btn-ghost {
background: transparent !important; border-color: transparent !important;
color: var(--text-dim) !important; box-shadow: none !important;
}
.podman-btn-ghost:hover {
background: var(--surface-2) !important; border-color: transparent !important;
color: var(--text) !important; box-shadow: none !important;
}
/* A ghost button can still carry danger intent (e.g. a template's
"Delete") — needs its own !important since .podman-btn-ghost's color
would otherwise win by rule order. */
.podman-btn-ghost.podman-btn-danger { color: var(--bad) !important; }
.podman-btn-ghost.podman-btn-danger:hover { background: var(--bad-bg) !important; color: var(--bad) !important; }
.podman-subnav {
margin: 14px 0 0; padding: 0; display: flex; gap: 4px; border-bottom: 1px solid var(--border);
@@ -183,6 +222,33 @@
.podman-pod-head .name { font-weight: 700; font-size: 13.5px; }
.podman-pod-head .infra { font-size: 11.5px; color: var(--text-faint); }
.podman-badge { display: inline-block; font-size: 10.5px; font-weight: 700; color: var(--text-dim); background: var(--surface-3); padding: 2px 8px; border-radius: 100px; margin-top: 6px; }
.podman-template-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; padding: 18px; }
.podman-template-grid .podman-empty-note { grid-column: 1 / -1; }
.podman-template-card {
border: 1px solid var(--border); border-radius: 10px; padding: 14px; background: var(--surface);
display: flex; flex-direction: column; gap: 10px;
}
.podman-template-icon { width: 40px; height: 40px; border-radius: 8px; object-fit: cover; background: var(--surface-2); }
.podman-template-icon-fallback {
display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 16px;
color: var(--accent); border: 1px solid var(--border);
}
.podman-template-name { font-weight: 700; font-size: 13.5px; }
.podman-template-overview { font-size: 12px; color: var(--text-dim); line-height: 1.4; }
.podman-template-actions { display: flex; gap: 8px; margin-top: auto; padding-top: 4px; }
.podman-template-actions .podman-btn { flex: 1; justify-content: center; padding: 6px 10px; font-size: 12px; }
.podman-local-template-list { max-height: 220px; overflow-y: auto; border: 1px solid var(--border); border-radius: 7px; margin-top: 8px; }
.podman-local-template-item {
display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-bottom: 1px solid var(--border);
}
.podman-local-template-item:last-child { border-bottom: none; }
.podman-local-template-name { font-weight: 600; font-size: 12.5px; white-space: nowrap; }
.podman-local-template-item .podman-row-sub { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.podman-local-template-item .podman-btn { flex: none; padding: 5px 10px; font-size: 11.5px; }
.podman-logs-layout { display: grid; grid-template-columns: 200px 1fr; min-height: 460px; }
@media (max-width: 760px) { .podman-logs-layout { grid-template-columns: 1fr; } }
.podman-logs-side { border-right: 1px solid var(--border); }
@@ -274,9 +340,31 @@
}
.podman-row-group-item select {
background: var(--surface-2); border: 1px solid var(--border); border-radius: 7px; padding: 7px 9px;
font-size: 12.5px; color: var(--text); font-family: var(--font-ui); flex: none;
font-size: 12.5px; color: var(--text); font-family: var(--font-ui);
/* flex:none alone wasn't enough — its auto flex-basis still let the
select stretch to fill the row (verified live: "TCP"/"Volume"
dropdowns spanned almost the entire row width). An explicit width
pins it to content-appropriate size regardless. */
flex: none; width: 110px;
}
.podman-row-group-item span { color: var(--text-faint); font-size: 12px; flex: none; }
/*
* Port number fields share the row with a select + remove button, unlike
* the wide source/path/key/value fields elsewhere in these row groups —
* left on flex:1 like everything else, both port inputs fought the fixed-
* width select/button for space and got squeezed down to a few pixels
* (found live: they rendered as near-invisible slivers). Fixed width,
* not flex-grown.
*/
.podman-row-group-item input.podman-input-narrow { flex: none; width: 90px; }
/* Row-remove (x) reads as a normal button like everything else at full
.podman-btn weight — muted/borderless by default, only turning
"danger" red on hover, so it registers as a quiet per-row affordance
rather than competing with the form's actual actions. */
.podman-row-group-item .podman-row-remove-btn {
background: transparent; border-color: transparent; color: var(--text-faint); flex: none;
}
.podman-row-group-item .podman-row-remove-btn:hover { background: var(--bad-bg); border-color: transparent; color: var(--bad); }
/* Anchored dropdown context menu — see app.js openContextMenu(). */
.podman-context-menu {