Dashboard: - Plain-count stat tiles (Running/Stopped/Pods/Images/Volumes/Networks) separated from a single "Resource Usage" card (CPU/Memory/Swap/Storage meter rows) instead of forcing both into one tile grid, which produced awkward spanning-tile/dead-cell layouts. - Fixed CPU usage never changing (libpod's own cpuUtilization is computed once and never resampled) by computing it from /proc/stat deltas instead. - Fixed memory usage reading far too high by using /proc/meminfo's MemAvailable instead of libpod's raw (non-reclaimable-aware) memFree. - Added an Autostart Queue table reusing podman-autostart.sh's own failure-counter files. - Dashboard and Containers now auto-refresh every ~2s (paused when the tab is hidden or a modal is open). Toasts: - Real success/warn/error/info toast notifications replacing every alert() used for one-way feedback, across every panel. Container detail modal: - 5 new tabs: Resources, Logs, Console, Events, Healthcheck. Containers panel: - Folders to group containers (name + icon), stored in the plugin's own folders.json — a folder's header always shows an icon+name+status chip per member, matching Unraid's own Docker page folders. "Move to Folder" becomes "Remove from Folder" once a container is already grouped. - Containers can carry an icon URL and a WebUI URL (small button next to the name), both stored as container labels and auto-filled from templates where applicable. - Settings: an "Add container" control for the Autostart order table. Fixes: - Context menus now measure their own rendered size and flip above the anchor when there isn't room below, instead of running off-screen. - Containers table now uses table-layout:fixed with explicit column widths — auto layout was shifting every column (and the header) on every folder expand/collapse, and briefly again when a flex wrapper was mistakenly placed directly on a <td>. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
202 lines
8.3 KiB
JavaScript
202 lines
8.3 KiB
JavaScript
/**
|
|
* javascript/pods.js
|
|
*
|
|
* Pods panel: one card per pod with its member containers nested inside,
|
|
* backed by ajax/pods.php (which itself cross-references containers.php's
|
|
* data server-side — see that file for why).
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
const P = window.Podman;
|
|
let allPods = [];
|
|
|
|
function portRowHtml() {
|
|
return '' +
|
|
'<div class="podman-row-group-item">' +
|
|
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
|
|
'<span>→</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">×</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
function addRow(groupEl) {
|
|
const div = document.createElement('div');
|
|
div.innerHTML = portRowHtml();
|
|
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 openCreatePodModal() {
|
|
const backdrop = document.createElement('div');
|
|
backdrop.className = 'podman-modal-backdrop';
|
|
backdrop.innerHTML = '' +
|
|
'<div class="podman-modal" role="dialog" aria-modal="true">' +
|
|
'<div class="podman-modal-head"><h3>New Pod</h3></div>' +
|
|
'<form class="podman-modal-body">' +
|
|
'<div class="podman-modal-field"><label>Name</label>' +
|
|
'<input type="text" id="cp-name" placeholder="my-pod">' +
|
|
'<div class="hint">Letters, digits, ".", "_", "-" only — no spaces.</div></div>' +
|
|
'<div class="podman-modal-field"><label>Port mappings</label>' +
|
|
'<div class="podman-row-group" id="cp-ports"></div>' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
|
|
'<div class="hint">Shared by every container later added to this pod.</div></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);
|
|
|
|
const portsGroup = backdrop.querySelector('#cp-ports');
|
|
addRow(portsGroup);
|
|
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup); });
|
|
backdrop.querySelector('#cp-name').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 name = backdrop.querySelector('#cp-name').value.trim();
|
|
if (!name) {
|
|
showError('"Name" is required.');
|
|
return;
|
|
}
|
|
if (!/^[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 submitBtn = backdrop.querySelector('[data-role="submit"]');
|
|
submitBtn.disabled = true;
|
|
P.post('pods', 'create', { name: name, ports: ports }).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 memberRow(m) {
|
|
return '' +
|
|
'<tr>' +
|
|
'<td>' + P.escapeHtml(m.name) + '</td>' +
|
|
'<td class="mono podman-row-sub">' + P.escapeHtml(m.image) + '</td>' +
|
|
'<td><span class="podman-chip ' + P.stateChipClass(m.state) + '"><span class="d"></span>' + P.escapeHtml(m.state) + '</span></td>' +
|
|
'</tr>';
|
|
}
|
|
|
|
function podCard(pod) {
|
|
const members = pod.members.length
|
|
? pod.members.map(memberRow).join('')
|
|
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
|
|
|
|
return '' +
|
|
'<div class="podman-pod-card" data-name="' + P.escapeHtml(pod.name) + '">' +
|
|
'<div class="podman-pod-head">' +
|
|
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
|
|
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
|
|
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
|
|
'<button type="button" class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>' +
|
|
'</div>' +
|
|
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
|
|
'<tbody>' + members + '</tbody></table></div>' +
|
|
'</div>';
|
|
}
|
|
|
|
function render() {
|
|
const grid = P.el('pods-grid');
|
|
grid.innerHTML = allPods.length
|
|
? allPods.map(podCard).join('')
|
|
: '<div class="podman-empty-note">No pods yet — create one, or run a container with a "pod" set from the Create Container form.</div>';
|
|
}
|
|
|
|
function load() {
|
|
const container = P.el('podman-panel-pods');
|
|
if (!P.el('pods-grid')) {
|
|
container.innerHTML = '' +
|
|
'<div class="podman-card">' +
|
|
'<div class="podman-toolbar">' +
|
|
'<strong style="flex:1;">Group containers sharing network/storage namespaces</strong>' +
|
|
'<button class="podman-btn podman-btn-primary" id="pods-create-btn">+ New Pod</button>' +
|
|
'</div>' +
|
|
'<div id="pods-grid"></div>' +
|
|
'</div>';
|
|
P.el('pods-create-btn').addEventListener('click', openCreatePodModal);
|
|
P.el('pods-grid').addEventListener('click', handleCardClick);
|
|
}
|
|
return P.get('pods', 'list').then(function (data) {
|
|
allPods = data;
|
|
render();
|
|
}).catch(function (err) {
|
|
P.el('pods-grid').innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function handleAction(name, action, extra) {
|
|
return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) {
|
|
P.toast('Action failed: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function handleCardClick(e) {
|
|
const btn = e.target.closest('button[data-action="menu"]');
|
|
if (!btn) return;
|
|
const pod = allPods.find(function (p) { return p.name === btn.closest('.podman-pod-card').dataset.name; });
|
|
if (!pod) return;
|
|
|
|
const items = [];
|
|
if (pod.status === 'running') {
|
|
items.push({ label: 'Stop', onClick: function () { handleAction(pod.name, 'stop', { timeout: 10 }); } });
|
|
items.push({ label: 'Restart', onClick: function () { handleAction(pod.name, 'restart', { timeout: 10 }); } });
|
|
} else {
|
|
items.push({ label: 'Start', onClick: function () { handleAction(pod.name, 'start'); } });
|
|
}
|
|
items.push('separator');
|
|
items.push({
|
|
label: 'Remove',
|
|
danger: true,
|
|
onClick: function () {
|
|
if (!confirm('Remove pod "' + pod.name + '" and all its member containers?')) return;
|
|
handleAction(pod.name, 'remove', { force: true });
|
|
},
|
|
});
|
|
P.openContextMenu(btn, items);
|
|
}
|
|
|
|
P.registerPanel('pods', { init: load, refresh: load });
|
|
})();
|