Files
pulse/public/assets/app.js
T

686 lines
24 KiB
JavaScript
Raw Normal View History

/* =====================================================================
PULSE — shared frontend shell (WP-B)
---------------------------------------------------------------------
Loaded after /web_template/base.js and before /assets/pages/<page>.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, '<br>');
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 =
'<div class="lt-modal-overlay pulse-confirm pulse-confirm--' + type + '" id="' + id + '"' +
' aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="' + id + '-title">' +
'<div class="lt-modal lt-modal-sm">' +
'<div class="lt-modal-header" style="color:' + color + ';">' +
'<span class="lt-modal-title" id="' + id + '-title">' + icon + ' ' + title + '</span>' +
'<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>' +
'</div>' +
'<div class="lt-modal-body">' +
'<p class="pulse-confirm-msg">' + message + '</p>' +
'</div>' +
'<div class="lt-modal-footer">' +
'<button type="button" class="' + confirmClass + '" id="' + id + '-confirm">' + confirmLabel + '</button>' +
(withCancel
? '<button type="button" class="lt-btn lt-btn-ghost" id="' + id + '-cancel">' + cancelLabel + '</button>'
: '') +
'</div>' +
'</div>' +
'</div>';
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);