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:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>';
|
||||
|
||||
@@ -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>→</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">×</button>' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@
|
||||
'<input type="text" class="mono" data-field="source" placeholder="my-volume or /mnt/cache/...">' +
|
||||
'<span>→</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">×</button>' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</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">×</button>' +
|
||||
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</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">⬆ 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 });
|
||||
})();
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user