Files
peardock/libs/uiUtils.js
T
Raven Scott 5b1acdf324
Release rolling / release (push) Has been cancelled
Use tray spinner for pending UI loading states
Replace em-dash and ad-hoc loaders with the shared job-tray spinner
for deploy templates, host/swarm/tunnels/events, health latency,
container stats/logs/processes, and registry/volume browsers.
2026-07-16 14:30:59 -04:00

491 lines
14 KiB
JavaScript

/**
* Shared UI utility functions
* Used by both app.js and templateDeploy.js to avoid code duplication
*
* Feedback policy:
* - Bottom job drawer (#job-drawer) is the primary surface for actions.
* - Top-center toasts are OFF by default (never double-notify with the tray).
* - Same message within a short window is deduped across toast / tray / job.
*/
import notificationManager from './notifications.js';
/** @type {Map<string, number>} */
const recentFeedback = new Map()
const FEEDBACK_DEDUPE_MS = 10_000
/**
* Normalize message for dedupe keys.
* @param {string} type
* @param {string} message
*/
function feedbackKey(type, message) {
return `${type}|${String(message || '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 160)
.toLowerCase()}`
}
/**
* Register that feedback was already shown (job tray, etc.) so showAlert skips it.
* @param {string} type
* @param {string} message
*/
export function markFeedbackShown(type, message) {
const key = feedbackKey(type || 'info', message)
recentFeedback.set(key, Date.now())
if (recentFeedback.size > 100) {
const now = Date.now()
for (const [k, t] of recentFeedback) {
if (now - t > FEEDBACK_DEDUPE_MS * 2) recentFeedback.delete(k)
}
}
}
/**
* @param {string} type
* @param {string} message
* @returns {boolean}
*/
export function wasFeedbackRecentlyShown(type, message) {
const key = feedbackKey(type || 'info', message)
const last = recentFeedback.get(key) || 0
return Date.now() - last < FEEDBACK_DEDUPE_MS
}
/**
* True when bottom job drawer is visible or an activity job is in flight.
*/
export function isJobTrayActive() {
if (typeof document === 'undefined') return false
const drawer = document.getElementById('job-drawer')
if (drawer?.classList.contains('job-drawer--visible')) return true
if (typeof window !== 'undefined' && window.peardockOps?.getActivityJobId?.()) return true
return false
}
/**
* Close all open Bootstrap modals and clean up any lingering backdrops
*/
export function closeAllModals() {
const modals = document.querySelectorAll('.modal.show, .modal[style*="display"]');
modals.forEach((modal) => {
const modalInstance = bootstrap.Modal.getInstance(modal);
if (modalInstance) {
modalInstance.hide();
} else {
const newInstance = new bootstrap.Modal(modal);
newInstance.hide();
}
modal.classList.remove('show');
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
modal.removeAttribute('aria-modal');
});
document.querySelectorAll('.modal-backdrop').forEach((backdrop) => {
backdrop.remove();
});
document.body.classList.remove('modal-open');
document.body.style.paddingRight = '';
document.body.style.overflow = '';
const terminalModal = document.getElementById('terminal-modal');
if (terminalModal && terminalModal.style.display !== 'none') {
terminalModal.style.display = 'none';
}
}
/**
* Activity / progress indicator — routes to the job/event tray (no full-screen spinner).
* @param {string} message
*/
export function showStatusIndicator(message = 'Processing...') {
removeLegacyStatusOverlay();
if (typeof window !== 'undefined' && window.peardockOps?.showActivity) {
window.peardockOps.showActivity(message);
markFeedbackShown('info', message);
return;
}
try {
notificationManager.add('info', message, { autoDismiss: false, key: 'status-activity' });
markFeedbackShown('info', message);
} catch {
// ignore
}
}
/**
* @param {string} message
*/
export function updateStatusIndicator(message) {
if (typeof window !== 'undefined' && window.peardockOps?.updateActivity) {
window.peardockOps.updateActivity(message);
markFeedbackShown('info', message);
return;
}
try {
notificationManager.add('info', message, { autoDismiss: false, key: 'status-activity' });
markFeedbackShown('info', message);
} catch {
// ignore
}
}
/** @type {{ ok: boolean, timer: ReturnType<typeof setTimeout>|null }|null} */
let pendingActivityFinish = null
function clearPendingActivityFinish() {
if (pendingActivityFinish?.timer) clearTimeout(pendingActivityFinish.timer)
pendingActivityFinish = null
}
/**
* If hideStatusIndicator() just ran without a message, the next showAlert in
* the same turn supplies the final line to the bottom tray (one notification).
* @param {string} type
* @param {string} text
* @returns {boolean}
*/
function claimPendingActivity(type, text) {
if (!pendingActivityFinish) return false
if (typeof window === 'undefined' || !window.peardockOps?.completeActivity) {
clearPendingActivityFinish()
return false
}
if (pendingActivityFinish.timer) clearTimeout(pendingActivityFinish.timer)
pendingActivityFinish = null
const ok = type === 'success' || type === 'info'
window.peardockOps.completeActivity(ok, text)
markFeedbackShown(type, text)
clearTopToasts()
return true
}
/**
* Finish activity in the bottom tray. Prefer passing the final user message so
* callers do not also need showAlert for the same outcome.
*
* Bare hideStatusIndicator() defers one tick so a following showAlert can
* complete the same tray entry (avoids job + toast/tray double fire).
*
* @param {boolean} [ok=true]
* @param {string} [message]
*/
export function hideStatusIndicator(ok = true, message) {
const success = ok !== false
removeLegacyStatusOverlay()
if (typeof window === 'undefined' || !window.peardockOps?.completeActivity) {
return
}
if (message != null && String(message).trim()) {
clearPendingActivityFinish()
window.peardockOps.completeActivity(success, String(message).trim())
markFeedbackShown(success ? 'success' : 'danger', message)
return
}
// Defer bare complete so hideStatusIndicator(); showAlert(...) shares one tray slot
clearPendingActivityFinish()
pendingActivityFinish = { ok: success, timer: null }
pendingActivityFinish.timer = setTimeout(() => {
if (!pendingActivityFinish) return
const p = pendingActivityFinish
pendingActivityFinish = null
window.peardockOps?.completeActivity?.(p.ok)
}, 0)
}
function removeLegacyStatusOverlay() {
const remove = () => {
document.getElementById('status-indicator')?.remove();
};
if (typeof document === 'undefined') return;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', remove, { once: true });
} else {
remove();
}
}
/**
* Ensure toast host exists (top-center stack) — only used when toast: true is forced.
* @returns {HTMLElement|null}
*/
function ensureToastStack() {
if (typeof document === 'undefined') return null;
let stack = document.getElementById('toast-stack');
if (stack) return stack;
const legacy = document.getElementById('alert-container');
if (legacy) {
legacy.id = 'toast-stack';
legacy.className = 'toast-stack';
legacy.removeAttribute('style');
legacy.setAttribute('aria-live', 'polite');
legacy.setAttribute('aria-atomic', 'false');
return legacy;
}
stack = document.createElement('div');
stack.id = 'toast-stack';
stack.className = 'toast-stack';
stack.setAttribute('aria-live', 'polite');
stack.setAttribute('aria-atomic', 'false');
document.body.appendChild(stack);
return stack;
}
/**
* Clear any leftover top-center toasts (e.g. after routing to job tray).
*/
export function clearTopToasts() {
if (typeof document === 'undefined') return;
const stack = document.getElementById('toast-stack');
if (stack) stack.innerHTML = '';
}
/**
* Try to finish / update the bottom job tray instead of a separate notification.
* @param {string} type
* @param {string} text
* @returns {boolean} true if handled by job tray
*/
function routeToJobTrayIfActive(type, text) {
if (typeof window === 'undefined' || !window.peardockOps) return false;
const ops = window.peardockOps;
const hasActivity = typeof ops.getActivityJobId === 'function' && ops.getActivityJobId();
const drawerActive =
typeof document !== 'undefined' &&
document.getElementById('job-drawer')?.classList.contains('job-drawer--visible');
if (!hasActivity && !drawerActive) return false;
// Active lightweight activity → complete with this message (single surface)
if (hasActivity && typeof ops.completeActivity === 'function') {
const ok = type === 'success' || type === 'info';
ops.completeActivity(ok, text);
markFeedbackShown(type, text);
clearTopToasts();
return true;
}
// Multi-step job already owns the drawer (deploy, etc.) — do not double-notify
if (drawerActive) {
markFeedbackShown(type, text);
clearTopToasts();
return true;
}
return false;
}
/**
* User feedback. Bottom job tray wins; top-center toasts are opt-in only.
*
* @param {string} type - success | danger | warning | info
* @param {string} message
* @param {{
* autoDismiss?: boolean,
* duration?: number,
* toast?: boolean,
* tray?: boolean,
* forceToast?: boolean,
* job?: boolean,
* key?: string,
* }} [options]
* toast / forceToast: show top-center toast (default false — avoid double UI)
* tray: write notification history bell (default true when not handled by job)
* job: allow routing into active job tray (default true)
* badge: force (true) or suppress (false) bell badge; default danger/warning only
*/
export function showAlert(type, message, options = {}) {
const text = String(message ?? '');
if (!text) return;
const tone = ['success', 'danger', 'warning', 'info'].includes(type) ? type : 'info';
// Same message already shown (job tray / prior alert) — never spam a second channel
if (options.force !== true && wasFeedbackRecentlyShown(tone, text)) {
return;
}
// hideStatusIndicator(); showAlert(...) → one bottom-tray completion
if (options.job !== false && claimPendingActivity(tone, text)) {
return;
}
// Prefer bottom action tray when it is already handling this operation
if (options.job !== false && routeToJobTrayIfActive(tone, text)) {
return;
}
// Top-center toast: OFF unless requested or Settings → forceToast
let settingsForceToast = false
try {
if (typeof window !== 'undefined' && window.__peardockSettings?.forceToast) {
settingsForceToast = true
} else if (typeof localStorage !== 'undefined') {
const raw = JSON.parse(localStorage.getItem('peardock.settings.v1') || '{}')
settingsForceToast = Boolean(raw.forceToast)
}
} catch {
// ignore
}
const wantToast =
options.forceToast === true ||
options.toast === true ||
(settingsForceToast && options.toast !== false && options.forceToast !== false)
// Bell history: keep for non-job feedback unless caller disables
const wantTray = options.tray !== false;
markFeedbackShown(tone, text);
if (wantTray) {
try {
notificationManager.add(tone, text, {
autoDismiss: false,
...options,
});
} catch {
// ignore
}
}
if (!wantToast) return;
const stack = ensureToastStack();
if (!stack) return;
const {
autoDismiss = true,
duration = 4500,
} = options;
const iconMap = {
success: 'fa-circle-check',
danger: 'fa-circle-xmark',
warning: 'fa-triangle-exclamation',
info: 'fa-circle-info',
};
const el = document.createElement('div');
el.className = `pd-toast pd-toast--${tone}`;
el.setAttribute('role', 'status');
el.innerHTML = `
<i class="fas ${iconMap[tone]} pd-toast-icon" aria-hidden="true"></i>
<div class="pd-toast-body">${escapeToast(text)}</div>
<button type="button" class="pd-toast-close" aria-label="Dismiss">
<i class="fas fa-xmark"></i>
</button>`;
let timer = null;
const dismiss = () => {
if (timer) clearTimeout(timer);
el.classList.add('pd-toast--leaving');
setTimeout(() => el.remove(), 220);
};
el.querySelector('.pd-toast-close')?.addEventListener('click', dismiss);
el.addEventListener('mouseenter', () => {
if (timer) clearTimeout(timer);
});
el.addEventListener('mouseleave', () => {
if (autoDismiss !== false) {
timer = setTimeout(dismiss, 2000);
}
});
stack.prepend(el);
while (stack.children.length > 5) {
stack.lastElementChild?.remove();
}
if (autoDismiss !== false) {
timer = setTimeout(dismiss, duration);
}
}
function escapeToast(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/**
* Job-tray spinner (same markup as client/jobs.js active step).
* Use for pending / loading UI instead of em-dash "—" placeholders.
*/
export const JOB_SPINNER_HTML =
'<span class="job-spinner" role="status" aria-label="Loading"><span class="job-spinner-ring"></span></span>';
/**
* @param {string} [label]
* @param {{ center?: boolean, className?: string }} [opts]
* @returns {string}
*/
export function jobSpinnerHtml(label = 'Loading…', opts = {}) {
const cls = [
'pd-job-loading',
'd-inline-flex',
'align-items-center',
'gap-2',
opts.center ? 'justify-content-center' : '',
opts.className || '',
]
.filter(Boolean)
.join(' ');
if (!label) {
return `<span class="${cls}">${JOB_SPINNER_HTML}</span>`;
}
const safe = String(label)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
return `<span class="${cls}">${JOB_SPINNER_HTML}<span>${safe}</span></span>`;
}
/**
* Compact spinner for table cells / metric chips (no label).
* @returns {string}
*/
export function jobSpinnerPending() {
return JOB_SPINNER_HTML;
}
/**
* Loading row for table bodies.
* @param {number} colspan
* @param {string} [label]
* @returns {string}
*/
export function jobSpinnerLoadingRow(colspan, label = 'Loading…') {
return `<tr class="pd-loading-row"><td colspan="${colspan}" class="text-muted">${jobSpinnerHtml(label)}</td></tr>`;
}
/**
* Centered block for list containers / empty panes.
* @param {string} [label]
* @returns {string}
*/
export function jobSpinnerLoadingBlock(label = 'Loading…') {
return `<div class="text-center text-muted py-4 d-flex align-items-center justify-content-center gap-2">${jobSpinnerHtml(label)}</div>`;
}
// Bridge for jobs.js (avoids circular import) — mark feedback when jobs complete
if (typeof globalThis !== 'undefined') {
globalThis.__peardockMarkFeedback = markFeedbackShown
globalThis.__peardockJobSpinnerHtml = jobSpinnerHtml
globalThis.__peardockJobSpinnerPending = jobSpinnerPending
}