Add container detail view (Overview/Environment/Labels/Mounts/Networks/Inspect tabs)
Clicking a container's name now opens a tabbed detail modal, fed entirely
by the existing inspect action's raw libpod data — no new backend needed.
Field names (Config.Env, Config.Labels, Mounts[].Source/Destination/RW,
NetworkSettings.Networks{}.IPAddress/Gateway/MacAddress, HostConfig.
RestartPolicy) verified against a real inspect response before building
the tabs around them.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,8 @@
|
|||||||
return '' +
|
return '' +
|
||||||
'<tr data-id="' + P.escapeHtml(c.id) + '">' +
|
'<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><span class="podman-chip ' + P.stateChipClass(c.state) + '"><span class="d"></span>' + P.escapeHtml(c.health || c.state) + '</span></td>' +
|
||||||
'<td><div class="podman-row-name"><span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</div></td>' +
|
'<td><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></td>' +
|
||||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
|
'<td class="mono podman-row-sub">' + P.escapeHtml(c.image) + '</td>' +
|
||||||
'<td>' + cpuMem + '</td>' +
|
'<td>' + cpuMem + '</td>' +
|
||||||
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '—') + '</td>' +
|
'<td class="mono podman-row-sub">' + P.escapeHtml(c.ports.join(', ') || '—') + '</td>' +
|
||||||
@@ -77,6 +78,132 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 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.
|
||||||
|
|
||||||
|
function kvTable(rows) {
|
||||||
|
if (rows.length === 0) return '<div class="podman-detail-empty">None.</div>';
|
||||||
|
return '<table class="podman-detail-table">' + rows.map(function (r) {
|
||||||
|
return '<tr><td>' + P.escapeHtml(r[0]) + '</td><td class="mono">' + P.escapeHtml(r[1]) + '</td></tr>';
|
||||||
|
}).join('') + '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderOverviewTab(d) {
|
||||||
|
const cfg = d.Config || {};
|
||||||
|
const hostCfg = d.HostConfig || {};
|
||||||
|
return kvTable([
|
||||||
|
['Name', (d.Name || '').replace(/^\//, '')],
|
||||||
|
['ID', d.Id || ''],
|
||||||
|
['Image', cfg.Image || d.Image || ''],
|
||||||
|
['Created', d.Created || ''],
|
||||||
|
['Command', (cfg.Cmd || []).join(' ') || (cfg.Entrypoint || []).join(' ') || '—'],
|
||||||
|
['State', (d.State && d.State.Status) || '—'],
|
||||||
|
['Restart policy', (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || '—'],
|
||||||
|
['Restart count', String(d.RestartCount || 0)],
|
||||||
|
['Privileged', hostCfg.Privileged ? 'yes' : 'no'],
|
||||||
|
['Working dir', cfg.WorkingDir || '—'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEnvTab(d) {
|
||||||
|
const env = (d.Config && d.Config.Env) || [];
|
||||||
|
return kvTable(env.map(function (line) {
|
||||||
|
const idx = line.indexOf('=');
|
||||||
|
return idx === -1 ? [line, ''] : [line.slice(0, idx), line.slice(idx + 1)];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLabelsTab(d) {
|
||||||
|
const labels = (d.Config && d.Config.Labels) || {};
|
||||||
|
return kvTable(Object.keys(labels).map(function (k) { return [k, labels[k]]; }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMountsTab(d) {
|
||||||
|
const mounts = d.Mounts || [];
|
||||||
|
if (mounts.length === 0) return '<div class="podman-detail-empty">No mounts.</div>';
|
||||||
|
return '<table class="podman-detail-table">' +
|
||||||
|
'<tr><td>Type</td><td>Source → Destination</td></tr>' +
|
||||||
|
mounts.map(function (m) {
|
||||||
|
const mode = m.RW ? 'rw' : 'ro';
|
||||||
|
return '<tr><td>' + P.escapeHtml(m.Type || '') + '</td><td class="mono">' +
|
||||||
|
P.escapeHtml(m.Source || '') + ' → ' + P.escapeHtml(m.Destination || '') +
|
||||||
|
' <span class="podman-row-sub">(' + mode + ')</span></td></tr>';
|
||||||
|
}).join('') + '</table>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNetworksTab(d) {
|
||||||
|
const networks = (d.NetworkSettings && d.NetworkSettings.Networks) || {};
|
||||||
|
const names = Object.keys(networks);
|
||||||
|
if (names.length === 0) return '<div class="podman-detail-empty">No networks (host or none mode).</div>';
|
||||||
|
return names.map(function (name) {
|
||||||
|
const n = networks[name];
|
||||||
|
return '<div style="margin-bottom:14px;"><div style="font-weight:700; font-size:12.5px; margin-bottom:6px;">' +
|
||||||
|
P.escapeHtml(name) + '</div>' + kvTable([
|
||||||
|
['IP address', n.IPAddress || '—'],
|
||||||
|
['Gateway', n.Gateway || '—'],
|
||||||
|
['MAC address', n.MacAddress || '—'],
|
||||||
|
['Aliases', (n.Aliases || []).join(', ') || '—'],
|
||||||
|
]) + '</div>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInspectTab(d) {
|
||||||
|
return '<div class="podman-detail-json">' + P.escapeHtml(JSON.stringify(d, null, 2)) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETAIL_TABS = [
|
||||||
|
{ id: 'overview', label: 'Overview', render: renderOverviewTab },
|
||||||
|
{ 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: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab },
|
||||||
|
];
|
||||||
|
|
||||||
|
function openDetailModal(c) {
|
||||||
|
const backdrop = document.createElement('div');
|
||||||
|
backdrop.className = 'podman-modal-backdrop';
|
||||||
|
backdrop.innerHTML = '' +
|
||||||
|
'<div class="podman-modal podman-modal-xwide" role="dialog" aria-modal="true">' +
|
||||||
|
'<div class="podman-modal-head"><h3>' + P.escapeHtml(c.name) + '</h3></div>' +
|
||||||
|
'<div class="podman-detail-tabs">' + DETAIL_TABS.map(function (t, i) {
|
||||||
|
return '<button type="button" data-tab="' + t.id + '"' + (i === 0 ? ' class="active"' : '') + '>' + t.label + '</button>';
|
||||||
|
}).join('') + '</div>' +
|
||||||
|
'<div class="podman-detail-body"><div class="podman-loading">Loading…</div></div>' +
|
||||||
|
'<div class="podman-modal-actions"><button type="button" class="podman-btn" data-role="cancel">Close</button></div>' +
|
||||||
|
'</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(); });
|
||||||
|
document.addEventListener('keydown', function onKey(e) {
|
||||||
|
if (e.key === 'Escape') { backdrop.remove(); document.removeEventListener('keydown', onKey); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = backdrop.querySelector('.podman-detail-body');
|
||||||
|
P.get('containers', 'inspect', { id: c.id }).then(function (data) {
|
||||||
|
function showTab(tabId) {
|
||||||
|
const tab = DETAIL_TABS.find(function (t) { return t.id === tabId; });
|
||||||
|
body.innerHTML = tab.render(data);
|
||||||
|
}
|
||||||
|
backdrop.querySelectorAll('[data-tab]').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
backdrop.querySelectorAll('[data-tab]').forEach(function (b) { b.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
showTab(btn.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
showTab('overview');
|
||||||
|
}).catch(function (err) {
|
||||||
|
body.innerHTML = '<div class="podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function applyFilters() {
|
function applyFilters() {
|
||||||
return allContainers.filter(function (c) {
|
return allContainers.filter(function (c) {
|
||||||
if (filter === 'running' && c.state !== 'running') return false;
|
if (filter === 'running' && c.state !== 'running') return false;
|
||||||
@@ -328,9 +455,14 @@
|
|||||||
if (!btn || btn.disabled) return;
|
if (!btn || btn.disabled) return;
|
||||||
const row = btn.closest('tr');
|
const row = btn.closest('tr');
|
||||||
const id = row.dataset.id;
|
const id = row.dataset.id;
|
||||||
if (btn.dataset.action === 'menu') {
|
if (btn.dataset.action === 'menu' || btn.dataset.action === 'details') {
|
||||||
const c = allContainers.find(function (x) { return x.id === id; });
|
const c = allContainers.find(function (x) { return x.id === id; });
|
||||||
if (c) openRowMenu(c, btn);
|
if (!c) return;
|
||||||
|
if (btn.dataset.action === 'menu') {
|
||||||
|
openRowMenu(c, btn);
|
||||||
|
} else {
|
||||||
|
openDetailModal(c);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
handleAction(id, btn.dataset.action, btn);
|
handleAction(id, btn.dataset.action, btn);
|
||||||
|
|||||||
@@ -145,6 +145,12 @@
|
|||||||
.podman-plugin tbody tr:hover { background: var(--surface-2); }
|
.podman-plugin tbody tr:hover { background: var(--surface-2); }
|
||||||
.podman-table-wrap { overflow-x: auto; }
|
.podman-table-wrap { overflow-x: auto; }
|
||||||
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
.podman-row-name { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||||
|
.podman-row-name-btn {
|
||||||
|
appearance: none; border: none; background: none; padding: 0; cursor: pointer;
|
||||||
|
color: var(--text); font-family: var(--font-ui); font-size: 13px; text-align: left;
|
||||||
|
}
|
||||||
|
.podman-row-name-btn:hover { color: var(--accent-strong); }
|
||||||
|
.podman-row-name-btn:hover .ico { border-color: var(--accent); }
|
||||||
.podman-row-name .ico {
|
.podman-row-name .ico {
|
||||||
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
|
width: 26px; height: 26px; border-radius: 6px; flex: none; background: var(--surface-3);
|
||||||
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
|
display: grid; place-items: center; font-size: 12px; border: 1px solid var(--border); color: var(--text-dim);
|
||||||
@@ -286,3 +292,26 @@
|
|||||||
.podman-context-menu button.danger { color: var(--bad); }
|
.podman-context-menu button.danger { color: var(--bad); }
|
||||||
.podman-context-menu button[disabled] { opacity: .4; cursor: not-allowed; }
|
.podman-context-menu button[disabled] { opacity: .4; cursor: not-allowed; }
|
||||||
.podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }
|
.podman-context-menu-sep { height: 1px; background: var(--border); margin: 4px 2px; }
|
||||||
|
|
||||||
|
/* Container detail modal — see containers.js openDetailModal(). */
|
||||||
|
.podman-modal-xwide { max-width: 760px; }
|
||||||
|
.podman-detail-tabs {
|
||||||
|
display: flex; gap: 2px; padding: 0 20px; border-bottom: 1px solid var(--border); overflow-x: auto;
|
||||||
|
}
|
||||||
|
.podman-detail-tabs button {
|
||||||
|
appearance: none; background: none; border: none; border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-dim); padding: 10px 12px; font-size: 12.5px; font-weight: 600; cursor: pointer;
|
||||||
|
white-space: nowrap; font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
.podman-detail-tabs button:hover { color: var(--text); }
|
||||||
|
.podman-detail-tabs button.active { color: var(--accent-strong); border-bottom-color: var(--accent); }
|
||||||
|
.podman-detail-body { padding: 16px 20px; max-height: 50vh; overflow-y: auto; }
|
||||||
|
.podman-detail-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||||
|
.podman-detail-table td { padding: 6px 0; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||||
|
.podman-detail-table td:first-child { color: var(--text-faint); font-weight: 600; width: 160px; padding-right: 12px; }
|
||||||
|
.podman-detail-table td.mono { font-family: var(--font-mono); word-break: break-all; }
|
||||||
|
.podman-detail-json {
|
||||||
|
background: #0f1114; color: #c7ccd4; font-family: var(--font-mono); font-size: 11.8px;
|
||||||
|
padding: 14px 16px; border-radius: 8px; white-space: pre-wrap; word-break: break-all; line-height: 1.6;
|
||||||
|
}
|
||||||
|
.podman-detail-empty { color: var(--text-faint); font-size: 12.5px; padding: 8px 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user