/** * 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) + ''; } // --- Toast notifications ---------------------------------------------------- // // Replaces alert() for one-way feedback ("Saved.", "Removed 3 image(s)", // "Save failed: ..."). Confirmations stay native confirm() — a toast is // for telling the user something happened, not for asking them a // yes/no question. Command output that can run to hundreds of lines // (e.g. `podman compose up`) stays in the existing openLogModal() // pattern instead — a toast has to stay short and auto-dismiss, which // doesn't fit a scrolling log. const TOAST_ICON = { success: '✓', warn: '!', error: '✕', info: 'ℹ' }; const TOAST_DURATION_MS = { success: 4000, warn: 5000, error: 7000, info: 4000 }; let toastContainer = null; function toastRoot() { if (!toastContainer) { toastContainer = document.createElement('div'); toastContainer.className = 'podman-toast-container'; (document.querySelector('.podman-plugin') || document.body).appendChild(toastContainer); } return toastContainer; } /** * @param {string} message * @param {('success'|'warn'|'error'|'info')} [type='info'] */ function toast(message, type) { const kind = TOAST_ICON[type] ? type : 'info'; const root = toastRoot(); const node = document.createElement('div'); node.className = 'podman-toast podman-toast-' + kind; node.innerHTML = '' + TOAST_ICON[kind] + '' + '' + ''; node.querySelector('.msg').textContent = message; root.appendChild(node); let dismissed = false; function dismiss() { if (dismissed) return; dismissed = true; node.classList.add('leaving'); // Not animationend — that never fires when prefers-reduced-motion // disables the animation (podman.css sets animation:none for it), // which would leave the toast stuck on screen forever. setTimeout(function () { node.remove(); }, 160); } node.querySelector('.close').addEventListener('click', dismiss); setTimeout(dismiss, TOAST_DURATION_MS[kind]); } // --- 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 modal with a scrolling monospace log pane — for actions that run * several steps in sequence (checking/updating containers) where a plain * confirm()/alert() at the very end leaves the user with no feedback * that anything is happening while it runs. Returns {log, done} rather * than closing itself, since the caller knows when the whole sequence * (not just one call) has actually finished. * * @param {string} title * @returns {{log: (line: string) => void, done: (closeLabel?: string) => void}} */ function openLogModal(title) { const backdrop = document.createElement('div'); backdrop.className = 'podman-modal-backdrop'; backdrop.innerHTML = '' + ''; (document.querySelector('.podman-plugin') || document.body).appendChild(backdrop); const pane = backdrop.querySelector('#podman-log-modal-pane'); const closeBtn = backdrop.querySelector('[data-role="close"]'); function close() { backdrop.remove(); } closeBtn.addEventListener('click', close); backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); }); document.addEventListener('keydown', function onKey(e) { if (e.key === 'Escape' && !closeBtn.disabled) { close(); document.removeEventListener('keydown', onKey); } }); function log(line) { const row = document.createElement('div'); row.textContent = line; pane.appendChild(row); pane.scrollTop = pane.scrollHeight; } function done(closeLabel) { closeBtn.disabled = false; closeBtn.textContent = closeLabel || 'Close'; } return { log: log, done: done }; } /** * 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(''); (document.querySelector('.podman-plugin') || document.body).appendChild(menu); // Viewport-relative (see the "position: fixed" comment on // .podman-context-menu in podman.css) — no scrollY/scrollX added. // Measured AFTER appending (not assumed) since the menu's height // varies with its item count — a long menu (e.g. a container's row // menu with Details/Pause/Kill/Rename/Edit/Remove) opened from a row // near the bottom of the viewport used to run off-screen with no way // to reach its last few items. Flips above the anchor instead when // there isn't enough room below, and clamps horizontally the same way. const rect = anchorEl.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect(); const margin = 8; let top = rect.bottom + 4; if (top + menuRect.height > window.innerHeight - margin) { top = rect.top - menuRect.height - 4; } top = Math.max(margin, top); let left = rect.right - menuRect.width; left = Math.min(left, window.innerWidth - menuRect.width - margin); left = Math.max(margin, left); menu.style.top = top + 'px'; menu.style.left = left + 'px'; // 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