Add reproducible build system, native Unraid plugin, and WebUI
- versions.env pins podman, conmon, crun, netavark, aardvark-dns, passt, and fuse-overlayfs to verified upstream source checksums; SlackBuild recipes, scripts/build-packages.sh, checksums.sh, release.sh, and update-versions.sh implement the reproducible pipeline; GitHub Actions workflows build in a Slackware container and publish releases without committing any binaries. - plugin/podman.plg installs/updates/removes all eight packages (the seven components plus the plugin's own unraid-podman scaffolding package) via upgradepkg, using the official Unraid array-event hook mechanism (event/disks_mounted, event/stopping) instead of editing /boot/config/go. rc.podman and the sbin/ helper scripts implement storage creation, config seeding/sync, preflight checks, autostart with per-container Safe-Mode, and package verify/update/rollback. - webui/plugins/podman implements the Dashboard, Containers, Pods, Images, Volumes, Networks, Logs, Terminal, Compose, and Settings panels against the approved mockup (webui/mockups/prototype.html), talking to podman system service exclusively via PodmanClient.php (libpod REST API over the Unix socket), with two documented exceptions: Terminal's one-shot exec model and Compose's use of the podman compose CLI, since libpod has no REST equivalent for either. - docs/ARCHITECTURE.md and docs/ROADMAP.md record the design decisions and honest current status (syntax-checked, unit- and integration-tested against fake sockets/servers; not yet run against a real Unraid/Podman/Slackware system). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* javascript/app.js
|
||||
*
|
||||
* Shared runtime for the Podman plugin page: the AJAX helper every panel
|
||||
* module uses to talk to webui/plugins/podman/ajax/*.php, small DOM
|
||||
* utilities to avoid repeating the same escaping/formatting logic in ten
|
||||
* places, and the sub-tab router that shows/hides panels and lazily
|
||||
* initializes each one's module the first time it's opened.
|
||||
*
|
||||
* Loaded first (before any panel module) — see Podman.page.
|
||||
*/
|
||||
window.Podman = (function () {
|
||||
'use strict';
|
||||
|
||||
const BASE = '/plugins/podman/ajax/';
|
||||
|
||||
/**
|
||||
* Calls one ajax/<file>.php?action=<action> endpoint and resolves with
|
||||
* response.data, or rejects with an Error carrying the server's message
|
||||
* — every ajax/*.php endpoint replies with the same {ok, data|error}
|
||||
* envelope (see include/helpers.php's podman_json_response/_error), so
|
||||
* this one function is the only place that envelope shape is known.
|
||||
*
|
||||
* @param {string} file e.g. "containers"
|
||||
* @param {string} action e.g. "list"
|
||||
* @param {('GET'|'POST')} method
|
||||
* @param {object|null} body sent as JSON for POST
|
||||
* @param {object} query extra query-string params (e.g. {id: "..."})
|
||||
*/
|
||||
function call(file, action, method, body, query) {
|
||||
const params = new URLSearchParams(Object.assign({ action: action }, query || {}));
|
||||
const url = BASE + file + '.php?' + params.toString();
|
||||
|
||||
const opts = { method: method, headers: {} };
|
||||
if (body !== undefined && body !== null) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
return fetch(url, opts)
|
||||
.then(function (res) {
|
||||
return res.json().then(function (envelope) {
|
||||
if (!envelope.ok) {
|
||||
throw new Error(envelope.error || ('Request failed (' + res.status + ')'));
|
||||
}
|
||||
return envelope.data;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function get(file, action, query) {
|
||||
return call(file, action, 'GET', null, query);
|
||||
}
|
||||
|
||||
function post(file, action, body) {
|
||||
return call(file, action, 'POST', body || {}, {});
|
||||
}
|
||||
|
||||
// --- DOM helpers ---------------------------------------------------------
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value == null ? '' : value).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
function el(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const value = bytes / Math.pow(1024, i);
|
||||
return (value >= 100 || i === 0 ? value.toFixed(0) : value.toFixed(1)) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds == null) return '—';
|
||||
if (seconds < 60) return seconds + 's';
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return days + 'd ' + hours + 'h';
|
||||
if (hours > 0) return hours + 'h ' + minutes + 'm';
|
||||
return minutes + 'm';
|
||||
}
|
||||
|
||||
function formatRelativeTime(unixSeconds) {
|
||||
if (!unixSeconds) return '—';
|
||||
const diff = Math.max(0, Math.floor(Date.now() / 1000) - unixSeconds);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
}
|
||||
|
||||
/** Status string (from libpod's container "State") -> chip color class. */
|
||||
function stateChipClass(state) {
|
||||
switch (state) {
|
||||
case 'running': return 'podman-chip-good';
|
||||
case 'paused': return 'podman-chip-warn';
|
||||
case 'exited':
|
||||
case 'created': return 'podman-chip-neutral';
|
||||
default: return 'podman-chip-bad';
|
||||
}
|
||||
}
|
||||
|
||||
function loadingRow(colspan, label) {
|
||||
return '<tr><td colspan="' + colspan + '" class="podman-loading">' + escapeHtml(label || 'Loading…') + '</td></tr>';
|
||||
}
|
||||
|
||||
function errorRow(colspan, message) {
|
||||
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
||||
}
|
||||
|
||||
// --- Panel router ----------------------------------------------------------
|
||||
|
||||
const panelModules = {};
|
||||
const initialized = {};
|
||||
|
||||
/** Called by each panel's own JS file (e.g. containers.js) to register itself. */
|
||||
function registerPanel(name, module) {
|
||||
panelModules[name] = module;
|
||||
}
|
||||
|
||||
function activatePanel(name) {
|
||||
document.querySelectorAll('.podman-subnav button').forEach(function (btn) {
|
||||
btn.classList.toggle('active', btn.dataset.panel === name);
|
||||
});
|
||||
document.querySelectorAll('.podman-panel').forEach(function (panel) {
|
||||
panel.classList.toggle('active', panel.id === 'podman-panel-' + name);
|
||||
});
|
||||
|
||||
const module = panelModules[name];
|
||||
if (!module) return;
|
||||
|
||||
if (!initialized[name]) {
|
||||
initialized[name] = true;
|
||||
if (typeof module.init === 'function') module.init();
|
||||
} else if (typeof module.refresh === 'function') {
|
||||
module.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function boot() {
|
||||
const subnav = document.querySelector('.podman-subnav');
|
||||
if (!subnav) return;
|
||||
|
||||
subnav.addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-panel]');
|
||||
if (btn) activatePanel(btn.dataset.panel);
|
||||
});
|
||||
|
||||
const refreshBtn = el('podman-refresh-all');
|
||||
if (refreshBtn) {
|
||||
refreshBtn.addEventListener('click', function () {
|
||||
const active = document.querySelector('.podman-subnav button.active');
|
||||
if (active) activatePanel(active.dataset.panel);
|
||||
});
|
||||
}
|
||||
|
||||
// Activate whichever panel is marked active in the initial HTML
|
||||
// (Dashboard, by default — see Podman.page).
|
||||
const initial = document.querySelector('.podman-subnav button.active');
|
||||
activatePanel(initial ? initial.dataset.panel : 'dashboard');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', boot);
|
||||
|
||||
return {
|
||||
get: get,
|
||||
post: post,
|
||||
escapeHtml: escapeHtml,
|
||||
el: el,
|
||||
formatBytes: formatBytes,
|
||||
formatDuration: formatDuration,
|
||||
formatRelativeTime: formatRelativeTime,
|
||||
stateChipClass: stateChipClass,
|
||||
loadingRow: loadingRow,
|
||||
errorRow: errorRow,
|
||||
registerPanel: registerPanel,
|
||||
activatePanel: activatePanel,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* javascript/compose.js
|
||||
*
|
||||
* Compose panel: project list + read-only YAML view + up/down/pull,
|
||||
* backed by ajax/compose.php. See that file's header comment — this is
|
||||
* the one panel whose backend shells out to the `podman compose` CLI,
|
||||
* because no REST equivalent for Compose exists in libpod.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let projects = [];
|
||||
let selected = null;
|
||||
|
||||
function statusChip(status) {
|
||||
const cls = status === 'up' ? 'podman-chip-good' : (status === 'down' ? 'podman-chip-neutral' : 'podman-chip-warn');
|
||||
return '<span class="podman-chip ' + cls + '"><span class="d"></span>' + P.escapeHtml(status) + '</span>';
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
P.el('compose-sidebar').innerHTML = projects.map(function (p) {
|
||||
return '<div class="podman-compose-proj' + (p.name === selected ? ' active' : '') + '" data-name="' + P.escapeHtml(p.name) + '">' +
|
||||
'<div class="name" style="display:flex; justify-content:space-between; gap:8px;">' + P.escapeHtml(p.name) + ' ' + statusChip(p.status) + '</div>' +
|
||||
'<div class="path">' + P.escapeHtml(p.path) + '</div>' +
|
||||
'</div>';
|
||||
}).join('') || '<div class="podman-empty-note">No compose projects under /boot/config/plugins/podman/compose/</div>';
|
||||
}
|
||||
|
||||
function loadYaml(name) {
|
||||
P.el('compose-title').textContent = name + ' / compose.yaml';
|
||||
P.el('compose-yaml').textContent = 'Loading…';
|
||||
return P.get('compose', 'get', { project: name }).then(function (data) {
|
||||
P.el('compose-yaml').textContent = data.yaml;
|
||||
}).catch(function (err) {
|
||||
P.el('compose-yaml').textContent = 'Error: ' + err.message;
|
||||
});
|
||||
}
|
||||
|
||||
function selectProject(name) {
|
||||
selected = name;
|
||||
renderSidebar();
|
||||
loadYaml(name);
|
||||
}
|
||||
|
||||
function loadProjects() {
|
||||
return P.get('compose', 'list').then(function (data) {
|
||||
projects = data;
|
||||
if (!selected && projects.length > 0) selected = projects[0].name;
|
||||
renderSidebar();
|
||||
if (selected) loadYaml(selected);
|
||||
}).catch(function (err) {
|
||||
P.el('compose-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function runAction(action) {
|
||||
if (!selected) return;
|
||||
const btn = P.el('compose-action-' + action);
|
||||
btn.disabled = true;
|
||||
P.post('compose', action, { project: selected }).then(function (data) {
|
||||
alert((data.output || 'Done.').slice(0, 2000));
|
||||
return loadProjects();
|
||||
}).catch(function (err) {
|
||||
alert('podman compose ' + action + ' failed: ' + err.message);
|
||||
}).finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('compose-sidebar').addEventListener('click', function (e) {
|
||||
const item = e.target.closest('.podman-compose-proj[data-name]');
|
||||
if (item) selectProject(item.dataset.name);
|
||||
});
|
||||
P.el('compose-action-up').addEventListener('click', function () { runAction('up'); });
|
||||
P.el('compose-action-down').addEventListener('click', function () { runAction('down'); });
|
||||
P.el('compose-action-pull').addEventListener('click', function () { runAction('pull'); });
|
||||
|
||||
return loadProjects();
|
||||
}
|
||||
|
||||
P.registerPanel('compose', { init: init, refresh: loadProjects });
|
||||
})();
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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 = '';
|
||||
|
||||
function iconLabel(name) {
|
||||
return P.escapeHtml(name.slice(0, 2).toUpperCase());
|
||||
}
|
||||
|
||||
function rowHtml(c) {
|
||||
const cpuMem = c.state === 'running'
|
||||
? '<span class="podman-row-sub">running</span>'
|
||||
: '<span class="podman-row-sub">—</span>';
|
||||
|
||||
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-row-name"><span class="ico">' + iconLabel(c.name) + '</span>' + P.escapeHtml(c.name) + '</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">' + actionButtons(c) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function actionButtons(c) {
|
||||
if (c.state === 'running') {
|
||||
return '' +
|
||||
'<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="remove" title="Remove" disabled>🗑</button>';
|
||||
}
|
||||
return '' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="start" title="Start">▶</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove">🗑</button>';
|
||||
}
|
||||
|
||||
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('')
|
||||
: '<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');
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
function handleAction(id, action, btn) {
|
||||
const doIt = function (extra) {
|
||||
btn.disabled = true;
|
||||
return P.post('containers', action, Object.assign({ id: id }, extra)).then(load).catch(function (err) {
|
||||
alert('Action failed: ' + err.message);
|
||||
btn.disabled = false;
|
||||
});
|
||||
};
|
||||
if (action === 'remove') {
|
||||
if (!confirm('Remove this container? This does not remove its volumes.')) return;
|
||||
doIt({ force: true });
|
||||
} else {
|
||||
doIt({});
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
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');
|
||||
handleAction(row.dataset.id, btn.dataset.action, btn);
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('containers', { init: init, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* javascript/dashboard.js
|
||||
*
|
||||
* Dashboard panel: summary stat tiles fed by ajax/system.php?action=summary.
|
||||
* The Activity list and the CPU/Memory sparkline in the mockup were
|
||||
* illustrative sample data with no backing API (libpod has no "recent
|
||||
* events for a container fleet" convenience endpoint beyond raw
|
||||
* /events streaming, which is a separate follow-up — see the note
|
||||
* rendered in place of it below) — rather than fake data pretending to be
|
||||
* live, this real implementation shows what's genuinely available now
|
||||
* (the summary counts) and a clear placeholder for what needs the events
|
||||
* stream, so nobody mistakes a mock for a working feature.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
|
||||
function render(summary) {
|
||||
if (!summary.reachable) {
|
||||
P.el('podman-panel-dashboard').innerHTML =
|
||||
'<div class="podman-card"><div class="podman-error">' +
|
||||
'Cannot reach the Podman API socket (' + P.escapeHtml(summary.socketPath) + '). ' +
|
||||
'Is rc.podman running? Try <code>rc.podman status</code> from a terminal.' +
|
||||
'</div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
P.el('stat-running').textContent = summary.containers.running;
|
||||
P.el('stat-running-total').textContent = '/ ' + summary.containers.total;
|
||||
P.el('stat-pods').textContent = summary.pods;
|
||||
P.el('stat-images').textContent = summary.images;
|
||||
P.el('stat-volumes').textContent = summary.volumes;
|
||||
P.el('stat-networks').textContent = summary.networks;
|
||||
P.el('stat-images-size').textContent = summary.storage.imagesSizeFormatted;
|
||||
|
||||
const meta = P.el('podman-header-meta');
|
||||
if (meta) {
|
||||
meta.innerHTML =
|
||||
'<span class="dot-good">●</span> podman.sock connected' +
|
||||
(summary.podmanVersion ? ' · v' + P.escapeHtml(summary.podmanVersion) : '') +
|
||||
' · ' + summary.containers.running + ' of ' + summary.containers.total + ' containers running';
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
return P.get('system', 'summary').then(render).catch(function (err) {
|
||||
P.el('podman-panel-dashboard').innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('dashboard', { init: load, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* javascript/images.js
|
||||
*
|
||||
* Images panel: table + a "Pull Image" action, backed by ajax/images.php.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let images = [];
|
||||
|
||||
function rowHtml(img) {
|
||||
const created = img.createdAt ? P.formatRelativeTime(img.createdAt) : '—';
|
||||
return '' +
|
||||
'<tr data-id="' + P.escapeHtml(img.id) + '">' +
|
||||
'<td>' + P.escapeHtml(img.repository) + '</td>' +
|
||||
'<td class="mono">' + P.escapeHtml(img.tag) + '</td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(img.shortId) + '</td>' +
|
||||
'<td class="tnum">' + P.escapeHtml(img.sizeFormatted) + '</td>' +
|
||||
'<td class="tnum">' + created + '</td>' +
|
||||
'<td class="tnum">' + img.usedBy + '</td>' +
|
||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||
(img.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = P.el('images-tbody');
|
||||
tbody.innerHTML = images.length
|
||||
? images.map(rowHtml).join('')
|
||||
: '<tr><td colspan="7" class="podman-empty-note">No images.</td></tr>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const tbody = P.el('images-tbody');
|
||||
tbody.innerHTML = P.loadingRow(7);
|
||||
return P.get('images', 'list').then(function (data) {
|
||||
images = data;
|
||||
render();
|
||||
}).catch(function (err) {
|
||||
tbody.innerHTML = P.errorRow(7, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('images-pull-btn').addEventListener('click', function () {
|
||||
const reference = prompt('Image to pull (e.g. docker.io/library/postgres:16):');
|
||||
if (!reference) return;
|
||||
P.post('images', 'pull', { reference: reference }).then(load).catch(function (err) {
|
||||
alert('Pull failed: ' + err.message);
|
||||
});
|
||||
});
|
||||
|
||||
P.el('images-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action="remove"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const id = btn.closest('tr').dataset.id;
|
||||
if (!confirm('Remove this image?')) return;
|
||||
btn.disabled = true;
|
||||
P.post('images', 'remove', { id: id }).then(load).catch(function (err) {
|
||||
alert('Remove failed: ' + err.message);
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('images', { init: init, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* javascript/logs.js
|
||||
*
|
||||
* Logs panel: container selector sidebar + log pane, backed by
|
||||
* ajax/containers.php?action=logs. "Follow" polls on an interval rather
|
||||
* than opening a persistent stream — see javascript/terminal.js for the
|
||||
* same underlying constraint (PHP-FPM's request lifecycle) applied to a
|
||||
* different panel.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let containers = [];
|
||||
let selectedId = null;
|
||||
let follow = true;
|
||||
let pollHandle = null;
|
||||
|
||||
function levelClass(line) {
|
||||
if (/\berror\b/i.test(line)) return 'lvl-error';
|
||||
if (/\bwarn(ing)?\b/i.test(line)) return 'lvl-warn';
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderLog(text) {
|
||||
const pane = P.el('log-pane');
|
||||
const atBottom = pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 20;
|
||||
const lines = text.split('\n').filter(function (l) { return l.length > 0; });
|
||||
pane.innerHTML = lines.map(function (line) {
|
||||
const cls = levelClass(line);
|
||||
return '<div class="l' + (cls ? ' ' + cls : '') + '">' + P.escapeHtml(line) + '</div>';
|
||||
}).join('') || '<div class="l podman-row-sub">(no output)</div>';
|
||||
if (atBottom) pane.scrollTop = pane.scrollHeight;
|
||||
}
|
||||
|
||||
function loadLogs() {
|
||||
if (!selectedId) return Promise.resolve();
|
||||
return P.get('containers', 'logs', { id: selectedId, tail: 300 }).then(function (data) {
|
||||
renderLog(data.text);
|
||||
}).catch(function (err) {
|
||||
P.el('log-pane').innerHTML = '<div class="l podman-error">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderSidebar() {
|
||||
const side = P.el('logs-sidebar');
|
||||
side.innerHTML = containers.map(function (c) {
|
||||
return '<div class="item' + (c.id === selectedId ? ' active' : '') + '" data-id="' + P.escapeHtml(c.id) + '">' +
|
||||
'<span class="podman-chip ' + P.stateChipClass(c.state) + '" style="padding:2px 6px;"><span class="d"></span></span> ' +
|
||||
P.escapeHtml(c.name) + '</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectContainer(id) {
|
||||
selectedId = id;
|
||||
renderSidebar();
|
||||
loadLogs();
|
||||
}
|
||||
|
||||
function setPolling(enabled) {
|
||||
follow = enabled;
|
||||
if (pollHandle) clearInterval(pollHandle);
|
||||
if (follow) {
|
||||
pollHandle = setInterval(loadLogs, 4000);
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('logs-sidebar').addEventListener('click', function (e) {
|
||||
const item = e.target.closest('.item[data-id]');
|
||||
if (item) selectContainer(item.dataset.id);
|
||||
});
|
||||
|
||||
document.querySelectorAll('#logs-follow-toggle button').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('#logs-follow-toggle button').forEach(function (b) { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
setPolling(btn.dataset.follow === 'true');
|
||||
});
|
||||
});
|
||||
|
||||
P.el('logs-filter').addEventListener('input', function (e) {
|
||||
const term = e.target.value.toLowerCase();
|
||||
P.el('log-pane').querySelectorAll('.l').forEach(function (line) {
|
||||
line.style.display = term === '' || line.textContent.toLowerCase().includes(term) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
return P.get('containers', 'list').then(function (data) {
|
||||
containers = data;
|
||||
if (containers.length > 0) {
|
||||
selectedId = containers[0].id;
|
||||
}
|
||||
renderSidebar();
|
||||
setPolling(true);
|
||||
return loadLogs();
|
||||
}).catch(function (err) {
|
||||
P.el('logs-sidebar').innerHTML = '<div class="podman-error" style="padding:14px;">' + P.escapeHtml(err.message) + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('logs', { init: init, refresh: loadLogs });
|
||||
})();
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* javascript/networks.js
|
||||
*
|
||||
* Networks panel: table + create/remove, backed by ajax/networks.php.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let networks = [];
|
||||
|
||||
function rowHtml(n) {
|
||||
const nameCell = n.isDefault
|
||||
? '<strong>' + P.escapeHtml(n.name) + '</strong> <span class="podman-row-sub">(default)</span>'
|
||||
: P.escapeHtml(n.name);
|
||||
const removeDisabled = n.isDefault || n.containers > 0;
|
||||
return '' +
|
||||
'<tr data-name="' + P.escapeHtml(n.name) + '">' +
|
||||
'<td>' + nameCell + '</td>' +
|
||||
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(n.driver) + '</span></td>' +
|
||||
'<td class="mono">' + P.escapeHtml(n.subnet || '—') + '</td>' +
|
||||
'<td class="mono">' + P.escapeHtml(n.gateway || '—') + '</td>' +
|
||||
'<td class="tnum">' + n.containers + '</td>' +
|
||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||
(removeDisabled ? ' disabled' : '') + ' title="Remove">🗑</button></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = P.el('networks-tbody');
|
||||
tbody.innerHTML = networks.length
|
||||
? networks.map(rowHtml).join('')
|
||||
: '<tr><td colspan="6" class="podman-empty-note">No networks.</td></tr>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const tbody = P.el('networks-tbody');
|
||||
tbody.innerHTML = P.loadingRow(6);
|
||||
return P.get('networks', 'list').then(function (data) {
|
||||
networks = data;
|
||||
render();
|
||||
}).catch(function (err) {
|
||||
tbody.innerHTML = P.errorRow(6, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('networks-create-btn').addEventListener('click', function () {
|
||||
const name = prompt('New network name:');
|
||||
if (!name) return;
|
||||
const subnet = prompt('Subnet (optional, e.g. 10.89.2.0/24):') || undefined;
|
||||
P.post('networks', 'create', { name: name, driver: 'bridge', subnet: subnet }).then(load).catch(function (err) {
|
||||
alert('Create failed: ' + err.message);
|
||||
});
|
||||
});
|
||||
|
||||
P.el('networks-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action="remove"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const name = btn.closest('tr').dataset.name;
|
||||
if (!confirm('Remove network "' + name + '"?')) return;
|
||||
btn.disabled = true;
|
||||
P.post('networks', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
alert('Remove failed: ' + err.message);
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('networks', { init: init, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* javascript/pods.js
|
||||
*
|
||||
* Pods panel: one card per pod with its member containers nested inside,
|
||||
* backed by ajax/pods.php (which itself cross-references containers.php's
|
||||
* data server-side — see that file for why).
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
|
||||
function memberRow(m) {
|
||||
return '' +
|
||||
'<tr>' +
|
||||
'<td>' + P.escapeHtml(m.name) + '</td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(m.image) + '</td>' +
|
||||
'<td><span class="podman-chip ' + P.stateChipClass(m.state) + '"><span class="d"></span>' + P.escapeHtml(m.state) + '</span></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function podCard(pod) {
|
||||
const members = pod.members.length
|
||||
? pod.members.map(memberRow).join('')
|
||||
: '<tr><td colspan="3" class="podman-empty-note">No member containers</td></tr>';
|
||||
|
||||
return '' +
|
||||
'<div class="podman-pod-card">' +
|
||||
'<div class="podman-pod-head">' +
|
||||
'<span class="podman-chip ' + P.stateChipClass(pod.status) + '"><span class="d"></span>' + P.escapeHtml(pod.status) + '</span>' +
|
||||
'<span class="name">' + P.escapeHtml(pod.name) + '</span>' +
|
||||
'<span class="infra">' + pod.containersTotal + ' container(s)</span>' +
|
||||
'</div>' +
|
||||
'<div class="podman-table-wrap"><table><thead><tr><th>Container</th><th>Image</th><th>Status</th></tr></thead>' +
|
||||
'<tbody>' + members + '</tbody></table></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const container = P.el('podman-panel-pods');
|
||||
return P.get('pods', 'list').then(function (pods) {
|
||||
container.innerHTML = pods.length
|
||||
? pods.map(podCard).join('')
|
||||
: '<div class="podman-card"><div class="podman-empty-note">No pods yet.</div></div>';
|
||||
}).catch(function (err) {
|
||||
container.innerHTML = '<div class="podman-card"><div class="podman-error">' + P.escapeHtml(err.message) + '</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('pods', { init: load, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* javascript/settings.js
|
||||
*
|
||||
* Settings panel: reads/writes unraid-podman's own configuration via
|
||||
* ajax/settings.php (podman.cfg + the autostart list) — not a
|
||||
* PodmanClient/libpod concern, see that file's header comment.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let autostartNames = [];
|
||||
|
||||
function renderAutostart() {
|
||||
const tbody = P.el('autostart-tbody');
|
||||
tbody.innerHTML = autostartNames.length
|
||||
? autostartNames.map(function (name, i) {
|
||||
return '<tr data-index="' + i + '">' +
|
||||
'<td class="tnum">' + (i + 1) + '</td>' +
|
||||
'<td>' + P.escapeHtml(name) + '</td>' +
|
||||
'<td class="podman-actions">' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="up"' + (i === 0 ? ' disabled' : '') + ' title="Move up">↑</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="down"' + (i === autostartNames.length - 1 ? ' disabled' : '') + ' title="Move down">↓</button>' +
|
||||
'<button class="podman-btn podman-btn-icon" data-action="remove" title="Remove from autostart">🗑</button>' +
|
||||
'</td></tr>';
|
||||
}).join('')
|
||||
: '<tr><td colspan="3" class="podman-empty-note">No containers in the autostart chain.</td></tr>';
|
||||
}
|
||||
|
||||
function saveAutostart() {
|
||||
return P.post('settings', 'autostart_save', { names: autostartNames }).catch(function (err) {
|
||||
alert('Could not save autostart order: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function fillForm(settings) {
|
||||
P.el('settings-storage-path').value = settings.storagePath;
|
||||
P.el('settings-storage-size').value = settings.storageImageSizeGb;
|
||||
P.el('settings-enabled').checked = settings.enabled;
|
||||
P.el('settings-stop-timeout').value = settings.stopTimeoutSeconds;
|
||||
|
||||
autostartNames = settings.autostart.slice();
|
||||
renderAutostart();
|
||||
|
||||
const versions = settings.packageVersions || {};
|
||||
const order = ['PODMAN', 'CONMON', 'CRUN', 'NETAVARK', 'AARDVARK_DNS', 'PASST', 'FUSE_OVERLAYFS'];
|
||||
P.el('settings-package-versions').textContent = order
|
||||
.map(function (k) { return k.toLowerCase().replace('_', '-') + ' ' + (versions[k + '_INSTALLED_VERSION'] || '?'); })
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function load() {
|
||||
return P.get('settings', 'get').then(fillForm).catch(function (err) {
|
||||
alert('Could not load settings: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function save() {
|
||||
const body = {
|
||||
storagePath: P.el('settings-storage-path').value.trim(),
|
||||
storageImageSizeGb: parseInt(P.el('settings-storage-size').value, 10) || 20,
|
||||
enabled: P.el('settings-enabled').checked,
|
||||
stopTimeoutSeconds: parseInt(P.el('settings-stop-timeout').value, 10) || 10,
|
||||
};
|
||||
return P.post('settings', 'save', body).then(function () {
|
||||
alert('Saved. Restart podman (rc.podman restart) to apply storage/enabled changes.');
|
||||
}).catch(function (err) {
|
||||
alert('Save failed: ' + err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('settings-save-btn').addEventListener('click', save);
|
||||
|
||||
P.el('autostart-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action]');
|
||||
if (!btn) return;
|
||||
const i = parseInt(btn.closest('tr').dataset.index, 10);
|
||||
const action = btn.dataset.action;
|
||||
|
||||
if (action === 'remove') {
|
||||
autostartNames.splice(i, 1);
|
||||
} else if (action === 'up' && i > 0) {
|
||||
[autostartNames[i - 1], autostartNames[i]] = [autostartNames[i], autostartNames[i - 1]];
|
||||
} else if (action === 'down' && i < autostartNames.length - 1) {
|
||||
[autostartNames[i + 1], autostartNames[i]] = [autostartNames[i], autostartNames[i + 1]];
|
||||
}
|
||||
renderAutostart();
|
||||
saveAutostart();
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('settings', { init: init, refresh: load });
|
||||
})();
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* javascript/terminal.js
|
||||
*
|
||||
* Terminal panel: one-command-at-a-time exec via ajax/exec.php. See that
|
||||
* file's header comment for the full, honest explanation of why this is
|
||||
* "type a command, see its output" rather than a true interactive PTY —
|
||||
* the short version is that libpod's interactive exec needs a persistent
|
||||
* bidirectional connection this PHP/AJAX stack doesn't have, and faking
|
||||
* interactivity on top of that would break the moment a user ran anything
|
||||
* that expects a real terminal (vim, an interactive prompt, etc).
|
||||
*
|
||||
* `cd` is handled client-side: this module tracks a per-session `cwd` and
|
||||
* passes it as the exec's working directory on every call, so at least
|
||||
* directory navigation feels persistent even though nothing else is.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let cwd = '/';
|
||||
let containerId = null;
|
||||
|
||||
function appendLine(html) {
|
||||
const out = P.el('term-output');
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = html;
|
||||
out.appendChild(div);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
function promptHtml() {
|
||||
return '<span class="prompt">root</span>:<span class="path">' + P.escapeHtml(cwd) + '</span>$';
|
||||
}
|
||||
|
||||
function runCommand(cmd) {
|
||||
appendLine(promptHtml() + ' ' + P.escapeHtml(cmd));
|
||||
|
||||
// `cd <dir>` is intercepted client-side (see file header) rather than
|
||||
// sent as a real command, since a one-shot exec has no way to report
|
||||
// "the working directory changed" back to us otherwise.
|
||||
const cdMatch = cmd.trim().match(/^cd\s+(\S+)$/);
|
||||
if (cdMatch) {
|
||||
cwd = cdMatch[1].startsWith('/') ? cdMatch[1] : (cwd.replace(/\/$/, '') + '/' + cdMatch[1]);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return P.post('exec', 'run', { id: containerId, cmd: cmd, cwd: cwd }).then(function (data) {
|
||||
if (data.output) appendLine('<span class="mono">' + P.escapeHtml(data.output).replace(/\n/g, '<br>') + '</span>');
|
||||
}).catch(function (err) {
|
||||
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
|
||||
});
|
||||
}
|
||||
|
||||
function populateContainerSelect(containers) {
|
||||
const select = P.el('term-container-select');
|
||||
select.innerHTML = containers
|
||||
.filter(function (c) { return c.state === 'running'; })
|
||||
.map(function (c) { return '<option value="' + P.escapeHtml(c.id) + '">' + P.escapeHtml(c.name) + '</option>'; })
|
||||
.join('');
|
||||
containerId = select.value || null;
|
||||
}
|
||||
|
||||
function init() {
|
||||
const input = P.el('term-input');
|
||||
|
||||
P.el('term-container-select').addEventListener('change', function (e) {
|
||||
containerId = e.target.value;
|
||||
cwd = '/';
|
||||
P.el('term-output').innerHTML = '';
|
||||
});
|
||||
|
||||
input.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Enter') return;
|
||||
const cmd = input.value;
|
||||
input.value = '';
|
||||
if (!containerId) {
|
||||
appendLine('<span style="color:#ef6470;">No running container selected.</span>');
|
||||
return;
|
||||
}
|
||||
if (cmd.trim() === '') return;
|
||||
runCommand(cmd);
|
||||
});
|
||||
|
||||
return P.get('containers', 'list').then(populateContainerSelect).catch(function (err) {
|
||||
appendLine('<span style="color:#ef6470;">' + P.escapeHtml(err.message) + '</span>');
|
||||
});
|
||||
}
|
||||
|
||||
P.registerPanel('terminal', { init: init });
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* javascript/volumes.js
|
||||
*
|
||||
* Volumes panel: named-volume table + create/remove, backed by
|
||||
* ajax/volumes.php. Bind mounts are deliberately not shown here — see
|
||||
* that file's header comment.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
const P = window.Podman;
|
||||
let volumes = [];
|
||||
|
||||
function rowHtml(v) {
|
||||
return '' +
|
||||
'<tr data-name="' + P.escapeHtml(v.name) + '">' +
|
||||
'<td>' + P.escapeHtml(v.name) + '</td>' +
|
||||
'<td><span class="podman-chip podman-chip-neutral">' + P.escapeHtml(v.driver) + '</span></td>' +
|
||||
'<td class="mono podman-row-sub">' + P.escapeHtml(v.mountpoint) + '</td>' +
|
||||
'<td class="tnum">' + v.usedBy + '</td>' +
|
||||
'<td class="podman-actions"><button class="podman-btn podman-btn-icon" data-action="remove"' +
|
||||
(v.usedBy > 0 ? ' disabled title="In use by a container"' : ' title="Remove"') + '>🗑</button></td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function render() {
|
||||
const tbody = P.el('volumes-tbody');
|
||||
tbody.innerHTML = volumes.length
|
||||
? volumes.map(rowHtml).join('')
|
||||
: '<tr><td colspan="5" class="podman-empty-note">No named volumes.</td></tr>';
|
||||
}
|
||||
|
||||
function load() {
|
||||
const tbody = P.el('volumes-tbody');
|
||||
tbody.innerHTML = P.loadingRow(5);
|
||||
return P.get('volumes', 'list').then(function (data) {
|
||||
volumes = data;
|
||||
render();
|
||||
}).catch(function (err) {
|
||||
tbody.innerHTML = P.errorRow(5, err.message);
|
||||
});
|
||||
}
|
||||
|
||||
function init() {
|
||||
P.el('volumes-create-btn').addEventListener('click', function () {
|
||||
const name = prompt('New volume name:');
|
||||
if (!name) return;
|
||||
P.post('volumes', 'create', { name: name }).then(load).catch(function (err) {
|
||||
alert('Create failed: ' + err.message);
|
||||
});
|
||||
});
|
||||
|
||||
P.el('volumes-tbody').addEventListener('click', function (e) {
|
||||
const btn = e.target.closest('button[data-action="remove"]');
|
||||
if (!btn || btn.disabled) return;
|
||||
const name = btn.closest('tr').dataset.name;
|
||||
if (!confirm('Remove volume "' + name + '"? This deletes its data.')) return;
|
||||
btn.disabled = true;
|
||||
P.post('volumes', 'remove', { name: name }).then(load).catch(function (err) {
|
||||
alert('Remove failed: ' + err.message);
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
return load();
|
||||
}
|
||||
|
||||
P.registerPanel('volumes', { init: init, refresh: load });
|
||||
})();
|
||||
Reference in New Issue
Block a user