Rework Dashboard, add toasts, container folders/icons/WebUI links, detail tabs
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:
@@ -126,6 +126,60 @@ window.Podman = (function () {
|
||||
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
||||
}
|
||||
|
||||
// --- Toast notifications ----------------------------------------------------
|
||||
//
|
||||
// Replaces alert() for one-way feedback ("Saved.", "Removed 3 image(s)",
|
||||
// "Save failed: ..."). Confirmations stay native confirm() — a toast is
|
||||
// for telling the user something happened, not for asking them a
|
||||
// yes/no question. Command output that can run to hundreds of lines
|
||||
// (e.g. `podman compose up`) stays in the existing openLogModal()
|
||||
// pattern instead — a toast has to stay short and auto-dismiss, which
|
||||
// doesn't fit a scrolling log.
|
||||
|
||||
const TOAST_ICON = { success: '✓', warn: '!', error: '✕', info: 'ℹ' };
|
||||
const TOAST_DURATION_MS = { success: 4000, warn: 5000, error: 7000, info: 4000 };
|
||||
let toastContainer = null;
|
||||
|
||||
function toastRoot() {
|
||||
if (!toastContainer) {
|
||||
toastContainer = document.createElement('div');
|
||||
toastContainer.className = 'podman-toast-container';
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(toastContainer);
|
||||
}
|
||||
return toastContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} message
|
||||
* @param {('success'|'warn'|'error'|'info')} [type='info']
|
||||
*/
|
||||
function toast(message, type) {
|
||||
const kind = TOAST_ICON[type] ? type : 'info';
|
||||
const root = toastRoot();
|
||||
|
||||
const node = document.createElement('div');
|
||||
node.className = 'podman-toast podman-toast-' + kind;
|
||||
node.innerHTML =
|
||||
'<span class="ico">' + TOAST_ICON[kind] + '</span>' +
|
||||
'<span class="msg"></span>' +
|
||||
'<button type="button" class="close" aria-label="Dismiss">✕</button>';
|
||||
node.querySelector('.msg').textContent = message;
|
||||
root.appendChild(node);
|
||||
|
||||
let dismissed = false;
|
||||
function dismiss() {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
node.classList.add('leaving');
|
||||
// Not animationend — that never fires when prefers-reduced-motion
|
||||
// disables the animation (podman.css sets animation:none for it),
|
||||
// which would leave the toast stuck on screen forever.
|
||||
setTimeout(function () { node.remove(); }, 160);
|
||||
}
|
||||
node.querySelector('.close').addEventListener('click', dismiss);
|
||||
setTimeout(dismiss, TOAST_DURATION_MS[kind]);
|
||||
}
|
||||
|
||||
// --- Modal form dialog -----------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -299,12 +353,32 @@ window.Podman = (function () {
|
||||
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
|
||||
}).join('');
|
||||
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
|
||||
|
||||
// Viewport-relative (see the "position: fixed" comment on
|
||||
// .podman-context-menu in podman.css) — no scrollY/scrollX added.
|
||||
// Measured AFTER appending (not assumed) since the menu's height
|
||||
// varies with its item count — a long menu (e.g. a container's row
|
||||
// menu with Details/Pause/Kill/Rename/Edit/Remove) opened from a row
|
||||
// near the bottom of the viewport used to run off-screen with no way
|
||||
// to reach its last few items. Flips above the anchor instead when
|
||||
// there isn't enough room below, and clamps horizontally the same way.
|
||||
const rect = anchorEl.getBoundingClientRect();
|
||||
menu.style.top = (rect.bottom + 4) + 'px';
|
||||
menu.style.left = (rect.right - 180) + 'px';
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
|
||||
const menuRect = menu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
|
||||
let top = rect.bottom + 4;
|
||||
if (top + menuRect.height > window.innerHeight - margin) {
|
||||
top = rect.top - menuRect.height - 4;
|
||||
}
|
||||
top = Math.max(margin, top);
|
||||
|
||||
let left = rect.right - menuRect.width;
|
||||
left = Math.min(left, window.innerWidth - menuRect.width - margin);
|
||||
left = Math.max(margin, left);
|
||||
|
||||
menu.style.top = top + 'px';
|
||||
menu.style.left = left + 'px';
|
||||
|
||||
// menu.children includes the separator <div>s too, so indexing into it
|
||||
// directly (by a counter that only advances for real items) drifts by
|
||||
@@ -399,6 +473,29 @@ window.Podman = (function () {
|
||||
// (Dashboard, by default — see Podman.page).
|
||||
const initial = document.querySelector('.podman-subnav button.active');
|
||||
activatePanel(initial ? initial.dataset.panel : 'dashboard');
|
||||
|
||||
setInterval(autoRefreshTick, AUTO_REFRESH_INTERVAL_MS);
|
||||
}
|
||||
|
||||
// Only panels that opt in via `autoRefresh: true` (Dashboard, Containers)
|
||||
// get polled — most panels (Settings, Compose, Terminal, ...) have
|
||||
// in-progress forms or connections an unexpected refresh would disrupt.
|
||||
// Paused while the tab is hidden (nothing to look at) and while any
|
||||
// modal is open (a full-panel re-render mid-edit would be jarring),
|
||||
// rather than fighting those cases with more state.
|
||||
const AUTO_REFRESH_INTERVAL_MS = 2000;
|
||||
|
||||
function autoRefreshTick() {
|
||||
if (document.hidden) return;
|
||||
if (document.querySelector('.podman-modal-backdrop')) return;
|
||||
|
||||
const activeBtn = document.querySelector('.podman-subnav button.active');
|
||||
if (!activeBtn) return;
|
||||
const name = activeBtn.dataset.panel;
|
||||
const module = panelModules[name];
|
||||
if (module && module.autoRefresh && initialized[name] && typeof module.refresh === 'function') {
|
||||
module.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
@@ -414,6 +511,7 @@ window.Podman = (function () {
|
||||
stateChipClass: stateChipClass,
|
||||
loadingRow: loadingRow,
|
||||
errorRow: errorRow,
|
||||
toast: toast,
|
||||
openFormModal: openFormModal,
|
||||
openLogModal: openLogModal,
|
||||
openContextMenu: openContextMenu,
|
||||
|
||||
@@ -85,11 +85,17 @@
|
||||
if (!selected) return;
|
||||
const btn = P.el('compose-action-' + action);
|
||||
btn.disabled = true;
|
||||
// `podman compose` output can run to many lines — a log modal (same
|
||||
// pattern as Settings' service Start/Stop/Restart) fits that; a toast
|
||||
// has to stay short and auto-dismiss.
|
||||
const modal = P.openLogModal('podman compose ' + action + ' — ' + selected);
|
||||
P.post('compose', action, { project: selected }).then(function (data) {
|
||||
alert((data.output || 'Done.').slice(0, 2000));
|
||||
(data.output || 'Done.').split('\n').forEach(function (line) { modal.log(line); });
|
||||
modal.done();
|
||||
return loadProjects();
|
||||
}).catch(function (err) {
|
||||
alert('podman compose ' + action + ' failed: ' + err.message);
|
||||
modal.log('Failed: ' + err.message);
|
||||
modal.done('Close');
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
@@ -100,9 +106,10 @@
|
||||
const btn = P.el('compose-action-save');
|
||||
btn.disabled = true;
|
||||
P.post('compose', 'save', { project: selected, yaml: P.el('compose-yaml').value }).then(function () {
|
||||
P.toast('Saved ' + selected + '.', 'success');
|
||||
return loadProjects();
|
||||
}).catch(function (err) {
|
||||
alert('Save failed: ' + err.message);
|
||||
P.toast('Save failed: ' + err.message, 'error');
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
@@ -113,11 +120,13 @@
|
||||
if (!confirm('Delete project "' + selected + '"? This stops it (if running) and permanently removes its compose.yaml.')) return;
|
||||
const btn = P.el('compose-action-delete');
|
||||
btn.disabled = true;
|
||||
const name = selected;
|
||||
P.post('compose', 'remove', { project: selected }).then(function () {
|
||||
selected = null;
|
||||
P.toast('Deleted ' + name + '.', 'success');
|
||||
return loadProjects();
|
||||
}).catch(function (err) {
|
||||
alert('Delete failed: ' + err.message);
|
||||
P.toast('Delete failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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">⋮</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">↑ 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(', ') || '—') + '</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">▶ Open Console</button>' +
|
||||
'<button type="button" class="podman-btn podman-btn-ghost podman-btn-danger" id="detail-term-disconnect-btn" disabled>■ 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 });
|
||||
})();
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
/**
|
||||
* javascript/dashboard.js
|
||||
*
|
||||
* Dashboard panel: summary stat tiles fed by ajax/system.php?action=summary.
|
||||
* The Activity list and the CPU/Memory sparkline in the mockup were
|
||||
* illustrative sample data with no backing API (libpod has no "recent
|
||||
* events for a container fleet" convenience endpoint beyond raw
|
||||
* /events streaming, which is a separate follow-up — see the note
|
||||
* rendered in place of it below) — rather than fake data pretending to be
|
||||
* live, this real implementation shows what's genuinely available now
|
||||
* (the summary counts) and a clear placeholder for what needs the events
|
||||
* stream, so nobody mistakes a mock for a working feature.
|
||||
* Dashboard panel: plain-count stat tiles (Running/Stopped/Pods/Images/
|
||||
* Volumes/Networks) plus a single "Resource Usage" card (CPU/Memory/Swap/
|
||||
* Storage as meter rows) — kept as two visually distinct groups rather
|
||||
* than forcing bar-and-percentage metrics into the same tile shape as
|
||||
* simple counts, which is what produced the awkward spanning-tile/dead-
|
||||
* grid-cell layout this replaced. Both fed by ajax/system.php?action=
|
||||
* summary, plus the Autostart Queue table fed by action=autostart_queue.
|
||||
* A live scrolling event feed is deliberately out of scope here — libpod
|
||||
* has no "recent events for a container fleet" convenience endpoint, only
|
||||
* raw /events streaming, which is a separate feature (its own connection
|
||||
* lifecycle, not a snapshot this summary call can produce) rather than
|
||||
* something to fake with sample data.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -26,12 +29,16 @@
|
||||
}
|
||||
|
||||
P.el('stat-running').textContent = summary.containers.running;
|
||||
P.el('stat-running').className = summary.containers.running > 0 ? 'tone-good' : '';
|
||||
P.el('stat-running-total').textContent = '/ ' + summary.containers.total;
|
||||
P.el('stat-stopped').textContent = summary.containers.stopped;
|
||||
P.el('stat-stopped').classList.toggle('tone-bad', summary.containers.stopped > 0);
|
||||
P.el('stat-pods').textContent = summary.pods;
|
||||
P.el('stat-images').textContent = summary.images;
|
||||
P.el('stat-volumes').textContent = summary.volumes;
|
||||
P.el('stat-networks').textContent = summary.networks;
|
||||
P.el('stat-images-size').textContent = summary.storage.imagesSizeFormatted;
|
||||
|
||||
renderResourceUsage(summary.host, summary.storage);
|
||||
|
||||
const meta = P.el('podman-header-meta');
|
||||
if (meta) {
|
||||
@@ -42,11 +49,96 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Bars stay accent-colored under normal load and only shift to warn/bad
|
||||
// once usage is high enough to actually be worth noticing at a glance —
|
||||
// a bar that's always the same color regardless of value doesn't tell
|
||||
// you anything a number alone didn't.
|
||||
function barSeverityClass(percent) {
|
||||
if (percent === null || percent === undefined) {
|
||||
return '';
|
||||
}
|
||||
if (percent >= 90) {
|
||||
return 'bad';
|
||||
}
|
||||
if (percent >= 75) {
|
||||
return 'warn';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function resourceRow(label, valueText, percent) {
|
||||
const pct = percent === null || percent === undefined ? 0 : percent;
|
||||
return (
|
||||
'<div class="podman-resource-row">' +
|
||||
'<span class="k">' + P.escapeHtml(label) + '</span>' +
|
||||
'<div class="podman-usage-mini" style="flex:1;">' +
|
||||
'<div class="track"><span class="' + barSeverityClass(percent) + '" style="width:' + pct + '%"></span></div>' +
|
||||
'<span class="num">' + pct + '%</span>' +
|
||||
'</div>' +
|
||||
'<span class="v">' + P.escapeHtml(valueText) + '</span>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderResourceUsage(host, storage) {
|
||||
if (!host || !storage) {
|
||||
return;
|
||||
}
|
||||
let html = resourceRow('CPU (' + host.cpuCount + ')', '', host.cpuPercent) +
|
||||
resourceRow('Memory', P.formatBytes(host.memUsedBytes) + ' / ' + P.formatBytes(host.memTotalBytes), host.memPercent);
|
||||
if (host.swapTotalBytes > 0) {
|
||||
html += resourceRow('Swap', P.formatBytes(host.swapUsedBytes) + ' / ' + P.formatBytes(host.swapTotalBytes),
|
||||
Math.round(host.swapUsedBytes / host.swapTotalBytes * 100));
|
||||
}
|
||||
html += resourceRow('Storage',
|
||||
storage.graphUsedFormatted + (storage.graphAllocatedBytes ? ' / ' + storage.graphAllocatedFormatted : '') +
|
||||
' (' + storage.imagesSizeFormatted + ' images)',
|
||||
storage.graphUsedPercent);
|
||||
if (host.uptime) {
|
||||
html += '<div class="hint" style="margin-top:10px;">Uptime: ' + P.escapeHtml(host.uptime) + '</div>';
|
||||
}
|
||||
P.el('dashboard-resource-rows').innerHTML = html;
|
||||
}
|
||||
|
||||
const STATUS_CHIP_CLASS = {
|
||||
started: 'podman-chip-good',
|
||||
stopped: 'podman-chip-neutral',
|
||||
failed: 'podman-chip-bad',
|
||||
'safe-mode': 'podman-chip-warn',
|
||||
unknown: 'podman-chip-neutral',
|
||||
};
|
||||
|
||||
function renderAutostartQueue(data) {
|
||||
const tbody = P.el('dashboard-autostart-tbody');
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
const entries = data.entries || [];
|
||||
if (!entries.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="podman-empty-note">No containers configured for autostart.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = entries.map(function (e) {
|
||||
const chipClass = STATUS_CHIP_CLASS[e.status] || 'podman-chip-neutral';
|
||||
return '<tr>' +
|
||||
'<td class="tnum">' + e.position + '</td>' +
|
||||
'<td>' + P.escapeHtml(e.name) + '</td>' +
|
||||
'<td class="tnum">' + (e.delaySeconds > 0 ? e.delaySeconds + 's' : '—') + '</td>' +
|
||||
'<td><span class="podman-chip ' + chipClass + '"><span class="d"></span>' + P.escapeHtml(e.statusLabel) + '</span></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function load() {
|
||||
return P.get('system', 'summary').then(render).catch(function (err) {
|
||||
return P.get('system', 'summary').then(function (summary) {
|
||||
render(summary);
|
||||
if (summary.reachable) {
|
||||
return P.get('system', 'autostart_queue').then(renderAutostartQueue);
|
||||
}
|
||||
}).catch(function (err) {
|
||||
P.el('podman-panel-dashboard').innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('dashboard', { init: load, refresh: load });
|
||||
P.registerPanel('dashboard', { init: load, refresh: load, autoRefresh: true });
|
||||
})();
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
// removes every image on the host.
|
||||
const unused = images.filter(function (img) { return img.usedBy === 0; });
|
||||
if (!unused.length) {
|
||||
alert('No unused images to remove — every image is referenced by at least one container.');
|
||||
P.toast('No unused images to remove — every image is referenced by at least one container.', 'info');
|
||||
return;
|
||||
}
|
||||
const totalBytes = unused.reduce(function (sum, img) { return sum + img.sizeBytes; }, 0);
|
||||
@@ -81,11 +81,11 @@
|
||||
btn.disabled = true;
|
||||
P.post('images', 'prune').then(function (result) {
|
||||
btn.disabled = false;
|
||||
alert('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.');
|
||||
P.toast('Removed ' + result.removedCount + ' image(s), reclaimed ' + P.formatBytes(result.reclaimedBytes) + '.', 'success');
|
||||
return load();
|
||||
}).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
alert('Prune failed: ' + err.message);
|
||||
P.toast('Prune failed: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@
|
||||
if (!confirm('Remove this image?')) return;
|
||||
btn.disabled = true;
|
||||
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
|
||||
alert('Remove failed: ' + err.message);
|
||||
P.toast('Remove failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
if (!confirm('Remove network "' + name + '"?')) return;
|
||||
btn.disabled = true;
|
||||
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
alert('Remove failed: ' + err.message);
|
||||
P.toast('Remove failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
|
||||
function handleAction(name, action, extra) {
|
||||
return P.post('pods', action, Object.assign({ name: name }, extra)).then(load).catch(function (err) {
|
||||
alert('Action failed: ' + err.message);
|
||||
P.toast('Action failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let autostartNames = [];
|
||||
let allContainerNames = [];
|
||||
|
||||
function renderAutostart() {
|
||||
const tbody = P.el('autostart-tbody');
|
||||
@@ -24,11 +25,24 @@
|
||||
'</div></td></tr>';
|
||||
}).join('')
|
||||
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
|
||||
renderAddOptions();
|
||||
}
|
||||
|
||||
// Repopulated after every add/remove so it only ever offers containers
|
||||
// that exist and aren't already in the chain — avoids duplicate entries
|
||||
// and offering a container name that no longer exists.
|
||||
function renderAddOptions() {
|
||||
const select = P.el('autostart-add-select');
|
||||
const available = allContainerNames.filter(function (name) { return autostartNames.indexOf(name) === -1; });
|
||||
select.innerHTML = available.length
|
||||
? available.map(function (name) { return '<option value="' + P.escapeHtml(name) + '">' + P.escapeHtml(name) + '</option>'; }).join('')
|
||||
: '<option value="">All containers already added</option>';
|
||||
P.el('autostart-add-btn').disabled = !available.length;
|
||||
}
|
||||
|
||||
function saveAutostart() {
|
||||
return P.post('settings', 'autostart_save', { names: autostartNames }).catch(function (err) {
|
||||
alert('Could not save autostart order: ' + err.message);
|
||||
P.toast('Could not save autostart order: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,8 +66,19 @@
|
||||
}
|
||||
|
||||
function load() {
|
||||
return P.get('settings', 'get').then(fillForm).catch(function (err) {
|
||||
alert('Could not load settings: ' + err.message);
|
||||
return Promise.all([
|
||||
P.get('settings', 'get').then(fillForm),
|
||||
// Needed for the "Add container" dropdown — a separate, tolerant
|
||||
// fetch so a containers.php hiccup doesn't also break the rest of
|
||||
// Settings (autostart reorder/remove don't need this list at all).
|
||||
P.get('containers', 'list').then(function (containers) {
|
||||
allContainerNames = containers.map(function (c) { return c.name; }).sort();
|
||||
renderAddOptions();
|
||||
}).catch(function () {
|
||||
allContainerNames = [];
|
||||
}),
|
||||
]).catch(function (err) {
|
||||
P.toast('Could not load settings: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -170,11 +195,11 @@
|
||||
P.post('disks', 'format', { device: select.value }).then(function (data) {
|
||||
close();
|
||||
P.el('settings-storage-path').value = data.mountPath;
|
||||
alert('Formatted and mounted at ' + data.mountPath + '. Storage path has been filled in below — click "Save Settings" to use it, then Restart Podman.');
|
||||
P.toast('Formatted and mounted at ' + data.mountPath + '. Click "Save Settings" below, then Restart Podman.', 'warn');
|
||||
}).catch(function (err) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Format Disk';
|
||||
alert('Format failed: ' + err.message);
|
||||
P.toast('Format failed: ' + err.message, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -190,15 +215,24 @@
|
||||
stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10,
|
||||
};
|
||||
return P.post('settings', 'save', body).then(function () {
|
||||
alert('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.');
|
||||
P.toast('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.', 'warn');
|
||||
}).catch(function (err) {
|
||||
alert('Save failed: ' + err.message);
|
||||
P.toast('Save failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('settings-save-btn').addEventListener('click', save);
|
||||
|
||||
P.el('autostart-add-btn').addEventListener('click', function () {
|
||||
const select = P.el('autostart-add-select');
|
||||
const name = select.value;
|
||||
if (!name) return;
|
||||
autostartNames.push(name);
|
||||
renderAutostart();
|
||||
saveAutostart();
|
||||
});
|
||||
|
||||
P.el('autostart-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
P.openCreateContainerModal(config);
|
||||
}).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
alert('Could not load template: ' + err.message);
|
||||
P.toast('Could not load template: ' + err.message, 'error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -97,7 +97,7 @@
|
||||
URL.revokeObjectURL(url);
|
||||
}).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
alert('Export failed: ' + err.message);
|
||||
P.toast('Export failed: ' + err.message, 'error');
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -107,7 +107,7 @@
|
||||
btn.disabled = true;
|
||||
P.post('templates', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
btn.disabled = false;
|
||||
alert('Delete failed: ' + err.message);
|
||||
P.toast('Delete failed: ' + err.message, 'error');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
if (!confirm('Remove volume "' + name + '"? This deletes its data.')) return;
|
||||
btn.disabled = true;
|
||||
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
alert('Remove failed: ' + err.message);
|
||||
P.toast('Remove failed: ' + err.message, 'error');
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user