/**
* 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 '
';
}
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 '
';
});
}
// 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 '
';
}
// 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) + '
';
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