Files
unraid-podman/webui/plugins/podman/javascript/app.js
T
maggesandClaude Sonnet 5 7e2ed451e3
Lint / ShellCheck (push) Successful in 14s
Lint / Validate .plg XML (push) Successful in 11s
Lint / EditorConfig (push) Successful in 6s
Add Apps/Store tab, template WebUI/URL import, container RO volumes/device passthrough/run-as-user, and in-app confirm dialogs
Replaces every native confirm() with a shared P.confirm() modal (a hung
native dialog was found live to block the whole tab, including
auto-refresh, and once even double-confirmed an unrelated deletion).
Also fixes Edit Container silently resetting to Bridge/blanking the
Static IP for any container on a custom network, and a context menu
losing its anchor to a mid-read auto-refresh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 19:24:32 +00:00

582 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>';
}
// --- Toast notifications ----------------------------------------------------
//
// Replaces alert() for one-way feedback ("Saved.", "Removed 3 image(s)",
// "Save failed: ..."). Confirmations use confirmModal() below instead —
// 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 =
'<span class="ico">' + TOAST_ICON[kind] + '</span>' +
'<span class="msg"></span>' +
'<button type="button" class="close" aria-label="Dismiss">&#10005;</button>';
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]);
}
// --- Confirm dialog ----------------------------------------------------
/**
* In-app replacement for browser-native confirm() — a native confirm()
* blocks the entire tab (including this plugin's own ~2s auto-refresh,
* and any browser automation driving the page) until dismissed, can't
* be styled/themed, and — found live — is easy to lose track of: a
* hung dialog blocked screenshots/JS entirely, and a follow-up
* keypress meant to dismiss just one of them ended up confirming a
* second, unrelated one too. Returns a Promise<boolean> (true =
* confirmed) instead of blocking synchronously.
*
* @param {string} message
* @param {object} [opts]
* @param {string} [opts.title='Confirm']
* @param {string} [opts.confirmLabel='Confirm']
* @param {string} [opts.cancelLabel='Cancel']
* @param {boolean} [opts.danger=false] solid red confirm button, for
* destructive/data-losing actions (delete, remove, format, ...).
*/
function confirmModal(message, opts) {
opts = opts || {};
return new Promise(function (resolve) {
const backdrop = document.createElement('div');
backdrop.className = 'podman-modal-backdrop';
backdrop.innerHTML = '' +
'<div class="podman-modal" role="alertdialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + escapeHtml(opts.title || 'Confirm') + '</h3></div>' +
'<div class="podman-modal-body"><p class="podman-confirm-message"></p></div>' +
'<div class="podman-modal-actions">' +
'<button type="button" class="podman-btn podman-btn-ghost" data-role="cancel">' + escapeHtml(opts.cancelLabel || 'Cancel') + '</button>' +
'<button type="button" class="podman-btn ' + (opts.danger ? 'podman-btn-ghost podman-btn-danger' : 'podman-btn-primary') + '" data-role="confirm">' +
escapeHtml(opts.confirmLabel || 'Confirm') + '</button>' +
'</div></div>';
backdrop.querySelector('.podman-confirm-message').textContent = message;
(document.querySelector('.podman-plugin') || document.body).appendChild(backdrop);
backdrop.querySelector('[data-role="confirm"]').focus();
function settle(result) {
backdrop.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') settle(false);
}
backdrop.querySelector('[data-role="cancel"]').addEventListener('click', function () { settle(false); });
backdrop.querySelector('[data-role="confirm"]').addEventListener('click', function () { settle(true); });
backdrop.addEventListener('click', function (e) { if (e.target === backdrop) settle(false); });
document.addEventListener('keydown', onKey);
});
}
// --- 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 podman-btn-ghost" 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 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 = '' +
'<div class="podman-modal podman-modal-wide" role="dialog" aria-modal="true">' +
'<div class="podman-modal-head"><h3>' + escapeHtml(title) + '</h3></div>' +
'<div class="podman-modal-body"><div class="podman-log-pane" id="podman-log-modal-pane"></div></div>' +
'<div class="podman-modal-actions"><button type="button" class="podman-btn podman-btn-primary" data-role="close" disabled>Working…</button></div>' +
'</div>';
(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 '<div class="podman-context-menu-sep"></div>';
return '<button type="button" class="' + (item.danger ? 'danger' : '') + '"' +
(item.disabled ? ' disabled' : '') + '>' + escapeHtml(item.label) + '</button>';
}).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 <div>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 <div> instead of its own <button>, so
// clicking it did nothing. querySelectorAll('button') only ever
// returns the actual buttons, in the same order as the non-separator
// items, so indexing into that stays aligned regardless of separators.
const buttons = menu.querySelectorAll('button');
let buttonIndex = 0;
items.forEach(function (item) {
if (item === 'separator') return;
const btn = buttons[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');
setInterval(autoRefreshTick, AUTO_REFRESH_INTERVAL_MS);
}
// Only panels that opt in via `autoRefresh: true` (Dashboard, Containers)
// get polled — most panels (Settings, Compose, Terminal, ...) have
// in-progress forms or connections an unexpected refresh would disrupt.
// Paused while the tab is hidden (nothing to look at), while any modal
// is open (a full-panel re-render mid-edit would be jarring), and while
// a context menu is open — found live: a re-render replaces every row's
// DOM node wholesale, so a menu opened from a row (its anchor button)
// still LOOKS open but is now anchored to a detached element; opening a
// submenu from it (e.g. "Move to Folder") then positions itself
// relative to that stale anchor instead of anywhere sensible.
const AUTO_REFRESH_INTERVAL_MS = 2000;
function autoRefreshTick() {
if (document.hidden) return;
if (document.querySelector('.podman-modal-backdrop')) return;
if (document.querySelector('.podman-context-menu')) return;
const activeBtn = document.querySelector('.podman-subnav button.active');
if (!activeBtn) return;
const name = activeBtn.dataset.panel;
const module = panelModules[name];
if (module && module.autoRefresh && initialized[name] && typeof module.refresh === 'function') {
module.refresh();
}
}
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,
toast: toast,
confirm: confirmModal,
openFormModal: openFormModal,
openLogModal: openLogModal,
openContextMenu: openContextMenu,
registerPanel: registerPanel,
activatePanel: activatePanel,
};
})();