Replaces every native confirm() with a shared P.confirm() modal (a hung native dialog was found live to block the whole tab, including auto-refresh, and once even double-confirmed an unrelated deletion). Also fixes Edit Container silently resetting to Bridge/blanking the Static IP for any container on a custom network, and a context menu losing its anchor to a mid-read auto-refresh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1428 lines
69 KiB
JavaScript
1428 lines
69 KiB
JavaScript
/**
|
|
* 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 '<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) {
|
|
P.confirm('Delete folder "' + f.name + '"? Its containers are not affected — they just become ungrouped.', { danger: true, confirmLabel: 'Delete' }).then(function (ok) {
|
|
if (!ok) 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);
|
|
}
|
|
|
|
function rowHtml(c) {
|
|
const cpuMem = c.state === 'running' && c.cpuPercent != null
|
|
? '<span class="tnum">' + c.cpuPercent.toFixed(1) + '%</span> <span class="podman-row-sub">/ ' + P.formatBytes(c.memUsageBytes) + '</span>'
|
|
: '<span class="podman-row-sub">—</span>';
|
|
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><div class="podman-name-cell"><button type="button" class="podman-row-name podman-row-name-btn" data-action="menu">' +
|
|
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>' +
|
|
'<td class="tnum">' + P.formatDuration(c.uptimeSeconds) + '</td>' +
|
|
'<td class="podman-actions"><div class="podman-actions-row">' + actionButtons(c) + '</div></td>' +
|
|
'</tr>';
|
|
}
|
|
|
|
function actionButtons(c) {
|
|
const updateBtn = hasUpdate(c)
|
|
? '<button class="podman-btn podman-btn-icon" data-action="update" title="Update to the newer image">↑</button>'
|
|
: '';
|
|
if (c.state === 'running') {
|
|
return updateBtn +
|
|
'<button class="podman-btn podman-btn-icon" data-action="restart" title="Restart">↻</button>' +
|
|
'<button class="podman-btn podman-btn-icon" data-action="stop" title="Stop">■</button>' +
|
|
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
|
}
|
|
if (c.state === 'paused') {
|
|
return updateBtn +
|
|
'<button class="podman-btn podman-btn-icon" data-action="unpause" title="Resume">▶</button>' +
|
|
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
|
}
|
|
return updateBtn +
|
|
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
|
'<button class="podman-btn podman-btn-icon" data-action="menu" title="More">⋮</button>';
|
|
}
|
|
|
|
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, readOnly: m.RW === false });
|
|
} else if (m.Type === 'volume') {
|
|
list.push({ kind: 'named', source: m.Name || m.Source, containerPath: m.Destination, readOnly: m.RW === false });
|
|
}
|
|
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); });
|
|
|
|
// Anything under /dev/ that ISN'T one of the GPU paths above — the
|
|
// plugin's own generic device-passthrough field (see build_container_
|
|
// spec()'s comment on why this is host-path-equals-container-path only).
|
|
const devices = (hostCfg.Devices || [])
|
|
.map(function (dev) { return dev.PathOnHost; })
|
|
.filter(function (path) { return path && !/^\/dev\/dri\/(card|renderD)\d+$/.test(path); })
|
|
.map(function (path) { return { path: path }; });
|
|
|
|
// HostConfig.NetworkMode is only reliable for "host"/"none" — a
|
|
// container attached to a CUSTOM network (e.g. a macvlan like "Lan")
|
|
// still reports NetworkMode as the generic "bridge", regardless of
|
|
// what it's actually on (verified live: a running container on "Lan"
|
|
// showed NetworkMode:"bridge" while NetworkSettings.Networks only had
|
|
// a "Lan" entry, not a "bridge" one at all). The real network's name
|
|
// is that one NetworkSettings.Networks key instead — except when it's
|
|
// podman's own literal default bridge network, named "podman", which
|
|
// maps back to our own "bridge" nsmode option. Getting this wrong
|
|
// silently reset the Network dropdown to Bridge on every edit and
|
|
// blanked out the Static IP field, even for a container that had one.
|
|
const networksMap = (d.NetworkSettings && d.NetworkSettings.Networks) || {};
|
|
const networkKeys = Object.keys(networksMap);
|
|
let networkMode = hostCfg.NetworkMode || 'bridge';
|
|
let netInfo = null;
|
|
if (networkMode === 'bridge' && networkKeys.length === 1 && networkKeys[0] !== 'podman') {
|
|
networkMode = networkKeys[0];
|
|
netInfo = networksMap[networkMode];
|
|
}
|
|
const staticIp = netInfo && netInfo.IPAddress ? netInfo.IPAddress : '';
|
|
|
|
return {
|
|
name: (d.Name || c.name || '').replace(/^\//, ''),
|
|
image: cfg.Image || c.image,
|
|
networkMode: networkMode,
|
|
staticIp: staticIp,
|
|
pod: c.podName || '',
|
|
privileged: !!hostCfg.Privileged,
|
|
restartPolicy: (hostCfg.RestartPolicy && hostCfg.RestartPolicy.Name) || 'no',
|
|
user: cfg.User || '',
|
|
ports: ports,
|
|
volumes: volumes,
|
|
env: env,
|
|
gpuDevices: gpuDevices,
|
|
devices: devices,
|
|
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,
|
|
devices: prefill.devices,
|
|
privileged: prefill.privileged,
|
|
user: prefill.user,
|
|
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<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>';
|
|
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>';
|
|
}
|
|
|
|
// 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: '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 = '' +
|
|
'<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);
|
|
|
|
// 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 = '<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 () {
|
|
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() {
|
|
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 = '<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() {
|
|
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 <select> populated from the
|
|
// real network list, none of which fits the generic helper. Reuses its
|
|
// .podman-modal-* CSS classes for visual consistency.
|
|
|
|
function portRowHtml() {
|
|
return '' +
|
|
'<div class="podman-row-group-item">' +
|
|
'<input type="text" class="mono podman-input-narrow" data-field="hostPort" placeholder="Host port">' +
|
|
'<span>→</span>' +
|
|
'<input type="text" class="mono podman-input-narrow" data-field="containerPort" placeholder="Container port">' +
|
|
'<select data-field="protocol"><option value="tcp">TCP</option><option value="udp">UDP</option></select>' +
|
|
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
function volumeRowHtml() {
|
|
return '' +
|
|
'<div class="podman-row-group-item">' +
|
|
'<select data-field="kind"><option value="named">Volume</option><option value="path">Host path</option></select>' +
|
|
'<input type="text" class="mono" data-field="source" placeholder="my-volume or /mnt/cache/...">' +
|
|
'<span>→</span>' +
|
|
'<input type="text" class="mono" data-field="containerPath" placeholder="/data">' +
|
|
'<label class="podman-row-checkbox-label" title="Mount read-only">' +
|
|
'<input type="checkbox" data-field="readOnly"> RO</label>' +
|
|
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
function deviceRowHtml() {
|
|
return '' +
|
|
'<div class="podman-row-group-item">' +
|
|
'<input type="text" class="mono" data-field="path" placeholder="/dev/ttyACM0">' +
|
|
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
function envRowHtml() {
|
|
return '' +
|
|
'<div class="podman-row-group-item">' +
|
|
'<input type="text" class="mono" data-field="key" placeholder="KEY">' +
|
|
'<span>=</span>' +
|
|
'<input type="text" class="mono" data-field="value" placeholder="value">' +
|
|
'<button type="button" class="podman-btn podman-btn-icon podman-row-remove-btn" data-remove-row title="Remove">×</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
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) return;
|
|
if (input.type === 'checkbox') {
|
|
input.checked = !!values[input.dataset.field];
|
|
} else {
|
|
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.type === 'checkbox' ? input.checked : 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 = '' +
|
|
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
|
|
'<div class="podman-modal-head"><h3>' + (editing ? 'Edit Container' : 'New Container') + '</h3></div>' +
|
|
'<form class="podman-modal-body">' +
|
|
'<div class="podman-modal-field"><label>Image</label>' +
|
|
'<input type="text" id="cc-image" placeholder="docker.io/library/postgres:16"></div>' +
|
|
'<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>' +
|
|
'<div class="podman-modal-field" id="cc-static-ip-field" style="display:none;"><label>Static IP (optional)</label>' +
|
|
'<input type="text" class="mono" id="cc-static-ip" placeholder="10.1.1.222">' +
|
|
'<div class="hint">Leave blank to let the network assign one automatically.</div></div>' +
|
|
'<div class="podman-modal-field"><label>Pod (optional)</label>' +
|
|
'<select id="cc-pod"><option value="">None</option></select>' +
|
|
'<div class="hint">Joins the pod\'s shared network namespace instead of the setting above.</div></div>' +
|
|
'<div class="podman-modal-field" id="cc-ports-field"><label>Port mappings</label>' +
|
|
'<div class="podman-row-group" id="cc-ports"></div>' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-add="port">+ Add port</button>' +
|
|
'<div class="hint" id="cc-ports-macvlan-hint" style="display:none;">Not needed on a macvlan network — the container gets its own address on the LAN.</div></div>' +
|
|
'<div class="podman-modal-field"><label>Volumes</label>' +
|
|
'<div class="podman-row-group" id="cc-volumes"></div>' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-add="volume">+ Add volume</button></div>' +
|
|
'<div class="podman-modal-field"><label>Environment variables</label>' +
|
|
'<div class="podman-row-group" id="cc-env"></div>' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-add="env">+ Add variable</button></div>' +
|
|
'<div class="podman-modal-field"><label>Restart policy</label>' +
|
|
'<select id="cc-restart"><option value="no">No</option><option value="on-failure">On failure</option>' +
|
|
'<option value="always">Always</option><option value="unless-stopped">Unless stopped</option></select></div>' +
|
|
'<div class="podman-modal-field"><label>Run as user (optional)</label>' +
|
|
'<input type="text" class="mono" id="cc-user" placeholder="99:100">' +
|
|
'<div class="hint">Overrides the image\'s own default user — needed when a bind-mounted directory is owned by a specific UID:GID (Unraid\'s own containers commonly use "99:100").</div></div>' +
|
|
'<div class="podman-modal-field" id="cc-gpu-field" style="display:none;"><label>GPU passthrough</label>' +
|
|
'<select id="cc-gpu-select"><option value="">None</option></select></div>' +
|
|
'<div class="podman-modal-field"><label>Device passthrough (optional)</label>' +
|
|
'<div class="podman-row-group" id="cc-devices"></div>' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-add="device">+ Add device</button>' +
|
|
'<div class="hint">A host device path (e.g. a USB serial adapter) mounted at the same path inside the container — for a GPU, use the field above instead.</div></div>' +
|
|
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
|
'<input type="checkbox" id="cc-privileged"> Privileged</label></div>' +
|
|
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
|
'<input type="checkbox" id="cc-start" checked> Start after create</label></div>' +
|
|
'<div class="podman-modal-field podman-modal-checkbox"><label>' +
|
|
'<input type="checkbox" id="cc-save-template"> Save as template</label></div>' +
|
|
'<div class="podman-modal-field" id="cc-template-fields" style="display:none;">' +
|
|
'<label>Template name</label><input type="text" id="cc-template-name" placeholder="my-template">' +
|
|
'<label style="margin-top:10px;">Icon URL (optional)</label><input type="text" id="cc-template-icon" placeholder="https://...">' +
|
|
'<label style="margin-top:10px;">Category (optional)</label><input type="text" id="cc-template-category" placeholder="Databases:">' +
|
|
'<label style="margin-top:10px;">Description (optional)</label><input type="text" id="cc-template-overview" placeholder="What this template runs">' +
|
|
'</div>' +
|
|
'</form>' +
|
|
'<div class="podman-modal-actions">' +
|
|
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">Cancel</button>' +
|
|
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' + (editing ? 'Save & Recreate' : 'Create') + '</button>' +
|
|
'</div></div>';
|
|
|
|
(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.user) backdrop.querySelector('#cc-user').value = prefill.user;
|
|
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 <option>'s data-driver (set when the real network
|
|
// list loads — the three built-ins are never macvlan).
|
|
function updateNetworkFieldsVisibility() {
|
|
const select = backdrop.querySelector('#cc-network');
|
|
const selectedOption = select.options[select.selectedIndex];
|
|
const isMacvlan = !!(selectedOption && selectedOption.dataset.driver === 'macvlan');
|
|
backdrop.querySelector('#cc-static-ip-field').style.display = isMacvlan ? '' : 'none';
|
|
backdrop.querySelector('#cc-ports').style.display = isMacvlan ? 'none' : '';
|
|
backdrop.querySelector('[data-add="port"]').style.display = isMacvlan ? 'none' : '';
|
|
backdrop.querySelector('#cc-ports-macvlan-hint').style.display = isMacvlan ? '' : 'none';
|
|
}
|
|
backdrop.querySelector('#cc-network').addEventListener('change', updateNetworkFieldsVisibility);
|
|
|
|
const portsGroup = backdrop.querySelector('#cc-ports');
|
|
const volumesGroup = backdrop.querySelector('#cc-volumes');
|
|
const envGroup = backdrop.querySelector('#cc-env');
|
|
const devicesGroup = backdrop.querySelector('#cc-devices');
|
|
// A template may carry zero, one, or several rows of each kind — always
|
|
// leave at least one (blank) row so the user has somewhere to type,
|
|
// matching the blank-form behavior.
|
|
(prefill.ports && prefill.ports.length ? prefill.ports : [{}]).forEach(function (row) { addRow(portsGroup, portRowHtml, row); });
|
|
(prefill.volumes && prefill.volumes.length ? prefill.volumes : [{}]).forEach(function (row) { addRow(volumesGroup, volumeRowHtml, row); });
|
|
(prefill.env && prefill.env.length ? prefill.env : [{}]).forEach(function (row) { addRow(envGroup, envRowHtml, row); });
|
|
(prefill.devices && prefill.devices.length ? prefill.devices : [{}]).forEach(function (row) { addRow(devicesGroup, deviceRowHtml, row); });
|
|
|
|
backdrop.querySelector('[data-add="port"]').addEventListener('click', function () { addRow(portsGroup, portRowHtml); });
|
|
backdrop.querySelector('[data-add="volume"]').addEventListener('click', function () { addRow(volumesGroup, volumeRowHtml); });
|
|
backdrop.querySelector('[data-add="env"]').addEventListener('click', function () { addRow(envGroup, envRowHtml); });
|
|
backdrop.querySelector('[data-add="device"]').addEventListener('click', function () { addRow(devicesGroup, deviceRowHtml); });
|
|
|
|
// Populate the network dropdown with any existing custom (non-default)
|
|
// podman networks, in addition to the built-in bridge/host/none modes
|
|
// — best-effort: if the list call fails, the three built-ins still work.
|
|
P.get('networks', 'list').then(function (networks) {
|
|
const select = backdrop.querySelector('#cc-network');
|
|
networks.filter(function (n) { return !n.isDefault; }).forEach(function (n) {
|
|
const opt = document.createElement('option');
|
|
opt.value = n.name;
|
|
opt.textContent = n.name + (n.driver === 'macvlan' ? ' (macvlan)' : '');
|
|
opt.dataset.driver = n.driver;
|
|
select.appendChild(opt);
|
|
});
|
|
// Re-applied here (not just at load time above) because a custom
|
|
// network's <option> doesn't exist yet until this list comes back —
|
|
// setting .value to it any earlier would silently no-op and leave
|
|
// the select on its default "bridge" option instead. Matters for
|
|
// openEditContainerModal(): a container already on a custom network
|
|
// needs that option to exist before it can be selected.
|
|
if (prefill.networkMode) select.value = prefill.networkMode;
|
|
updateNetworkFieldsVisibility();
|
|
}).catch(function () { /* built-in modes still usable */ });
|
|
|
|
P.get('pods', 'list').then(function (pods) {
|
|
const select = backdrop.querySelector('#cc-pod');
|
|
pods.forEach(function (p) {
|
|
const opt = document.createElement('option');
|
|
opt.value = p.name;
|
|
opt.textContent = p.name;
|
|
select.appendChild(opt);
|
|
});
|
|
if (prefill.pod) select.value = prefill.pod;
|
|
}).catch(function () { /* pod selection stays optional */ });
|
|
|
|
// Only shown when the host actually has a passthrough-capable GPU
|
|
// (AMD/Intel via /dev/dri — see ajax/containers.php's gpu_list(); NVIDIA
|
|
// is deliberately excluded there since it needs a different runtime) —
|
|
// best-effort, same as networks/pods above.
|
|
P.get('containers', 'list_gpus').then(function (gpus) {
|
|
if (!gpus.length) return;
|
|
const field = backdrop.querySelector('#cc-gpu-field');
|
|
const select = backdrop.querySelector('#cc-gpu-select');
|
|
field.style.display = '';
|
|
gpus.forEach(function (gpu, i) {
|
|
const devices = [gpu.render, gpu.card].filter(Boolean).join(', ');
|
|
const opt = document.createElement('option');
|
|
opt.value = String(i);
|
|
opt.textContent = gpu.vendor + ' GPU (' + devices + ')';
|
|
select.appendChild(opt);
|
|
});
|
|
select.dataset.gpus = JSON.stringify(gpus);
|
|
// Pre-select whichever detected GPU the container being edited is
|
|
// already using (matched by device path, not index — gpu_list()'s
|
|
// order isn't guaranteed stable across requests).
|
|
if (prefill.gpuDevices && prefill.gpuDevices.length) {
|
|
const matchIndex = gpus.findIndex(function (gpu) {
|
|
return prefill.gpuDevices.indexOf(gpu.render) !== -1 || prefill.gpuDevices.indexOf(gpu.card) !== -1;
|
|
});
|
|
if (matchIndex !== -1) select.value = String(matchIndex);
|
|
}
|
|
}).catch(function () { /* GPU passthrough stays unavailable */ });
|
|
|
|
backdrop.querySelector('#cc-image').focus();
|
|
|
|
backdrop.querySelector('#cc-save-template').addEventListener('change', function (e) {
|
|
backdrop.querySelector('#cc-template-fields').style.display = e.target.checked ? '' : 'none';
|
|
});
|
|
|
|
function close() { backdrop.remove(); }
|
|
|
|
function showError(message) {
|
|
let box = backdrop.querySelector('.podman-modal-error');
|
|
if (!box) {
|
|
box = document.createElement('div');
|
|
box.className = 'podman-modal-error';
|
|
backdrop.querySelector('.podman-modal-body').appendChild(box);
|
|
}
|
|
box.textContent = message;
|
|
}
|
|
|
|
function submit() {
|
|
if (editing) {
|
|
P.confirm(
|
|
'This stops and removes the existing container, then creates a new one with these settings under the same name. ' +
|
|
'Named volumes and bind-mounted data are not affected — only the container itself. Continue?',
|
|
{ confirmLabel: 'Continue' }
|
|
).then(function (ok) { if (ok) proceed(); });
|
|
} else {
|
|
proceed();
|
|
}
|
|
}
|
|
|
|
function proceed() {
|
|
const image = backdrop.querySelector('#cc-image').value.trim();
|
|
if (!image) {
|
|
showError('"Image" is required.');
|
|
return;
|
|
}
|
|
const name = backdrop.querySelector('#cc-name').value.trim();
|
|
// Same character set podman itself enforces — checked here too so
|
|
// a typo (most commonly a space, e.g. copying a template's display
|
|
// name straight in) gets caught before a round trip to the server.
|
|
if (name && !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
|
|
showError('"Name" can only contain letters, digits, ".", "_", "-" — no spaces. Try "' + name.replace(/[^a-zA-Z0-9_.-]+/g, '-') + '" instead.');
|
|
return;
|
|
}
|
|
const networkSelect = backdrop.querySelector('#cc-network');
|
|
const selectedNetworkOption = networkSelect.options[networkSelect.selectedIndex];
|
|
const isMacvlan = !!(selectedNetworkOption && selectedNetworkOption.dataset.driver === 'macvlan');
|
|
// Port mappings map a host port to a container port through NAT —
|
|
// meaningless on a macvlan network, where the container already has
|
|
// its own real address on the LAN (see updateNetworkFieldsVisibility()
|
|
// above, which also hides the UI for this) — so none are sent even
|
|
// if some were left over from switching the network dropdown after
|
|
// adding a few.
|
|
const ports = isMacvlan ? [] : readRows(portsGroup).filter(function (r) { return r.hostPort && r.containerPort; });
|
|
const staticIp = isMacvlan ? backdrop.querySelector('#cc-static-ip').value.trim() : '';
|
|
const volumes = readRows(volumesGroup).filter(function (r) { return r.source && r.containerPath; });
|
|
const env = readRows(envGroup).filter(function (r) { return r.key; });
|
|
const devices = readRows(devicesGroup).filter(function (r) { return r.path; });
|
|
|
|
const saveAsTemplate = backdrop.querySelector('#cc-save-template').checked;
|
|
const templateName = backdrop.querySelector('#cc-template-name').value.trim();
|
|
if (saveAsTemplate && !templateName) {
|
|
showError('"Template name" is required when "Save as template" is checked.');
|
|
return;
|
|
}
|
|
|
|
const networkMode = backdrop.querySelector('#cc-network').value;
|
|
const privileged = backdrop.querySelector('#cc-privileged').checked;
|
|
const gpuSelect = backdrop.querySelector('#cc-gpu-select');
|
|
const gpus = gpuSelect.dataset.gpus ? JSON.parse(gpuSelect.dataset.gpus) : [];
|
|
const selectedGpu = gpuSelect.value !== '' ? gpus[Number(gpuSelect.value)] : null;
|
|
const gpuDevices = selectedGpu ? [selectedGpu.render, selectedGpu.card].filter(Boolean) : [];
|
|
|
|
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
|
submitBtn.disabled = true;
|
|
|
|
// Editing an existing container: no in-place "modify" API exists
|
|
// (see the comment on openEditContainerModal above), so this stops
|
|
// and removes the old one first — best-effort stop (it may already
|
|
// be stopped) followed by a forced remove — before creating the
|
|
// replacement under whatever name is in the form now.
|
|
const removeOld = editing
|
|
? P.post('containers', 'stop', { id: editing.id }).catch(function () { /* already stopped is fine */ })
|
|
.then(function () { return P.post('containers', 'remove', { id: editing.id, force: true }); })
|
|
: Promise.resolve();
|
|
|
|
removeOld.then(function () {
|
|
return P.post('containers', 'create', {
|
|
image: image,
|
|
name: backdrop.querySelector('#cc-name').value.trim(),
|
|
networkMode: networkMode,
|
|
staticIp: staticIp,
|
|
pod: backdrop.querySelector('#cc-pod').value,
|
|
ports: ports,
|
|
volumes: volumes,
|
|
env: env,
|
|
restartPolicy: backdrop.querySelector('#cc-restart').value,
|
|
gpuDevices: gpuDevices,
|
|
devices: devices,
|
|
privileged: privileged,
|
|
user: backdrop.querySelector('#cc-user').value.trim(),
|
|
icon: backdrop.querySelector('#cc-icon').value.trim(),
|
|
webuiUrl: backdrop.querySelector('#cc-weburl').value.trim(),
|
|
startAfterCreate: backdrop.querySelector('#cc-start').checked,
|
|
});
|
|
}).then(function () {
|
|
// Best-effort: a template-save failure shouldn't undo or block
|
|
// the container that was just successfully created.
|
|
if (!saveAsTemplate) return null;
|
|
return P.post('templates', 'save', {
|
|
name: templateName,
|
|
image: image,
|
|
networkMode: networkMode,
|
|
privileged: privileged,
|
|
ports: ports,
|
|
volumes: volumes,
|
|
env: env,
|
|
icon: backdrop.querySelector('#cc-template-icon').value.trim(),
|
|
category: backdrop.querySelector('#cc-template-category').value.trim(),
|
|
overview: backdrop.querySelector('#cc-template-overview').value.trim(),
|
|
webUrl: backdrop.querySelector('#cc-weburl').value.trim(),
|
|
}).catch(function (err) {
|
|
P.toast('Container created, but saving the template failed: ' + err.message, 'warn');
|
|
});
|
|
}).then(function () {
|
|
close();
|
|
return load();
|
|
}).catch(function (err) {
|
|
submitBtn.disabled = false;
|
|
showError((editing ? 'The old container may already be removed. ' : '') + err.message);
|
|
});
|
|
}
|
|
|
|
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', close);
|
|
backdrop.querySelector('[data-role="submit"]').addEventListener('click', submit);
|
|
backdrop.querySelector('form').addEventListener('submit', function (e) { e.preventDefault(); submit(); });
|
|
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
|
|
document.addEventListener('keydown', function onKey(e) {
|
|
if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onKey); }
|
|
});
|
|
}
|
|
|
|
function handleAction(id, action, btn) {
|
|
const doIt = function (extra) {
|
|
if (btn) btn.disabled = true;
|
|
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
|
|
P.toast('Action failed: ' + err.message, 'error');
|
|
if (btn) btn.disabled = false;
|
|
});
|
|
};
|
|
if (action === 'remove') {
|
|
P.confirm('Remove this container? This does not remove its volumes.', { danger: true, confirmLabel: 'Remove' }).then(function (ok) {
|
|
if (ok) doIt({ force: true });
|
|
});
|
|
} else if (action === 'kill') {
|
|
P.confirm('Send SIGKILL to this container? Unlike Stop, this does not let it shut down gracefully.', { danger: true, confirmLabel: 'Kill' }).then(function (ok) {
|
|
if (ok) doIt({});
|
|
});
|
|
} else {
|
|
doIt({});
|
|
}
|
|
}
|
|
|
|
function init() {
|
|
P.el('containers-create-btn').addEventListener('click', openCreateContainerModal);
|
|
|
|
P.el('containers-search').addEventListener('input', function (e) {
|
|
searchTerm = e.target.value.trim().toLowerCase();
|
|
renderTable();
|
|
});
|
|
|
|
document.querySelectorAll('#containers-filterset button').forEach(function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
document.querySelectorAll('#containers-filterset button').forEach(function (b) { b.classList.remove('active'); });
|
|
btn.classList.add('active');
|
|
filter = btn.dataset.filter;
|
|
renderTable();
|
|
});
|
|
});
|
|
|
|
P.el('containers-tbody').addEventListener('click', function (e) {
|
|
const btn = e.target.closest('button[data-action]');
|
|
if (!btn || btn.disabled) return;
|
|
const row = btn.closest('tr');
|
|
|
|
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;
|
|
if (btn.dataset.action === 'menu') {
|
|
openRowMenu(c, btn);
|
|
} else if (btn.dataset.action === 'update') {
|
|
P.confirm('Update "' + c.name + '" to the newer image? It is stopped and recreated with the same settings.', { confirmLabel: 'Update' }).then(function (ok) {
|
|
if (!ok) return;
|
|
btn.disabled = true;
|
|
const modal = P.openLogModal('Updating ' + c.name);
|
|
updateContainer(c, modal.log).then(function () {
|
|
modal.done();
|
|
return load();
|
|
}).catch(function (err) {
|
|
modal.log('Failed: ' + err.message);
|
|
modal.done();
|
|
btn.disabled = false;
|
|
});
|
|
});
|
|
} else {
|
|
openDetailModal(c);
|
|
}
|
|
return;
|
|
}
|
|
handleAction(id, btn.dataset.action, btn);
|
|
});
|
|
|
|
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();
|
|
}
|
|
|
|
// Exposed for templates.js's "Use template" action, which needs to open
|
|
// this same modal pre-filled — templates.js loads after containers.js
|
|
// (see Podman.page's script list), so this is already set by then.
|
|
P.openCreateContainerModal = openCreateContainerModal;
|
|
|
|
P.registerPanel('containers', { init: init, refresh: load, autoRefresh: true });
|
|
})();
|