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:
@@ -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 });
|
||||
})();
|
||||
Reference in New Issue
Block a user