From 68d0a906a6c96bf4e45a3af5ddc68b15db77b21f Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 21:57:33 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20TDS=20migration=20stage=202=20?= =?UTF-8?q?=E2=80=94=20six=20page=20views=20and=20page=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard, Workers, Workflows, Executions, Quick Command, Scheduler pages on the shared layout, all behaviour via Pulse delegated actions (no inline handlers) - Executions: server-driven Manual/Automated views, compare mode, detail modal with all log types, ?open= deep link, cross-page re-run via sessionStorage - Workflows: run modal (dry-run available for every workflow), create modal pre-filled with the example definition - Fix EJS comment in page-header partial that broke rendering Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp --- public/assets/pages/dashboard.css | 36 ++ public/assets/pages/executions.css | 257 +++++++++ public/assets/pages/executions.js | 854 +++++++++++++++++++++++++++++ public/assets/pages/quick.css | 85 +++ public/assets/pages/quick.js | 365 ++++++++++++ public/assets/pages/scheduler.css | 10 + public/assets/pages/scheduler.js | 286 ++++++++++ public/assets/pages/workers.css | 32 ++ public/assets/pages/workers.js | 257 +++++++++ public/assets/pages/workflows.css | 37 ++ public/assets/pages/workflows.js | 355 ++++++++++++ views/pages/executions.ejs | 118 ++++ views/pages/quick.ejs | 89 +++ views/pages/scheduler.ejs | 62 +++ views/pages/workflows.ejs | 2 +- views/partials/page-header.ejs | 3 +- 16 files changed, 2845 insertions(+), 3 deletions(-) create mode 100644 public/assets/pages/dashboard.css create mode 100644 public/assets/pages/executions.css create mode 100644 public/assets/pages/executions.js create mode 100644 public/assets/pages/quick.css create mode 100644 public/assets/pages/quick.js create mode 100644 public/assets/pages/scheduler.css create mode 100644 public/assets/pages/scheduler.js create mode 100644 public/assets/pages/workers.css create mode 100644 public/assets/pages/workers.js create mode 100644 public/assets/pages/workflows.css create mode 100644 public/assets/pages/workflows.js create mode 100644 views/pages/executions.ejs create mode 100644 views/pages/quick.ejs create mode 100644 views/pages/scheduler.ejs 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 + ? '' + : ''; + + 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; + }, + }); +})(); 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 = '
No workers available
'; + 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 = '
No command history yet
'; + 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
' + + '
Execution ID: ' + esc(executionId) + ' — ' + + 'view in Executions' + + '
' + + '
' + + '
'; + } + + 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 + '
' + + '
' + + '
' + + '' + + '' + + '' + rows + '' + + '
WorkerResult
'; + } + + 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 = + '' + + '' + + '' + _schedules.map(rowHtml).join('') + '' + + '
NameCommandScheduleWorkersLast runNext runStatusActions
'; + } + + function renderError() { + const wrap = document.getElementById('sc-wrap'); + if (!wrap) return; + wrap.innerHTML = + '
' + + '' + + '
Failed to load schedules
' + + '
'; + } + + 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 = '
No workers available
'; + 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 ( + '
' + + '' + + '' + esc(worker.name) + '' + + '' + esc(String(worker.status || '').toUpperCase()) + '' + + '
' + + '
' + + '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 = '
Failed to load workers
'; + } + + /* ------------------------------------------------------------------- + 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 = + '
' + + '' + + '' + + '' + rows + '' + + '
NameDescriptionCreatedActions
' + + '
'; + } + + function renderLoadError(message) { + var el = document.getElementById('wf-list'); + if (!el) return; + el.innerHTML = + ''; + } + + 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. +%> +
+
+

Executions

+

Workflow and quick-command run history

+
+
+
+ + + + <% if (user && user.isAdmin) { %> + + <% } %> +
+
+
+ +
+ + + +
+ + +
+ + + +
+
+ + + +
+
+ +
+
+ +
+
+
+
Loading…
+
+
+
+
+
Loading…
+
+
+
+
+ + + + + + 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: '' + + '' +}) %> + +
+
+
+ +
+ + +
+
+ +
+ + +
+ + + +
+ + +
Ctrl+Enter to execute
+
+ + +
+ +
+
+ + + + + + 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 ? '' : '') +}) %> + +
+
+
+
Loading…
+
+
+
+ + + 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 }) %>