feat(ui): TDS migration stage 1 — EJS layout, CSP, vendored design system, shared runtime
- Vendor web_template base.css/base.js (v1.2 bbec859) under public/web_template with sync script - Add views/layout.ejs (fixed upstream EJS comment delimiters, mobile drawer, cmd palette, keys help, WS status, theme toggle) and lib/render.js two-step renderer - Add page routes for /, /workers, /workflows, /executions, /quick, /scheduler (stub views) - helmet with strict nonce CSP (script-src-attr 'none'), /csp-report, report-only toggle - lib/pageauth.js: shared users upsert + HTML 401/403 for page routes - PULSE_DEV_READONLY guard disabling background writers for local testing - public/assets/app.js shared runtime (Pulse namespace, delegated actions, WS singleton, themed confirm modal) and app.css - eslint globals for browser files; ignore vendored assets Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
This commit is contained in:
@@ -1,6 +1,13 @@
|
||||
{
|
||||
"env": { "browser": true, "es2021": true },
|
||||
"parserOptions": { "ecmaVersion": 2021, "sourceType": "script" },
|
||||
"globals": {
|
||||
"lt": "readonly",
|
||||
"Pulse": "writable",
|
||||
"CSRF_TOKEN": "readonly",
|
||||
"CURRENT_USER": "readonly",
|
||||
"PULSE_CONFIG": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
"no-unused-vars": "warn",
|
||||
"no-empty": "warn",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/* =====================================================================
|
||||
PULSE — shell additions on top of /web_template/base.css
|
||||
---------------------------------------------------------------------
|
||||
Rules here are Pulse-specific only. No `.lt-*` base rule is redefined;
|
||||
`.lt-*` selectors appear only as ancestors/descendants of a `.pulse-*`
|
||||
class, or as scoped layout tweaks inside the Pulse header cluster.
|
||||
Page-specific styles live in /assets/pages/<page>.css.
|
||||
===================================================================== */
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
1. Header right cluster — WS status, last-refreshed stamp, buttons
|
||||
--------------------------------------------------------------------- */
|
||||
.pulse-header-right,
|
||||
.lt-header-right:has(#pulse-ws-status) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
row-gap: 2px;
|
||||
}
|
||||
|
||||
#pulse-last-refreshed {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* The stamp is the first thing to go when space runs out. */
|
||||
@media (max-width: 767px) {
|
||||
#pulse-last-refreshed { display: none; }
|
||||
}
|
||||
|
||||
@media (max-width: 479px) {
|
||||
.pulse-header-right,
|
||||
.lt-header-right:has(#pulse-ws-status) { gap: var(--space-xs); }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
2. Confirm / alert modal (built by Pulse.confirm / Pulse.alertModal)
|
||||
--------------------------------------------------------------------- */
|
||||
.pulse-confirm .lt-modal-header { border-bottom: 1px solid var(--border-color); }
|
||||
.pulse-confirm .pulse-confirm-msg {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pulse-confirm .lt-modal-footer { gap: var(--space-sm); }
|
||||
|
||||
.pulse-confirm--warning .lt-modal { border-top: 2px solid var(--accent-amber); }
|
||||
.pulse-confirm--error .lt-modal { border-top: 2px solid var(--accent-red); }
|
||||
.pulse-confirm--info .lt-modal { border-top: 2px solid var(--accent-cyan); }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
3. Compact meta line (base.css already provides .lt-kv-grid for
|
||||
key/value blocks — use that; this is only for inline meta strings)
|
||||
--------------------------------------------------------------------- */
|
||||
.pulse-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-xs) var(--space-md);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
.pulse-meta > span { white-space: nowrap; }
|
||||
.pulse-meta-label {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.pulse-meta-val { color: var(--text-secondary); }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
4. Log viewer wrapper — caps the height of a long .lt-log-output run
|
||||
--------------------------------------------------------------------- */
|
||||
.pulse-log {
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
overflow-x: auto;
|
||||
background: var(--bg-terminal);
|
||||
border: 1px solid var(--border-dim);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
.pulse-log .lt-log-output {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.pulse-log--sm { max-height: 220px; }
|
||||
.pulse-log--lg { max-height: 60vh; }
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.pulse-log { max-height: 300px; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
5. Running rows — amber left-border pulse (port of exec-running-pulse)
|
||||
--------------------------------------------------------------------- */
|
||||
.pulse-running {
|
||||
border-left: 2px solid var(--accent-amber);
|
||||
animation: pulse-running-border 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-running-border {
|
||||
0%, 100% { border-left-color: var(--accent-green); box-shadow: none; }
|
||||
50% { border-left-color: var(--accent-amber); box-shadow: -2px 0 8px rgba(255, 179, 0, 0.35); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pulse-running {
|
||||
animation: none;
|
||||
border-left-color: var(--accent-amber);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
6. Utilities
|
||||
--------------------------------------------------------------------- */
|
||||
.is-hidden { display: none !important; }
|
||||
|
||||
.pulse-nowrap { white-space: nowrap; }
|
||||
.pulse-mono { font-family: var(--font-mono); }
|
||||
.pulse-dim { color: var(--text-dim); }
|
||||
.pulse-scroll-x { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
7. Light theme overrides for the additions above
|
||||
--------------------------------------------------------------------- */
|
||||
html[data-theme="light"] #pulse-last-refreshed { color: var(--text-muted); }
|
||||
|
||||
html[data-theme="light"] .pulse-confirm .pulse-confirm-msg { color: var(--text-secondary); }
|
||||
|
||||
html[data-theme="light"] .pulse-log {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
html[data-theme="light"] .pulse-meta { color: var(--text-muted); }
|
||||
html[data-theme="light"] .pulse-meta-val { color: var(--text-primary); }
|
||||
html[data-theme="light"] .pulse-dim { color: var(--text-muted); }
|
||||
|
||||
html[data-theme="light"] .pulse-running {
|
||||
box-shadow: none;
|
||||
}
|
||||
@@ -0,0 +1,685 @@
|
||||
/* =====================================================================
|
||||
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);
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 182 B |
@@ -0,0 +1,212 @@
|
||||
/* =====================================================================
|
||||
PULSE — Dashboard page (WP-C)
|
||||
Owns DOM id prefix `dash-` and action namespace `dash:*`.
|
||||
===================================================================== */
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
const esc = Pulse.esc;
|
||||
const fmt = Pulse.fmt;
|
||||
|
||||
let _executions = [];
|
||||
let _workers = [];
|
||||
|
||||
function parseMeta(worker) {
|
||||
if (!worker || !worker.metadata) return null;
|
||||
if (typeof worker.metadata === 'string') {
|
||||
try { return JSON.parse(worker.metadata); } catch (e) { return null; }
|
||||
}
|
||||
return worker.metadata;
|
||||
}
|
||||
|
||||
function isAutomated(exec) {
|
||||
const by = String((exec && exec.started_by) || '');
|
||||
return by.indexOf('gandalf:') === 0 || by.indexOf('scheduler:') === 0;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
Stats
|
||||
------------------------------------------------------------------- */
|
||||
function renderStats(workers, runningTotal) {
|
||||
const total = workers.length;
|
||||
const online = workers.filter(w => w.status === 'online').length;
|
||||
const offline = total - online;
|
||||
const set = (id, val) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = String(val);
|
||||
};
|
||||
set('dash-stat-total-val', total);
|
||||
set('dash-stat-online-val', online);
|
||||
set('dash-stat-offline-val', offline);
|
||||
set('dash-stat-running-val', runningTotal);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
Recent executions (5 most recent manual runs)
|
||||
------------------------------------------------------------------- */
|
||||
function executionRowHtml(e) {
|
||||
const statusClass = fmt.status(e.status);
|
||||
const statusText = esc(String(e.status || '').toUpperCase());
|
||||
const name = e.workflow_name ? esc(e.workflow_name) : '[Quick Command]';
|
||||
const startedBy = esc(e.started_by || '');
|
||||
const startedAt = fmt.dateTime(e.started_at);
|
||||
const isRunning = String(e.status || '').toLowerCase() === 'running';
|
||||
const elapsed = isRunning ? esc(fmt.elapsed(e.started_at)) : '—';
|
||||
return (
|
||||
'<tr data-action="dash:open-execution" data-execution-id="' + esc(e.id) + '">' +
|
||||
'<td data-label="Status"><span class="' + statusClass + '">' + statusText + '</span></td>' +
|
||||
'<td data-label="Name">' + name + '</td>' +
|
||||
'<td data-label="Started By">' + startedBy + '</td>' +
|
||||
'<td data-label="Started At">' + esc(startedAt) + '</td>' +
|
||||
'<td data-label="Elapsed" class="dash-elapsed"' +
|
||||
(isRunning ? ' data-started-at="' + esc(e.started_at) + '"' : '') + '>' + elapsed + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderExecutions(executions) {
|
||||
const wrap = document.getElementById('dash-executions-wrap');
|
||||
if (!wrap) return;
|
||||
const manual = executions.filter(e => !isAutomated(e)).slice(0, 5);
|
||||
if (manual.length === 0) {
|
||||
wrap.innerHTML =
|
||||
'<div class="lt-empty-state lt-empty-state--sm">' +
|
||||
'<div class="lt-empty-state-title">No executions yet</div>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML =
|
||||
'<table class="lt-table lt-table-sm lt-table-responsive">' +
|
||||
'<thead><tr><th>Status</th><th>Name</th><th>Started By</th><th>Started At</th><th>Elapsed</th></tr></thead>' +
|
||||
'<tbody>' + manual.map(executionRowHtml).join('') + '</tbody>' +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
Workers summary
|
||||
------------------------------------------------------------------- */
|
||||
function workerRowHtml(w) {
|
||||
const meta = parseMeta(w);
|
||||
const dotClass = w.status === 'online' ? 'lt-dot-up' : 'lt-dot-down';
|
||||
const statusClass = fmt.status(w.status);
|
||||
const lastSeen = fmt.ago(w.last_heartbeat);
|
||||
let stats = '';
|
||||
if (meta) {
|
||||
stats =
|
||||
'<span>CPU: ' + esc(meta.cpus || '?') + ' cores</span>' +
|
||||
'<span>RAM: ' + fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + '</span>' +
|
||||
'<span>Tasks: ' + esc(meta.activeTasks || 0) + '/' + esc(meta.maxConcurrentTasks || 0) + '</span>';
|
||||
}
|
||||
return (
|
||||
'<div class="dash-worker-row" data-worker-id="' + esc(w.id) + '">' +
|
||||
'<div class="dash-worker-main">' +
|
||||
'<span class="lt-dot ' + dotClass + '"></span>' +
|
||||
'<span class="dash-worker-name">' + esc(w.name) + '</span>' +
|
||||
'<span class="' + statusClass + '">' + esc(String(w.status || '').toUpperCase()) + '</span>' +
|
||||
'<span class="dash-worker-lastseen" data-last-heartbeat="' + esc(w.last_heartbeat || '') + '">Last seen: ' + esc(lastSeen) + '</span>' +
|
||||
'</div>' +
|
||||
(stats ? '<div class="dash-worker-stats">' + stats + '</div>' : '') +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderWorkers(workers) {
|
||||
const wrap = document.getElementById('dash-workers-wrap');
|
||||
if (!wrap) return;
|
||||
if (workers.length === 0) {
|
||||
wrap.innerHTML =
|
||||
'<div class="lt-empty-state lt-empty-state--sm">' +
|
||||
'<div class="lt-empty-state-title">No workers connected</div>' +
|
||||
'</div>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = '<div class="dash-worker-list">' + workers.map(workerRowHtml).join('') + '</div>';
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
Live tick — update elapsed times on running rows and worker last-seen
|
||||
------------------------------------------------------------------- */
|
||||
function onTick() {
|
||||
document.querySelectorAll('#dash-executions-wrap .dash-elapsed[data-started-at]').forEach(el => {
|
||||
el.textContent = fmt.elapsed(el.getAttribute('data-started-at'));
|
||||
});
|
||||
document.querySelectorAll('#dash-workers-wrap .dash-worker-lastseen[data-last-heartbeat]').forEach(el => {
|
||||
const v = el.getAttribute('data-last-heartbeat');
|
||||
if (v) el.textContent = 'Last seen: ' + fmt.ago(v);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
Data loading
|
||||
------------------------------------------------------------------- */
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
_workers = await Pulse.api.get('/api/workers') || [];
|
||||
} catch (e) {
|
||||
_workers = [];
|
||||
console.error('[Pulse:dashboard] failed to load workers', e);
|
||||
}
|
||||
renderWorkers(_workers);
|
||||
return _workers;
|
||||
}
|
||||
|
||||
async function loadExecutions() {
|
||||
try {
|
||||
const data = await Pulse.api.get('/api/executions?limit=50&hide_internal=true');
|
||||
_executions = (data && data.executions) || [];
|
||||
} catch (e) {
|
||||
_executions = [];
|
||||
console.error('[Pulse:dashboard] failed to load executions', e);
|
||||
}
|
||||
renderExecutions(_executions);
|
||||
return _executions;
|
||||
}
|
||||
|
||||
async function loadRunningCount() {
|
||||
try {
|
||||
const data = await Pulse.api.get('/api/executions?status=running&limit=1');
|
||||
return (data && data.total) || 0;
|
||||
} catch (e) {
|
||||
console.error('[Pulse:dashboard] failed to load running count', e);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const [workers, , runningTotal] = await Promise.all([
|
||||
loadWorkers(),
|
||||
loadExecutions(),
|
||||
loadRunningCount(),
|
||||
]);
|
||||
renderStats(workers, runningTotal);
|
||||
}
|
||||
|
||||
function init() {
|
||||
Pulse.actions.registerAll({
|
||||
'dash:goto-workers': () => { window.location.href = '/workers'; },
|
||||
'dash:goto-executions': () => { window.location.href = '/executions'; },
|
||||
'dash:open-execution': (el) => {
|
||||
const id = el.getAttribute('data-execution-id');
|
||||
if (id) window.location.href = '/executions?open=' + encodeURIComponent(id);
|
||||
},
|
||||
});
|
||||
Pulse.events.on('tick', onTick);
|
||||
return refresh();
|
||||
}
|
||||
|
||||
function onEvent(type) {
|
||||
if (type === 'worker_update') {
|
||||
Promise.all([loadWorkers(), loadRunningCount()]).then(([workers, rt]) => renderStats(workers, rt));
|
||||
return true;
|
||||
}
|
||||
if (type === 'execution_started' || type === 'execution_status' ||
|
||||
type === 'command_result' || type === 'workflow_result' ||
|
||||
type === 'executions_bulk_deleted') {
|
||||
Promise.all([loadExecutions(), loadRunningCount()]).then(([, rt]) => renderStats(_workers, rt));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Pulse.registerPage({ name: 'dashboard', init, refresh, onEvent });
|
||||
})();
|
||||
@@ -0,0 +1 @@
|
||||
v1.2 bbec859 2026-09-09T01:34:49Z
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user