/** * javascript/containers.js * * Containers panel: table of all containers with lifecycle actions * (start/stop/restart/remove), backed entirely by ajax/containers.php. */ (function () { 'use strict'; const P = window.Podman; let allContainers = []; let filter = 'all'; let searchTerm = ''; // Keyed by image reference (not container id) — several containers // commonly share the same image, and ajax/containers.php's // check_updates action itself already dedupes registry requests the // same way. Persists across load()/renderTable() refreshes so the // 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 '' + fallback + ''; } return ''; } 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 ''; } function folderHeaderHtml(f, members) { const collapsed = !!collapsedFolders[f.id]; const runningCount = members.filter(function (c) { return c.state === 'running'; }).length; return '' + '' + '
' + '' + '
' + members.map(folderMemberChipHtml).join('') + '
' + '' + '
'; } function hasUpdate(c) { const status = imageUpdateStatus[c.image]; return !!(status && status.updateAvailable); } function rowHtml(c) { const cpuMem = c.state === 'running' && c.cpuPercent != null ? '' + c.cpuPercent.toFixed(1) + '% / ' + P.formatBytes(c.memUsageBytes) + '' : ''; const updateBadge = hasUpdate(c) ? ' ↑ Update' : ''; // A plain , 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 ? ' URL' : ''; return '' + '' + '' + P.escapeHtml(c.health || c.state) + '' + '
' + webuiLink + updateBadge + '
' + '' + P.escapeHtml(c.image) + '' + '' + cpuMem + '' + '' + P.escapeHtml(c.ports.join(', ') || '—') + '' + '' + P.formatDuration(c.uptimeSeconds) + '' + '
' + actionButtons(c) + '
' + ''; } function actionButtons(c) { const updateBtn = hasUpdate(c) ? '' : ''; if (c.state === 'running') { return updateBtn + '' + '' + ''; } if (c.state === 'paused') { return updateBtn + '' + ''; } return updateBtn + '' + ''; } 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', danger: true, disabled: c.state === 'running', onClick: function () { handleAction(c.id, 'remove'); }, }); P.openContextMenu(anchorBtn, items); } function openRenameModal(c) { P.openFormModal({ title: 'Rename Container', submitLabel: 'Rename', fields: [{ name: 'name', label: 'New name', required: true, placeholder: c.name }], onSubmit: function (values) { return P.post('containers', 'rename', { id: c.id, name: values.name }).then(load); }, }); } // --- Edit (recreate) -------------------------------------------------------- // // Podman/Docker have no "modify a running container" API for most of // this (image, ports, volumes, env, ...) — the only real way to "edit" // is to stop the old one, remove it (this does NOT touch named volumes, // only the container itself), and create a new one under the same name // with the changed settings. Same pattern Unraid's own Docker Manager // and every other Docker/Podman WebUI uses. Reuses the existing // "inspect" action (already fetched for the detail modal) rather than // adding a new endpoint — envToPrefill()/etc. below just reshape that // same raw libpod inspect JSON into openCreateContainerModal's prefill // shape. // Auto-injected by the container runtime itself, not something a user // set through this form — dropped so the edit form isn't full of noise // that didn't come from the original Create Container submission. const AUTO_ENV_KEYS = ['PATH', 'HOSTNAME', 'HOME', 'container', 'TERM']; function inspectToPrefill(c, d) { const cfg = d.Config || {}; const hostCfg = d.HostConfig || {}; const ports = []; Object.keys((hostCfg.PortBindings) || {}).forEach(function (key) { const [containerPort, protocol] = key.split('/'); ((hostCfg.PortBindings[key]) || []).forEach(function (binding) { ports.push({ hostPort: binding.HostPort, containerPort: containerPort, protocol: protocol || 'tcp' }); }); }); const volumes = (d.Mounts || []).reduce(function (list, m) { if (m.Type === 'bind') { list.push({ kind: 'path', source: m.Source, containerPath: m.Destination }); } else if (m.Type === 'volume') { list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination }); } return list; }, []); const env = (cfg.Env || []).reduce(function (list, line) { const idx = line.indexOf('='); const key = idx === -1 ? line : line.slice(0, idx); if (AUTO_ENV_KEYS.indexOf(key) === -1) { list.push({ key: key, value: idx === -1 ? '' : line.slice(idx + 1) }); } return list; }, []); // Only the /dev/dri paths our own GPU passthrough checkbox could have // added — same host-path pattern ajax/containers.php's build_container_ // spec() validates against, so a container with some unrelated device // mapping (added outside this UI) doesn't get misread as a GPU pick. const gpuDevices = (hostCfg.Devices || []) .map(function (dev) { return dev.PathOnHost; }) .filter(function (path) { return /^\/dev\/dri\/(card|renderD)\d+$/.test(path); }); // Only meaningful on a macvlan network (see updateNetworkFieldsVisibility() // in openCreateContainerModal) — the container's actual address on // that network, so editing one doesn't blank out an IP it was // deliberately given. const netName = hostCfg.NetworkMode; const netInfo = d.NetworkSettings && d.NetworkSettings.Networks && d.NetworkSettings.Networks[netName]; const staticIp = netInfo && netInfo.IPAddress ? netInfo.IPAddress : ''; return { name: (d.Name || c.name || '').replace(/^\//, ''), image: cfg.Image || c.image, networkMode: hostCfg.NetworkMode || 'bridge', staticIp: staticIp, pod: c.podName || '', privileged: !!hostCfg.Privileged, restartPolicy: (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || 'no', ports: ports, volumes: volumes, env: env, gpuDevices: gpuDevices, icon: c.icon || '', webUrl: c.webUrl || '', }; } function openEditContainerModal(c) { P.get('containers', 'inspect', { id: c.id }).then(function (d) { openCreateContainerModal(inspectToPrefill(c, d), { id: c.id }); }).catch(function (err) { P.toast('Could not load container config: ' + err.message, 'error'); }); } // --- Update (pull + recreate, unchanged settings) --------------------------- // // "Update" is the same stop/remove/recreate as Edit — see that comment // above — except nothing in the config changes and an image pull happens // first. Reuses inspectToPrefill() so both features read a container's // current settings the exact same way. // // Both this and checkForUpdates()/updateAll() below take a `log` // callback and write one line per step to it — a plain confirm()/alert() // at the very end left no visible sign anything was happening while a // check or a several-container update ran (found live: clicking "Check // for Updates" against two already-current images looked completely // inert). See app.js's openLogModal() for the small scrolling log window // these lines end up in. function updateContainer(c, log) { return P.get('containers', 'inspect', { id: c.id }).then(function (d) { const prefill = inspectToPrefill(c, d); log('Pulling ' + prefill.image + '…'); return P.post('images', 'pull', { reference: prefill.image }) .then(function () { log('Stopping ' + c.name + '…'); return P.post('containers', 'stop', { id: c.id }).catch(function () { /* already stopped is fine */ }); }) .then(function () { log('Removing old container…'); return P.post('containers', 'remove', { id: c.id, force: true }); }) .then(function () { log('Creating new container…'); return P.post('containers', 'create', { image: prefill.image, name: prefill.name, networkMode: prefill.networkMode, staticIp: prefill.staticIp, pod: prefill.pod, ports: prefill.ports, volumes: prefill.volumes, env: prefill.env, restartPolicy: prefill.restartPolicy, gpuDevices: prefill.gpuDevices, privileged: prefill.privileged, icon: prefill.icon, webuiUrl: prefill.webUrl, startAfterCreate: true, }); }).then(function () { // The image just pulled is now current — clear the stale flag // for it specifically rather than wiping every row's status, // since other images may still be genuinely outdated. delete imageUpdateStatus[prefill.image]; log('Done: ' + c.name + ' is up to date.'); }); }); } function checkForUpdates() { const modal = P.openLogModal('Check for Updates'); modal.log('Checking every image currently in use…'); return P.get('containers', 'check_updates').then(function (results) { imageUpdateStatus = results; let updatable = 0; Object.keys(results).forEach(function (ref) { const r = results[ref]; if (r.error) { modal.log('! ' + ref + ' — ' + r.error); } else if (r.updateAvailable) { updatable++; modal.log('↑ ' + ref + ' — update available'); } else { modal.log('✓ ' + ref + ' — up to date'); } }); modal.log(''); modal.log(updatable ? updatable + ' image(s) have an update available.' : 'Everything is up to date.'); modal.done(); renderTable(); }).catch(function (err) { modal.log('Check failed: ' + err.message); modal.done(); }); } function updateAll() { const btn = P.el('containers-update-all-btn'); btn.disabled = true; const modal = P.openLogModal('Update All'); modal.log('Checking every image currently in use…'); P.get('containers', 'check_updates').then(function (results) { imageUpdateStatus = results; renderTable(); const targets = allContainers.filter(hasUpdate); if (!targets.length) { modal.log('Everything is already up to date.'); modal.done(); btn.disabled = false; return; } modal.log(targets.length + ' container(s) to update: ' + targets.map(function (c) { return c.name; }).join(', ')); modal.log(''); // Sequential, not parallel — several containers stopping/recreating // at once is harder to reason about if one of them fails partway, // and avoids hammering the same registry with simultaneous pulls. const failures = []; targets.reduce(function (chain, c) { return chain.then(function () { return updateContainer(c, modal.log).catch(function (err) { modal.log('Failed: ' + c.name + ' — ' + err.message); failures.push(c.name); }); }); }, Promise.resolve()).then(function () { modal.log(''); 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(); }); }).catch(function (err) { modal.log('Check failed: ' + err.message); modal.done(); btn.disabled = false; }); } // --- Detail view ----------------------------------------------------------- // // 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 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 '
None.
'; return '' + rows.map(function (r) { return ''; }).join('') + '
' + P.escapeHtml(r[0]) + '' + P.escapeHtml(r[1]) + '
'; } 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 '
No mounts.
'; return '' + '' + mounts.map(function (m) { const mode = m.RW ? 'rw' : 'ro'; return ''; }).join('') + '
TypeSource → Destination
' + P.escapeHtml(m.Type || '') + '' + P.escapeHtml(m.Source || '') + ' → ' + P.escapeHtml(m.Destination || '') + ' (' + mode + ')
'; } function renderNetworksTab(d) { const networks = (d.NetworkSettings && d.NetworkSettings.Networks) || {}; const names = Object.keys(networks); if (names.length === 0) return '
No networks (host or none mode).
'; return names.map(function (name) { const n = networks[name]; return '
' + P.escapeHtml(name) + '
' + kvTable([ ['IP address', n.IPAddress || '—'], ['Gateway', n.Gateway || '—'], ['MAC address', n.MacAddress || '—'], ['Aliases', (n.Aliases || []).join(', ') || '—'], ]) + '
'; }).join(''); } function renderInspectTab(d) { return '
' + P.escapeHtml(JSON.stringify(d, null, 2)) + '
'; } // 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 '
No log output.
'; return '
' + lines.map(function (line) { const cls = /\berror\b/i.test(line) ? 'lvl-error' : (/\bwarn(ing)?\b/i.test(line) ? 'lvl-warn' : ''); return '
' + P.escapeHtml(line) + '
'; }).join('') + '
'; }); } // 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 '
No events in the last 7 days.
'; const rows = events.slice().reverse(); return '' + '' + rows.map(function (e) { const when = new Date(e.time * 1000).toLocaleString(); return '' + ''; }).join('') + '
TimeEvent
' + P.escapeHtml(P.formatRelativeTime(e.time)) + '' + P.escapeHtml(e.Action || e.status || '') + '
'; }); } function renderHealthTab(d) { const health = (d.State && d.State.Health) || null; if (!health || !Array.isArray(health.Log) || !health.Log.length) { return '
No healthcheck configured for this container.
'; } const rows = health.Log.slice().reverse(); return '' + '' + rows.map(function (entry) { const ok = entry.ExitCode === 0; return '' + '' + ''; }).join('') + '
TimeResultOutput
' + P.escapeHtml(entry.Start || '') + '' + (ok ? 'ok' : 'exit ' + entry.ExitCode) + '' + P.escapeHtml((entry.Output || '').trim().slice(0, 300)) + '
'; } // 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 '
Container must be running to open a console.
'; } return '' + '
' + '' + '' + '' + '
' + '

Pick a shell and click "Open Console".

'; } 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 = '

Opening console…

'; 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 = ''; }, 200); }).catch(function (err) { wrap.innerHTML = '

Could not open console: ' + P.escapeHtml(err.message) + '

'; }).finally(function () { openBtn.disabled = false; }); }); disconnectBtn.addEventListener('click', function () { disconnectBtn.disabled = true; closeSession().finally(function () { body.querySelector('#detail-term-frame-wrap').innerHTML = '

Disconnected.

'; }); }); 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: 'events', label: 'Events', render: renderEventsTab }, { id: 'health', label: 'Healthcheck', render: renderHealthTab }, { id: 'inspect', label: 'Inspect (JSON)', render: renderInspectTab }, ]; function openDetailModal(c) { const backdrop = document.createElement('div'); backdrop.className = 'podman-modal-backdrop'; backdrop.innerHTML = '' + ''; (document.querySelector('.podman-plugin') || document.body).appendChild(backdrop); // 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') { 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; }); const result = tab.render(data, c); if (result && typeof result.then === 'function') { body.innerHTML = '
Loading…
'; result.then(function (html) { body.innerHTML = html; if (tab.wire) activeCleanup = tab.wire(body, data, c) || null; }).catch(function (err) { body.innerHTML = '
' + P.escapeHtml(err.message) + '
'; }); } 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 () { 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 = '
' + P.escapeHtml(err.message) + '
'; }); } function applyFilters() { return allContainers.filter(function (c) { if (filter === 'running' && c.state !== 'running') return false; if (filter === 'stopped' && c.state === 'running') return false; if (searchTerm && c.name.toLowerCase().indexOf(searchTerm) === -1 && c.image.toLowerCase().indexOf(searchTerm) === -1) return false; return true; }); } function renderTable() { const tbody = P.el('containers-tbody'); const visible = applyFilters(); if (!visible.length) { tbody.innerHTML = 'No containers match.'; 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 || 'No containers match.'; } function renderCounts() { const running = allContainers.filter(function (c) { return c.state === 'running'; }).length; P.el('containers-count-all').textContent = 'All ' + allContainers.length; P.el('containers-count-running').textContent = 'Running ' + running; P.el('containers-count-stopped').textContent = 'Stopped ' + (allContainers.length - running); } function load() { const tbody = P.el('containers-tbody'); // 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(); renderTable(); }).catch(function (err) { tbody.innerHTML = P.errorRow(7, err.message); }); } // --- Create Container ----------------------------------------------------- // // Purpose-built modal (not app.js's generic openFormModal, which only // supports flat text fields) — port/volume/env rows are dynamic // add/remove groups, and network needs a ' + '' + '' + '' + '' + ''; } function volumeRowHtml() { return '' + '
' + '' + '' + '' + '' + '' + '
'; } function envRowHtml() { return '' + '
' + '' + '=' + '' + '' + '
'; } function addRow(groupEl, rowHtmlFn, values) { const div = document.createElement('div'); div.innerHTML = rowHtmlFn(); const row = div.firstElementChild; row.querySelector('[data-remove-row]').addEventListener('click', function () { row.remove(); }); if (values) { row.querySelectorAll('[data-field]').forEach(function (input) { if (values[input.dataset.field] !== undefined) input.value = values[input.dataset.field]; }); } groupEl.appendChild(row); } function readRows(groupEl) { return Array.from(groupEl.children).map(function (row) { const values = {}; row.querySelectorAll('[data-field]').forEach(function (input) { values[input.dataset.field] = input.value.trim(); }); return values; }); } /** * @param {object|null} prefill Optional template data (same shape * ajax/templates.php's "get" action returns, plus "name"/"pod" which * only inspectToPrefill() sets) to seed the form with — used by * templates.js's "Use template" action and openEditContainerModal() * below. null/omitted opens a blank form, same as the toolbar's * "+ New Container" button. * @param {{id:string}|null} editing When set, this is an edit of an * existing container rather than a fresh create: submitting stops and * removes container `editing.id` first, then creates a new one under * whatever name/settings are in the form (see the Podman/Docker have * no in-place "modify" API comment on openEditContainerModal above). */ function openCreateContainerModal(prefill, editing) { prefill = prefill || {}; const backdrop = document.createElement('div'); backdrop.className = 'podman-modal-backdrop'; backdrop.innerHTML = '' + ''; (document.querySelector('.podman-plugin') || document.body).appendChild(backdrop); 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; if (prefill.staticIp) backdrop.querySelector('#cc-static-ip').value = prefill.staticIp; // Macvlan containers get their own address directly on the LAN (see // the ajax/networks.php macvlan work) — port mappings are meaningless // for them (there's no host-side NAT to map through) and a static IP // becomes a relevant option instead of a Bridge/Host/None-only // concept. Toggled on network-select change and once up front below, // driven by each