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
+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 });
})();