2026-09-08 21:46:24 -04:00
|
|
|
/* =====================================================================
|
|
|
|
|
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
|
|
|
|
|
------------------------------------------------------------------- */
|
2026-09-08 22:17:15 -04:00
|
|
|
/* Toast a load failure at most once per outage, so the 30 s auto-refresh
|
|
|
|
|
does not stack a toast every cycle while the API is down. */
|
|
|
|
|
let _loadFailed = false;
|
|
|
|
|
function reportLoadError(what, e) {
|
|
|
|
|
console.error('[Pulse:dashboard] failed to load ' + what, e);
|
|
|
|
|
if (_loadFailed) return;
|
|
|
|
|
_loadFailed = true;
|
|
|
|
|
if (Pulse.toast) Pulse.toast.error((e && e.message) || ('Failed to load ' + what));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-08 21:46:24 -04:00
|
|
|
async function loadWorkers() {
|
|
|
|
|
try {
|
|
|
|
|
_workers = await Pulse.api.get('/api/workers') || [];
|
2026-09-08 22:17:15 -04:00
|
|
|
_loadFailed = false;
|
2026-09-08 21:46:24 -04:00
|
|
|
} catch (e) {
|
|
|
|
|
_workers = [];
|
2026-09-08 22:17:15 -04:00
|
|
|
reportLoadError('workers', e);
|
2026-09-08 21:46:24 -04:00
|
|
|
}
|
|
|
|
|
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 = [];
|
2026-09-08 22:17:15 -04:00
|
|
|
reportLoadError('executions', e);
|
2026-09-08 21:46:24 -04:00
|
|
|
}
|
|
|
|
|
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 });
|
|
|
|
|
})();
|