/* ===================================================================== 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 ? '' : ''; const completed = e.completed_at ? esc(Pulse.fmt.dateTime(e.completed_at)) : (running ? '' + esc(Pulse.fmt.elapsed(e.started_at)) + '' : '—'); return '' + check + '' + esc(e.status) + '' + '' + esc(execName(e)) + '' + '' + esc(e.started_by || '') + '' + '' + esc(Pulse.fmt.dateTime(e.started_at)) + '' + '' + completed + '' + ''; } function listHtml(view) { const st = state[view]; if (st.error) { return '
' + '
Failed to load executions
' + '
' + esc(st.error) + '
'; } if (!st.loaded) { return '
Loading…
'; } const rows = filteredRows(view); if (!rows.length) { const filtering = !!(searchTerm() || statusTerm()); return '
' + '
' + '
' + (filtering ? 'No executions match your filters' : 'No executions yet') + '
'; } const head = '' + (state.compareMode ? '✓' : '') + 'StatusNameStarted by' + 'StartedCompleted / Elapsed'; let html = '
' + '' + head + '' + rows.map(rowHtml).join('') + '
'; if (st.hasMore) { html += ''; } 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 '
' + '
[' + esc(timeOfDay(ts)) + ']
' + '
' + title + '
' + (details ? '
' + details + '
' : '') + '
'; } function field(label, value) { return '
' + esc(label) + ': ' + value + '
'; } function out(text, isErr) { return '
' + esc(text) + '
'; } function promptOptions(options, executionId) { return (options || []).map((opt) => { if (executionId) { return ''; } return ''; }).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', '' + esc(log.command) + '') + (log.worker_id ? field('Worker', esc(log.worker_id)) : '')); } if (a === 'command_result') { const ok = !!log.success; return entry(ok ? 'success' : 'error', ts, (ok ? '✓' : '✗') + ' 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, '▶ Step ' + esc(log.step) + ': ' + esc(log.step_name || ''), ''); } if (a === 'step_completed') { return entry('success', ts, '✓ Step ' + esc(log.step) + ' Completed: ' + esc(log.step_name || ''), ''); } if (a === 'waiting') { return entry('warning', ts, '⏳ 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) => '
' + esc(k) + '
' + esc(pairs[k]) + '
' ).join(''); return entry('ex-log-dim', ts, '⚙ Parsed ' + keys.length + ' variable' + (keys.length !== 1 ? 's' : ''), keys.length ? '
' + rows + '
' : ''); } if (a === 'route_taken') { return entry('ex-log-info', ts, '⇒ Auto-route: Step ' + esc(log.step), log.label ? '
' + esc(log.label) + '
' + (log.goto ? '
→ ' + esc(log.goto) + '
' : '') : ''); } if (a === 'no_workers') { return entry('error', ts, '✗ Step ' + esc(log.step) + ': No Workers Available', '
' + esc(log.message) + '
'); } if (a === 'worker_offline') { return entry('error', ts, '⚠ Worker Offline', field('Worker ID', esc(log.worker_id || ''))); } if (a === 'workflow_error') { return entry('error', ts, '✗ Workflow Error', field('Error', esc(log.error))); } if (a === 'execution_aborted') { return entry('error', ts, '⛔ Execution Aborted', field('Aborted by', esc(log.aborted_by))); } if (a === 'prompt') { return entry('ex-log-info', ts, '❓ Step ' + esc(log.step) + ': ' + esc(log.step_name || 'Prompt'), (log.output ? out(log.output, false) : '') + '
' + esc(log.message || '') + '
' + '
' + promptOptions(log.options, executionId) + '
'); } if (a === 'prompt_response') { return entry('success', ts, '↪ Response: ' + esc(log.response || '') + '' + (log.responded_by ? 'by ' + esc(log.responded_by) + '' : ''), ''); } if (a === 'step_skipped') { return entry('ex-log-dim', ts, '⊘ Step ' + esc(log.step) + ' Skipped' + (log.reason ? ': ' + esc(log.reason) : ''), ''); } if (a === 'dry_run_skipped') { return entry('warning', ts, '🔍 [DRY RUN] Step ' + esc(log.step) + ' Skipped: ' + esc(log.step_name || ''), ''); } if (a === 'execution_timeout') { return entry('error', ts, '⏱ Execution Timeout', '
' + esc(log.message || 'Execution exceeded maximum allowed time') + '
'); } if (a === 'goto_error') { return entry('error', ts, '✗ Goto Error', field('Target', esc(String(log.target || '')))); } if (a === 'step_error') { return entry('error', ts, '✗ 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 ? '✓' : '✗') + ' Workflow Result: ' + (ok ? 'Success' : 'Failed'), log.message ? '
' + esc(log.message) + '
' : ''); } 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, '⚙ Parameters: ' + (str || '(none)'), ''); } if (a === 'server_restart_recovery') { return entry('error', ts, '⚠ Server Restart Recovery', '
' + esc(log.message || 'Execution interrupted by server restart') + '
'); } /* 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 '
' + '
Status
' + '
' + esc(ex.status) + '
' + '
Workflow
' + esc(name) + '
' + '
Started by
' + esc(ex.started_by || '') + '
' + '
Started
' + esc(Pulse.fmt.dateTime(ex.started_at)) + '
' + '
Completed
' + (ex.completed_at ? esc(Pulse.fmt.dateTime(ex.completed_at)) : (ex.status === 'running' ? esc(Pulse.fmt.elapsed(ex.started_at)) + ' elapsed' : '—')) + '
' + '
Execution ID
' + '' + esc(id) + '' + '' + '
' + '
'; } function detailPrompt(ex) { if (!ex.waiting_for_input || !ex.prompt) return ''; const p = ex.prompt; return '
' + '' + '
' + '
Waiting for Input
' + (p.output ? out(p.output, false) : '') + '
' + esc(p.message || '') + '
' + '
' + promptOptions(p.options, true) + '
' + '
'; } function detailLogs(id, ex) { const logs = Array.isArray(ex.logs) ? ex.logs : []; if (!logs.length) { return '
No logs recorded
'; } 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 '

Execution Logs

' + body + '
'; } function detailFooter(id, ex) { let html = ''; if (ex.status === 'running') { html += ''; } const cmdLog = (Array.isArray(ex.logs) ? ex.logs : []).find((l) => l.action === 'command_sent' && l.command); if (cmdLog) { html += ''; } html += ''; html += ''; 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 = '
Loading…
'; 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 = '
' + '
Error loading execution details
' + '
' + esc(e && e.message ? e.message : String(e)) + '
'; 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 'Execution ' + (idx + 1) + '' + '' + esc(ex.status) + '' + '' + esc(Pulse.fmt.dateTime(ex.started_at)) + '' + '' + esc(duration) + ''; }).join(''); return '

Comparison Summary

' + '
' + '' + '' + rows + '
ExecutionStatusStartedDuration
'; } 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 '
' + '
Execution ' + (idx + 1) + '' + '
' + esc(name) + '
' + '
' + '
STDOUT:
' + out(stdout || 'No output', false) + (stderr ? '
STDERR:
' + out(stderr, true) : '') + '
'; }).join(''); return '

Output Comparison

' + '
' + cols + '
'; } 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('
' + (i + 1) + ': ' + (esc(x) || '(empty)') + '
'); } else { diff++; lines.push('
' + '
' + (i + 1) + ' [Exec 1]: ' + (esc(x) || '(empty)') + '
' + '
' + (i + 1) + ' [Exec 2]: ' + (esc(y) || '(empty)') + '
' + '
'); } } return '

Diff Analysis

' + '
✓ Identical lines: ' + same + '' + ' | ' + '≠ Different lines: ' + diff + '
' + '
' + lines.join('') + '
'; } 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 = '
Loading…
'; 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 = '
' + '
Failed to load execution details
'; 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= ------------------------------------------------------------------ */ 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; }, }); })();