diff --git a/public/assets/pages/dashboard.css b/public/assets/pages/dashboard.css
new file mode 100644
index 0000000..d656d2e
--- /dev/null
+++ b/public/assets/pages/dashboard.css
@@ -0,0 +1,36 @@
+/* Dashboard page (WP-C) — recent executions / workers summary layout. */
+.dash-worker-list {
+ display: flex;
+ flex-direction: column;
+}
+
+.dash-worker-row {
+ padding: var(--space-sm) var(--space-md);
+ border-bottom: 1px solid var(--border-color-dim);
+}
+.dash-worker-row:last-child { border-bottom: none; }
+
+.dash-worker-main {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ flex-wrap: wrap;
+}
+
+.dash-worker-name {
+ font-weight: 700;
+}
+
+.dash-worker-lastseen {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ margin-left: auto;
+}
+
+.dash-worker-stats {
+ display: flex;
+ gap: var(--space-md);
+ font-size: 0.7rem;
+ color: var(--text-secondary);
+ margin-top: 4px;
+}
diff --git a/public/assets/pages/executions.css b/public/assets/pages/executions.css
new file mode 100644
index 0000000..2e73bfd
--- /dev/null
+++ b/public/assets/pages/executions.css
@@ -0,0 +1,257 @@
+/* =====================================================================
+ PULSE — Executions page (WP-E) styles
+ ---------------------------------------------------------------------
+ Only additions the design system does not already provide. Everything
+ is namespaced `.ex-*` or scoped under an `#ex-*` id, except the two
+ log-entry colour modifiers that extend `.lt-log-entry` (base.css ships
+ .success/.warning/.error only — grey and cyan are Pulse-local).
+ ===================================================================== */
+
+/* ---------------------------------------------------------------------
+ 1. Modal sizing — base.css has .lt-modal-sm but no .lt-modal-lg, so the
+ large variant is scoped to this page's two modals to avoid colliding
+ with any other page that defines the same class.
+ --------------------------------------------------------------------- */
+#ex-detail-modal .lt-modal,
+#ex-compare-modal .lt-modal {
+ max-width: min(1100px, 94vw);
+ width: min(1100px, 94vw);
+}
+#ex-detail-modal .lt-modal-body,
+#ex-compare-modal .lt-modal-body {
+ max-height: 72vh;
+ overflow-y: auto;
+}
+#ex-detail-modal .lt-modal-footer {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-sm);
+ justify-content: flex-end;
+}
+
+/* ---------------------------------------------------------------------
+ 2. Tab-bar count badges
+ --------------------------------------------------------------------- */
+#ex-count-manual:empty,
+#ex-count-automated:empty { display: none; }
+
+/* ---------------------------------------------------------------------
+ 3. Rows
+ --------------------------------------------------------------------- */
+.ex-row { cursor: pointer; }
+.ex-row:focus-visible {
+ outline: 2px solid var(--accent-cyan);
+ outline-offset: -2px;
+}
+.ex-row.is-selected td {
+ background: rgba(255, 179, 0, 0.12);
+}
+.ex-row.is-selected td:first-child {
+ border-left: 3px solid var(--accent-amber);
+}
+.ex-col-check { width: 2rem; }
+.ex-check {
+ display: inline-block;
+ min-width: 1rem;
+ color: var(--accent-amber);
+ font-weight: 700;
+}
+.ex-elapsed {
+ font-family: var(--font-mono);
+ color: var(--accent-amber);
+}
+
+#ex-compare-toggle.is-active {
+ border-color: var(--accent-amber);
+ color: var(--accent-amber);
+}
+
+/* ---------------------------------------------------------------------
+ 4. Log entries — extra colour semantics on top of base.css
+ --------------------------------------------------------------------- */
+.lt-log-entry.ex-log-dim {
+ border-left-color: var(--border-color);
+ opacity: 0.85;
+}
+.lt-log-entry.ex-log-dim .ex-log-title { color: var(--text-dim); }
+
+.lt-log-entry.ex-log-info { border-left-color: var(--accent-cyan); }
+.lt-log-entry.ex-log-info .ex-log-title { color: var(--accent-cyan); }
+
+.ex-log-title {
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--text-primary);
+ overflow-wrap: anywhere;
+}
+.lt-log-entry.success .ex-log-title { color: var(--accent-green); }
+.lt-log-entry.warning .ex-log-title { color: var(--accent-amber); }
+.lt-log-entry.error .ex-log-title { color: var(--accent-red); }
+
+.ex-log-details { margin-top: 0.3rem; }
+.ex-log-field {
+ font-size: 0.72rem;
+ color: var(--text-secondary);
+ margin-bottom: 0.2rem;
+ overflow-wrap: anywhere;
+}
+.ex-log-label {
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-size: 0.66rem;
+}
+.ex-log-answer { color: var(--accent-amber); }
+.ex-log-by {
+ color: var(--text-muted);
+ font-size: 0.66rem;
+ margin-left: 0.6rem;
+ font-weight: 400;
+}
+.ex-log-output-err {
+ color: var(--accent-red);
+ border-left-color: var(--accent-red);
+ text-shadow: none;
+}
+
+.ex-code {
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ color: var(--accent-cyan);
+ overflow-wrap: anywhere;
+}
+
+/* Parsed-variable table (parse_complete) */
+.ex-parse-table {
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ gap: 1px var(--space-md);
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+}
+.ex-parse-key { color: var(--text-dim); }
+.ex-parse-val { color: var(--text-secondary); overflow-wrap: anywhere; }
+
+.ex-route-label { font-size: 0.72rem; color: var(--text-secondary); }
+.ex-route-goto { font-size: 0.72rem; color: var(--accent-cyan); }
+
+/* ---------------------------------------------------------------------
+ 5. Prompt (waiting for input)
+ --------------------------------------------------------------------- */
+.ex-prompt-box { margin: var(--space-md) 0; }
+.ex-prompt-msg {
+ color: var(--text-primary);
+ font-size: 0.74rem;
+ margin: 0.4rem 0;
+}
+.ex-opt-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-xs);
+ margin-top: var(--space-sm);
+}
+.ex-opt.is-answered { opacity: 0.55; }
+
+/* ---------------------------------------------------------------------
+ 6. Detail modal layout
+ --------------------------------------------------------------------- */
+.ex-section-title {
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--accent-orange);
+ margin: var(--space-lg) 0 var(--space-sm);
+}
+.ex-id-cell {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ flex-wrap: wrap;
+}
+
+/* ---------------------------------------------------------------------
+ 7. Compare modal
+ --------------------------------------------------------------------- */
+.ex-cmp-grid {
+ display: grid;
+ gap: var(--space-md);
+}
+.ex-cmp-col {
+ border: 1px solid var(--border-dim);
+ background: var(--bg-terminal);
+ min-width: 0;
+}
+.ex-cmp-head {
+ padding: var(--space-sm);
+ border-bottom: 1px solid var(--border-dim);
+ background: var(--bg-secondary);
+ color: var(--accent-amber);
+ font-family: var(--font-mono);
+ font-size: 0.74rem;
+}
+.ex-cmp-sub {
+ color: var(--text-dim);
+ font-size: 0.66rem;
+ overflow-wrap: anywhere;
+}
+.ex-cmp-body { padding: var(--space-sm); }
+.ex-cmp-label {
+ font-family: var(--font-mono);
+ font-size: 0.66rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--accent-amber);
+ margin-top: var(--space-sm);
+}
+.ex-cmp-label--err { color: var(--accent-red); }
+.ex-cmp-col .lt-log-output {
+ max-height: 320px;
+ overflow: auto;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+@media (max-width: 767px) {
+ .ex-cmp-grid { grid-template-columns: 1fr !important; }
+}
+
+/* ---------------------------------------------------------------------
+ 8. Diff
+ --------------------------------------------------------------------- */
+.ex-diff-stats {
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ margin-bottom: var(--space-sm);
+}
+.ex-diff {
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+}
+.ex-diff-same {
+ color: var(--text-muted);
+ padding: 1px 0;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.ex-diff-line {
+ background: rgba(255, 179, 0, 0.1);
+ border-left: 3px solid var(--accent-amber);
+ padding: 2px 4px;
+ margin: 2px 0;
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+.ex-diff-a { color: var(--accent-green); }
+.ex-diff-b { color: var(--accent-amber); }
+
+/* ---------------------------------------------------------------------
+ 9. Light theme
+ --------------------------------------------------------------------- */
+html[data-theme="light"] .ex-cmp-col { background: var(--bg-tertiary); }
+html[data-theme="light"] .ex-cmp-head { background: var(--bg-secondary); }
+html[data-theme="light"] .ex-log-title { color: var(--text-primary); }
+html[data-theme="light"] .ex-diff-same { color: var(--text-muted); }
+html[data-theme="light"] .ex-row.is-selected td { background: rgba(255, 179, 0, 0.18); }
diff --git a/public/assets/pages/executions.js b/public/assets/pages/executions.js
new file mode 100644
index 0000000..e22455b
--- /dev/null
+++ b/public/assets/pages/executions.js
@@ -0,0 +1,854 @@
+/* =====================================================================
+ 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
+ ? '
' + (selected ? '✓' : '') + ' | '
+ : '';
+
+ 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 '';
+ }
+
+ 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 ? '| ✓ | ' : '') +
+ 'Status | Name | Started by | ' +
+ 'Started | Completed / 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 '';
+ }
+ 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 = '';
+ 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
' +
+ '' +
+ '| Execution | Status | Started | Duration | ' +
+ '
' + rows + '
';
+ }
+
+ 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 = '';
+ 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;
+ },
+ });
+})();
diff --git a/public/assets/pages/quick.css b/public/assets/pages/quick.css
new file mode 100644
index 0000000..82e9ba5
--- /dev/null
+++ b/public/assets/pages/quick.css
@@ -0,0 +1,85 @@
+/* PULSE — Quick Command page (WP-F) styles. Prefix: qc- */
+
+.qc-mode-row {
+ display: flex;
+ gap: var(--space-lg, 1.5rem);
+ flex-wrap: wrap;
+}
+
+.qc-radio-label {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+}
+
+.qc-worker-list {
+ background: var(--bg-primary);
+ border: 1px solid var(--border-color);
+ padding: 0.75rem;
+ max-height: 220px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.qc-worker-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.4rem 0.5rem;
+ cursor: pointer;
+ border: 1px solid transparent;
+}
+
+.qc-worker-row--online {
+ background: rgba(0, 255, 136, 0.05);
+}
+
+.qc-worker-actions {
+ margin-top: 0.6rem;
+}
+
+.qc-result {
+ margin-top: 1.25rem;
+}
+
+.qc-scroll-list {
+ max-height: 400px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.qc-list-item {
+ cursor: pointer;
+ padding: 0.75rem;
+ background: var(--bg-primary);
+ border: 1px solid var(--border-dim, var(--border-color));
+ border-left: 3px solid var(--accent-green);
+ transition: var(--transition-fast, background 0.15s);
+}
+
+.qc-list-item:hover {
+ background: rgba(0, 212, 255, 0.06);
+}
+
+.qc-list-item-title {
+ color: var(--accent-green);
+ font-weight: 700;
+ margin-bottom: 0.25rem;
+}
+
+.qc-list-item-code {
+ font-family: var(--font-mono);
+ font-size: 0.85em;
+ color: var(--accent-amber);
+ margin-bottom: 0.25rem;
+}
+
+.qc-list-item-meta {
+ color: var(--text-muted);
+ font-size: 0.85em;
+}
diff --git a/public/assets/pages/quick.js b/public/assets/pages/quick.js
new file mode 100644
index 0000000..3565b76
--- /dev/null
+++ b/public/assets/pages/quick.js
@@ -0,0 +1,365 @@
+/* =====================================================================
+ PULSE — Quick Command page (WP-F)
+ Owns DOM id prefix `qc-` and action namespace `qc:*`.
+ ===================================================================== */
+'use strict';
+
+(function () {
+ const esc = Pulse.esc;
+
+ const HISTORY_KEY = 'commandHistory';
+ const HISTORY_LIMIT = 50;
+ const RERUN_KEY = 'pulse_rerun';
+
+ /* Copied verbatim from the pre-redesign public/index.html commandTemplates
+ array (12 templates) — kept static here since pageConfig only carries
+ isAdmin from server.js (WP-A), not a templates list. */
+ const TEMPLATES = [
+ { name: 'System Info', cmd: 'uname -a', desc: 'Show system information' },
+ { name: 'Uptime', cmd: 'uptime', desc: 'Show system uptime and load' },
+ { name: 'Disk Usage', cmd: 'df -h', desc: 'Show disk space usage' },
+ { name: 'Memory Usage', cmd: 'free -h', desc: 'Show memory usage' },
+ { name: 'CPU Info', cmd: 'lscpu', desc: 'Show CPU information' },
+ { name: 'Running Processes', cmd: 'ps aux --sort=-%mem | head -20', desc: 'Top 20 processes by memory' },
+ { name: 'Network Interfaces', cmd: 'ip addr show', desc: 'Show network interfaces' },
+ { name: 'Active Connections', cmd: 'ss -tunap', desc: 'Show active network connections' },
+ { name: 'Docker Containers', cmd: 'docker ps -a', desc: 'List all Docker containers' },
+ { name: 'System Log Tail', cmd: 'tail -n 50 /var/log/syslog', desc: 'Last 50 lines of system log' },
+ { name: 'Who is Logged In', cmd: 'w', desc: 'Show logged in users' },
+ { name: 'Last Logins', cmd: 'last -n 20', desc: 'Show last 20 logins' },
+ ];
+
+ let _workers = [];
+
+ /* -------------------------------------------------------------------
+ History (localStorage, verbatim key/shape from the old UI)
+ ------------------------------------------------------------------- */
+ function loadHistory() {
+ return Pulse.util.storage.get(HISTORY_KEY, []) || [];
+ }
+
+ function addToHistory(command, workerLabel) {
+ const history = loadHistory();
+ history.unshift({ command: command, worker: workerLabel, timestamp: new Date().toISOString() });
+ if (history.length > HISTORY_LIMIT) history.splice(HISTORY_LIMIT);
+ Pulse.util.storage.set(HISTORY_KEY, history);
+ }
+
+ /* -------------------------------------------------------------------
+ Worker select / checkbox list
+ ------------------------------------------------------------------- */
+ function currentMode() {
+ const checked = document.querySelector('input[name="qc-exec-mode"]:checked');
+ return checked ? checked.value : 'single';
+ }
+
+ function applyMode() {
+ const mode = currentMode();
+ const single = document.getElementById('qc-single-mode');
+ const multi = document.getElementById('qc-multi-mode');
+ if (single) single.hidden = mode !== 'single';
+ if (multi) multi.hidden = mode !== 'multi';
+ }
+
+ function renderWorkerSelect(workers, preserveId) {
+ const select = document.getElementById('qc-worker');
+ if (!select) return;
+ if (workers.length === 0) {
+ select.innerHTML = '';
+ return;
+ }
+ select.innerHTML = workers.map(w =>
+ ''
+ ).join('');
+ if (preserveId && workers.some(w => w.id === preserveId)) select.value = preserveId;
+ }
+
+ function renderWorkerCheckboxes(workers, preserveIds) {
+ const wrap = document.getElementById('qc-worker-list');
+ if (!wrap) return;
+ if (workers.length === 0) {
+ wrap.innerHTML = '';
+ return;
+ }
+ const keep = preserveIds || new Set();
+ wrap.innerHTML = workers.map(w => {
+ const checked = keep.has(w.id) ? ' checked' : '';
+ return (
+ ''
+ );
+ }).join('');
+ }
+
+ function selectedWorkerCheckboxIds() {
+ return Array.from(document.querySelectorAll('input[name="qc-worker-cb"]:checked')).map(cb => cb.value);
+ }
+
+ async function loadWorkers() {
+ const select = document.getElementById('qc-worker');
+ const preserveSingle = select ? select.value : '';
+ const preserveMulti = new Set(selectedWorkerCheckboxIds());
+ try {
+ _workers = await Pulse.api.get('/api/workers') || [];
+ } catch (e) {
+ _workers = [];
+ console.error('[Pulse:quick] failed to load workers', e);
+ }
+ renderWorkerSelect(_workers, preserveSingle);
+ renderWorkerCheckboxes(_workers, preserveMulti);
+ return _workers;
+ }
+
+ /* -------------------------------------------------------------------
+ Templates modal
+ ------------------------------------------------------------------- */
+ function renderTemplates() {
+ const list = document.getElementById('qc-templates-list');
+ if (!list) return;
+ list.innerHTML = TEMPLATES.map((t, i) =>
+ '' +
+ '
' + esc(t.name) + '
' +
+ '
' + esc(t.cmd) + '
' +
+ '
' + esc(t.desc) + '
' +
+ '
'
+ ).join('');
+ }
+
+ function renderHistory() {
+ const list = document.getElementById('qc-history-list');
+ if (!list) return;
+ const history = loadHistory();
+ if (history.length === 0) {
+ list.innerHTML = '';
+ return;
+ }
+ list.innerHTML = history.map((item, i) =>
+ '' +
+ '
' + esc(item.command) + '
' +
+ '
' + esc(Pulse.fmt.dateTime(item.timestamp)) + ' — ' + esc(item.worker) + '
' +
+ '
'
+ ).join('');
+ }
+
+ /* -------------------------------------------------------------------
+ Result rendering
+ ------------------------------------------------------------------- */
+ function renderSingleSuccess(executionId) {
+ const wrap = document.getElementById('qc-result');
+ if (!wrap) return;
+ wrap.innerHTML =
+ '' +
+ '
✓' +
+ '
' +
+ '
Command sent successfully
' +
+ '
' +
+ '
' +
+ '
';
+ }
+
+ function renderSingleFailure(message) {
+ const wrap = document.getElementById('qc-result');
+ if (!wrap) return;
+ wrap.innerHTML =
+ '' +
+ '
✕' +
+ '
' +
+ '
Command failed
' +
+ '
' + esc(message) + '
' +
+ '
' +
+ '
';
+ }
+
+ function renderMultiResult(results, successCount, failCount) {
+ const wrap = document.getElementById('qc-result');
+ if (!wrap) return;
+ const rows = results.map(r =>
+ '' +
+ '| ' + esc(r.worker) + ' | ' +
+ '' +
+ (r.success
+ ? '✓ Sent (' + esc(String(r.executionId || '').slice(0, 8)) + '…)'
+ : '✕ ' + esc(r.error) + '') +
+ ' | ' +
+ '
'
+ ).join('');
+ wrap.innerHTML =
+ '' +
+ '
' +
+ '
Multi-worker execution complete
' +
+ '
Success: ' + successCount + ' | Failed: ' + failCount + '
' +
+ '
' +
+ '
' +
+ '' +
+ '| Worker | Result |
' +
+ '' + rows + '' +
+ '
';
+ }
+
+ function renderExecuting(count) {
+ const wrap = document.getElementById('qc-result');
+ if (!wrap) return;
+ wrap.innerHTML = 'Executing' +
+ (count > 1 ? ' on ' + count + ' worker(s)' : '') + '…
';
+ }
+
+ /* -------------------------------------------------------------------
+ Execute
+ ------------------------------------------------------------------- */
+ async function execute() {
+ const commandEl = document.getElementById('qc-command');
+ const command = commandEl ? commandEl.value.trim() : '';
+ if (!command) {
+ Pulse.toast.error('Please enter a command');
+ return;
+ }
+ const mode = currentMode();
+
+ if (mode === 'single') {
+ const select = document.getElementById('qc-worker');
+ const workerId = select ? select.value : '';
+ if (!workerId) {
+ Pulse.toast.error('Please select a worker');
+ return;
+ }
+ const worker = _workers.find(w => w.id === workerId);
+ const workerName = worker ? worker.name : 'Unknown';
+ renderExecuting(1);
+ try {
+ const data = await Pulse.api.post('/api/workers/' + encodeURIComponent(workerId) + '/command', { command });
+ addToHistory(command, workerName);
+ renderSingleSuccess(data && data.execution_id);
+ Pulse.beep('success');
+ } catch (e) {
+ renderSingleFailure(e.message || 'Failed to execute command');
+ Pulse.beep('error');
+ }
+ return;
+ }
+
+ const workerIds = selectedWorkerCheckboxIds();
+ if (workerIds.length === 0) {
+ Pulse.toast.error('Please select at least one worker');
+ return;
+ }
+ renderExecuting(workerIds.length);
+ const results = [];
+ let successCount = 0;
+ let failCount = 0;
+ for (const workerId of workerIds) {
+ const worker = _workers.find(w => w.id === workerId);
+ const workerLabel = worker ? worker.name : workerId;
+ try {
+ const data = await Pulse.api.post('/api/workers/' + encodeURIComponent(workerId) + '/command', { command });
+ results.push({ worker: workerLabel, success: true, executionId: data && data.execution_id });
+ successCount++;
+ } catch (e) {
+ results.push({ worker: workerLabel, success: false, error: e.message || 'Failed to execute' });
+ failCount++;
+ }
+ }
+ addToHistory(command, workerIds.length + ' workers');
+ renderMultiResult(results, successCount, failCount);
+ if (failCount === 0) Pulse.beep('success');
+ else if (successCount > 0) Pulse.beep('info');
+ else Pulse.beep('error');
+ }
+
+ /* -------------------------------------------------------------------
+ Cross-page re-run (from Executions page)
+ ------------------------------------------------------------------- */
+ function consumeRerun() {
+ let raw;
+ try { raw = window.sessionStorage.getItem(RERUN_KEY); } catch (e) { return; }
+ if (!raw) return;
+ try { window.sessionStorage.removeItem(RERUN_KEY); } catch (e) { /* ignore */ }
+ let payload;
+ try { payload = JSON.parse(raw); } catch (e) { return; }
+ if (!payload) return;
+
+ const singleRadio = document.getElementById('qc-mode-single');
+ if (singleRadio) singleRadio.checked = true;
+ applyMode();
+
+ if (payload.worker_id) {
+ const select = document.getElementById('qc-worker');
+ const hasWorker = select && _workers.some(w => w.id === payload.worker_id);
+ if (hasWorker) {
+ select.value = payload.worker_id;
+ } else {
+ Pulse.toast.warning('Original worker is no longer available — select a worker to run this command.');
+ }
+ }
+ const commandEl = document.getElementById('qc-command');
+ if (commandEl) {
+ commandEl.value = payload.command || '';
+ commandEl.focus();
+ }
+ }
+
+ /* -------------------------------------------------------------------
+ Data loading / refresh
+ ------------------------------------------------------------------- */
+ async function refresh() {
+ await loadWorkers();
+ }
+
+ function init() {
+ Pulse.actions.registerAll({
+ 'qc:mode': applyMode,
+ 'qc:templates-open': () => { renderTemplates(); if (window.lt && lt.modal) lt.modal.open('qc-templates-modal'); },
+ 'qc:template-use': (el) => {
+ const idx = Number(el.getAttribute('data-index'));
+ const tpl = TEMPLATES[idx];
+ if (!tpl) return;
+ const commandEl = document.getElementById('qc-command');
+ if (commandEl) commandEl.value = tpl.cmd;
+ if (window.lt && lt.modal) lt.modal.close('qc-templates-modal');
+ },
+ 'qc:history-open': () => { renderHistory(); if (window.lt && lt.modal) lt.modal.open('qc-history-modal'); },
+ 'qc:history-use': (el) => {
+ const idx = Number(el.getAttribute('data-index'));
+ const history = loadHistory();
+ const item = history[idx];
+ if (!item) return;
+ const commandEl = document.getElementById('qc-command');
+ if (commandEl) commandEl.value = item.command;
+ if (window.lt && lt.modal) lt.modal.close('qc-history-modal');
+ },
+ 'qc:select-all': () => {
+ document.querySelectorAll('input[name="qc-worker-cb"]').forEach(cb => { cb.checked = true; });
+ },
+ 'qc:select-online': () => {
+ document.querySelectorAll('input[name="qc-worker-cb"]').forEach(cb => {
+ cb.checked = cb.getAttribute('data-status') === 'online';
+ });
+ },
+ 'qc:clear-all': () => {
+ document.querySelectorAll('input[name="qc-worker-cb"]').forEach(cb => { cb.checked = false; });
+ },
+ 'qc:execute': execute,
+ });
+
+ if (window.lt && lt.keys && typeof lt.keys.on === 'function') {
+ lt.keys.on('ctrl+enter', () => { execute(); });
+ }
+
+ applyMode();
+ return loadWorkers().then(() => { consumeRerun(); });
+ }
+
+ function onEvent(type) {
+ if (type === 'worker_update') {
+ loadWorkers();
+ return true;
+ }
+ return false;
+ }
+
+ Pulse.registerPage({ name: 'quick', init, refresh, onEvent });
+})();
diff --git a/public/assets/pages/scheduler.css b/public/assets/pages/scheduler.css
new file mode 100644
index 0000000..b93b048
--- /dev/null
+++ b/public/assets/pages/scheduler.css
@@ -0,0 +1,10 @@
+/* PULSE — Scheduler page (WP-F) styles. Prefix: sc- */
+
+.sc-row-disabled {
+ opacity: 0.6;
+}
+
+.sc-countdown {
+ color: var(--text-muted);
+ font-size: 0.85em;
+}
diff --git a/public/assets/pages/scheduler.js b/public/assets/pages/scheduler.js
new file mode 100644
index 0000000..8b4dc0b
--- /dev/null
+++ b/public/assets/pages/scheduler.js
@@ -0,0 +1,286 @@
+/* =====================================================================
+ PULSE — Scheduler page (WP-F)
+ Owns DOM id prefix `sc-` and action namespace `sc:*`.
+ ===================================================================== */
+'use strict';
+
+(function () {
+ const esc = Pulse.esc;
+ const fmt = Pulse.fmt;
+
+ let _schedules = [];
+ let _workers = [];
+
+ /* -------------------------------------------------------------------
+ Helpers
+ ------------------------------------------------------------------- */
+ function parseWorkerIds(raw) {
+ if (Array.isArray(raw)) return raw;
+ if (typeof raw === 'string') {
+ try {
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : raw.split(',').filter(Boolean);
+ } catch (e) {
+ return raw.split(',').filter(Boolean);
+ }
+ }
+ return [];
+ }
+
+ function scheduleDescription(s) {
+ if (s.schedule_type === 'interval') return 'Every ' + esc(s.schedule_value) + ' minutes';
+ if (s.schedule_type === 'hourly') return 'Every ' + esc(s.schedule_value) + ' hour(s)';
+ if (s.schedule_type === 'daily') return 'Daily at ' + esc(s.schedule_value);
+ if (s.schedule_type === 'cron') return 'Cron: ' + esc(s.schedule_value);
+ return esc(s.schedule_value || '');
+ }
+
+ function workerNamesHtml(ids) {
+ if (!ids.length) return '—';
+ return ids.map(id => {
+ const w = _workers.find(worker => worker.id === id);
+ const label = w ? w.name : String(id).slice(0, 8);
+ return '' + esc(label) + '';
+ }).join(' ');
+ }
+
+ function nextRunCountdown(nextRun) {
+ const d = fmt.safeDate(nextRun);
+ if (!d) return '';
+ const secs = Math.round((d.getTime() - Date.now()) / 1000);
+ if (secs <= 0) return 'now';
+ if (secs < 60) return secs + 's';
+ if (secs < 3600) return Math.round(secs / 60) + 'm';
+ return Math.round(secs / 3600) + 'h';
+ }
+
+ /* -------------------------------------------------------------------
+ Table rendering
+ ------------------------------------------------------------------- */
+ function rowHtml(s) {
+ const workerIds = parseWorkerIds(s.worker_ids);
+ const lastRun = s.last_run ? esc(fmt.dateTime(s.last_run)) : 'Never';
+ const nextRunDate = fmt.safeDate(s.next_run);
+ const countdown = nextRunCountdown(s.next_run);
+ const nextRun = nextRunDate
+ ? esc(fmt.dateTime(s.next_run)) + (countdown ? ' (in ' + esc(countdown) + ')' : '')
+ : 'Not scheduled';
+ const statusBadge = s.enabled
+ ? 'ENABLED'
+ : 'DISABLED';
+ const adminActions = Pulse.isAdmin
+ ? '' +
+ '' +
+ '' +
+ '
'
+ : '';
+ return (
+ '' +
+ '| ' + esc(s.name || '') + ' | ' +
+ '' + esc(s.command || '') + ' | ' +
+ '' + scheduleDescription(s) + ' | ' +
+ '' + workerNamesHtml(workerIds) + ' | ' +
+ '' + lastRun + ' | ' +
+ '' + nextRun + ' | ' +
+ '' + statusBadge + ' | ' +
+ '' + adminActions + ' | ' +
+ '
'
+ );
+ }
+
+ function render() {
+ const wrap = document.getElementById('sc-wrap');
+ if (!wrap) return;
+ if (_schedules.length === 0) {
+ wrap.innerHTML = 'No scheduled commands yet
';
+ return;
+ }
+ wrap.innerHTML =
+ '' +
+ '| Name | Command | Schedule | Workers | Last run | Next run | Status | Actions |
' +
+ '' + _schedules.map(rowHtml).join('') + '' +
+ '
';
+ }
+
+ function renderError() {
+ const wrap = document.getElementById('sc-wrap');
+ if (!wrap) return;
+ wrap.innerHTML =
+ '';
+ }
+
+ function onTick() {
+ document.querySelectorAll('#sc-wrap .sc-countdown[data-next-run]').forEach(el => {
+ const countdown = nextRunCountdown(el.getAttribute('data-next-run'));
+ el.textContent = countdown ? '(in ' + countdown + ')' : '';
+ });
+ }
+
+ /* -------------------------------------------------------------------
+ Create modal
+ ------------------------------------------------------------------- */
+ function renderCreateWorkerList() {
+ const wrap = document.getElementById('sc-worker-list');
+ if (!wrap) return;
+ if (_workers.length === 0) {
+ wrap.innerHTML = '';
+ return;
+ }
+ wrap.innerHTML = _workers.map(w =>
+ ''
+ ).join('');
+ }
+
+ function updateValueInput() {
+ const type = document.getElementById('sc-type').value;
+ const container = document.getElementById('sc-value-container');
+ if (!container) return;
+ if (type === 'interval') {
+ container.innerHTML =
+ '' +
+ '';
+ } else if (type === 'hourly') {
+ container.innerHTML =
+ '' +
+ '';
+ } else if (type === 'daily') {
+ container.innerHTML =
+ '' +
+ '';
+ }
+ }
+
+ function resetCreateForm() {
+ const form = document.getElementById('sc-create-form');
+ if (form) form.reset();
+ const type = document.getElementById('sc-type');
+ if (type) type.value = 'interval';
+ updateValueInput();
+ renderCreateWorkerList();
+ }
+
+ async function submitCreate() {
+ const name = document.getElementById('sc-name').value.trim();
+ const command = document.getElementById('sc-command').value.trim();
+ const type = document.getElementById('sc-type').value;
+ const valueEl = document.getElementById('sc-value');
+ const value = valueEl ? valueEl.value : '';
+ const workerIds = Array.from(document.querySelectorAll('input[name="sc-worker-cb"]:checked')).map(cb => cb.value);
+
+ if (!name || !command || !value || workerIds.length === 0) {
+ Pulse.toast.error('Please fill in all fields and select at least one worker');
+ return;
+ }
+
+ try {
+ await Pulse.api.post('/api/scheduled-commands', {
+ name: name,
+ command: command,
+ worker_ids: workerIds,
+ schedule_type: type,
+ schedule_value: value,
+ });
+ if (window.lt && lt.modal) lt.modal.close('sc-create-modal');
+ Pulse.toast.success('Schedule created successfully');
+ resetCreateForm();
+ await load();
+ } catch (e) {
+ Pulse.toast.error('Failed to create schedule: ' + (e.message || 'unknown error'));
+ }
+ }
+
+ async function toggleSchedule(id) {
+ try {
+ const data = await Pulse.api.put('/api/scheduled-commands/' + encodeURIComponent(id) + '/toggle');
+ Pulse.toast.success('Schedule ' + (data && data.enabled ? 'enabled' : 'disabled'));
+ await load();
+ } catch (e) {
+ Pulse.toast.error('Failed to toggle schedule: ' + (e.message || 'unknown error'));
+ }
+ }
+
+ async function deleteSchedule(id, name) {
+ const ok = await Pulse.confirm({
+ title: 'Delete schedule',
+ message: 'Delete scheduled command: ' + name + '?',
+ type: 'error',
+ confirmLabel: 'DELETE',
+ });
+ if (!ok) return;
+ try {
+ await Pulse.api.delete('/api/scheduled-commands/' + encodeURIComponent(id));
+ Pulse.toast.success('Schedule deleted');
+ await load();
+ } catch (e) {
+ Pulse.toast.error('Failed to delete schedule: ' + (e.message || 'unknown error'));
+ }
+ }
+
+ /* -------------------------------------------------------------------
+ Data loading
+ ------------------------------------------------------------------- */
+ async function loadWorkers() {
+ try {
+ _workers = await Pulse.api.get('/api/workers') || [];
+ } catch (e) {
+ _workers = [];
+ console.error('[Pulse:scheduler] failed to load workers', e);
+ }
+ return _workers;
+ }
+
+ async function loadSchedules() {
+ try {
+ _schedules = await Pulse.api.get('/api/scheduled-commands') || [];
+ render();
+ } catch (e) {
+ _schedules = [];
+ console.error('[Pulse:scheduler] failed to load schedules', e);
+ renderError();
+ }
+ return _schedules;
+ }
+
+ async function load() {
+ await loadWorkers();
+ await loadSchedules();
+ }
+
+ async function refresh() {
+ await load();
+ }
+
+ function init() {
+ Pulse.actions.registerAll({
+ 'sc:create-open': () => {
+ resetCreateForm();
+ if (window.lt && lt.modal) lt.modal.open('sc-create-modal');
+ },
+ 'sc:create-submit': submitCreate,
+ 'sc:type': updateValueInput,
+ 'sc:toggle': (el) => toggleSchedule(el.getAttribute('data-id')),
+ 'sc:delete': (el) => deleteSchedule(el.getAttribute('data-id'), el.getAttribute('data-name')),
+ });
+ Pulse.events.on('tick', onTick);
+ return load();
+ }
+
+ function onEvent(type) {
+ if (type === 'worker_update') {
+ loadWorkers().then(render);
+ return true;
+ }
+ return false;
+ }
+
+ Pulse.registerPage({ name: 'scheduler', init, refresh, onEvent });
+})();
diff --git a/public/assets/pages/workers.css b/public/assets/pages/workers.css
new file mode 100644
index 0000000..03a5ee1
--- /dev/null
+++ b/public/assets/pages/workers.css
@@ -0,0 +1,32 @@
+/* Workers page (WP-C) — grid layout for worker cards. */
+.wk-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: var(--space-md);
+}
+
+.wk-card-header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ margin-bottom: var(--space-sm);
+}
+
+.wk-name {
+ font-weight: 700;
+ flex: 1;
+}
+
+.wk-lastseen {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ margin-bottom: var(--space-sm);
+}
+
+.wk-card .lt-card-footer {
+ margin-top: var(--space-md);
+ padding-top: var(--space-sm);
+ border-top: 1px solid var(--border-color-dim);
+ display: flex;
+ justify-content: flex-end;
+}
diff --git a/public/assets/pages/workers.js b/public/assets/pages/workers.js
new file mode 100644
index 0000000..45d61f9
--- /dev/null
+++ b/public/assets/pages/workers.js
@@ -0,0 +1,257 @@
+/* =====================================================================
+ PULSE — Workers page (WP-C)
+ Owns DOM id prefix `wk-` and action namespace `wk:*`.
+ ===================================================================== */
+'use strict';
+
+(function () {
+ const esc = Pulse.esc;
+ const fmt = Pulse.fmt;
+
+ 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 memPct(meta) {
+ if (!meta || !meta.totalMem) return 0;
+ return ((meta.totalMem - meta.freeMem) / meta.totalMem * 100);
+ }
+
+ function loadAvgText(meta) {
+ if (!meta || !meta.loadavg) return 'N/A';
+ return meta.loadavg.map(l => Number(l).toFixed(2)).join(', ');
+ }
+
+ /* -------------------------------------------------------------------
+ Card construction
+ ------------------------------------------------------------------- */
+ function footerHtml(worker) {
+ if (!Pulse.isAdmin) return '';
+ return (
+ ''
+ );
+ }
+
+ function cardHtml(worker) {
+ const meta = parseMeta(worker);
+ const online = worker.status === 'online';
+ const pct = memPct(meta).toFixed(1);
+ return (
+ '' +
+ '' +
+ 'Last seen: ' + esc(fmt.ago(worker.last_heartbeat)) +
+ '
' +
+ '' +
+ '
System
' +
+ '
' + (meta ? esc((meta.platform || 'N/A') + ' ' + (meta.arch || '') + ' | ' + (meta.cpus || '?') + ' CPU cores') : 'N/A') + '
' +
+ '
Memory
' +
+ '
' +
+ (meta ? esc(fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + ' (' + pct + '% used)') : 'N/A') +
+ '
' +
+ '
' +
+ '
Load Avg
' +
+ '
' + esc(loadAvgText(meta)) + '
' +
+ '
Uptime
' +
+ '
' + esc(fmt.uptime(meta && meta.uptime)) + '
' +
+ '
Active Tasks
' +
+ '
' + esc((meta && meta.activeTasks || 0) + ' / ' + (meta && meta.maxConcurrentTasks || 0)) + '
' +
+ '
' +
+ footerHtml(worker)
+ );
+ }
+
+ function createCard(worker) {
+ const div = document.createElement('div');
+ div.className = 'lt-card wk-card';
+ div.setAttribute('data-worker-id', worker.id);
+ div.classList.add(worker.status === 'online' ? 'wk-card-online' : 'wk-card-offline');
+ div.innerHTML = cardHtml(worker);
+ return div;
+ }
+
+ /* Update an existing card's fields in place — never replaces the card
+ node itself, so identity is preserved across refreshes. */
+ function patchCard(card, worker) {
+ const meta = parseMeta(worker);
+ const online = worker.status === 'online';
+
+ card.classList.toggle('wk-card-online', online);
+ card.classList.toggle('wk-card-offline', !online);
+
+ const dot = card.querySelector('[data-wk-dot]');
+ if (dot) dot.className = 'lt-dot ' + (online ? 'lt-dot-up' : 'lt-dot-down');
+
+ const name = card.querySelector('[data-wk-name]');
+ if (name) name.textContent = worker.name;
+
+ const status = card.querySelector('[data-wk-status]');
+ if (status) {
+ status.className = fmt.status(worker.status);
+ status.textContent = String(worker.status || '').toUpperCase();
+ }
+
+ const lastSeen = card.querySelector('[data-wk-lastseen]');
+ if (lastSeen) {
+ lastSeen.setAttribute('data-last-heartbeat', worker.last_heartbeat || '');
+ lastSeen.textContent = 'Last seen: ' + fmt.ago(worker.last_heartbeat);
+ }
+
+ const system = card.querySelector('[data-wk-system]');
+ if (system) system.textContent = meta ? ((meta.platform || 'N/A') + ' ' + (meta.arch || '') + ' | ' + (meta.cpus || '?') + ' CPU cores') : 'N/A';
+
+ const pct = memPct(meta).toFixed(1);
+ const memory = card.querySelector('[data-wk-memory]');
+ if (memory) {
+ const bar = memory.querySelector('[data-wk-membar]');
+ const text = meta ? (fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + ' (' + pct + '% used)') : 'N/A';
+ if (memory.firstChild && memory.firstChild.nodeType === Node.TEXT_NODE) {
+ memory.firstChild.textContent = text;
+ } else {
+ memory.insertBefore(document.createTextNode(text), memory.firstChild);
+ }
+ if (bar) bar.style.width = (meta ? pct : 0) + '%';
+ }
+
+ const load = card.querySelector('[data-wk-load]');
+ if (load) load.textContent = loadAvgText(meta);
+
+ const uptime = card.querySelector('[data-wk-uptime]');
+ if (uptime) uptime.textContent = fmt.uptime(meta && meta.uptime);
+
+ const tasks = card.querySelector('[data-wk-tasks]');
+ if (tasks) tasks.textContent = (meta && meta.activeTasks || 0) + ' / ' + (meta && meta.maxConcurrentTasks || 0);
+
+ // Admin footer can appear/disappear if isAdmin changes mid-session (rare,
+ // but keep it correct rather than assuming it's static).
+ const hasFooter = !!card.querySelector('.lt-card-footer');
+ if (Pulse.isAdmin && !hasFooter) {
+ card.insertAdjacentHTML('beforeend', footerHtml(worker));
+ } else if (!Pulse.isAdmin && hasFooter) {
+ const f = card.querySelector('.lt-card-footer');
+ if (f) f.remove();
+ } else if (hasFooter) {
+ const btn = card.querySelector('[data-action="wk:delete"]');
+ if (btn) btn.setAttribute('data-worker-name', worker.name);
+ }
+ }
+
+ /* -------------------------------------------------------------------
+ Grid rendering — patches existing cards, adds/removes as needed.
+ ------------------------------------------------------------------- */
+ function renderGrid(workers) {
+ const grid = document.getElementById('wk-grid');
+ if (!grid) return;
+
+ if (workers.length === 0) {
+ grid.innerHTML =
+ '' +
+ '
⚙
' +
+ '
No workers connected
' +
+ '
Workers appear here once they connect and send a heartbeat.
' +
+ '
';
+ return;
+ }
+
+ const loading = document.getElementById('wk-loading');
+ if (loading) loading.remove();
+
+ const seen = new Set();
+ const order = [];
+ workers.forEach(worker => {
+ seen.add(String(worker.id));
+ let card = grid.querySelector('.wk-card[data-worker-id="' + cssEscape(worker.id) + '"]');
+ if (!card) {
+ card = createCard(worker);
+ } else {
+ patchCard(card, worker);
+ }
+ order.push(card);
+ });
+
+ // Remove cards for workers no longer present.
+ Array.prototype.slice.call(grid.querySelectorAll('.wk-card')).forEach(card => {
+ if (!seen.has(String(card.getAttribute('data-worker-id')))) card.remove();
+ });
+
+ // Ensure DOM order matches API order without detaching untouched nodes
+ // unnecessarily (appendChild on an already-positioned node is a no-op
+ // move, not a re-create, so identity is preserved either way).
+ order.forEach(card => grid.appendChild(card));
+ }
+
+ function cssEscape(v) {
+ if (window.CSS && CSS.escape) return CSS.escape(String(v));
+ return String(v).replace(/["\\]/g, '\\$&');
+ }
+
+ function renderError() {
+ const grid = document.getElementById('wk-grid');
+ if (!grid) return;
+ grid.innerHTML = '';
+ }
+
+ /* -------------------------------------------------------------------
+ Data loading + actions
+ ------------------------------------------------------------------- */
+ async function loadWorkers() {
+ try {
+ const workers = await Pulse.api.get('/api/workers') || [];
+ renderGrid(workers);
+ } catch (e) {
+ console.error('[Pulse:workers] failed to load workers', e);
+ renderError();
+ }
+ }
+
+ async function deleteWorker(el) {
+ const id = el.getAttribute('data-worker-id');
+ const name = el.getAttribute('data-worker-name') || 'this worker';
+ const ok = await Pulse.confirm({
+ title: 'Delete worker',
+ message: 'Delete worker ' + name + '?',
+ type: 'error',
+ });
+ if (!ok) return;
+ try {
+ await Pulse.api.delete('/api/workers/' + encodeURIComponent(id));
+ Pulse.toast.success('Worker deleted');
+ await loadWorkers();
+ } catch (e) {
+ Pulse.toast.error((e && e.message) || 'Failed to delete worker');
+ }
+ }
+
+ function onTick() {
+ document.querySelectorAll('#wk-grid [data-wk-lastseen][data-last-heartbeat]').forEach(el => {
+ const v = el.getAttribute('data-last-heartbeat');
+ if (v) el.textContent = 'Last seen: ' + fmt.ago(v);
+ });
+ }
+
+ function init() {
+ Pulse.actions.register('wk:delete', deleteWorker);
+ Pulse.events.on('tick', onTick);
+ return loadWorkers();
+ }
+
+ function onEvent(type) {
+ if (type === 'worker_update') {
+ loadWorkers();
+ return true;
+ }
+ return false;
+ }
+
+ Pulse.registerPage({ name: 'workers', init, refresh: loadWorkers, onEvent });
+})();
diff --git a/public/assets/pages/workflows.css b/public/assets/pages/workflows.css
new file mode 100644
index 0000000..1ddc689
--- /dev/null
+++ b/public/assets/pages/workflows.css
@@ -0,0 +1,37 @@
+/* Workflows page (WP-D) — extends base.css, never overrides its variables. */
+
+.wf-json-textarea {
+ font-family: var(--font-mono);
+ min-height: 200px;
+}
+
+.wf-json-textarea-lg {
+ min-height: 320px;
+ font-size: 0.8rem;
+}
+
+.wf-checkbox-label {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ font-size: 0.8rem;
+ color: var(--text-secondary);
+ cursor: pointer;
+}
+
+.wf-required {
+ color: var(--accent-red);
+}
+
+/* base.css defines .lt-field-error as a hook (see base.js field validation)
+ but ships no visual style for it — Workflows renders it directly for
+ JSON-parse / server-side errors, so give it one here. */
+.lt-field-error {
+ color: var(--accent-red);
+ font-size: 0.72rem;
+ letter-spacing: 0.02em;
+}
+
+.wf-input-invalid {
+ border-color: var(--accent-red) !important;
+}
diff --git a/public/assets/pages/workflows.js b/public/assets/pages/workflows.js
new file mode 100644
index 0000000..11cf75e
--- /dev/null
+++ b/public/assets/pages/workflows.js
@@ -0,0 +1,355 @@
+/* =====================================================================
+ PULSE — Workflows page (WP-D)
+ ---------------------------------------------------------------------
+ Owns: DOM id prefix `wf-`, action namespace `wf:*`.
+ Consumes the frozen window.Pulse contract from /assets/app.js.
+ ===================================================================== */
+'use strict';
+
+(function () {
+ // Example workflow definition. The Create modal is pre-filled with this
+ // every time it opens (fixes the old bug where the textarea was blanked).
+ var EXAMPLE_DEFINITION = {
+ steps: [
+ {
+ name: 'Example Step',
+ type: 'execute',
+ targets: ['all'],
+ command: "echo 'Hello from PULSE'"
+ }
+ ]
+ };
+
+ // id -> parsed definition object, rebuilt on every list load.
+ var _registry = {};
+ // id -> raw row from the list response (name/description/etc.).
+ var _rowsById = {};
+ // workflow id currently targeted by the Run modal.
+ var _pendingWorkflowId = null;
+
+ function paramBadge(def) {
+ var params = (def && def.params) || [];
+ if (!params.length) return '';
+ var n = params.length;
+ return ' ' + n + ' param' + (n > 1 ? 's' : '') + '';
+ }
+
+ function parseDefinition(raw) {
+ if (raw && typeof raw === 'object') return raw;
+ if (typeof raw === 'string') {
+ try { return JSON.parse(raw); } catch (e) { return {}; }
+ }
+ return {};
+ }
+
+ function renderEmptyState() {
+ return (
+ '' +
+ '
⚙
' +
+ '
No workflows yet
' +
+ '
Workflows let you run multi-step jobs across one or more workers.
' +
+ '
' +
+ '
'
+ );
+ }
+
+ function renderRow(w) {
+ var def = _registry[w.id] || {};
+ var isAdmin = window.Pulse.isAdmin;
+ var esc = window.Pulse.esc;
+ var actions =
+ '';
+ if (isAdmin) {
+ actions +=
+ ' ' +
+ ' ';
+ }
+ var createdAt = window.Pulse.fmt.dateTime(w.created_at);
+ return (
+ '' +
+ '| ' + esc(w.name) + paramBadge(def) + ' | ' +
+ '' + esc(w.description || 'No description') + ' | ' +
+ '' + esc(w.created_by || 'Unknown') + ' · ' + esc(createdAt) + ' | ' +
+ '' + actions + ' | ' +
+ '
'
+ );
+ }
+
+ function renderList(workflows) {
+ var el = document.getElementById('wf-list');
+ if (!el) return;
+ if (!workflows.length) {
+ el.innerHTML = renderEmptyState();
+ return;
+ }
+ var rows = workflows.map(renderRow).join('');
+ el.innerHTML =
+ '' +
+ '
' +
+ '| Name | Description | Created | Actions |
' +
+ '' + rows + '' +
+ '
' +
+ '
';
+ }
+
+ function renderLoadError(message) {
+ var el = document.getElementById('wf-list');
+ if (!el) return;
+ el.innerHTML =
+ '' +
+ '⚠' +
+ '' + window.Pulse.esc(message || 'Failed to load workflows') + '' +
+ '
';
+ }
+
+ function loadWorkflows() {
+ return window.Pulse.api.get('/api/workflows').then(function (workflows) {
+ var list = workflows || [];
+ _registry = {};
+ _rowsById = {};
+ list.forEach(function (w) {
+ _rowsById[w.id] = w;
+ _registry[w.id] = parseDefinition(w.definition);
+ });
+ renderList(list);
+ }).catch(function (e) {
+ renderLoadError(e && e.message);
+ });
+ }
+
+ /* ---------------------------------------------------------------
+ Create modal
+ --------------------------------------------------------------- */
+ function showFieldError(id, message) {
+ var el = document.getElementById(id);
+ if (!el) return;
+ el.textContent = message || '';
+ el.hidden = !message;
+ }
+
+ function openCreateModal() {
+ document.getElementById('wf-create-form').reset();
+ document.getElementById('wf-create-definition').value = JSON.stringify(EXAMPLE_DEFINITION, null, 2);
+ showFieldError('wf-create-error', '');
+ window.lt.modal.open('wf-create-modal');
+ }
+
+ function submitCreateForm(formEl) {
+ var name = formEl.elements['name'].value.trim();
+ var description = formEl.elements['description'].value.trim();
+ var definitionText = formEl.elements['definition'].value;
+ var webhookUrl = formEl.elements['webhook_url'].value.trim() || null;
+
+ if (!name) {
+ showFieldError('wf-create-error', 'Name is required.');
+ return;
+ }
+
+ var definition;
+ try {
+ definition = JSON.parse(definitionText);
+ } catch (e) {
+ showFieldError('wf-create-error', 'Invalid JSON: ' + e.message);
+ return;
+ }
+ showFieldError('wf-create-error', '');
+
+ return window.Pulse.api.post('/api/workflows', {
+ name: name,
+ description: description,
+ definition: definition,
+ webhook_url: webhookUrl
+ }).then(function () {
+ window.lt.modal.close('wf-create-modal');
+ window.Pulse.toast.success('Workflow created');
+ return loadWorkflows();
+ }).catch(function (e) {
+ showFieldError('wf-create-error', (e && e.message) || 'Failed to create workflow');
+ });
+ }
+
+ /* ---------------------------------------------------------------
+ Edit modal (admin)
+ --------------------------------------------------------------- */
+ function openEditModal(workflowId) {
+ showFieldError('wf-edit-error', '');
+ return window.Pulse.api.get('/api/workflows/' + encodeURIComponent(workflowId)).then(function (wf) {
+ document.getElementById('wf-edit-id').value = wf.id;
+ document.getElementById('wf-edit-name').value = wf.name || '';
+ document.getElementById('wf-edit-description').value = wf.description || '';
+ document.getElementById('wf-edit-definition').value = JSON.stringify(parseDefinition(wf.definition), null, 2);
+ document.getElementById('wf-edit-webhook').value = wf.webhook_url || '';
+ window.lt.modal.open('wf-edit-modal');
+ }).catch(function (e) {
+ window.Pulse.toast.error((e && e.message) || 'Failed to load workflow');
+ });
+ }
+
+ function submitEditForm(formEl) {
+ var id = formEl.elements['id'].value;
+ var name = formEl.elements['name'].value.trim();
+ var description = formEl.elements['description'].value.trim();
+ var definitionText = formEl.elements['definition'].value;
+ var webhookUrl = formEl.elements['webhook_url'].value.trim() || null;
+
+ if (!name) {
+ showFieldError('wf-edit-error', 'Name is required.');
+ return;
+ }
+
+ var definition;
+ try {
+ definition = JSON.parse(definitionText);
+ } catch (e) {
+ showFieldError('wf-edit-error', 'Invalid JSON: ' + e.message);
+ return;
+ }
+ showFieldError('wf-edit-error', '');
+
+ return window.Pulse.api.put('/api/workflows/' + encodeURIComponent(id), {
+ name: name,
+ description: description,
+ definition: definition,
+ webhook_url: webhookUrl
+ }).then(function () {
+ window.lt.modal.close('wf-edit-modal');
+ window.Pulse.toast.success('Workflow saved');
+ return loadWorkflows();
+ }).catch(function (e) {
+ showFieldError('wf-edit-error', (e && e.message) || 'Failed to save workflow');
+ });
+ }
+
+ /* ---------------------------------------------------------------
+ Delete (admin)
+ --------------------------------------------------------------- */
+ function deleteWorkflow(workflowId, workflowName) {
+ return window.Pulse.confirm({
+ title: 'Delete Workflow',
+ message: 'Delete workflow "' + workflowName + '"? This cannot be undone.',
+ type: 'error',
+ confirmLabel: 'DELETE'
+ }).then(function (ok) {
+ if (!ok) return;
+ return window.Pulse.api.delete('/api/workflows/' + encodeURIComponent(workflowId)).then(function () {
+ window.Pulse.toast.success('Workflow deleted');
+ return loadWorkflows();
+ }).catch(function (e) {
+ window.Pulse.toast.error((e && e.message) || 'Failed to delete workflow');
+ });
+ });
+ }
+
+ /* ---------------------------------------------------------------
+ Run modal (execute): always shown, replaces the old confirm().
+ --------------------------------------------------------------- */
+ function paramFieldHtml(p) {
+ var esc = window.Pulse.esc;
+ var label = esc(p.label || p.name);
+ var required = p.required ? ' *' : '';
+ return (
+ '' +
+ '' +
+ '' +
+ '
'
+ );
+ }
+
+ function openRunModal(workflowId) {
+ var row = _rowsById[workflowId];
+ var def = _registry[workflowId] || {};
+ var paramDefs = def.params || [];
+
+ _pendingWorkflowId = workflowId;
+ document.getElementById('wf-run-workflow-id').value = workflowId;
+ document.getElementById('wf-run-name').textContent = row ? row.name : 'this workflow';
+ document.getElementById('wf-run-dryrun').checked = false;
+
+ var paramsEl = document.getElementById('wf-run-params');
+ paramsEl.innerHTML = paramDefs.map(paramFieldHtml).join('');
+
+ window.lt.modal.open('wf-run-modal');
+
+ var first = paramsEl.querySelector('input');
+ if (first) setTimeout(function () { first.focus(); }, 60);
+ }
+
+ function submitRunForm() {
+ if (!_pendingWorkflowId) return;
+ var def = _registry[_pendingWorkflowId] || {};
+ var paramDefs = def.params || [];
+ var params = {};
+
+ for (var i = 0; i < paramDefs.length; i++) {
+ var p = paramDefs[i];
+ var el = document.getElementById('wf-run-param-' + p.name);
+ var val = el ? el.value.trim() : '';
+ if (p.required && !val) {
+ el.classList.add('wf-input-invalid');
+ el.focus();
+ return;
+ }
+ el && el.classList.remove('wf-input-invalid');
+ if (val) params[p.name] = val;
+ }
+
+ var dryRun = document.getElementById('wf-run-dryrun').checked;
+ var workflowId = _pendingWorkflowId;
+
+ return window.Pulse.api.post('/api/executions', {
+ workflow_id: workflowId,
+ params: params,
+ dry_run: dryRun
+ }).then(function (result) {
+ window.lt.modal.close('wf-run-modal');
+ _pendingWorkflowId = null;
+ window.Pulse.toast.success(dryRun ? 'Dry run started' : 'Workflow started');
+ window.location.href = '/executions?open=' + encodeURIComponent(result.id);
+ }).catch(function (e) {
+ window.Pulse.toast.error((e && e.message) || 'Failed to start execution');
+ });
+ }
+
+ /* ---------------------------------------------------------------
+ Actions + page registration
+ --------------------------------------------------------------- */
+ window.Pulse.actions.registerAll({
+ 'wf:create-open': function () { openCreateModal(); },
+ 'wf:create-submit': function (el) { return submitCreateForm(el); },
+ 'wf:execute': function (el) { openRunModal(el.getAttribute('data-workflow-id')); },
+ 'wf:edit-open': function (el) { return openEditModal(el.getAttribute('data-workflow-id')); },
+ 'wf:edit-submit': function (el) { return submitEditForm(el); },
+ 'wf:delete': function (el) {
+ return deleteWorkflow(el.getAttribute('data-workflow-id'), el.getAttribute('data-workflow-name'));
+ },
+ 'wf:param-submit': function () { return submitRunForm(); }
+ });
+
+ // Enter in a param input submits the Run form (matches old behaviour).
+ document.addEventListener('keydown', function (ev) {
+ if (ev.key !== 'Enter') return;
+ var target = ev.target;
+ if (!target || target.tagName !== 'INPUT') return;
+ if (!target.closest || !target.closest('#wf-run-form')) return;
+ ev.preventDefault();
+ submitRunForm();
+ });
+
+ window.Pulse.registerPage({
+ name: 'workflows',
+ init: function () { return loadWorkflows(); },
+ refresh: function () { return loadWorkflows(); },
+ onEvent: function (type) {
+ if (type === 'workflow_created' || type === 'workflow_updated' || type === 'workflow_deleted') {
+ loadWorkflows();
+ return true;
+ }
+ return false;
+ }
+ });
+})();
diff --git a/views/pages/executions.ejs b/views/pages/executions.ejs
new file mode 100644
index 0000000..af0b0b2
--- /dev/null
+++ b/views/pages/executions.ejs
@@ -0,0 +1,118 @@
+<%#
+ Executions page (WP-E).
+
+ Owns DOM id prefix `ex-` and action namespace `ex:*`.
+ All behaviour lives in /assets/pages/executions.js (auto-loaded by the layout);
+ styling additions in /assets/pages/executions.css (auto-linked).
+
+ The page-header is written out inline rather than through the shared partial
+ because the Clear Completed button is admin-only and the partial takes its
+ actions as a pre-rendered string.
+%>
+
+
+
+
+
+
+
+
+
+
+
+
+
▤
+
+
Compare mode
+
Select 2–5 executions to compare their outputs. Click a row to toggle selection.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/views/pages/quick.ejs b/views/pages/quick.ejs
new file mode 100644
index 0000000..956d28e
--- /dev/null
+++ b/views/pages/quick.ejs
@@ -0,0 +1,89 @@
+<%#
+ Quick Command page (WP-F). Ad-hoc command execution against one or more
+ workers. All data loaded client-side by /assets/pages/quick.js.
+%>
+<%- include('../partials/page-header', {
+ title: 'Quick Command',
+ subtitle: 'Execute a command on selected workers instantly',
+ actions: '' +
+ ''
+}) %>
+
+
+
+
+
+
+
+
diff --git a/views/pages/scheduler.ejs b/views/pages/scheduler.ejs
new file mode 100644
index 0000000..294d376
--- /dev/null
+++ b/views/pages/scheduler.ejs
@@ -0,0 +1,62 @@
+<%#
+ Scheduler page (WP-F). List of scheduled commands + admin create/toggle/delete.
+ All data loaded client-side by /assets/pages/scheduler.js.
+%>
+<%- include('../partials/page-header', {
+ title: 'Scheduler',
+ subtitle: 'Automate command execution with flexible scheduling',
+ actions: '' +
+ (pageConfig && pageConfig.isAdmin ? '' : '')
+}) %>
+
+
+
+
+
+
+
+
+
+
diff --git a/views/pages/workflows.ejs b/views/pages/workflows.ejs
index 0c593dd..331a0e4 100644
--- a/views/pages/workflows.ejs
+++ b/views/pages/workflows.ejs
@@ -43,7 +43,7 @@
- Steps run in order against the selected targets.
+ Steps run in order against the selected targets.
diff --git a/views/partials/page-header.ejs b/views/partials/page-header.ejs
index 746de26..be33471 100644
--- a/views/partials/page-header.ejs
+++ b/views/partials/page-header.ejs
@@ -1,8 +1,7 @@
<%#
Page header partial.
Locals: title (string), subtitle (optional string), actions (optional raw HTML).
- Usage from a page view:
- <%- include('../partials/page-header', { title: 'Workers', subtitle: '', actions: '' }) %>
+ Usage from a page view: include('../partials/page-header', { title, subtitle, actions })
%>