Files
unraid-podman/webui/plugins/podman/javascript/app.js
T
maggesandClaude Sonnet 5 8ac9cde621 Add Pod lifecycle management, fix nav registration and context-menu bugs
Pods panel could previously only list pods - there was no way to create
one, start/stop/restart it, or attach a container to it from the UI.
Adds a "New Pod" modal (name + port mappings), a per-pod lifecycle menu
(start/stop/restart/remove), and an optional "Pod" field on the Create
Container modal to join an existing pod's network namespace. Backend
verified live against the real podman socket (/pods/create, /pods/{name}/
restart, container "pod" field).

Also fixes three real bugs found via live testing:
- Podman.page used Menu="Podman" instead of Menu="Tasks:<rank>", so the
  plugin never actually appeared in Unraid's top navigation (traced through
  PageBuilder.php/DefaultPageLayout.php/Navigation/Main.php - only pages
  registered under "Tasks" become top-level tabs).
- app.js's shared context-menu component mis-mapped every item positioned
  after a 'separator' entry to the wrong DOM element (an off-by-one against
  menu.children, which includes the separator <div>s) - so "Remove", which
  always sits after a separator, silently did nothing when clicked. Fixed
  by indexing into querySelectorAll('button') instead.
- That same menu was positioned via "position: absolute" math that assumed
  a viewport-relative containing block, but Unraid's own page wrapper
  (webGui/styles/default-base.css's ".content") sets position:relative,
  so the menu rendered far from its anchor button. Switched to
  "position: fixed" with viewport-relative coordinates.

Incidentally, pods add a hidden "infra" container that was leaking into
the plain Containers list with no working lifecycle of its own (always
"running", so its own Remove was permanently disabled) - now filtered out
via libpod's IsInfra flag. And every action-buttons table cell used
"display: flex" directly on the <td>, which browsers can size
inconsistently row to row - moved onto an inner wrapper div instead, and
bumped .podman-btn-icon's touch target size.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-12 16:26:49 +00:00

376 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 { '&': '&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>';
}
// --- 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 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('');
// 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 <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');
}
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,
};
})();