First increment of the big WebUI feature-parity spec (see task list) — row-level actions were about to run out of icon-button space, so this adds a small anchored dropdown menu (app.js openContextMenu) for secondary per-container actions instead of cramming more buttons into every row. All four new actions verified live against the real podman API before wiring up the UI (same discipline as the CSRF/pull/compose bugs found earlier — field names and response shapes checked against the running socket, not assumed from docs). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
366 lines
14 KiB
JavaScript
366 lines
14 KiB
JavaScript
/**
|
|
* 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);
|
|
}
|
|
// 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) {
|
|
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>';
|
|
}
|
|
|
|
// --- 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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Small anchored dropdown menu — used for secondary per-row actions
|
|
* (pause/kill/rename/...) that would otherwise clutter a table row with
|
|
* one icon button each. Only one menu is ever open at a time.
|
|
*
|
|
* @param {HTMLElement} anchorEl button the menu opens from/closes on
|
|
* @param {Array<{label:string, danger?:boolean, disabled?:boolean, onClick?:Function}|'separator'>} items
|
|
*/
|
|
let openMenuCloser = null;
|
|
function openContextMenu(anchorEl, items) {
|
|
if (openMenuCloser) {
|
|
openMenuCloser();
|
|
return;
|
|
}
|
|
|
|
const menu = document.createElement('div');
|
|
menu.className = 'podman-context-menu';
|
|
menu.innerHTML = items.map(function (item) {
|
|
if (item === 'separator') return '<div class="podman-context-menu-sep"></div>';
|
|
return '<button type="button" class="' + (item.danger ? 'danger' : '') + '"' +
|
|
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
|
|
}).join('');
|
|
|
|
const rect = anchorEl.getBoundingClientRect();
|
|
menu.style.top = (rect.bottom + window.scrollY + 4) + 'px';
|
|
menu.style.left = (rect.right + window.scrollX - 180) + 'px';
|
|
(document.querySelector('.podman-plugin') || document.body).appendChild(menu);
|
|
|
|
let buttonIndex = 0;
|
|
items.forEach(function (item) {
|
|
if (item === 'separator') return;
|
|
const btn = menu.children[buttonIndex];
|
|
buttonIndex++;
|
|
if (item.disabled) return;
|
|
btn.addEventListener('click', function (e) {
|
|
e.stopPropagation();
|
|
close();
|
|
if (item.onClick) item.onClick();
|
|
});
|
|
});
|
|
|
|
function close() {
|
|
menu.remove();
|
|
document.removeEventListener('click', onOutsideClick);
|
|
document.removeEventListener('keydown', onKey);
|
|
openMenuCloser = null;
|
|
}
|
|
function onOutsideClick(e) {
|
|
if (!menu.contains(e.target)) close();
|
|
}
|
|
function onKey(e) {
|
|
if (e.key === 'Escape') close();
|
|
}
|
|
|
|
openMenuCloser = close;
|
|
// Deferred so the click that opened the menu doesn't immediately
|
|
// trigger onOutsideClick via event bubbling.
|
|
setTimeout(function () {
|
|
document.addEventListener('click', onOutsideClick);
|
|
document.addEventListener('keydown', onKey);
|
|
}, 0);
|
|
}
|
|
|
|
// --- 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,
|
|
openFormModal: openFormModal,
|
|
openContextMenu: openContextMenu,
|
|
registerPanel: registerPanel,
|
|
activatePanel: activatePanel,
|
|
};
|
|
})();
|