Files
unraid-podman/webui/plugins/podman/javascript/containers.js
T
maggesandClaude Sonnet 5 993e187f59 Add container pause/resume/kill/rename + reusable context-menu component
First increment of the big WebUI feature-parity spec (see task list) —
row-level actions were about to run out of icon-button space, so this adds
a small anchored dropdown menu (app.js openContextMenu) for secondary
per-container actions instead of cramming more buttons into every row.

All four new actions verified live against the real podman API before
wiring up the UI (same discipline as the CSRF/pull/compose bugs found
earlier — field names and response shapes checked against the running
socket, not assumed from docs).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 11:56:46 +00:00

344 lines
15 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><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(', ') || '&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);
},
});
}
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>&rarr;</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">&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" 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" data-remove-row title="Remove">&times;</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) {
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') {
const c = allContainers.find(function (x) { return x.id === id; });
if (c) openRowMenu(c, btn);
return;
}
handleAction(id, btn.dataset.action, btn);
});
return load();
}
P.registerPanel('containers', { init: init, refresh: load });
})();