Rework Dashboard, add toasts, container folders/icons/WebUI links, detail tabs
Lint / ShellCheck (push) Successful in 11s
Lint / Validate .plg XML (push) Successful in 10s
Lint / EditorConfig (push) Successful in 6s

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>
This commit is contained in:
2026-07-13 21:32:34 +00:00
co-authored by Claude Sonnet 5
parent 1ca78e7115
commit 23898ff62e
20 changed files with 1416 additions and 100 deletions
+467 -22
View File
@@ -17,11 +17,156 @@
// badge doesn't disappear on the next auto-refresh; only re-running
// "Check for Updates" replaces it.
let imageUpdateStatus = {};
// Container folders — purely cosmetic grouping (podman itself has no
// such concept), backed by ajax/folders.php. Collapse state is
// intentionally in-memory only (not persisted): it's a per-visit UI
// convenience, not data worth a config-file round trip.
let folders = [];
let collapsedFolders = {};
function iconLabel(name) {
return P.escapeHtml(name.slice(0, 2).toUpperCase());
}
// The fallback text goes into a data-* attribute (plain HTML-attribute
// escaping) and is read back via .dataset in the error handler, rather
// than being concatenated into the onerror string as JS source — that
// second approach only stays safe as long as the text can never contain
// a quote, which is true for container names today (letters/digits/
// ./_/- only) but not for the free-text folder names below, so both
// use this same safer pattern rather than having two different rules
// depending on which kind of name is involved.
function iconWithFallbackHtml(iconUrl, fallbackText) {
const fallback = P.escapeHtml(fallbackText);
if (!iconUrl) {
return '<span class="ico">' + fallback + '</span>';
}
return '<span class="ico"><img src="' + P.escapeHtml(iconUrl) + '" alt="" loading="lazy" data-fallback="' + fallback + '" ' +
'onerror="this.replaceWith(document.createTextNode(this.dataset.fallback))"></span>';
}
function containerIconHtml(c) {
return iconWithFallbackHtml(c.icon, c.name.slice(0, 2).toUpperCase());
}
// --- Folders -----------------------------------------------------------
function saveFolders() {
return P.post('folders', 'save', { folders: folders }).then(function (data) {
folders = data.folders;
}).catch(function (err) {
P.toast('Could not save folders: ' + err.message, 'error');
});
}
function openNewFolderModal(containerNameToAssign) {
P.openFormModal({
title: 'New Folder',
submitLabel: 'Create',
fields: [
{ name: 'name', label: 'Folder name', required: true, placeholder: 'Media' },
{ name: 'icon', label: 'Icon URL (optional)', placeholder: 'https://...' },
],
onSubmit: function (values) {
folders.push({
id: '',
name: values.name,
icon: values.icon || '',
containers: containerNameToAssign ? [containerNameToAssign] : [],
});
return saveFolders().then(renderTable);
},
});
}
function openEditFolderModal(f) {
P.openFormModal({
title: 'Edit Folder',
submitLabel: 'Save',
fields: [
{ name: 'name', label: 'Folder name', required: true, placeholder: f.name },
{ name: 'icon', label: 'Icon URL (optional)', placeholder: f.icon || 'https://...' },
],
onSubmit: function (values) {
f.name = values.name;
f.icon = values.icon || '';
return saveFolders().then(renderTable);
},
});
}
function deleteFolder(f) {
if (!confirm('Delete folder "' + f.name + '"? Its containers are not affected — they just become ungrouped.')) return;
folders = folders.filter(function (x) { return x.id !== f.id; });
saveFolders().then(renderTable);
}
function assignToFolder(containerName, folderId) {
folders.forEach(function (f) {
const idx = f.containers.indexOf(containerName);
if (idx !== -1) f.containers.splice(idx, 1);
});
if (folderId) {
const target = folders.find(function (f) { return f.id === folderId; });
if (target) target.containers.push(containerName);
}
saveFolders().then(renderTable);
}
function openMoveToFolderMenu(c, anchorBtn) {
const currentFolder = folders.find(function (f) { return f.containers.indexOf(c.name) !== -1; });
const items = folders.map(function (f) {
return {
label: (f.id === (currentFolder && currentFolder.id) ? '✓ ' : '') + f.name,
onClick: function () { assignToFolder(c.name, f.id); },
};
});
if (items.length) items.push('separator');
if (currentFolder) {
items.push({ label: 'Remove from folder', onClick: function () { assignToFolder(c.name, null); } });
}
items.push({ label: '+ New folder…', onClick: function () { openNewFolderModal(c.name); } });
P.openContextMenu(anchorBtn, items);
}
function openFolderMenu(f, anchorBtn) {
P.openContextMenu(anchorBtn, [
{ label: 'Rename / Edit Icon', onClick: function () { openEditFolderModal(f); } },
{ label: 'Delete Folder', danger: true, onClick: function () { deleteFolder(f); } },
]);
}
// Matches Unraid's own Docker page folder rows: the header always
// shows a compact icon+name+status chip per member — collapsed or
// expanded — so folding a group away doesn't hide its state entirely.
// Expand/collapse only controls whether the FULL per-container detail
// rows also render underneath (see renderTable()).
function folderMemberChipHtml(c) {
return '<button type="button" class="podman-folder-member" data-action="menu" data-id="' + P.escapeHtml(c.id) + '">' +
iconWithFallbackHtml(c.icon, c.name.slice(0, 2).toUpperCase()) +
'<span class="name">' + P.escapeHtml(c.name) + '</span>' +
'<span class="dot ' + (c.state === 'running' ? 'good' : 'bad') + '"></span>' +
'<span class="state">' + P.escapeHtml(c.state) + '</span>' +
'</button>';
}
function folderHeaderHtml(f, members) {
const collapsed = !!collapsedFolders[f.id];
const runningCount = members.filter(function (c) { return c.state === 'running'; }).length;
return '<tr class="podman-folder-row" data-folder-id="' + P.escapeHtml(f.id) + '">' +
'<td colspan="7">' +
'<div class="podman-folder-head">' +
'<button type="button" class="podman-folder-toggle" data-action="toggle-folder">' +
'<span class="chevron">' + (collapsed ? '▸' : '▾') + '</span>' +
iconWithFallbackHtml(f.icon, f.name.slice(0, 2).toUpperCase()) +
'<span class="name">' + P.escapeHtml(f.name) + '</span>' +
'<span class="count">' + runningCount + '/' + members.length + ' running</span>' +
'</button>' +
'<div class="podman-folder-members">' + members.map(folderMemberChipHtml).join('') + '</div>' +
'<button type="button" class="podman-btn podman-btn-icon" data-action="folder-menu" title="Folder options">&#8942;</button>' +
'</div></td></tr>';
}
function hasUpdate(c) {
const status = imageUpdateStatus[c.image];
return !!(status && status.updateAvailable);
@@ -34,12 +179,18 @@
const updateBadge = hasUpdate(c)
? ' <span class="podman-badge-update" title="A newer image is available">&#8593; Update</span>'
: '';
// A plain <a>, not a data-action button — the tbody's click handler
// only ever looks for button[data-action], so this just navigates
// normally with no extra wiring.
const webuiLink = c.webUrl
? ' <a class="podman-webui-link" href="' + P.escapeHtml(c.webUrl) + '" target="_blank" rel="noopener noreferrer" title="Open WebUI">URL</a>'
: '';
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>' + updateBadge + '</td>' +
'<td><div class="podman-name-cell"><button type="button" class="podman-row-name podman-row-name-btn" data-action="details">' +
containerIconHtml(c) + '<span class="text">' + P.escapeHtml(c.name) + '</span></button>' + webuiLink + updateBadge + '</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>' +
@@ -70,12 +221,24 @@
function openRowMenu(c, anchorBtn) {
const items = [];
items.push({ label: 'Details', onClick: function () { openDetailModal(c); } });
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({ label: 'Edit', onClick: function () { openEditContainerModal(c); } });
// Once a container is already grouped, "Move to Folder" (which reads
// as "add to a folder") is redundant and ambiguous — the one action
// that actually makes sense from here is taking it back out. Moving
// it to a *different* folder still works, just via ungrouping first;
// that's a deliberately rarer path than "add" or "remove".
const currentFolder = folders.find(function (f) { return f.containers.indexOf(c.name) !== -1; });
if (currentFolder) {
items.push({ label: 'Remove from Folder', onClick: function () { assignToFolder(c.name, null); } });
} else {
items.push({ label: 'Move to Folder', onClick: function () { openMoveToFolderMenu(c, anchorBtn); } });
}
items.push('separator');
items.push({
label: 'Remove',
@@ -173,6 +336,8 @@
volumes: volumes,
env: env,
gpuDevices: gpuDevices,
icon: c.icon || '',
webUrl: c.webUrl || '',
};
}
@@ -180,7 +345,7 @@
P.get('containers', 'inspect', { id: c.id }).then(function (d) {
openCreateContainerModal(inspectToPrefill(c, d), { id: c.id });
}).catch(function (err) {
alert('Could not load container config: ' + err.message);
P.toast('Could not load container config: ' + err.message, 'error');
});
}
@@ -226,6 +391,8 @@
restartPolicy: prefill.restartPolicy,
gpuDevices: prefill.gpuDevices,
privileged: prefill.privileged,
icon: prefill.icon,
webuiUrl: prefill.webUrl,
startAfterCreate: true,
});
}).then(function () {
@@ -298,6 +465,25 @@
modal.log(failures.length
? (targets.length - failures.length) + ' updated, ' + failures.length + ' failed.'
: 'All ' + targets.length + ' updated.');
// The image(s) each updated container used before are now
// superseded (recreate() points it at the freshly-pulled one) and
// have zero containers referencing them — the same "unused"
// definition images.js's own Prune button uses. Only worth doing
// if at least one container actually updated; skipped entirely if
// every update failed, since nothing changed to clean up.
if (failures.length < targets.length) {
modal.log('');
modal.log('Removing old, now-unused images…');
return P.post('images', 'prune').then(function (result) {
modal.log(result.removedCount
? 'Removed ' + result.removedCount + ' old image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.'
: 'No unused images left to remove.');
}).catch(function (err) {
modal.log('Image cleanup failed: ' + err.message);
});
}
}).then(function () {
modal.done();
btn.disabled = false;
return load();
@@ -311,11 +497,15 @@
// --- 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.
// Most tabs are sliced straight from the one inspect payload already
// fetched when the modal opens (Config.Env, Config.Labels, Mounts,
// NetworkSettings.Networks, HostConfig.RestartPolicy, State.Health.Log,
// ... all checked live against a real inspect response, not assumed
// from docs). A few need more: Logs and Events fetch lazily when their
// tab is first opened (a render() may return a Promise<string> instead
// of a string — see showTab() below), and Console opens a real
// ttyd/podman-exec session instead of just rendering (see
// renderConsoleTab/wireConsoleTab).
function kvTable(rows) {
if (rows.length === 0) return '<div class="podman-detail-empty">None.</div>';
@@ -387,12 +577,165 @@
return '<div class="podman-detail-json">' + P.escapeHtml(JSON.stringify(d, null, 2)) + '</div>';
}
// Live stats (cpuPercent/memUsageBytes/memLimitBytes) come from the row
// data already fetched for the table (containers.php's list action) —
// no extra request needed, and it's a one-shot snapshot either way
// (this modal doesn't auto-refresh internally). Configured limits come
// from the inspect payload's HostConfig; a 0 in any of these fields is
// podman's own way of saying "unlimited", not a real zero.
function renderResourcesTab(d, c) {
const hostCfg = d.HostConfig || {};
const rows = [];
if (c.state === 'running') {
rows.push(['CPU usage', c.cpuPercent != null ? c.cpuPercent.toFixed(1) + '%' : '—']);
// memLimitBytes is podman's cgroup-reported limit, which is the
// HOST's total memory when no real limit is configured (found
// live: showed "110 MB / 38.6 GB" for a container with no memory
// limit set, right above a "Memory limit: unlimited" row that
// contradicted it) — only show a "/ limit" suffix when HostConfig
// says a limit was actually configured.
rows.push(['Memory usage', c.memUsageBytes != null
? P.formatBytes(c.memUsageBytes) + (hostCfg.Memory > 0 ? ' / ' + P.formatBytes(c.memLimitBytes) : '')
: '—']);
}
const nanoCpus = hostCfg.NanoCpus || 0;
const cpuQuota = hostCfg.CpuQuota || 0;
const cpuPeriod = hostCfg.CpuPeriod || 0;
rows.push(
['Memory limit', hostCfg.Memory > 0 ? P.formatBytes(hostCfg.Memory) : 'unlimited'],
['Swap limit', hostCfg.MemorySwap > 0 ? P.formatBytes(hostCfg.MemorySwap) : 'unlimited'],
['CPU limit', nanoCpus > 0 ? (nanoCpus / 1e9) + ' core(s)' : (cpuQuota > 0 && cpuPeriod > 0 ? (cpuQuota / cpuPeriod).toFixed(2) + ' core(s)' : 'unlimited')],
['CPU shares', hostCfg.CpuShares > 0 ? String(hostCfg.CpuShares) : 'default (1024)'],
['PIDs limit', hostCfg.PidsLimit > 0 ? String(hostCfg.PidsLimit) : 'unlimited'],
['Block I/O weight', hostCfg.BlkioWeight > 0 ? String(hostCfg.BlkioWeight) : 'default']
);
return kvTable(rows);
}
function renderLogsTab(d, c) {
return P.get('containers', 'logs', { id: c.id, tail: 300 }).then(function (data) {
const lines = (data.text || '').split('\n').filter(function (l) { return l.length > 0; });
if (!lines.length) return '<div class="podman-detail-empty">No log output.</div>';
return '<div class="podman-log-pane">' + lines.map(function (line) {
const cls = /\berror\b/i.test(line) ? 'lvl-error' : (/\bwarn(ing)?\b/i.test(line) ? 'lvl-warn' : '');
return '<div class="l' + (cls ? ' ' + cls : '') + '">' + P.escapeHtml(line) + '</div>';
}).join('') + '</div>';
});
}
// Bounded, one-shot history (last 7 days) via PodmanClient::containerEvents()
// — NOT a live stream, see that method's own doc comment. Good enough for
// "what happened to this container recently" without the persistent-
// connection infrastructure a live feed would need.
function renderEventsTab(d, c) {
return P.get('containers', 'events', { id: c.id }).then(function (events) {
if (!events.length) return '<div class="podman-detail-empty">No events in the last 7 days.</div>';
const rows = events.slice().reverse();
return '<table class="podman-detail-table">' +
'<tr><td>Time</td><td>Event</td></tr>' +
rows.map(function (e) {
const when = new Date(e.time * 1000).toLocaleString();
return '<tr><td class="mono" title="' + P.escapeHtml(when) + '">' + P.escapeHtml(P.formatRelativeTime(e.time)) + '</td>' +
'<td>' + P.escapeHtml(e.Action || e.status || '') + '</td></tr>';
}).join('') + '</table>';
});
}
function renderHealthTab(d) {
const health = (d.State && d.State.Health) || null;
if (!health || !Array.isArray(health.Log) || !health.Log.length) {
return '<div class="podman-detail-empty">No healthcheck configured for this container.</div>';
}
const rows = health.Log.slice().reverse();
return '<table class="podman-detail-table">' +
'<tr><td>Time</td><td>Result</td><td>Output</td></tr>' +
rows.map(function (entry) {
const ok = entry.ExitCode === 0;
return '<tr><td class="mono">' + P.escapeHtml(entry.Start || '') + '</td>' +
'<td><span class="podman-chip ' + (ok ? 'podman-chip-good' : 'podman-chip-bad') + '"><span class="d"></span>' +
(ok ? 'ok' : 'exit ' + entry.ExitCode) + '</span></td>' +
'<td class="mono">' + P.escapeHtml((entry.Output || '').trim().slice(0, 300)) + '</td></tr>';
}).join('') + '</table>';
}
// Console is the one tab that isn't a static render — it opens a real
// ttyd/podman-exec session (same mechanism as the standalone Terminal
// panel, see terminal.js's own header comment for why this can't be a
// true persistent PTY over plain HTTP). Returns a cleanup function the
// modal calls when leaving this tab or closing altogether, so a session
// opened just to peek at a container's console doesn't leak an orphaned
// ttyd process the way a stale one did before terminal.js's own fix
// earlier this project (see openLiveTerminal()'s closeCurrent()).
function renderConsoleTab(d, c) {
if (c.state !== 'running') {
return '<div class="podman-detail-empty">Container must be running to open a console.</div>';
}
return '' +
'<div class="podman-term-launcher">' +
'<label>Shell <select class="podman-term-select" id="detail-term-shell"><option value="bash" selected>bash</option><option value="sh">sh</option></select></label>' +
'<button type="button" class="podman-btn podman-btn-primary" id="detail-term-open-btn">&#9654; Open Console</button>' +
'<button type="button" class="podman-btn podman-btn-ghost podman-btn-danger" id="detail-term-disconnect-btn" disabled>&#9632; Disconnect</button>' +
'</div>' +
'<div id="detail-term-frame-wrap"><p class="podman-empty-note">Pick a shell and click "Open Console".</p></div>';
}
function wireConsoleTab(body, d, c) {
const openBtn = body.querySelector('#detail-term-open-btn');
if (!openBtn) {
return null;
}
const disconnectBtn = body.querySelector('#detail-term-disconnect-btn');
let sessionOpen = false;
function closeSession() {
if (!sessionOpen) return Promise.resolve();
sessionOpen = false;
return P.post('exec', 'close', { name: c.name }).catch(function () {});
}
openBtn.addEventListener('click', function () {
const shell = body.querySelector('#detail-term-shell').value;
const wrap = body.querySelector('#detail-term-frame-wrap');
wrap.innerHTML = '<p class="podman-empty-note">Opening console…</p>';
openBtn.disabled = true;
closeSession().then(function () {
return P.post('exec', 'open', { name: c.name, shell: shell });
}).then(function (data) {
sessionOpen = true;
disconnectBtn.disabled = false;
// Same brief delay openLiveTerminal() uses — ttyd needs a moment
// to bind its socket before nginx can proxy to it.
setTimeout(function () {
wrap.innerHTML = '<iframe class="podman-term-frame" src="/logterminal/' + encodeURIComponent(data.sockName) + '/"></iframe>';
}, 200);
}).catch(function (err) {
wrap.innerHTML = '<p class="podman-empty-note">Could not open console: ' + P.escapeHtml(err.message) + '</p>';
}).finally(function () {
openBtn.disabled = false;
});
});
disconnectBtn.addEventListener('click', function () {
disconnectBtn.disabled = true;
closeSession().finally(function () {
body.querySelector('#detail-term-frame-wrap').innerHTML = '<p class="podman-empty-note">Disconnected.</p>';
});
});
return closeSession;
}
const DETAIL_TABS = [
{ id: 'overview', label: 'Overview', render: renderOverviewTab },
{ id: 'resources', label: 'Resources', render: renderResourcesTab },
{ id: 'logs', label: 'Logs', render: renderLogsTab },
{ id: 'console', label: 'Console', render: renderConsoleTab, wire: wireConsoleTab },
{ id: 'networks', label: 'Networks', render: renderNetworksTab },
{ id: 'mounts', label: 'Mounts', render: renderMountsTab },
{ 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: 'events', label: 'Events', render: renderEventsTab },
{ id: 'health', label: 'Healthcheck', render: renderHealthTab },
{ id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab },
];
@@ -410,17 +753,46 @@
'</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(); });
// Any tab can leave behind something that needs cleanup on the way
// out (currently only Console's ttyd session) — tracked here so
// every way of leaving the modal (switching tabs, Close button,
// backdrop click, Escape) goes through the same cleanup path.
let activeCleanup = null;
function closeModal() {
const cleanup = activeCleanup;
activeCleanup = null;
Promise.resolve(cleanup ? cleanup() : null).finally(function () { backdrop.remove(); });
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', closeModal);
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) closeModal(); });
document.addEventListener('keydown', function onKey(e) {
if (e.key === 'Escape') { backdrop.remove(); document.removeEventListener('keydown', onKey); }
if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', onKey); }
});
const body = backdrop.querySelector('.podman-detail-body');
P.get('containers', 'inspect', { id: c.id }).then(function (data) {
function showTab(tabId) {
if (activeCleanup) {
const cleanup = activeCleanup;
activeCleanup = null;
cleanup();
}
const tab = DETAIL_TABS.find(function (t) { return t.id === tabId; });
body.innerHTML = tab.render(data);
const result = tab.render(data, c);
if (result && typeof result.then === 'function') {
body.innerHTML = '<div class="podman-loading">Loading…</div>';
result.then(function (html) {
body.innerHTML = html;
if (tab.wire) activeCleanup = tab.wire(body, data, c) || null;
}).catch(function (err) {
body.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
});
} else {
body.innerHTML = result;
if (tab.wire) activeCleanup = tab.wire(body, data, c) || null;
}
}
backdrop.querySelectorAll('[data-tab]').forEach(function (btn) {
btn.addEventListener('click', function () {
@@ -447,9 +819,40 @@
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>';
if (!visible.length) {
tbody.innerHTML = '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
return;
}
// No folders defined at all: render the flat list exactly as before —
// nobody using this feature for the first time sees any change.
if (!folders.length) {
tbody.innerHTML = visible.map(rowHtml).join('');
return;
}
const visibleByName = {};
visible.forEach(function (c) { visibleByName[c.name] = c; });
const assigned = {};
let html = '';
folders.forEach(function (f) {
const members = f.containers.map(function (name) { return visibleByName[name]; }).filter(Boolean);
// Hide a folder only when the current filter/search hid every one of
// its actual members — a genuinely empty folder (nothing assigned
// yet, right after creating it) still needs to show up so there's
// somewhere to drag/assign a container into.
if (!members.length && f.containers.length > 0) return;
members.forEach(function (c) { assigned[c.name] = true; });
html += folderHeaderHtml(f, members);
if (!collapsedFolders[f.id]) {
html += members.map(rowHtml).join('');
}
});
const ungrouped = visible.filter(function (c) { return !assigned[c.name]; });
html += ungrouped.map(rowHtml).join('');
tbody.innerHTML = html || '<tr><td colspan="7" class="podman-empty-note">No containers match.</td></tr>';
}
function renderCounts() {
@@ -461,7 +864,13 @@
function load() {
const tbody = P.el('containers-tbody');
tbody.innerHTML = P.loadingRow(7);
// Only show the loading placeholder on the very first load — once
// rows are already on screen, auto-refresh (every ~2s) and manual
// Refresh clicks should swap data in place, not flash back to a
// spinner and lose the user's place every cycle.
if (!allContainers.length) {
tbody.innerHTML = P.loadingRow(7);
}
return P.get('containers', 'list').then(function (data) {
allContainers = data;
renderCounts();
@@ -560,6 +969,12 @@
'<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>Icon URL (optional)</label>' +
'<input type="text" id="cc-icon" placeholder="https://...">' +
'<div class="hint">Shown in the Containers table and Folders. Filled in automatically from a template.</div></div>' +
'<div class="podman-modal-field"><label>WebUI URL (optional)</label>' +
'<input type="text" id="cc-weburl" placeholder="http://10.1.1.1:8080/">' +
'<div class="hint">Adds a small open-in-new-tab button next to the name in the Containers table.</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>' +
@@ -606,6 +1021,8 @@
if (prefill.image) backdrop.querySelector('#cc-image').value = prefill.image;
if (prefill.name) backdrop.querySelector('#cc-name').value = prefill.name;
if (prefill.icon) backdrop.querySelector('#cc-icon').value = prefill.icon;
if (prefill.webUrl) backdrop.querySelector('#cc-weburl').value = prefill.webUrl;
if (prefill.networkMode) backdrop.querySelector('#cc-network').value = prefill.networkMode;
if (prefill.restartPolicy) backdrop.querySelector('#cc-restart').value = prefill.restartPolicy;
if (prefill.privileged) backdrop.querySelector('#cc-privileged').checked = true;
@@ -797,6 +1214,8 @@
restartPolicy: backdrop.querySelector('#cc-restart').value,
gpuDevices: gpuDevices,
privileged: privileged,
icon: backdrop.querySelector('#cc-icon').value.trim(),
webuiUrl: backdrop.querySelector('#cc-weburl').value.trim(),
startAfterCreate: backdrop.querySelector('#cc-start').checked,
});
}).then(function () {
@@ -815,7 +1234,7 @@
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);
P.toast('Container created, but saving the template failed: ' + err.message, 'warn');
});
}).then(function () {
close();
@@ -839,7 +1258,7 @@
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);
P.toast('Action failed: ' + err.message, 'error');
if (btn) btn.disabled = false;
});
};
@@ -875,7 +1294,24 @@
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 === 'toggle-folder' || btn.dataset.action === 'folder-menu') {
const folderId = row.dataset.folderId;
const f = folders.find(function (x) { return x.id === folderId; });
if (!f) return;
if (btn.dataset.action === 'toggle-folder') {
collapsedFolders[folderId] = !collapsedFolders[folderId];
renderTable();
} else {
openFolderMenu(f, btn);
}
return;
}
// A folder-header's member chips (see folderMemberChipHtml()) carry
// their own data-id directly on the <button>, since the row they
// sit in is the folder header (data-folder-id), not that container.
const id = btn.dataset.id || row.dataset.id;
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details' || btn.dataset.action === 'update') {
const c = allContainers.find(function (x) { return x.id === id; });
if (!c) return;
@@ -903,6 +1339,15 @@
P.el('containers-check-updates-btn').addEventListener('click', checkForUpdates);
P.el('containers-update-all-btn').addEventListener('click', updateAll);
P.el('containers-new-folder-btn').addEventListener('click', function () { openNewFolderModal(null); });
// Fetched once here, not inside load() — folders change rarely
// compared to container state, so there's no reason for every ~2s
// auto-refresh tick to re-fetch and re-render them too.
P.get('folders', 'list').then(function (data) {
folders = data.folders || [];
renderTable();
}).catch(function () { /* folders just stay empty — not fatal to the panel */ });
return load();
}
@@ -912,5 +1357,5 @@
// (see Podman.page's script list), so this is already set by then.
P.openCreateContainerModal = openCreateContainerModal;
P.registerPanel('containers', { init: init, refresh: load });
P.registerPanel('containers', { init: init, refresh: load, autoRefresh: true });
})();