/** * 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 = {}; function iconLabel(name) { return P.escapeHtml(name.slice(0, 2).toUpperCase()); } 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' : ''; return '' + '' + '' + P.escapeHtml(c.health || c.state) + '' + '' + 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 = []; 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); } }); 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, }; } function openEditContainerModal(c) { 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); }); } // --- 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, 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.'); modal.done(); btn.disabled = false; return load(); }); }).catch(function (err) { modal.log('Check failed: ' + err.message); modal.done(); btn.disabled = false; }); } // --- 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 '
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)) + '
'; } 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 = '' + ''; (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 = '
' + 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(); tbody.innerHTML = visible.length ? visible.map(rowHtml).join('') : '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'); 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.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