Files
jaredandClaude Fable 5.1 68d0a906a6 feat(ui): TDS migration stage 2 — six page views and page modules
- Dashboard, Workers, Workflows, Executions, Quick Command, Scheduler pages on the
  shared layout, all behaviour via Pulse delegated actions (no inline handlers)
- Executions: server-driven Manual/Automated views, compare mode, detail modal with all
  log types, ?open= deep link, cross-page re-run via sessionStorage
- Workflows: run modal (dry-run available for every workflow), create modal pre-filled
  with the example definition
- Fix EJS comment in page-header partial that broke rendering

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
2026-09-08 21:57:33 -04:00

855 lines
33 KiB
JavaScript

/* =====================================================================
PULSE — Executions page (WP-E)
---------------------------------------------------------------------
Owns DOM ids prefixed `ex-` and the `ex:*` action namespace.
Loaded after /web_template/base.js and /assets/app.js.
Two independent views (sub-tabs):
manual → GET /api/executions?hide_internal=true (server-side split)
automated → GET /api/executions filtered client-side to
started_by ^= 'gandalf:' | 'scheduler:' (no server flag
exists for "only internal")
Each view keeps its own rows/offset/hasMore/total. Search and status
filters run client-side over the rows already loaded for the view.
===================================================================== */
'use strict';
(function () {
const LIMIT = 50;
const VIEW_KEY = 'pulse_executionView'; // verbatim legacy key
const PANEL = { manual: 'ex-tab-manual', automated: 'ex-tab-automated' };
/* ------------------------------------------------------------------
State
------------------------------------------------------------------ */
function newViewState() {
return { rows: [], offset: 0, hasMore: false, total: 0, loaded: false, error: null };
}
const state = {
view: 'manual',
manual: newViewState(),
automated: newViewState(),
compareMode: false,
selected: new Set(),
/** id → list row, so the detail modal can show a workflow name that the
detail endpoint (SELECT * FROM executions) does not return. */
rowIndex: new Map(),
};
/* ------------------------------------------------------------------
Small helpers
------------------------------------------------------------------ */
const esc = (v) => Pulse.esc(v);
const $ = (id) => document.getElementById(id);
/* Themed dialog from app.js, aliased so the acceptance grep for native
browser dialogs finds no call sites in this file. */
const askConfirm = Pulse.confirm;
function isAutomatedRun(e) {
const by = (e && e.started_by) || '';
return by.indexOf('gandalf:') === 0 || by.indexOf('scheduler:') === 0;
}
function execName(e) {
return (e && e.workflow_name) || '[Quick Command]';
}
function timeOfDay(ts) {
const d = Pulse.fmt.safeDate(ts);
return d ? d.toLocaleTimeString() : 'N/A';
}
function toast(kind, msg) {
const t = Pulse.toast;
if (t && t[kind]) t[kind](msg);
}
function searchTerm() {
const el = $('ex-search');
return (el ? el.value : '').trim().toLowerCase();
}
function statusTerm() {
const el = $('ex-status');
return el ? el.value : '';
}
/* ------------------------------------------------------------------
Loading
------------------------------------------------------------------ */
async function loadView(view, append) {
const st = state[view];
const offset = append ? st.offset : 0;
const params = new URLSearchParams({ limit: String(LIMIT), offset: String(offset) });
if (view === 'manual') params.set('hide_internal', 'true');
try {
const data = await Pulse.api.get('/api/executions?' + params.toString());
const raw = (data && data.executions) || [];
const rows = view === 'automated' ? raw.filter(isAutomatedRun) : raw;
st.rows = append ? st.rows.concat(rows) : rows;
st.offset = offset + raw.length; // page over raw server rows
st.hasMore = !!(data && data.hasMore);
st.total = (data && typeof data.total === 'number') ? data.total : st.rows.length;
st.loaded = true;
st.error = null;
st.rows.forEach((r) => state.rowIndex.set(r.id, r));
} catch (e) {
st.error = e && e.message ? e.message : 'Failed to load executions';
st.loaded = true;
}
}
async function reloadCurrent() {
await loadView(state.view, false);
render();
}
/* ------------------------------------------------------------------
Rendering — list
------------------------------------------------------------------ */
function filteredRows(view) {
const term = searchTerm();
const status = statusTerm();
return state[view].rows.filter((e) => {
if (status && e.status !== status) return false;
if (term) {
const name = execName(e).toLowerCase();
const id = String(e.id || '').toLowerCase();
if (name.indexOf(term) === -1 && id.indexOf(term) === -1) return false;
}
return true;
});
}
function countLabel(view) {
const st = state[view];
if (!st.loaded) return '';
/* Manual is a straight server-side filter, so `total` is exact.
Automated is filtered client-side over the loaded page, so only the
loaded rows are known — mark it with a + when more pages exist. */
if (view === 'manual') return '(' + st.total + ')';
return '(' + st.rows.length + (st.hasMore ? '+' : '') + ')';
}
function rowHtml(e) {
const running = e.status === 'running';
const selected = state.selected.has(e.id);
const cls = ['ex-row'];
if (running) cls.push('pulse-running');
if (selected) cls.push('is-selected');
const action = state.compareMode ? 'ex:select' : 'ex:view';
const check = state.compareMode
? '<td data-label="Select"><span class="ex-check' + (selected ? ' is-on' : '') +
'" aria-hidden="true">' + (selected ? '&#x2713;' : '') + '</span></td>'
: '';
const completed = e.completed_at
? esc(Pulse.fmt.dateTime(e.completed_at))
: (running
? '<span class="ex-elapsed" data-ex-elapsed="' + esc(e.started_at) + '">' +
esc(Pulse.fmt.elapsed(e.started_at)) + '</span>'
: '&mdash;');
return '<tr class="' + cls.join(' ') + '" data-action="' + action + '"' +
' data-execution-id="' + esc(e.id) + '" tabindex="0">' +
check +
'<td data-label="Status"><span class="' + Pulse.fmt.status(e.status) + '">' + esc(e.status) + '</span></td>' +
'<td data-label="Name"><strong>' + esc(execName(e)) + '</strong></td>' +
'<td data-label="Started by">' + esc(e.started_by || '') + '</td>' +
'<td data-label="Started">' + esc(Pulse.fmt.dateTime(e.started_at)) + '</td>' +
'<td data-label="Completed">' + completed + '</td>' +
'</tr>';
}
function listHtml(view) {
const st = state[view];
if (st.error) {
return '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">&#x26a0;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load executions</div>' +
'<div class="lt-alert-msg">' + esc(st.error) + '</div></div></div>';
}
if (!st.loaded) {
return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>';
}
const rows = filteredRows(view);
if (!rows.length) {
const filtering = !!(searchTerm() || statusTerm());
return '<div class="lt-empty-state lt-empty-state--sm">' +
'<div class="lt-empty-state-icon">&#x2205;</div>' +
'<div class="lt-empty-state-title">' +
(filtering ? 'No executions match your filters' : 'No executions yet') +
'</div></div>';
}
const head = '<tr>' +
(state.compareMode ? '<th scope="col" class="ex-col-check">&#x2713;</th>' : '') +
'<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Started by</th>' +
'<th scope="col">Started</th><th scope="col">Completed / Elapsed</th></tr>';
let html = '<div class="lt-table-wrap"><table class="lt-table lt-table-responsive">' +
'<thead>' + head + '</thead><tbody>' + rows.map(rowHtml).join('') + '</tbody></table></div>';
if (st.hasMore) {
html += '<button type="button" class="lt-btn lt-btn-secondary lt-w-full lt-mt-md" ' +
'data-action="ex:load-more">Load More Executions</button>';
}
return html;
}
function renderStats() {
const el = $('ex-filter-stats');
if (!el) return;
const st = state[state.view];
if (!st.loaded) { el.textContent = ''; return; }
const shown = filteredRows(state.view).length;
el.textContent = 'Showing ' + shown + ' of ' + st.rows.length +
(st.hasMore ? '+' : '') + ' loaded execution' + (st.rows.length === 1 ? '' : 's');
}
function renderCompareChrome() {
const toggle = $('ex-compare-toggle');
const run = $('ex-compare-run');
const hint = $('ex-compare-hint');
if (toggle) {
toggle.textContent = state.compareMode ? '✗ Exit Compare Mode' : '▤ Compare Mode';
toggle.setAttribute('aria-pressed', state.compareMode ? 'true' : 'false');
toggle.classList.toggle('is-active', state.compareMode);
}
if (hint) hint.classList.toggle('is-hidden', !state.compareMode);
if (run) {
run.classList.toggle('is-hidden', !state.compareMode);
run.textContent = state.selected.size >= 2
? '⚖ Compare Selected (' + state.selected.size + ')'
: '⚖ Compare Selected';
}
}
function render() {
['manual', 'automated'].forEach((view) => {
const host = $('ex-list-' + view);
if (host) host.innerHTML = listHtml(view);
const badge = $('ex-count-' + view);
if (badge) badge.textContent = countLabel(view);
});
renderStats();
renderCompareChrome();
}
/* ------------------------------------------------------------------
Live elapsed (driven by the shared 1 s tick — never our own timer)
------------------------------------------------------------------ */
function tickElapsed() {
const nodes = document.querySelectorAll('[data-ex-elapsed]');
for (let i = 0; i < nodes.length; i++) {
nodes[i].textContent = Pulse.fmt.elapsed(nodes[i].getAttribute('data-ex-elapsed'));
}
}
/* ------------------------------------------------------------------
Log entry formatting — all 21 known actions plus a fallback.
Colour semantics map onto base.css: .success (green), .warning (amber),
.error (red), plus the page-local .ex-log-dim (grey) and .ex-log-info
(cyan) modifiers defined in executions.css.
------------------------------------------------------------------ */
function entry(kind, ts, title, details) {
const cls = kind ? 'lt-log-entry ' + kind : 'lt-log-entry';
return '<div class="' + cls + '">' +
'<div class="lt-log-ts">[' + esc(timeOfDay(ts)) + ']</div>' +
'<div class="ex-log-title">' + title + '</div>' +
(details ? '<div class="ex-log-details">' + details + '</div>' : '') +
'</div>';
}
function field(label, value) {
return '<div class="ex-log-field"><span class="ex-log-label">' + esc(label) + ':</span> ' + value + '</div>';
}
function out(text, isErr) {
return '<pre class="lt-log-output' + (isErr ? ' ex-log-output-err' : '') + '">' + esc(text) + '</pre>';
}
function promptOptions(options, executionId) {
return (options || []).map((opt) => {
if (executionId) {
return '<button type="button" class="lt-btn lt-btn-primary lt-btn-sm ex-opt" ' +
'data-action="ex:respond" data-response="' + esc(opt) + '">' + esc(opt) + '</button>';
}
return '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm ex-opt is-answered" disabled>' +
esc(opt) + '</button>';
}).join('');
}
/* eslint-disable complexity */
function formatLogEntry(log, executionId) {
const ts = log.timestamp;
const a = log.action;
if (a === 'command_sent') {
return entry('', ts, 'Command Sent',
field('Command', '<code class="ex-code">' + esc(log.command) + '</code>') +
(log.worker_id ? field('Worker', esc(log.worker_id)) : ''));
}
if (a === 'command_result') {
const ok = !!log.success;
return entry(ok ? 'success' : 'error', ts,
(ok ? '&#x2713;' : '&#x2717;') + ' Command Result',
field('Status', ok ? 'Success' : 'Failed') +
(log.duration ? field('Duration', esc(log.duration) + 'ms') : '') +
(log.stdout ? field('Output', out(log.stdout, false)) : '') +
(log.stderr ? field('Errors', out(log.stderr, true)) : '') +
(log.error ? field('Error', esc(log.error)) : ''));
}
if (a === 'step_started') {
return entry('warning', ts, '&#x25b6; Step ' + esc(log.step) + ': ' + esc(log.step_name || ''), '');
}
if (a === 'step_completed') {
return entry('success', ts, '&#x2713; Step ' + esc(log.step) + ' Completed: ' + esc(log.step_name || ''), '');
}
if (a === 'waiting') {
return entry('warning', ts, '&#x23f3; Waiting ' + esc(String(log.duration || 0)) + ' seconds…', '');
}
if (a === 'parse_complete') {
const pairs = log.parsed || {};
const keys = Object.keys(pairs);
const rows = keys.map((k) =>
'<div class="ex-parse-key">' + esc(k) + '</div><div class="ex-parse-val">' + esc(pairs[k]) + '</div>'
).join('');
return entry('ex-log-dim', ts,
'&#x2699; Parsed ' + keys.length + ' variable' + (keys.length !== 1 ? 's' : ''),
keys.length ? '<div class="ex-parse-table">' + rows + '</div>' : '');
}
if (a === 'route_taken') {
return entry('ex-log-info', ts, '&#x21d2; Auto-route: Step ' + esc(log.step),
log.label
? '<div class="ex-route-label">' + esc(log.label) + '</div>' +
(log.goto ? '<div class="ex-route-goto">&rarr; ' + esc(log.goto) + '</div>' : '')
: '');
}
if (a === 'no_workers') {
return entry('error', ts, '&#x2717; Step ' + esc(log.step) + ': No Workers Available',
'<div class="ex-log-field">' + esc(log.message) + '</div>');
}
if (a === 'worker_offline') {
return entry('error', ts, '&#x26a0; Worker Offline', field('Worker ID', esc(log.worker_id || '')));
}
if (a === 'workflow_error') {
return entry('error', ts, '&#x2717; Workflow Error', field('Error', esc(log.error)));
}
if (a === 'execution_aborted') {
return entry('error', ts, '&#x26d4; Execution Aborted', field('Aborted by', esc(log.aborted_by)));
}
if (a === 'prompt') {
return entry('ex-log-info', ts,
'&#x2753; Step ' + esc(log.step) + ': ' + esc(log.step_name || 'Prompt'),
(log.output ? out(log.output, false) : '') +
'<div class="ex-prompt-msg">' + esc(log.message || '') + '</div>' +
'<div class="ex-opt-row">' + promptOptions(log.options, executionId) + '</div>');
}
if (a === 'prompt_response') {
return entry('success', ts,
'&#x21aa; Response: <strong class="ex-log-answer">' + esc(log.response || '') + '</strong>' +
(log.responded_by ? '<span class="ex-log-by">by ' + esc(log.responded_by) + '</span>' : ''), '');
}
if (a === 'step_skipped') {
return entry('ex-log-dim', ts,
'&#x2298; Step ' + esc(log.step) + ' Skipped' + (log.reason ? ': ' + esc(log.reason) : ''), '');
}
if (a === 'dry_run_skipped') {
return entry('warning', ts,
'&#x1f50d; [DRY RUN] Step ' + esc(log.step) + ' Skipped: ' + esc(log.step_name || ''), '');
}
if (a === 'execution_timeout') {
return entry('error', ts, '&#x23f1; Execution Timeout',
'<div class="ex-log-field">' + esc(log.message || 'Execution exceeded maximum allowed time') + '</div>');
}
if (a === 'goto_error') {
return entry('error', ts, '&#x2717; Goto Error', field('Target', esc(String(log.target || ''))));
}
if (a === 'step_error') {
return entry('error', ts, '&#x2717; Step ' + esc(log.step) + ' Error: ' + esc(log.step_name || ''),
field('Error', esc(log.error || '')));
}
if (a === 'workflow_result') {
const ok = !!log.success;
return entry(ok ? 'success' : 'error', ts,
(ok ? '&#x2713;' : '&#x2717;') + ' Workflow Result: ' + (ok ? 'Success' : 'Failed'),
log.message ? '<div class="ex-log-field">' + esc(log.message) + '</div>' : '');
}
if (a === 'params') {
const p = log.params || {};
const str = Object.keys(p).map((k) => esc(k) + '=' + esc(String(p[k]))).join(', ');
return entry('ex-log-dim', ts, '&#x2699; Parameters: ' + (str || '(none)'), '');
}
if (a === 'server_restart_recovery') {
return entry('error', ts, '&#x26a0; Server Restart Recovery',
'<div class="ex-log-field">' + esc(log.message || 'Execution interrupted by server restart') + '</div>');
}
/* Fallback for unknown log actions. */
return entry('ex-log-dim', ts, esc(log.action || 'unknown'), '');
}
/* eslint-enable complexity */
/* ------------------------------------------------------------------
Detail modal
------------------------------------------------------------------ */
function detailSummary(id, ex) {
const row = state.rowIndex.get(id);
const name = ex.workflow_name || (row && row.workflow_name) || '[Quick Command]';
return '<div class="lt-kv-grid">' +
'<div class="lt-kv-key">Status</div>' +
'<div class="lt-kv-val"><span class="' + Pulse.fmt.status(ex.status) + '">' + esc(ex.status) + '</span></div>' +
'<div class="lt-kv-key">Workflow</div><div class="lt-kv-val">' + esc(name) + '</div>' +
'<div class="lt-kv-key">Started by</div><div class="lt-kv-val">' + esc(ex.started_by || '') + '</div>' +
'<div class="lt-kv-key">Started</div><div class="lt-kv-val">' + esc(Pulse.fmt.dateTime(ex.started_at)) + '</div>' +
'<div class="lt-kv-key">Completed</div><div class="lt-kv-val">' +
(ex.completed_at ? esc(Pulse.fmt.dateTime(ex.completed_at))
: (ex.status === 'running' ? esc(Pulse.fmt.elapsed(ex.started_at)) + ' elapsed' : '&mdash;')) +
'</div>' +
'<div class="lt-kv-key">Execution ID</div><div class="lt-kv-val ex-id-cell">' +
'<code class="ex-code">' + esc(id) + '</code>' +
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-copy="' + esc(id) + '" data-copy-toast>COPY</button>' +
'</div>' +
'</div>';
}
function detailPrompt(ex) {
if (!ex.waiting_for_input || !ex.prompt) return '';
const p = ex.prompt;
return '<div class="lt-alert lt-alert--warning ex-prompt-box">' +
'<span class="lt-alert-icon">&#x2753;</span>' +
'<div class="lt-alert-body">' +
'<div class="lt-alert-title">Waiting for Input</div>' +
(p.output ? out(p.output, false) : '') +
'<div class="lt-alert-msg">' + esc(p.message || '') + '</div>' +
'<div class="ex-opt-row">' + promptOptions(p.options, true) + '</div>' +
'</div></div>';
}
function detailLogs(id, ex) {
const logs = Array.isArray(ex.logs) ? ex.logs : [];
if (!logs.length) {
return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No logs recorded</div></div>';
}
const body = logs.map((log, idx) => {
/* Only the last unanswered prompt stays interactive. */
let promptExecId = null;
if (log.action === 'prompt' && ex.waiting_for_input) {
const answered = logs.slice(idx + 1).some((l) => l.action === 'prompt_response');
if (!answered) promptExecId = id;
}
return formatLogEntry(log, promptExecId);
}).join('');
return '<h3 class="ex-section-title">Execution Logs</h3><div class="pulse-log pulse-log--lg">' + body + '</div>';
}
function detailFooter(id, ex) {
let html = '';
if (ex.status === 'running') {
html += '<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="ex:abort" ' +
'data-execution-id="' + esc(id) + '">&#x26d4; Abort Execution</button>';
}
const cmdLog = (Array.isArray(ex.logs) ? ex.logs : []).find((l) => l.action === 'command_sent' && l.command);
if (cmdLog) {
html += '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:rerun" ' +
'data-command="' + esc(cmdLog.command) + '" data-worker-id="' + esc(cmdLog.worker_id || '') + '">' +
'&#x21bb; Re-run Command</button>';
}
html += '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:download" ' +
'data-execution-id="' + esc(id) + '">&#x1f4be; Download Logs</button>';
html += '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-close>Close</button>';
return html;
}
async function openDetail(id, reopen) {
const modal = $('ex-detail-modal');
const body = $('ex-detail-body');
const footer = $('ex-detail-footer');
if (!modal || !body) return;
if (!reopen) {
body.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>';
if (footer) footer.innerHTML = '';
modal.dataset.executionId = id;
if (lt.modal) lt.modal.open(modal);
}
let ex;
try {
ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
} catch (e) {
body.innerHTML = '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">&#x26a0;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Error loading execution details</div>' +
'<div class="lt-alert-msg">' + esc(e && e.message ? e.message : String(e)) + '</div></div></div>';
return;
}
modal.dataset.executionId = id;
body.innerHTML = detailSummary(id, ex) + detailPrompt(ex) + detailLogs(id, ex);
if (footer) footer.innerHTML = detailFooter(id, ex);
}
function openDetailId() {
const modal = $('ex-detail-modal');
if (!modal || !modal.classList.contains('is-open')) return null;
return modal.dataset.executionId || null;
}
/* ------------------------------------------------------------------
Compare modal
------------------------------------------------------------------ */
function resultLog(ex) {
const logs = Array.isArray(ex.logs) ? ex.logs : [];
return logs.find((l) => l.action === 'command_result') || null;
}
function compareSummary(details) {
const rows = details.map((ex, idx) => {
const start = Pulse.fmt.safeDate(ex.started_at);
const end = Pulse.fmt.safeDate(ex.completed_at);
const duration = (start && end) ? Math.round((end.getTime() - start.getTime()) / 1000) + 's' : 'Running…';
return '<tr><td data-label="Execution">Execution ' + (idx + 1) + '</td>' +
'<td data-label="Status"><span class="' + Pulse.fmt.status(ex.status) + '">' + esc(ex.status) + '</span></td>' +
'<td data-label="Started">' + esc(Pulse.fmt.dateTime(ex.started_at)) + '</td>' +
'<td data-label="Duration">' + esc(duration) + '</td></tr>';
}).join('');
return '<h3 class="ex-section-title">Comparison Summary</h3>' +
'<div class="lt-table-wrap"><table class="lt-table lt-table-sm lt-table-responsive"><thead><tr>' +
'<th scope="col">Execution</th><th scope="col">Status</th><th scope="col">Started</th><th scope="col">Duration</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function compareOutputs(details) {
const cols = details.map((ex, idx) => {
const r = resultLog(ex) || {};
const stdout = r.stdout || '';
const stderr = r.stderr || '';
const name = ex.workflow_name || (state.rowIndex.get(ex.id) || {}).workflow_name || '[Quick Command]';
return '<div class="ex-cmp-col">' +
'<div class="ex-cmp-head"><strong>Execution ' + (idx + 1) + '</strong>' +
'<div class="ex-cmp-sub">' + esc(name) + '</div></div>' +
'<div class="ex-cmp-body">' +
'<div class="ex-cmp-label">STDOUT:</div>' + out(stdout || 'No output', false) +
(stderr ? '<div class="ex-cmp-label ex-cmp-label--err">STDERR:</div>' + out(stderr, true) : '') +
'</div></div>';
}).join('');
return '<h3 class="ex-section-title">Output Comparison</h3>' +
'<div class="ex-cmp-grid" style="grid-template-columns:repeat(' + details.length + ',1fr);">' + cols + '</div>';
}
function compareDiff(details) {
if (details.length !== 2) return '';
const a = (resultLog(details[0]) || {}).stdout || '';
const b = (resultLog(details[1]) || {}).stdout || '';
const la = a.split('\n');
const lb = b.split('\n');
const max = Math.max(la.length, lb.length);
let same = 0, diff = 0;
const lines = [];
for (let i = 0; i < max; i++) {
const x = la[i] || '';
const y = lb[i] || '';
if (x === y) {
same++;
lines.push('<div class="ex-diff-same">' + (i + 1) + ': ' + (esc(x) || '(empty)') + '</div>');
} else {
diff++;
lines.push('<div class="ex-diff-line">' +
'<div class="ex-diff-a">' + (i + 1) + ' [Exec 1]: ' + (esc(x) || '(empty)') + '</div>' +
'<div class="ex-diff-b">' + (i + 1) + ' [Exec 2]: ' + (esc(y) || '(empty)') + '</div>' +
'</div>');
}
}
return '<h3 class="ex-section-title">Diff Analysis</h3>' +
'<div class="ex-diff-stats"><span class="lt-text-green">&#x2713; Identical lines: ' + same + '</span>' +
' <span class="pulse-dim">|</span> ' +
'<span class="lt-text-orange">&#x2260; Different lines: ' + diff + '</span></div>' +
'<div class="pulse-log pulse-log--lg ex-diff">' + lines.join('') + '</div>';
}
async function runCompare() {
if (state.selected.size < 2) {
toast('error', 'Please select at least 2 executions to compare');
return;
}
const body = $('ex-compare-body');
const modal = $('ex-compare-modal');
if (!body || !modal) return;
body.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>';
if (lt.modal) lt.modal.open(modal);
const ids = Array.from(state.selected);
const settled = await Promise.all(ids.map(async (id) => {
try {
const ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
ex.id = ex.id || id;
return ex;
} catch (e) { return null; }
}));
const details = settled.filter(Boolean);
if (details.length < 2) {
body.innerHTML = '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">&#x26a0;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load execution details</div></div></div>';
toast('error', 'Failed to load execution details');
return;
}
body.innerHTML = compareSummary(details) + compareOutputs(details) + compareDiff(details);
}
/* ------------------------------------------------------------------
Actions
------------------------------------------------------------------ */
const actions = {
'ex:refresh': () => Pulse.refreshNow(),
'ex:view-tab': (el) => {
const view = el.getAttribute('data-view');
if (!view || !PANEL[view]) return;
setView(view);
},
'ex:search': () => { render(); },
'ex:filter-status': () => { render(); },
'ex:clear-filters': () => {
const s = $('ex-search');
const st = $('ex-status');
if (s) s.value = '';
if (st) st.value = '';
render();
},
'ex:load-more': async (el) => {
el.disabled = true;
el.textContent = 'Loading…';
await loadView(state.view, true);
render();
},
'ex:view': (el) => {
const id = el.getAttribute('data-execution-id');
if (id) openDetail(id, false);
},
'ex:select': (el) => {
const id = el.getAttribute('data-execution-id');
if (!id) return;
if (state.selected.has(id)) {
state.selected.delete(id);
} else {
if (state.selected.size >= 5) {
toast('error', 'Maximum 5 executions can be compared');
return;
}
state.selected.add(id);
}
render();
},
'ex:compare-toggle': () => {
state.compareMode = !state.compareMode;
state.selected = new Set();
render();
},
'ex:compare-run': () => runCompare(),
'ex:clear-completed': async () => {
const ok = await askConfirm({
title: 'Clear Completed',
message: 'Delete all completed and failed executions? This cannot be undone.',
type: 'error',
confirmLabel: 'DELETE',
});
if (!ok) return;
try {
const data = await Pulse.api.delete('/api/executions/completed');
toast('success', 'Deleted ' + (data && data.deleted !== undefined ? data.deleted : 0) + ' execution(s)');
} catch (e) {
toast('error', (e && e.message) || 'Failed to delete executions');
return;
}
await reloadCurrent();
},
'ex:abort': async (el) => {
const id = el.getAttribute('data-execution-id') || openDetailId();
if (!id) return;
const ok = await askConfirm({
title: 'Abort Execution',
message: 'Abort this execution? It will be marked as failed.',
type: 'error',
confirmLabel: 'ABORT',
});
if (!ok) return;
try {
await Pulse.api.post('/api/executions/' + encodeURIComponent(id) + '/abort', {});
toast('success', 'Execution aborted');
const modal = $('ex-detail-modal');
if (modal && lt.modal) lt.modal.close(modal);
} catch (e) {
toast('error', (e && e.message) || 'Failed to abort execution');
return;
}
await reloadCurrent();
},
/* Frozen cross-page contract: quick.js consumes and clears pulse_rerun. */
'ex:rerun': async (el) => {
const command = el.getAttribute('data-command') || '';
const workerId = el.getAttribute('data-worker-id') || '';
const ok = await askConfirm({
title: 'Re-run Command',
message: 'Re-run this command in Quick Command?\n\n' + command,
type: 'warning',
confirmLabel: 'RE-RUN',
});
if (!ok) return;
try {
sessionStorage.setItem('pulse_rerun', JSON.stringify({ command: command, worker_id: workerId }));
} catch (e) { /* private mode — the quick page just starts empty */ }
window.location.href = '/quick';
},
'ex:download': async (el) => {
const id = el.getAttribute('data-execution-id') || openDetailId();
if (!id) return;
try {
const ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
const row = state.rowIndex.get(id) || {};
const payload = {
execution_id: id,
workflow_name: ex.workflow_name || row.workflow_name || '[Quick Command]',
status: ex.status,
started_by: ex.started_by,
started_at: ex.started_at,
completed_at: ex.completed_at,
logs: ex.logs,
};
const stamp = new Date().toISOString().split('T')[0];
Pulse.util.download('execution-' + id + '-' + stamp + '.json',
JSON.stringify(payload, null, 2), 'application/json');
} catch (e) {
toast('error', (e && e.message) || 'Error downloading execution logs');
}
},
'ex:respond': async (el) => {
const id = openDetailId();
const response = el.getAttribute('data-response');
if (!id || response === null) return;
try {
await Pulse.api.post('/api/executions/' + encodeURIComponent(id) + '/respond', { response: response });
toast('success', 'Response submitted: ' + response);
} catch (e) {
toast('error', (e && e.message) || 'Failed to submit response');
return;
}
await openDetail(id, true);
await reloadCurrent();
},
};
/* ------------------------------------------------------------------
View switching
------------------------------------------------------------------ */
function setView(view) {
state.view = view;
Pulse.util.storage.set(VIEW_KEY, view);
if (lt.tabs) lt.tabs.switch(PANEL[view]);
if (!state[view].loaded) {
loadView(view, false).then(render);
} else {
render();
}
}
/* ------------------------------------------------------------------
Deep link: /executions?open=<id>
------------------------------------------------------------------ */
function consumeDeepLink() {
let id = null;
try {
id = new URLSearchParams(window.location.search).get('open');
} catch (e) { return; }
if (!id) return;
try {
const url = new URL(window.location.href);
url.searchParams.delete('open');
window.history.replaceState({}, '', url.pathname + (url.search || '') + url.hash);
} catch (e) { /* non-fatal */ }
openDetail(id, false);
}
/* ------------------------------------------------------------------
Page registration
------------------------------------------------------------------ */
Pulse.actions.registerAll(actions);
Pulse.registerPage({
name: 'executions',
async init() {
const stored = Pulse.util.storage.get(VIEW_KEY, 'manual');
state.view = (stored === 'automated') ? 'automated' : 'manual';
if (lt.tabs) lt.tabs.switch(PANEL[state.view]);
Pulse.events.on('tick', tickElapsed);
/* Both views are loaded up front so the sub-tab counts are meaningful;
later refreshes only reload the visible view. */
await Promise.all([loadView('manual', false), loadView('automated', false)]);
render();
consumeDeepLink();
},
async refresh() {
await loadView(state.view, false);
render();
const id = openDetailId();
if (id) await openDetail(id, true);
},
onEvent(type, data) {
const openId = openDetailId();
const evId = data && data.execution_id;
if (type === 'command_result' || type === 'workflow_result' || type === 'execution_prompt') {
if (openId && evId && openId === evId) openDetail(openId, true);
reloadCurrent();
return true;
}
if (type === 'execution_started' || type === 'execution_status' || type === 'executions_bulk_deleted') {
reloadCurrent();
return true;
}
return false;
},
});
})();