Add catatonit/nftables/docker-compose packages, fix CSRF/streaming/storage bugs found by live testing
- Package #9-11: catatonit (pod infra init), nftables (netavark firewall backend), docker-compose (external compose provider for `podman compose`) — all vendored prebuilt binaries, versions.env pinned, propagated through build-packages.sh/release.sh/podman.plg/verify+update-packages.sh. - Fix WebUI: every POST action was silently failing (empty response body) because Unraid's own CSRF protection was never satisfied — app.js now sends the page's csrf_token as X-CSRF-Token. - Fix WebUI: PodmanClient::pullImage() assumed a single JSON response, but /images/pull actually streams newline-delimited JSON — every successful pull was throwing "Expected a JSON object/array response". - Fix WebUI: compose.php's up/down status detection had the same single-JSON-vs-NDJSON bug for `podman compose ps`, plus stderr was corrupting the parse. - Add cache-busting (?v=<mtime>) to Podman.page's script/style tags so a redeployed JS/CSS fix isn't served stale from browser cache. - Add a reusable modal dialog (app.js openFormModal) replacing prompt()/alert() for New Volume/Network/Pull Image. - Add host-path (bind-mount) support when creating a named volume. - Add Create Container (image, name, network mode incl. custom networks, ports, volumes, env, restart policy, privileged, start-after-create), auto-pulling the image on first use since /containers/create doesn't. All fixes verified live against a real podman system service and, where reachable, via the actual WebUI over the real socket — not just unit-level. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,17 @@ window.Podman = (function () {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
// Unraid's own webGui/include/local_prepend.php (auto_prepend_file on
|
||||
// every PHP request, not something this plugin controls) kills any
|
||||
// POST request with no output at all unless it carries the page's
|
||||
// CSRF token — either as a "csrf_token" POST field or this header.
|
||||
// `csrf_token` itself is a global var HeadInlineJS.php sets on every
|
||||
// Unraid page before plugin JS loads (verified live: without this
|
||||
// header, every mutating action failed with "JSON.parse: unexpected
|
||||
// end of data", i.e. an empty response body from csrf_terminate()).
|
||||
if (method === 'POST' && typeof window.csrf_token === 'string') {
|
||||
opts.headers['X-CSRF-Token'] = window.csrf_token;
|
||||
}
|
||||
|
||||
return fetch(url, opts)
|
||||
.then(function (res) {
|
||||
@@ -115,6 +126,109 @@ window.Podman = (function () {
|
||||
return '<tr><td colspan="' + colspan + '" class="podman-error">' + escapeHtml(message) + '</td></tr>';
|
||||
}
|
||||
|
||||
// --- Modal form dialog -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shows a small form modal in place of browser-native prompt()/confirm()
|
||||
* — needed for any action that takes more than one related value (e.g.
|
||||
* "New Volume" wants a name AND an optional host path together; chaining
|
||||
* prompt() calls for that is both bad UX and can't show both fields at
|
||||
* once, or offer a hint under the path field explaining what it does).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.title
|
||||
* @param {Array<{name:string, label:string, placeholder?:string, hint?:string, required?:boolean}>} opts.fields
|
||||
* @param {string} [opts.submitLabel]
|
||||
* @param {(values: Object<string,string>) => Promise<any>} opts.onSubmit
|
||||
* Called with {fieldName: value}. Rejecting keeps the modal open and
|
||||
* shows the error inline; resolving closes it.
|
||||
*/
|
||||
function openFormModal(opts) {
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'podman-modal-backdrop';
|
||||
|
||||
const fieldsHtml = opts.fields.map(function (f) {
|
||||
return '' +
|
||||
'<div class="podman-modal-field">' +
|
||||
'<label for="podman-modal-' + f.name + '">' + escapeHtml(f.label) + '</label>' +
|
||||
'<input type="text" id="podman-modal-' + f.name + '" name="' + f.name + '"' +
|
||||
(f.placeholder ? ' placeholder="' + escapeHtml(f.placeholder) + '"' : '') + '>' +
|
||||
(f.hint ? '<div class="hint">' + escapeHtml(f.hint) + '</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
backdrop.innerHTML = '' +
|
||||
'<div class="podman-modal" role="dialog" aria-modal="true">' +
|
||||
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title) + '</h3></div>' +
|
||||
'<form class="podman-modal-body">' + fieldsHtml + '</form>' +
|
||||
'<div class="podman-modal-actions">' +
|
||||
'<button type="button" class="podman-btn" data-role="cancel">Cancel</button>' +
|
||||
'<button type="button" class="podman-btn podman-btn-primary" data-role="submit">' +
|
||||
escapeHtml(opts.submitLabel || 'Create') + '</button>' +
|
||||
'</div></div>';
|
||||
|
||||
// Appended inside .podman-plugin, not document.body: the --surface/
|
||||
// --border/etc. custom properties this modal's CSS relies on are
|
||||
// scoped to .podman-plugin (see podman.css's token strategy comment),
|
||||
// so a modal appended to body would resolve none of them — verified
|
||||
// live: the backdrop dimming and card background were both missing,
|
||||
// only the (inherited-from-body) text was visible. position:fixed
|
||||
// still overlays the full viewport regardless of this nesting.
|
||||
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
|
||||
|
||||
const firstInput = backdrop.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
|
||||
function close() {
|
||||
backdrop.remove();
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const values = {};
|
||||
opts.fields.forEach(function (f) {
|
||||
values[f.name] = backdrop.querySelector('#podman-modal-' + f.name).value.trim();
|
||||
});
|
||||
for (const f of opts.fields) {
|
||||
if (f.required && !values[f.name]) {
|
||||
showError('"' + f.label + '" is required.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const submitBtn = backdrop.querySelector('[data-role="submit"]');
|
||||
submitBtn.disabled = true;
|
||||
Promise.resolve(opts.onSubmit(values)).then(close).catch(function (err) {
|
||||
submitBtn.disabled = false;
|
||||
showError(err.message || String(err));
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Panel router ----------------------------------------------------------
|
||||
|
||||
const panelModules = {};
|
||||
@@ -180,6 +294,7 @@ window.Podman = (function () {
|
||||
stateChipClass: stateChipClass,
|
||||
loadingRow: loadingRow,
|
||||
errorRow: errorRow,
|
||||
openFormModal: openFormModal,
|
||||
registerPanel: registerPanel,
|
||||
activatePanel: activatePanel,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user