Release rolling / release (push) Has been cancelled
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.
491 lines
14 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
/**
|
|
* 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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
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
|
|
}
|