- Package #9-11: catatonit (pod infra init), nftables (netavark firewall backend), docker-compose (external compose provider for `podman compose`) — all vendored prebuilt binaries, versions.env pinned, propagated through build-packages.sh/release.sh/podman.plg/verify+update-packages.sh. - Fix WebUI: every POST action was silently failing (empty response body) because Unraid's own CSRF protection was never satisfied — app.js now sends the page's csrf_token as X-CSRF-Token. - Fix WebUI: PodmanClient::pullImage() assumed a single JSON response, but /images/pull actually streams newline-delimited JSON — every successful pull was throwing "Expected a JSON object/array response". - Fix WebUI: compose.php's up/down status detection had the same single-JSON-vs-NDJSON bug for `podman compose ps`, plus stderr was corrupting the parse. - Add cache-busting (?v=<mtime>) to Podman.page's script/style tags so a redeployed JS/CSS fix isn't served stale from browser cache. - Add a reusable modal dialog (app.js openFormModal) replacing prompt()/alert() for New Volume/Network/Pull Image. - Add host-path (bind-mount) support when creating a named volume. - Add Create Container (image, name, network mode incl. custom networks, ports, volumes, env, restart policy, privileged, start-after-create), auto-pulling the image on first use since /containers/create doesn't. All fixes verified live against a real podman system service and, where reachable, via the actual WebUI over the real socket — not just unit-level. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
302 lines
13 KiB
JavaScript
302 lines
13 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">—</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><div class="podman-row-name"><span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</div></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(', ') || '—') + '</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">↻</button>' +
|
|
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">■</button>' +
|
|
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove" disabled>🗑</button>';
|
|
}
|
|
return '' +
|
|
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
|
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove">🗑</button>';
|
|
}
|
|
|
|
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" data-field="hostPort" placeholder="Host port">' +
|
|
'<span>→</span>' +
|
|
'<input type="text" class="mono" 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>' +
|
|
'</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>→</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>' +
|
|
'</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" data-remove-row title="Remove">×</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
function addRow(groupEl, rowHtmlFn) {
|
|
const div = document.createElement('div');
|
|
div.innerHTML = rowHtmlFn();
|
|
const row = div.firstElementChild;
|
|
row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); });
|
|
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;
|
|
});
|
|
}
|
|
|
|
function openCreateContainerModal() {
|
|
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>' +
|
|
'<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>' +
|
|
'<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>' +
|
|
'<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>' +
|
|
'<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>' +
|
|
'</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-primary" data-role="submit">Create</button>' +
|
|
'</div></div>';
|
|
|
|
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
|
|
|
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);
|
|
|
|
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();
|
|
|
|
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 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 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,
|
|
ports: ports,
|
|
volumes: volumes,
|
|
env: env,
|
|
restartPolicy: backdrop.querySelector('#cc-restart').value,
|
|
privileged: backdrop.querySelector('#cc-privileged').checked,
|
|
startAfterCreate: backdrop.querySelector('#cc-start').checked,
|
|
}).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) {
|
|
btn.disabled = true;
|
|
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
|
|
alert('Action failed: ' + err.message);
|
|
btn.disabled = false;
|
|
});
|
|
};
|
|
if (action === 'remove') {
|
|
if (!confirm('Remove this container? This does not remove its volumes.')) return;
|
|
doIt({ force: true });
|
|
} 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');
|
|
handleAction(row.dataset.id, btn.dataset.action, btn);
|
|
});
|
|
|
|
return load();
|
|
}
|
|
|
|
P.registerPanel('containers', { init: init, refresh: load });
|
|
})();
|