Files
unraid-podman/webui/plugins/podman/javascript/containers.js
T
maggesandClaude Sonnet 5 45e27f8575 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>
2026-07-12 13:02:50 +00:00

549 lines
25 KiB
JavaScript

/**
* javascript/containers.js
*
* Containers panel: table of all containers with lifecycle actions
* (start/stop/restart/remove), backed entirely by ajax/containers.php.
*/
(function () {
'use strict';
const P = window.Podman;
let allContainers = [];
let filter = 'all';
let searchTerm = '';
function iconLabel(name) {
return P.escapeHtml(name.slice(0, 2).toUpperCase());
}
function rowHtml(c) {
const cpuMem = c.state === 'running'
? '<span class="podman-row-sub">running</span>'
: '<span class="podman-row-sub">&mdash;</span>';
return '' +
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
'<td><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
'<td><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
'<span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</button></td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
'<td>' + cpuMem + '</td>' +
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '&mdash;') + '</td>' +
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</td>' +
'<td class="podman-actions">' + actionButtons(c) + '</td>' +
'</tr>';
}
function actionButtons(c) {
if (c.state === 'running') {
return '' +
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">&#8635;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">&#9632;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
if (c.state === 'paused') {
return '' +
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
return '' +
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">&#9654;</button>' +
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">&#8942;</button>';
}
function openRowMenu(c, anchorBtn) {
const items = [];
if (c.state === 'running') {
items.push({ label: 'Pause', onClick: function () { handleAction(c.id, 'pause'); } });
items.push({ label: 'Kill', danger: true, onClick: function () { handleAction(c.id, 'kill'); } });
}
items.push({ label: 'Rename', onClick: function () { openRenameModal(c); } });
items.push('separator');
items.push({
label: 'Remove',
danger: true,
disabled: c.state === 'running',
onClick: function () { handleAction(c.id, 'remove'); },
});
P.openContextMenu(anchorBtn, items);
}
function openRenameModal(c) {
P.openFormModal({
title: 'Rename Container',
submitLabel: 'Rename',
fields: [{ name: 'name', label: 'New name', required: true, placeholder: c.name }],
onSubmit: function (values) {
return P.post('containers', 'rename', { id: c.id, name: values.name }).then(load);
},
});
}
// --- Detail view -----------------------------------------------------------
//
// Fed entirely by the existing inspect action (raw libpod inspect JSON) —
// no new backend endpoint needed, just slicing that one payload into
// tabs. Field names below (Config.Env, Config.Labels, Mounts,
// NetworkSettings.Networks, HostConfig.RestartPolicy, ...) were checked
// live against a real inspect response, not assumed from docs.
function kvTable(rows) {
if (rows.length === 0) return '<div class="podman-detail-empty">None.</div>';
return '<table class="podman-detail-table">' + rows.map(function (r) {
return '<tr><td>' + P.escapeHtml(r[0]) + '</td><td class="mono">' + P.escapeHtml(r[1]) + '</td></tr>';
}).join('') + '</table>';
}
function renderOverviewTab(d) {
const cfg = d.Config || {};
const hostCfg = d.HostConfig || {};
return kvTable([
['Name', (d.Name || '').replace(/^\//, '')],
['ID', d.Id || ''],
['Image', cfg.Image || d.Image || ''],
['Created', d.Created || ''],
['Command', (cfg.Cmd || []).join(' ') || (cfg.Entrypoint || []).join(' ') || '—'],
['State', (d.State && d.State.Status) || '—'],
['Restart policy', (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || '—'],
['Restart count', String(d.RestartCount || 0)],
['Privileged', hostCfg.Privileged ? 'yes' : 'no'],
['Working dir', cfg.WorkingDir || '—'],
]);
}
function renderEnvTab(d) {
const env = (d.Config && d.Config.Env) || [];
return kvTable(env.map(function (line) {
const idx = line.indexOf('=');
return idx === -1 ? [line, ''] : [line.slice(0, idx), line.slice(idx + 1)];
}));
}
function renderLabelsTab(d) {
const labels = (d.Config && d.Config.Labels) || {};
return kvTable(Object.keys(labels).map(function (k) { return [k, labels[k]]; }));
}
function renderMountsTab(d) {
const mounts = d.Mounts || [];
if (mounts.length === 0) return '<div class="podman-detail-empty">No mounts.</div>';
return '<table class="podman-detail-table">' +
'<tr><td>Type</td><td>Source &rarr; Destination</td></tr>' +
mounts.map(function (m) {
const mode = m.RW ? 'rw' : 'ro';
return '<tr><td>' + P.escapeHtml(m.Type || '') + '</td><td class="mono">' +
P.escapeHtml(m.Source || '') + ' &rarr; ' + P.escapeHtml(m.Destination || '') +
' <span class="podman-row-sub">(' + mode + ')</span></td></tr>';
}).join('') + '</table>';
}
function renderNetworksTab(d) {
const networks = (d.NetworkSettings && d.NetworkSettings.Networks) || {};
const names = Object.keys(networks);
if (names.length === 0) return '<div class="podman-detail-empty">No networks (host or none mode).</div>';
return names.map(function (name) {
const n = networks[name];
return '<div style="margin-bottom:14px;"><div style="font-weight:700; font-size:12.5px; margin-bottom:6px;">' +
P.escapeHtml(name) + '</div>' + kvTable([
['IP address', n.IPAddress || '—'],
['Gateway', n.Gateway || '—'],
['MAC address', n.MacAddress || '—'],
['Aliases', (n.Aliases || []).join(', ') || '—'],
]) + '</div>';
}).join('');
}
function renderInspectTab(d) {
return '<div class="podman-detail-json">' + P.escapeHtml(JSON.stringify(d, null, 2)) + '</div>';
}
const DETAIL_TABS = [
{ id: 'overview', label: 'Overview', render: renderOverviewTab },
{ id: 'env', label: 'Environment', render: renderEnvTab },
{ id: 'labels', label: 'Labels', render: renderLabelsTab },
{ id: 'mounts', label: 'Mounts', render: renderMountsTab },
{ id: 'networks', label: 'Networks', render: renderNetworksTab },
{ id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab },
];
function openDetailModal(c) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal podman-modal-xwide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + P.escapeHtml(c.name) + '</h3></div>' +
'<div class="podman-detail-tabs">' + DETAIL_TABS.map(function (t, i) {
return '<button type="button" data-tab="' + t.id + '"' + (i === 0 ? ' class="active"' : '') + '>' + t.label + '</button>';
}).join('') + '</div>' +
'<div class="podman-detail-body"><div class="podman-loading">Loading…</div></div>' +
'<div class="podman-modal-actions"><button type="button" class="podman-btn" data-role="cancel">Close</button></div>' +
'</div>';
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', function () { backdrop.remove(); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) backdrop.remove(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { backdrop.remove(); document.removeEventListener('keydown', onKey); }
});
const body = backdrop.querySelector('.podman-detail-body');
P.get('containers', 'inspect', { id: c.id }).then(function (data) {
function showTab(tabId) {
const tab = DETAIL_TABS.find(function (t) { return t.id === tabId; });
body.innerHTML = tab.render(data);
}
backdrop.querySelectorAll('[data-tab]').forEach(function (btn) {
btn.addEventListener('click', function () {
backdrop.querySelectorAll('[data-tab]').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
showTab(btn.dataset.tab);
});
});
showTab('overview');
}).catch(function (err) {
body.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
}
function applyFilters() {
return allContainers.filter(function (c) {
if (filter === 'running' && c.state !== 'running') return false;
if (filter === 'stopped' && c.state === 'running') return false;
if (searchTerm && c.name.toLowerCase().indexOf(searchTerm) === -1 && c.image.toLowerCase().indexOf(searchTerm) === -1) return false;
return true;
});
}
function renderTable() {
const tbody = P.el('containers-tbody');
const visible = applyFilters();
tbody.innerHTML = visible.length
? visible.map(rowHtml).join('')
: '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
}
function renderCounts() {
const running = allContainers.filter(function (c) { return c.state === 'running'; }).length;
P.el('containers-count-all').textContent = 'All ' + allContainers.length;
P.el('containers-count-running').textContent = 'Running ' + running;
P.el('containers-count-stopped').textContent = 'Stopped ' + (allContainers.length - running);
}
function load() {
const tbody = P.el('containers-tbody');
tbody.innerHTML = P.loadingRow(7);
return P.get('containers', 'list').then(function (data) {
allContainers = data;
renderCounts();
renderTable();
}).catch(function (err) {
tbody.innerHTML = P.errorRow(7, err.message);
});
}
// --- Create Container -----------------------------------------------------
//
// Purpose-built modal (not app.js's generic openFormModal, which only
// supports flat text fields) — port/volume/env rows are dynamic
// add/remove groups, and network needs a <select> populated from the
// real network list, none of which fits the generic helper. Reuses its
// .podman-modal-* CSS classes for visual consistency.
function portRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
'<span>&rarr;</span>' +
'<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 podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function volumeRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<select data-field="kind"><option value="named">Volume</option><option value="path">Host path</option></select>' +
'<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 podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
function envRowHtml() {
return '' +
'<div class="podman-row-group-item">' +
'<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 podman-row-remove-btn" data-remove-row title="Remove">&times;</button>' +
'</div>';
}
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);
}
function readRows(groupEl) {
return Array.from(groupEl.children).map(function (row) {
const values = {};
row.querySelectorAll('[data-field]').forEach(function (input) {
values[input.dataset.field] = input.value.trim();
});
return values;
});
}
/**
* @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 = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>New Container</h3></div>' +
'<form class="podman-modal-body">' +
'<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 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 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 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 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>' +
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
'<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 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');
// 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); });
backdrop.querySelector('[data-add="env"]').addEventListener('click', function () { addRow(envGroup, envRowHtml); });
// Populate the network dropdown with any existing custom (non-default)
// podman networks, in addition to the built-in bridge/host/none modes
// — best-effort: if the list call fails, the three built-ins still work.
P.get('networks', 'list').then(function (networks) {
const select = backdrop.querySelector('#cc-network');
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
const opt = document.createElement('option');
opt.value = n.name;
opt.textContent = n.name;
select.appendChild(opt);
});
}).catch(function () { /* built-in modes still usable */ });
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) {
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 image = backdrop.querySelector('#cc-image').value.trim();
if (!image) {
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: networkMode,
ports: ports,
volumes: volumes,
env: env,
restartPolicy: backdrop.querySelector('#cc-restart').value,
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();
}).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); }
});
}
function handleAction(id, action, btn) {
const doIt = function (extra) {
if (btn) btn.disabled = true;
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
alert('Action failed: ' + err.message);
if (btn) btn.disabled = false;
});
};
if (action === 'remove') {
if (!confirm('Remove this container? This does not remove its volumes.')) return;
doIt({ force: true });
} else if (action === 'kill') {
if (!confirm('Send SIGKILL to this container? Unlike Stop, this does not let it shut down gracefully.')) return;
doIt({});
} else {
doIt({});
}
}
function init() {
P.el('containers-create-btn').addEventListener('click', openCreateContainerModal);
P.el('containers-search').addEventListener('input', function (e) {
searchTerm = e.target.value.trim().toLowerCase();
renderTable();
});
document.querySelectorAll('#containers-filterset button').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('#containers-filterset button').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
filter = btn.dataset.filter;
renderTable();
});
});
P.el('containers-tbody').addEventListener('click', function (e) {
const btn = e.target.closest('button[data-action]');
if (!btn || btn.disabled) return;
const row = btn.closest('tr');
const id = row.dataset.id;
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details') {
const c = allContainers.find(function (x) { return x.id === id; });
if (!c) return;
if (btn.dataset.action === 'menu') {
openRowMenu(c, btn);
} else {
openDetailModal(c);
}
return;
}
handleAction(id, btn.dataset.action, btn);
});
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 });
})();