/* ===================================================================== PULSE — shared frontend shell (WP-B) --------------------------------------------------------------------- Loaded after /web_template/base.js and before /assets/pages/.js. Exposes the frozen `window.Pulse` contract consumed by page modules. ===================================================================== */ 'use strict'; /* --------------------------------------------------------------------- 0. 401 → reload wrapper. Installed first, before anything can fetch. Authelia sessions expire; a 401 means "log in again", so force a full document reload which bounces through the SSO portal. --------------------------------------------------------------------- */ (function () { const _fetch = window.fetch; if (typeof _fetch !== 'function' || _fetch.__pulseWrapped) return; const wrapped = async function (...args) { const resp = await _fetch.apply(window, args); if (resp.status === 401) { window.location.reload(); throw new Error('Session expired — reloading'); } return resp; }; wrapped.__pulseWrapped = true; window.fetch = wrapped; })(); (function (global) { const LOG = '[Pulse]'; const noopLt = {}; /** base.js is a hard dependency, but never let its absence blank the page. */ function LT() { return global.lt || noopLt; } function warn() { const a = Array.prototype.slice.call(arguments); console.warn.apply(console, [LOG].concat(a)); } function err() { const a = Array.prototype.slice.call(arguments); console.error.apply(console, [LOG].concat(a)); } /* ------------------------------------------------------------------- 1. Escaping / formatting helpers Output formats are byte-compatible with the pre-redesign index.html helpers so page modules render identical strings. ------------------------------------------------------------------- */ function esc(text) { if (text === null || text === undefined) return ''; if (LT().escHtml) return LT().escHtml(text); const div = document.createElement('div'); div.textContent = String(text); return div.innerHTML; } /** null for falsy/invalid, otherwise a Date. */ function safeDate(val) { if (!val) return null; const d = val instanceof Date ? val : new Date(val); return isNaN(d.getTime()) ? null : d; } /** '0 B' for falsy; one decimal otherwise. */ function formatBytes(bytes) { if (!bytes || bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i]; } /** 'Nd Nh Nm' / 'Nh Nm' / 'Nm'; 'N/A' for falsy. */ function formatUptime(seconds) { if (!seconds) return 'N/A'; 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 ' + minutes + 'm'; if (hours > 0) return hours + 'h ' + minutes + 'm'; return minutes + 'm'; } /** 'Ns ago' / 'Nm ago' / 'Nh ago' / 'Nd ago'; 'just now' if in the future. */ function timeAgo(date) { const d = date instanceof Date ? date : safeDate(date); if (!d || isNaN(d.getTime())) return 'N/A'; const seconds = Math.floor((Date.now() - d.getTime()) / 1000); if (seconds < 0) return 'just now'; if (seconds < 60) return seconds + 's ago'; const minutes = Math.floor(seconds / 60); if (minutes < 60) return minutes + 'm ago'; const hours = Math.floor(minutes / 60); if (hours < 24) return hours + 'h ago'; return Math.floor(hours / 24) + 'd ago'; } /** 'Ns' / 'Nm Ns' / 'Nh Nm'; '' when startedAt is unusable. */ function formatElapsed(startedAt) { const start = safeDate(startedAt); if (!start) return ''; const secs = Math.floor((Date.now() - start.getTime()) / 1000); if (secs < 60) return secs + 's'; const mins = Math.floor(secs / 60); if (mins < 60) return mins + 'm ' + (secs % 60) + 's'; return Math.floor(mins / 60) + 'h ' + (mins % 60) + 'm'; } /** HH:MM:SS in local time. */ function clock(d) { const date = d instanceof Date ? d : (safeDate(d) || new Date()); const p = n => String(n).padStart(2, '0'); return p(date.getHours()) + ':' + p(date.getMinutes()) + ':' + p(date.getSeconds()); } /** toLocaleString(), or 'N/A'. */ function dateTime(val) { const d = safeDate(val); return d ? d.toLocaleString() : 'N/A'; } const STATUS_CLASSES = { online: 'online', offline: 'offline', running: 'running', completed: 'completed', failed: 'failed', waiting: 'pending', }; /** Badge classes for a status string. */ function statusClass(status) { const key = String(status || '').toLowerCase(); return 'lt-status lt-status-' + (STATUS_CLASSES[key] || 'pending'); } const fmt = { elapsed: formatElapsed, safeDate: safeDate, bytes: formatBytes, uptime: formatUptime, ago: timeAgo, clock: clock, dateTime: dateTime, status: statusClass, }; /* ------------------------------------------------------------------- 2. Small utilities ------------------------------------------------------------------- */ function download(filename, text, mime) { try { const blob = new Blob([text], { type: mime || 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename || 'download.txt'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); } catch (e) { err('download failed', e); } } /* Verbatim localStorage keys — `commandHistory` and `pulse_executionView` must keep working across the redesign, so NO prefix is applied. */ const storage = { get(key, fallback) { try { const raw = localStorage.getItem(key); if (raw === null) return fallback !== undefined ? fallback : null; return JSON.parse(raw); } catch (e) { return fallback !== undefined ? fallback : null; } }, set(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); return true; } catch (e) { warn('storage.set failed for', key, e); return false; } }, remove(key) { try { localStorage.removeItem(key); } catch (e) { /* ignore */ } }, }; /* ------------------------------------------------------------------- 3. Event bus (Pulse-local, independent of lt.bus) ------------------------------------------------------------------- */ const _handlers = new Map(); const events = { on(type, fn) { if (typeof fn !== 'function') return; if (!_handlers.has(type)) _handlers.set(type, []); _handlers.get(type).push(fn); }, off(type, fn) { const list = _handlers.get(type); if (list) _handlers.set(type, list.filter(f => f !== fn)); }, emit(type, data) { const list = _handlers.get(type); if (!list || !list.length) return; list.slice().forEach(fn => { try { fn(data, type); } catch (e) { err('event handler for "' + type + '"', e); } }); }, }; /* ------------------------------------------------------------------- 4. Action registry + delegated listeners ------------------------------------------------------------------- */ const _actions = Object.create(null); const _warned = Object.create(null); const actions = { register(name, fn) { if (!name || typeof fn !== 'function') { warn('actions.register: bad arguments', name); return; } if (_actions[name]) warn('action "' + name + '" re-registered'); _actions[name] = fn; }, registerAll(map) { Object.keys(map || {}).forEach(name => actions.register(name, map[name])); }, has(name) { return !!_actions[name]; }, names() { return Object.keys(_actions); }, }; function runAction(name, el, ev) { const fn = _actions[name]; if (!fn) { if (!_warned[name]) { _warned[name] = true; warn('unknown action "' + name + '"'); } return; } try { const r = fn(el, ev); if (r && typeof r.catch === 'function') { r.catch(e => err('action "' + name + '" rejected', e)); } } catch (e) { err('action "' + name + '" threw', e); } } function isDisabled(el) { return el.disabled === true || el.getAttribute('aria-disabled') === 'true' || el.classList.contains('is-disabled'); } function installDelegation() { document.addEventListener('click', function (ev) { let el; try { el = ev.target.closest && ev.target.closest('[data-action]'); } catch (e) { return; } if (!el) return; if (isDisabled(el)) { ev.preventDefault(); return; } const tag = el.tagName; if ((tag === 'A' && (el.getAttribute('href') === '#' || el.getAttribute('href') === '')) || (tag === 'BUTTON' && el.form && !el.getAttribute('type'))) { ev.preventDefault(); } runAction(el.getAttribute('data-action'), el, ev); }); document.addEventListener('change', function (ev) { let el; try { el = ev.target.closest && ev.target.closest('[data-change-action]'); } catch (e) { return; } if (!el || isDisabled(el)) return; runAction(el.getAttribute('data-change-action'), el, ev); }); /* Per-element 200 ms debounce, so two search boxes never share a timer. */ const _debounced = new WeakMap(); document.addEventListener('input', function (ev) { let el; try { el = ev.target.closest && ev.target.closest('[data-input-action]'); } catch (e) { return; } if (!el || isDisabled(el)) return; let fn = _debounced.get(el); if (!fn) { const mk = LT().debounce || function (f, ms) { let t; return function () { const a = arguments, c = this; clearTimeout(t); t = setTimeout(() => f.apply(c, a), ms); }; }; fn = mk(function (e) { runAction(el.getAttribute('data-input-action'), el, e); }, 200); _debounced.set(el, fn); } fn(ev); }); document.addEventListener('submit', function (ev) { let el; try { el = ev.target.closest && ev.target.closest('form[data-submit-action]'); } catch (e) { return; } if (!el) return; ev.preventDefault(); runAction(el.getAttribute('data-submit-action'), el, ev); }); } /* ------------------------------------------------------------------- 5. Confirm / alert modals (ported from tinker_tickets utils.js) ------------------------------------------------------------------- */ const MODAL_COLORS = { warning: 'var(--accent-amber)', error: 'var(--accent-red)', info: 'var(--accent-cyan)', }; const MODAL_ICONS = { warning: '[ ! ]', error: '[ X ]', info: '[ i ]' }; let _modalSeq = 0; function buildModal(opts, withCancel) { const o = opts || {}; const type = MODAL_ICONS[o.type] ? o.type : 'warning'; const id = 'pulse-confirm-' + (++_modalSeq) + '-' + Date.now(); const color = MODAL_COLORS[type]; const icon = MODAL_ICONS[type]; const title = esc(o.title || (withCancel ? 'Confirm' : 'Notice')); const message = esc(o.message === null || o.message === undefined ? '' : o.message).replace(/\n/g, '
'); const confirmLabel = esc(o.confirmLabel || (withCancel ? 'CONFIRM' : 'OK')); const cancelLabel = esc(o.cancelLabel || 'CANCEL'); const confirmClass = type === 'error' ? 'lt-btn lt-btn-danger' : 'lt-btn lt-btn-primary'; const html = ''; document.body.insertAdjacentHTML('beforeend', html); return { id: id, el: document.getElementById(id), type: type }; } function openModalEl(built, settle) { const el = built.el; let done = false; function finish(result) { if (done) return; done = true; try { if (LT().modal && el.classList.contains('is-open')) LT().modal.close(el); else el.classList.remove('is-open'); } catch (e) { err('modal close failed', e); } setTimeout(() => { if (el && el.parentNode) el.parentNode.removeChild(el); }, 300); settle(result); } /* ESC and backdrop clicks are handled globally by base.js, which fires lt:modalclose — treat both as a cancel. */ el.addEventListener('lt:modalclose', () => finish(false)); return finish; } function confirmModal(opts) { return new Promise(resolve => { let built; try { built = buildModal(opts, true); } catch (e) { err('confirm build failed', e); resolve(false); return; } const finish = openModalEl(built, resolve); const confirmBtn = document.getElementById(built.id + '-confirm'); const cancelBtn = document.getElementById(built.id + '-cancel'); if (confirmBtn) confirmBtn.addEventListener('click', () => finish(true)); if (cancelBtn) cancelBtn.addEventListener('click', () => finish(false)); try { if (LT().modal) LT().modal.open(built.el); else built.el.classList.add('is-open'); } catch (e) { err(e); } /* Destructive prompts should not have CONFIRM under the cursor/keyboard. */ if ((built.type === 'error' || built.type === 'warning') && cancelBtn) { setTimeout(() => { try { cancelBtn.focus(); } catch (e) { /* ignore */ } }, 60); } }); } function alertModal(opts) { return new Promise(resolve => { let built; try { built = buildModal(opts, false); } catch (e) { err('alert build failed', e); resolve(); return; } const finish = openModalEl(built, () => resolve()); const okBtn = document.getElementById(built.id + '-confirm'); if (okBtn) okBtn.addEventListener('click', () => finish(true)); try { if (LT().modal) LT().modal.open(built.el); else built.el.classList.add('is-open'); } catch (e) { err(e); } }); } /* ------------------------------------------------------------------- 6. Page registration & refresh ------------------------------------------------------------------- */ let _booted = false; let _pageInited = false; function registerPage(page) { if (!page || typeof page !== 'object') { warn('registerPage: expected an object'); return; } Pulse.page = page; if (_booted) initPage(); } async function initPage() { const page = Pulse.page; if (!page || _pageInited) return; _pageInited = true; if (typeof page.init !== 'function') return; try { await page.init(); } catch (e) { err('page init failed', e); toastSafe('error', 'Page failed to initialise'); } } function toastSafe(kind, msg) { try { const t = LT().toast; if (t && t[kind]) t[kind](msg); else console.log(LOG, kind + ':', msg); } catch (e) { /* never let a toast break a handler */ } } function stampRefreshed() { const el = document.getElementById('pulse-last-refreshed'); if (el) el.textContent = 'Refreshed: ' + clock(new Date()); } async function refreshNow() { const page = Pulse.page; if (page && typeof page.refresh === 'function') { try { await page.refresh(); } catch (e) { err('page refresh failed', e); toastSafe('error', 'Refresh failed'); } } stampRefreshed(); } /* ------------------------------------------------------------------- 7. WebSocket ------------------------------------------------------------------- */ const KNOWN_TYPES = [ 'command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted', 'workflow_updated', 'execution_prompt', 'executions_bulk_deleted', ]; let _wsHandle = null; let _wasDisconnected = false; function wsUrl() { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; return proto + '//' + location.host; } function setWsStatus(state) { const el = document.getElementById('pulse-ws-status'); if (!el) return; el.setAttribute('data-state', state); const labels = { connected: 'Connected', connecting: 'Connecting…', disconnected: 'Disconnected' }; const span = el.querySelector('span:last-child'); if (span) span.textContent = labels[state] || state; } function handleWsMessage(data) { try { if (!data || typeof data !== 'object' || !data.type) return; if (data.type === 'command_result' && !data.is_automated) { if (data.success) toastSafe('success', 'Command completed successfully'); else toastSafe('error', 'Command execution failed'); } let handled = false; const page = Pulse.page; if (page && typeof page.onEvent === 'function') { try { handled = page.onEvent(data.type, data) === true; } catch (e) { err('page onEvent failed for "' + data.type + '"', e); } } events.emit(data.type, data); if (KNOWN_TYPES.indexOf(data.type) === -1 && !handled) { refreshNow(); } } catch (e) { err('WebSocket message handling failed', e); } } function onWsOpen() { setWsStatus('connected'); if (_wasDisconnected) { _wasDisconnected = false; toastSafe('info', 'Live updates reconnected'); refreshNow(); } } function onWsClose() { _wasDisconnected = true; setWsStatus('disconnected'); } /** Raw-WebSocket fallback used only when lt.ws is missing. */ function connectRawWs() { let sock; function open() { setWsStatus('connecting'); try { sock = new WebSocket(wsUrl()); } catch (e) { err('WebSocket create failed', e); setTimeout(open, 5000); return; } sock.addEventListener('open', onWsOpen); sock.addEventListener('message', ev => { let data = ev.data; try { data = JSON.parse(ev.data); } catch (e) { /* non-JSON frame */ } handleWsMessage(data); }); sock.addEventListener('close', () => { onWsClose(); setTimeout(open, 5000); }); sock.addEventListener('error', e => warn('WebSocket error', e)); } open(); return { send(d) { if (sock && sock.readyState === 1) { sock.send(typeof d === 'string' ? d : JSON.stringify(d)); return true; } return false; } }; } function connectWs() { try { if (LT().ws && typeof LT().ws.connect === 'function') { _wsHandle = LT().ws.connect(wsUrl(), { statusEl: '#pulse-ws-status', reconnect: true, reconnectDelay: 2000, maxRetries: Number.MAX_SAFE_INTEGER, onOpen: onWsOpen, onClose: onWsClose, onError: e => warn('WebSocket error', e), onMessage: handleWsMessage, }); } else { warn('lt.ws unavailable — using raw WebSocket fallback'); _wsHandle = connectRawWs(); } } catch (e) { err('WebSocket setup failed', e); try { _wsHandle = connectRawWs(); } catch (e2) { err('WebSocket fallback failed', e2); } } } /* ------------------------------------------------------------------- 8. Boot ------------------------------------------------------------------- */ const NAV_ROUTES = [ { id: 'nav-dashboard', label: 'Dashboard', path: '/', tags: ['home', 'overview'] }, { id: 'nav-workers', label: 'Workers', path: '/workers', tags: ['agents', 'hosts'] }, { id: 'nav-workflows', label: 'Workflows', path: '/workflows', tags: ['jobs'] }, { id: 'nav-executions', label: 'Executions', path: '/executions', tags: ['history', 'logs', 'runs'] }, { id: 'nav-quick', label: 'Quick Command', path: '/quick', tags: ['run', 'shell', 'command'] }, { id: 'nav-scheduler', label: 'Scheduler', path: '/scheduler', tags: ['cron', 'schedule'] }, ]; function openKeysHelp() { const help = document.getElementById('lt-keys-help'); if (help && LT().modal) LT().modal.open(help); } function buildCommands() { const cmds = NAV_ROUTES.map(r => ({ id: r.id, label: r.label, icon: '→', group: 'Navigate', tags: r.tags, action: () => { window.location.href = r.path; }, })); cmds.push( { id: 'act-refresh', label: 'Refresh', icon: '⟳', group: 'Actions', kbd: 'R', tags: ['reload'], action: () => refreshNow() }, { id: 'act-theme', label: 'Toggle Theme', icon: '◐', group: 'Actions', tags: ['dark', 'light'], action: () => { if (LT().theme) LT().theme.toggle(); } }, { id: 'help-keys', label: 'Keyboard Shortcuts', icon: '?', group: 'Help', kbd: '?', tags: ['keys', 'shortcuts'], action: openKeysHelp } ); return cmds; } let _tickTimer = null; function startTicker() { if (_tickTimer) return; _tickTimer = setInterval(() => { if (document.hidden) return; // cheap: no work while backgrounded if (!_handlers.has('tick')) return; events.emit('tick'); }, 1000); } function boot() { if (_booted) return; _booted = true; try { if (LT().init) LT().init({ bootName: 'PULSE' }); } catch (e) { err('lt.init failed', e); } const themeBtn = document.getElementById('lt-theme-btn'); if (themeBtn) themeBtn.addEventListener('click', () => { if (LT().theme) LT().theme.toggle(); }); try { if (LT().cmdPalette) LT().cmdPalette.init(buildCommands()); } catch (e) { err('cmdPalette init failed', e); } try { if (LT().keys) { LT().keys.on('r', () => refreshNow()); LT().keys.on('?', openKeysHelp); } } catch (e) { err('key binding failed', e); } connectWs(); startTicker(); initPage(); /* The ONLY autoRefresh registration in the whole app. Page modules must never call lt.autoRefresh — they get refreshed through page.refresh(). */ try { if (LT().autoRefresh) LT().autoRefresh.start(() => refreshNow(), 30000); } catch (e) { err('autoRefresh start failed', e); } } /* ------------------------------------------------------------------- 9. Public surface ------------------------------------------------------------------- */ const Pulse = { user: global.CURRENT_USER || { username: '', name: '', email: '', groups: [], isAdmin: false }, isAdmin: false, config: global.PULSE_CONFIG || {}, page: null, actions: actions, events: events, registerPage: registerPage, refreshNow: refreshNow, confirm: confirmModal, alertModal: alertModal, get api() { return LT().api; }, get toast() { return LT().toast; }, get beep() { return LT().beep; }, esc: esc, fmt: fmt, util: { download: download, storage: storage }, /** Escape hatch for pages that need to push a frame upstream. */ ws: { send(d) { return _wsHandle && _wsHandle.send ? _wsHandle.send(d) : false; }, get handle() { return _wsHandle; } }, }; Pulse.isAdmin = !!(Pulse.user && Pulse.user.isAdmin) || !!(Pulse.config && Pulse.config.isAdmin); global.Pulse = Pulse; /* Reserved global actions (unprefixed namespace belongs to app.js). */ actions.registerAll({ 'app:refresh': () => refreshNow(), 'app:theme': () => { if (LT().theme) LT().theme.toggle(); }, 'open-nav-drawer': () => { if (LT().mobileNav) LT().mobileNav.open(); }, 'open-cmdpalette': () => { if (LT().cmdPalette) LT().cmdPalette.open(); }, 'app:keys-help': openKeysHelp, }); installDelegation(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { /* Script ran after parsing (deferred/injected): boot on the next tick so a page module loaded right after us can still register before init(). */ setTimeout(boot, 0); } })(window);