/** * 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/.php?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 '' + escapeHtml(label || 'Loading…') + ''; } function errorRow(colspan, message) { return '' + escapeHtml(message) + ''; } // --- 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) => Promise} 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 '' + '
' + '' + '' + (f.hint ? '
' + escapeHtml(f.hint) + '
' : '') + '
'; }).join(''); backdrop.innerHTML = '' + ''; // 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 '
'; return ''; }).join(''); // Viewport-relative (see the "position: fixed" comment on // .podman-context-menu in podman.css) — no scrollY/scrollX added. const rect = anchorEl.getBoundingClientRect(); menu.style.top = (rect.bottom + 4) + 'px'; menu.style.left = (rect.right - 180) + 'px'; (document.querySelector('.podman-plugin') || document.body).appendChild(menu); // menu.children includes the separator
s too, so indexing into it // directly (by a counter that only advances for real items) drifts by // one after every separator — e.g. "Remove" (after a separator) ended // up wired to the separator
instead of its own