feat(ui): TDS migration stage 2 — six page views and page modules
- Dashboard, Workers, Workflows, Executions, Quick Command, Scheduler pages on the shared layout, all behaviour via Pulse delegated actions (no inline handlers) - Executions: server-driven Manual/Automated views, compare mode, detail modal with all log types, ?open= deep link, cross-page re-run via sessionStorage - Workflows: run modal (dry-run available for every workflow), create modal pre-filled with the example definition - Fix EJS comment in page-header partial that broke rendering Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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); }
|
||||
@@ -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
|
||||
? '<td data-label="Select"><span class="ex-check' + (selected ? ' is-on' : '') +
|
||||
'" aria-hidden="true">' + (selected ? '✓' : '') + '</span></td>'
|
||||
: '';
|
||||
|
||||
const completed = e.completed_at
|
||||
? esc(Pulse.fmt.dateTime(e.completed_at))
|
||||
: (running
|
||||
? '<span class="ex-elapsed" data-ex-elapsed="' + esc(e.started_at) + '">' +
|
||||
esc(Pulse.fmt.elapsed(e.started_at)) + '</span>'
|
||||
: '—');
|
||||
|
||||
return '<tr class="' + cls.join(' ') + '" data-action="' + action + '"' +
|
||||
' data-execution-id="' + esc(e.id) + '" tabindex="0">' +
|
||||
check +
|
||||
'<td data-label="Status"><span class="' + Pulse.fmt.status(e.status) + '">' + esc(e.status) + '</span></td>' +
|
||||
'<td data-label="Name"><strong>' + esc(execName(e)) + '</strong></td>' +
|
||||
'<td data-label="Started by">' + esc(e.started_by || '') + '</td>' +
|
||||
'<td data-label="Started">' + esc(Pulse.fmt.dateTime(e.started_at)) + '</td>' +
|
||||
'<td data-label="Completed">' + completed + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function listHtml(view) {
|
||||
const st = state[view];
|
||||
if (st.error) {
|
||||
return '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">⚠</span>' +
|
||||
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load executions</div>' +
|
||||
'<div class="lt-alert-msg">' + esc(st.error) + '</div></div></div>';
|
||||
}
|
||||
if (!st.loaded) {
|
||||
return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading…</div></div>';
|
||||
}
|
||||
|
||||
const rows = filteredRows(view);
|
||||
if (!rows.length) {
|
||||
const filtering = !!(searchTerm() || statusTerm());
|
||||
return '<div class="lt-empty-state lt-empty-state--sm">' +
|
||||
'<div class="lt-empty-state-icon">∅</div>' +
|
||||
'<div class="lt-empty-state-title">' +
|
||||
(filtering ? 'No executions match your filters' : 'No executions yet') +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
const head = '<tr>' +
|
||||
(state.compareMode ? '<th scope="col" class="ex-col-check">✓</th>' : '') +
|
||||
'<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Started by</th>' +
|
||||
'<th scope="col">Started</th><th scope="col">Completed / Elapsed</th></tr>';
|
||||
|
||||
let html = '<div class="lt-table-wrap"><table class="lt-table lt-table-responsive">' +
|
||||
'<thead>' + head + '</thead><tbody>' + rows.map(rowHtml).join('') + '</tbody></table></div>';
|
||||
|
||||
if (st.hasMore) {
|
||||
html += '<button type="button" class="lt-btn lt-btn-secondary lt-w-full lt-mt-md" ' +
|
||||
'data-action="ex:load-more">Load More Executions</button>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const el = $('ex-filter-stats');
|
||||
if (!el) return;
|
||||
const st = state[state.view];
|
||||
if (!st.loaded) { el.textContent = ''; return; }
|
||||
const shown = filteredRows(state.view).length;
|
||||
el.textContent = 'Showing ' + shown + ' of ' + st.rows.length +
|
||||
(st.hasMore ? '+' : '') + ' loaded execution' + (st.rows.length === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
function renderCompareChrome() {
|
||||
const toggle = $('ex-compare-toggle');
|
||||
const run = $('ex-compare-run');
|
||||
const hint = $('ex-compare-hint');
|
||||
if (toggle) {
|
||||
toggle.textContent = state.compareMode ? '✗ Exit Compare Mode' : '▤ Compare Mode';
|
||||
toggle.setAttribute('aria-pressed', state.compareMode ? 'true' : 'false');
|
||||
toggle.classList.toggle('is-active', state.compareMode);
|
||||
}
|
||||
if (hint) hint.classList.toggle('is-hidden', !state.compareMode);
|
||||
if (run) {
|
||||
run.classList.toggle('is-hidden', !state.compareMode);
|
||||
run.textContent = state.selected.size >= 2
|
||||
? '⚖ Compare Selected (' + state.selected.size + ')'
|
||||
: '⚖ Compare Selected';
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
['manual', 'automated'].forEach((view) => {
|
||||
const host = $('ex-list-' + view);
|
||||
if (host) host.innerHTML = listHtml(view);
|
||||
const badge = $('ex-count-' + view);
|
||||
if (badge) badge.textContent = countLabel(view);
|
||||
});
|
||||
renderStats();
|
||||
renderCompareChrome();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Live elapsed (driven by the shared 1 s tick — never our own timer)
|
||||
------------------------------------------------------------------ */
|
||||
function tickElapsed() {
|
||||
const nodes = document.querySelectorAll('[data-ex-elapsed]');
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
nodes[i].textContent = Pulse.fmt.elapsed(nodes[i].getAttribute('data-ex-elapsed'));
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Log entry formatting — all 21 known actions plus a fallback.
|
||||
Colour semantics map onto base.css: .success (green), .warning (amber),
|
||||
.error (red), plus the page-local .ex-log-dim (grey) and .ex-log-info
|
||||
(cyan) modifiers defined in executions.css.
|
||||
------------------------------------------------------------------ */
|
||||
function entry(kind, ts, title, details) {
|
||||
const cls = kind ? 'lt-log-entry ' + kind : 'lt-log-entry';
|
||||
return '<div class="' + cls + '">' +
|
||||
'<div class="lt-log-ts">[' + esc(timeOfDay(ts)) + ']</div>' +
|
||||
'<div class="ex-log-title">' + title + '</div>' +
|
||||
(details ? '<div class="ex-log-details">' + details + '</div>' : '') +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function field(label, value) {
|
||||
return '<div class="ex-log-field"><span class="ex-log-label">' + esc(label) + ':</span> ' + value + '</div>';
|
||||
}
|
||||
|
||||
function out(text, isErr) {
|
||||
return '<pre class="lt-log-output' + (isErr ? ' ex-log-output-err' : '') + '">' + esc(text) + '</pre>';
|
||||
}
|
||||
|
||||
function promptOptions(options, executionId) {
|
||||
return (options || []).map((opt) => {
|
||||
if (executionId) {
|
||||
return '<button type="button" class="lt-btn lt-btn-primary lt-btn-sm ex-opt" ' +
|
||||
'data-action="ex:respond" data-response="' + esc(opt) + '">' + esc(opt) + '</button>';
|
||||
}
|
||||
return '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm ex-opt is-answered" disabled>' +
|
||||
esc(opt) + '</button>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/* eslint-disable complexity */
|
||||
function formatLogEntry(log, executionId) {
|
||||
const ts = log.timestamp;
|
||||
const a = log.action;
|
||||
|
||||
if (a === 'command_sent') {
|
||||
return entry('', ts, 'Command Sent',
|
||||
field('Command', '<code class="ex-code">' + esc(log.command) + '</code>') +
|
||||
(log.worker_id ? field('Worker', esc(log.worker_id)) : ''));
|
||||
}
|
||||
|
||||
if (a === 'command_result') {
|
||||
const ok = !!log.success;
|
||||
return entry(ok ? 'success' : 'error', ts,
|
||||
(ok ? '✓' : '✗') + ' 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) =>
|
||||
'<div class="ex-parse-key">' + esc(k) + '</div><div class="ex-parse-val">' + esc(pairs[k]) + '</div>'
|
||||
).join('');
|
||||
return entry('ex-log-dim', ts,
|
||||
'⚙ Parsed ' + keys.length + ' variable' + (keys.length !== 1 ? 's' : ''),
|
||||
keys.length ? '<div class="ex-parse-table">' + rows + '</div>' : '');
|
||||
}
|
||||
|
||||
if (a === 'route_taken') {
|
||||
return entry('ex-log-info', ts, '⇒ Auto-route: Step ' + esc(log.step),
|
||||
log.label
|
||||
? '<div class="ex-route-label">' + esc(log.label) + '</div>' +
|
||||
(log.goto ? '<div class="ex-route-goto">→ ' + esc(log.goto) + '</div>' : '')
|
||||
: '');
|
||||
}
|
||||
|
||||
if (a === 'no_workers') {
|
||||
return entry('error', ts, '✗ Step ' + esc(log.step) + ': No Workers Available',
|
||||
'<div class="ex-log-field">' + esc(log.message) + '</div>');
|
||||
}
|
||||
|
||||
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) : '') +
|
||||
'<div class="ex-prompt-msg">' + esc(log.message || '') + '</div>' +
|
||||
'<div class="ex-opt-row">' + promptOptions(log.options, executionId) + '</div>');
|
||||
}
|
||||
|
||||
if (a === 'prompt_response') {
|
||||
return entry('success', ts,
|
||||
'↪ Response: <strong class="ex-log-answer">' + esc(log.response || '') + '</strong>' +
|
||||
(log.responded_by ? '<span class="ex-log-by">by ' + esc(log.responded_by) + '</span>' : ''), '');
|
||||
}
|
||||
|
||||
if (a === 'step_skipped') {
|
||||
return entry('ex-log-dim', ts,
|
||||
'⊘ 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',
|
||||
'<div class="ex-log-field">' + esc(log.message || 'Execution exceeded maximum allowed time') + '</div>');
|
||||
}
|
||||
|
||||
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 ? '<div class="ex-log-field">' + esc(log.message) + '</div>' : '');
|
||||
}
|
||||
|
||||
if (a === 'params') {
|
||||
const p = log.params || {};
|
||||
const str = Object.keys(p).map((k) => esc(k) + '=' + esc(String(p[k]))).join(', ');
|
||||
return entry('ex-log-dim', ts, '⚙ Parameters: ' + (str || '(none)'), '');
|
||||
}
|
||||
|
||||
if (a === 'server_restart_recovery') {
|
||||
return entry('error', ts, '⚠ Server Restart Recovery',
|
||||
'<div class="ex-log-field">' + esc(log.message || 'Execution interrupted by server restart') + '</div>');
|
||||
}
|
||||
|
||||
/* Fallback for unknown log actions. */
|
||||
return entry('ex-log-dim', ts, esc(log.action || 'unknown'), '');
|
||||
}
|
||||
/* eslint-enable complexity */
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Detail modal
|
||||
------------------------------------------------------------------ */
|
||||
function detailSummary(id, ex) {
|
||||
const row = state.rowIndex.get(id);
|
||||
const name = ex.workflow_name || (row && row.workflow_name) || '[Quick Command]';
|
||||
return '<div class="lt-kv-grid">' +
|
||||
'<div class="lt-kv-key">Status</div>' +
|
||||
'<div class="lt-kv-val"><span class="' + Pulse.fmt.status(ex.status) + '">' + esc(ex.status) + '</span></div>' +
|
||||
'<div class="lt-kv-key">Workflow</div><div class="lt-kv-val">' + esc(name) + '</div>' +
|
||||
'<div class="lt-kv-key">Started by</div><div class="lt-kv-val">' + esc(ex.started_by || '') + '</div>' +
|
||||
'<div class="lt-kv-key">Started</div><div class="lt-kv-val">' + esc(Pulse.fmt.dateTime(ex.started_at)) + '</div>' +
|
||||
'<div class="lt-kv-key">Completed</div><div class="lt-kv-val">' +
|
||||
(ex.completed_at ? esc(Pulse.fmt.dateTime(ex.completed_at))
|
||||
: (ex.status === 'running' ? esc(Pulse.fmt.elapsed(ex.started_at)) + ' elapsed' : '—')) +
|
||||
'</div>' +
|
||||
'<div class="lt-kv-key">Execution ID</div><div class="lt-kv-val ex-id-cell">' +
|
||||
'<code class="ex-code">' + esc(id) + '</code>' +
|
||||
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-copy="' + esc(id) + '" data-copy-toast>COPY</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function detailPrompt(ex) {
|
||||
if (!ex.waiting_for_input || !ex.prompt) return '';
|
||||
const p = ex.prompt;
|
||||
return '<div class="lt-alert lt-alert--warning ex-prompt-box">' +
|
||||
'<span class="lt-alert-icon">❓</span>' +
|
||||
'<div class="lt-alert-body">' +
|
||||
'<div class="lt-alert-title">Waiting for Input</div>' +
|
||||
(p.output ? out(p.output, false) : '') +
|
||||
'<div class="lt-alert-msg">' + esc(p.message || '') + '</div>' +
|
||||
'<div class="ex-opt-row">' + promptOptions(p.options, true) + '</div>' +
|
||||
'</div></div>';
|
||||
}
|
||||
|
||||
function detailLogs(id, ex) {
|
||||
const logs = Array.isArray(ex.logs) ? ex.logs : [];
|
||||
if (!logs.length) {
|
||||
return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No logs recorded</div></div>';
|
||||
}
|
||||
const body = logs.map((log, idx) => {
|
||||
/* Only the last unanswered prompt stays interactive. */
|
||||
let promptExecId = null;
|
||||
if (log.action === 'prompt' && ex.waiting_for_input) {
|
||||
const answered = logs.slice(idx + 1).some((l) => l.action === 'prompt_response');
|
||||
if (!answered) promptExecId = id;
|
||||
}
|
||||
return formatLogEntry(log, promptExecId);
|
||||
}).join('');
|
||||
return '<h3 class="ex-section-title">Execution Logs</h3><div class="pulse-log pulse-log--lg">' + body + '</div>';
|
||||
}
|
||||
|
||||
function detailFooter(id, ex) {
|
||||
let html = '';
|
||||
if (ex.status === 'running') {
|
||||
html += '<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="ex:abort" ' +
|
||||
'data-execution-id="' + esc(id) + '">⛔ Abort Execution</button>';
|
||||
}
|
||||
const cmdLog = (Array.isArray(ex.logs) ? ex.logs : []).find((l) => l.action === 'command_sent' && l.command);
|
||||
if (cmdLog) {
|
||||
html += '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:rerun" ' +
|
||||
'data-command="' + esc(cmdLog.command) + '" data-worker-id="' + esc(cmdLog.worker_id || '') + '">' +
|
||||
'↻ Re-run Command</button>';
|
||||
}
|
||||
html += '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:download" ' +
|
||||
'data-execution-id="' + esc(id) + '">💾 Download Logs</button>';
|
||||
html += '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-close>Close</button>';
|
||||
return html;
|
||||
}
|
||||
|
||||
async function openDetail(id, reopen) {
|
||||
const modal = $('ex-detail-modal');
|
||||
const body = $('ex-detail-body');
|
||||
const footer = $('ex-detail-footer');
|
||||
if (!modal || !body) return;
|
||||
|
||||
if (!reopen) {
|
||||
body.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading…</div></div>';
|
||||
if (footer) footer.innerHTML = '';
|
||||
modal.dataset.executionId = id;
|
||||
if (lt.modal) lt.modal.open(modal);
|
||||
}
|
||||
|
||||
let ex;
|
||||
try {
|
||||
ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
|
||||
} catch (e) {
|
||||
body.innerHTML = '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">⚠</span>' +
|
||||
'<div class="lt-alert-body"><div class="lt-alert-title">Error loading execution details</div>' +
|
||||
'<div class="lt-alert-msg">' + esc(e && e.message ? e.message : String(e)) + '</div></div></div>';
|
||||
return;
|
||||
}
|
||||
|
||||
modal.dataset.executionId = id;
|
||||
body.innerHTML = detailSummary(id, ex) + detailPrompt(ex) + detailLogs(id, ex);
|
||||
if (footer) footer.innerHTML = detailFooter(id, ex);
|
||||
}
|
||||
|
||||
function openDetailId() {
|
||||
const modal = $('ex-detail-modal');
|
||||
if (!modal || !modal.classList.contains('is-open')) return null;
|
||||
return modal.dataset.executionId || null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Compare modal
|
||||
------------------------------------------------------------------ */
|
||||
function resultLog(ex) {
|
||||
const logs = Array.isArray(ex.logs) ? ex.logs : [];
|
||||
return logs.find((l) => l.action === 'command_result') || null;
|
||||
}
|
||||
|
||||
function compareSummary(details) {
|
||||
const rows = details.map((ex, idx) => {
|
||||
const start = Pulse.fmt.safeDate(ex.started_at);
|
||||
const end = Pulse.fmt.safeDate(ex.completed_at);
|
||||
const duration = (start && end) ? Math.round((end.getTime() - start.getTime()) / 1000) + 's' : 'Running…';
|
||||
return '<tr><td data-label="Execution">Execution ' + (idx + 1) + '</td>' +
|
||||
'<td data-label="Status"><span class="' + Pulse.fmt.status(ex.status) + '">' + esc(ex.status) + '</span></td>' +
|
||||
'<td data-label="Started">' + esc(Pulse.fmt.dateTime(ex.started_at)) + '</td>' +
|
||||
'<td data-label="Duration">' + esc(duration) + '</td></tr>';
|
||||
}).join('');
|
||||
return '<h3 class="ex-section-title">Comparison Summary</h3>' +
|
||||
'<div class="lt-table-wrap"><table class="lt-table lt-table-sm lt-table-responsive"><thead><tr>' +
|
||||
'<th scope="col">Execution</th><th scope="col">Status</th><th scope="col">Started</th><th scope="col">Duration</th>' +
|
||||
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
function compareOutputs(details) {
|
||||
const cols = details.map((ex, idx) => {
|
||||
const r = resultLog(ex) || {};
|
||||
const stdout = r.stdout || '';
|
||||
const stderr = r.stderr || '';
|
||||
const name = ex.workflow_name || (state.rowIndex.get(ex.id) || {}).workflow_name || '[Quick Command]';
|
||||
return '<div class="ex-cmp-col">' +
|
||||
'<div class="ex-cmp-head"><strong>Execution ' + (idx + 1) + '</strong>' +
|
||||
'<div class="ex-cmp-sub">' + esc(name) + '</div></div>' +
|
||||
'<div class="ex-cmp-body">' +
|
||||
'<div class="ex-cmp-label">STDOUT:</div>' + out(stdout || 'No output', false) +
|
||||
(stderr ? '<div class="ex-cmp-label ex-cmp-label--err">STDERR:</div>' + out(stderr, true) : '') +
|
||||
'</div></div>';
|
||||
}).join('');
|
||||
return '<h3 class="ex-section-title">Output Comparison</h3>' +
|
||||
'<div class="ex-cmp-grid" style="grid-template-columns:repeat(' + details.length + ',1fr);">' + cols + '</div>';
|
||||
}
|
||||
|
||||
function compareDiff(details) {
|
||||
if (details.length !== 2) return '';
|
||||
const a = (resultLog(details[0]) || {}).stdout || '';
|
||||
const b = (resultLog(details[1]) || {}).stdout || '';
|
||||
const la = a.split('\n');
|
||||
const lb = b.split('\n');
|
||||
const max = Math.max(la.length, lb.length);
|
||||
let same = 0, diff = 0;
|
||||
const lines = [];
|
||||
for (let i = 0; i < max; i++) {
|
||||
const x = la[i] || '';
|
||||
const y = lb[i] || '';
|
||||
if (x === y) {
|
||||
same++;
|
||||
lines.push('<div class="ex-diff-same">' + (i + 1) + ': ' + (esc(x) || '(empty)') + '</div>');
|
||||
} else {
|
||||
diff++;
|
||||
lines.push('<div class="ex-diff-line">' +
|
||||
'<div class="ex-diff-a">' + (i + 1) + ' [Exec 1]: ' + (esc(x) || '(empty)') + '</div>' +
|
||||
'<div class="ex-diff-b">' + (i + 1) + ' [Exec 2]: ' + (esc(y) || '(empty)') + '</div>' +
|
||||
'</div>');
|
||||
}
|
||||
}
|
||||
return '<h3 class="ex-section-title">Diff Analysis</h3>' +
|
||||
'<div class="ex-diff-stats"><span class="lt-text-green">✓ Identical lines: ' + same + '</span>' +
|
||||
' <span class="pulse-dim">|</span> ' +
|
||||
'<span class="lt-text-orange">≠ Different lines: ' + diff + '</span></div>' +
|
||||
'<div class="pulse-log pulse-log--lg ex-diff">' + lines.join('') + '</div>';
|
||||
}
|
||||
|
||||
async function runCompare() {
|
||||
if (state.selected.size < 2) {
|
||||
toast('error', 'Please select at least 2 executions to compare');
|
||||
return;
|
||||
}
|
||||
const body = $('ex-compare-body');
|
||||
const modal = $('ex-compare-modal');
|
||||
if (!body || !modal) return;
|
||||
|
||||
body.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading…</div></div>';
|
||||
if (lt.modal) lt.modal.open(modal);
|
||||
|
||||
const ids = Array.from(state.selected);
|
||||
const settled = await Promise.all(ids.map(async (id) => {
|
||||
try {
|
||||
const ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
|
||||
ex.id = ex.id || id;
|
||||
return ex;
|
||||
} catch (e) { return null; }
|
||||
}));
|
||||
const details = settled.filter(Boolean);
|
||||
|
||||
if (details.length < 2) {
|
||||
body.innerHTML = '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">⚠</span>' +
|
||||
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load execution details</div></div></div>';
|
||||
toast('error', 'Failed to load execution details');
|
||||
return;
|
||||
}
|
||||
body.innerHTML = compareSummary(details) + compareOutputs(details) + compareDiff(details);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Actions
|
||||
------------------------------------------------------------------ */
|
||||
const actions = {
|
||||
'ex:refresh': () => Pulse.refreshNow(),
|
||||
|
||||
'ex:view-tab': (el) => {
|
||||
const view = el.getAttribute('data-view');
|
||||
if (!view || !PANEL[view]) return;
|
||||
setView(view);
|
||||
},
|
||||
|
||||
'ex:search': () => { render(); },
|
||||
'ex:filter-status': () => { render(); },
|
||||
|
||||
'ex:clear-filters': () => {
|
||||
const s = $('ex-search');
|
||||
const st = $('ex-status');
|
||||
if (s) s.value = '';
|
||||
if (st) st.value = '';
|
||||
render();
|
||||
},
|
||||
|
||||
'ex:load-more': async (el) => {
|
||||
el.disabled = true;
|
||||
el.textContent = 'Loading…';
|
||||
await loadView(state.view, true);
|
||||
render();
|
||||
},
|
||||
|
||||
'ex:view': (el) => {
|
||||
const id = el.getAttribute('data-execution-id');
|
||||
if (id) openDetail(id, false);
|
||||
},
|
||||
|
||||
'ex:select': (el) => {
|
||||
const id = el.getAttribute('data-execution-id');
|
||||
if (!id) return;
|
||||
if (state.selected.has(id)) {
|
||||
state.selected.delete(id);
|
||||
} else {
|
||||
if (state.selected.size >= 5) {
|
||||
toast('error', 'Maximum 5 executions can be compared');
|
||||
return;
|
||||
}
|
||||
state.selected.add(id);
|
||||
}
|
||||
render();
|
||||
},
|
||||
|
||||
'ex:compare-toggle': () => {
|
||||
state.compareMode = !state.compareMode;
|
||||
state.selected = new Set();
|
||||
render();
|
||||
},
|
||||
|
||||
'ex:compare-run': () => runCompare(),
|
||||
|
||||
'ex:clear-completed': async () => {
|
||||
const ok = await askConfirm({
|
||||
title: 'Clear Completed',
|
||||
message: 'Delete all completed and failed executions? This cannot be undone.',
|
||||
type: 'error',
|
||||
confirmLabel: 'DELETE',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const data = await Pulse.api.delete('/api/executions/completed');
|
||||
toast('success', 'Deleted ' + (data && data.deleted !== undefined ? data.deleted : 0) + ' execution(s)');
|
||||
} catch (e) {
|
||||
toast('error', (e && e.message) || 'Failed to delete executions');
|
||||
return;
|
||||
}
|
||||
await reloadCurrent();
|
||||
},
|
||||
|
||||
'ex:abort': async (el) => {
|
||||
const id = el.getAttribute('data-execution-id') || openDetailId();
|
||||
if (!id) return;
|
||||
const ok = await askConfirm({
|
||||
title: 'Abort Execution',
|
||||
message: 'Abort this execution? It will be marked as failed.',
|
||||
type: 'error',
|
||||
confirmLabel: 'ABORT',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await Pulse.api.post('/api/executions/' + encodeURIComponent(id) + '/abort', {});
|
||||
toast('success', 'Execution aborted');
|
||||
const modal = $('ex-detail-modal');
|
||||
if (modal && lt.modal) lt.modal.close(modal);
|
||||
} catch (e) {
|
||||
toast('error', (e && e.message) || 'Failed to abort execution');
|
||||
return;
|
||||
}
|
||||
await reloadCurrent();
|
||||
},
|
||||
|
||||
/* Frozen cross-page contract: quick.js consumes and clears pulse_rerun. */
|
||||
'ex:rerun': async (el) => {
|
||||
const command = el.getAttribute('data-command') || '';
|
||||
const workerId = el.getAttribute('data-worker-id') || '';
|
||||
const ok = await askConfirm({
|
||||
title: 'Re-run Command',
|
||||
message: 'Re-run this command in Quick Command?\n\n' + command,
|
||||
type: 'warning',
|
||||
confirmLabel: 'RE-RUN',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
sessionStorage.setItem('pulse_rerun', JSON.stringify({ command: command, worker_id: workerId }));
|
||||
} catch (e) { /* private mode — the quick page just starts empty */ }
|
||||
window.location.href = '/quick';
|
||||
},
|
||||
|
||||
'ex:download': async (el) => {
|
||||
const id = el.getAttribute('data-execution-id') || openDetailId();
|
||||
if (!id) return;
|
||||
try {
|
||||
const ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
|
||||
const row = state.rowIndex.get(id) || {};
|
||||
const payload = {
|
||||
execution_id: id,
|
||||
workflow_name: ex.workflow_name || row.workflow_name || '[Quick Command]',
|
||||
status: ex.status,
|
||||
started_by: ex.started_by,
|
||||
started_at: ex.started_at,
|
||||
completed_at: ex.completed_at,
|
||||
logs: ex.logs,
|
||||
};
|
||||
const stamp = new Date().toISOString().split('T')[0];
|
||||
Pulse.util.download('execution-' + id + '-' + stamp + '.json',
|
||||
JSON.stringify(payload, null, 2), 'application/json');
|
||||
} catch (e) {
|
||||
toast('error', (e && e.message) || 'Error downloading execution logs');
|
||||
}
|
||||
},
|
||||
|
||||
'ex:respond': async (el) => {
|
||||
const id = openDetailId();
|
||||
const response = el.getAttribute('data-response');
|
||||
if (!id || response === null) return;
|
||||
try {
|
||||
await Pulse.api.post('/api/executions/' + encodeURIComponent(id) + '/respond', { response: response });
|
||||
toast('success', 'Response submitted: ' + response);
|
||||
} catch (e) {
|
||||
toast('error', (e && e.message) || 'Failed to submit response');
|
||||
return;
|
||||
}
|
||||
await openDetail(id, true);
|
||||
await reloadCurrent();
|
||||
},
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
View switching
|
||||
------------------------------------------------------------------ */
|
||||
function setView(view) {
|
||||
state.view = view;
|
||||
Pulse.util.storage.set(VIEW_KEY, view);
|
||||
if (lt.tabs) lt.tabs.switch(PANEL[view]);
|
||||
if (!state[view].loaded) {
|
||||
loadView(view, false).then(render);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Deep link: /executions?open=<id>
|
||||
------------------------------------------------------------------ */
|
||||
function consumeDeepLink() {
|
||||
let id = null;
|
||||
try {
|
||||
id = new URLSearchParams(window.location.search).get('open');
|
||||
} catch (e) { return; }
|
||||
if (!id) return;
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('open');
|
||||
window.history.replaceState({}, '', url.pathname + (url.search || '') + url.hash);
|
||||
} catch (e) { /* non-fatal */ }
|
||||
openDetail(id, false);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
Page registration
|
||||
------------------------------------------------------------------ */
|
||||
Pulse.actions.registerAll(actions);
|
||||
|
||||
Pulse.registerPage({
|
||||
name: 'executions',
|
||||
|
||||
async init() {
|
||||
const stored = Pulse.util.storage.get(VIEW_KEY, 'manual');
|
||||
state.view = (stored === 'automated') ? 'automated' : 'manual';
|
||||
if (lt.tabs) lt.tabs.switch(PANEL[state.view]);
|
||||
|
||||
Pulse.events.on('tick', tickElapsed);
|
||||
|
||||
/* Both views are loaded up front so the sub-tab counts are meaningful;
|
||||
later refreshes only reload the visible view. */
|
||||
await Promise.all([loadView('manual', false), loadView('automated', false)]);
|
||||
render();
|
||||
consumeDeepLink();
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
await loadView(state.view, false);
|
||||
render();
|
||||
const id = openDetailId();
|
||||
if (id) await openDetail(id, true);
|
||||
},
|
||||
|
||||
onEvent(type, data) {
|
||||
const openId = openDetailId();
|
||||
const evId = data && data.execution_id;
|
||||
|
||||
if (type === 'command_result' || type === 'workflow_result' || type === 'execution_prompt') {
|
||||
if (openId && evId && openId === evId) openDetail(openId, true);
|
||||
reloadCurrent();
|
||||
return true;
|
||||
}
|
||||
if (type === 'execution_started' || type === 'execution_status' || type === 'executions_bulk_deleted') {
|
||||
reloadCurrent();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = '<option value="">No workers available</option>';
|
||||
return;
|
||||
}
|
||||
select.innerHTML = workers.map(w =>
|
||||
'<option value="' + esc(w.id) + '">' + esc(w.name) + ' (' + esc(w.status) + ')</option>'
|
||||
).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 = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No workers available</div></div>';
|
||||
return;
|
||||
}
|
||||
const keep = preserveIds || new Set();
|
||||
wrap.innerHTML = workers.map(w => {
|
||||
const checked = keep.has(w.id) ? ' checked' : '';
|
||||
return (
|
||||
'<label class="qc-worker-row' + (w.status === 'online' ? ' qc-worker-row--online' : '') + '">' +
|
||||
'<input type="checkbox" class="lt-checkbox" name="qc-worker-cb" value="' + esc(w.id) + '" data-status="' + esc(w.status) + '"' + checked + '>' +
|
||||
'<span class="' + Pulse.fmt.status(w.status) + '">' + (w.status === 'online' ? '●' : '○') + '</span>' +
|
||||
'<strong>' + esc(w.name) + '</strong>' +
|
||||
'</label>'
|
||||
);
|
||||
}).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) =>
|
||||
'<div class="qc-list-item" data-action="qc:template-use" data-index="' + i + '">' +
|
||||
'<div class="qc-list-item-title">' + esc(t.name) + '</div>' +
|
||||
'<div class="qc-list-item-code"><code>' + esc(t.cmd) + '</code></div>' +
|
||||
'<div class="qc-list-item-meta">' + esc(t.desc) + '</div>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderHistory() {
|
||||
const list = document.getElementById('qc-history-list');
|
||||
if (!list) return;
|
||||
const history = loadHistory();
|
||||
if (history.length === 0) {
|
||||
list.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No command history yet</div></div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = history.map((item, i) =>
|
||||
'<div class="qc-list-item" data-action="qc:history-use" data-index="' + i + '">' +
|
||||
'<div class="qc-list-item-code"><code>' + esc(item.command) + '</code></div>' +
|
||||
'<div class="qc-list-item-meta">' + esc(Pulse.fmt.dateTime(item.timestamp)) + ' — ' + esc(item.worker) + '</div>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
Result rendering
|
||||
------------------------------------------------------------------- */
|
||||
function renderSingleSuccess(executionId) {
|
||||
const wrap = document.getElementById('qc-result');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML =
|
||||
'<div class="lt-alert lt-alert--success">' +
|
||||
'<span class="lt-alert-icon">✓</span>' +
|
||||
'<div class="lt-alert-body">' +
|
||||
'<div class="lt-alert-title">Command sent successfully</div>' +
|
||||
'<div class="lt-alert-msg">Execution ID: <code>' + esc(executionId) + '</code> — ' +
|
||||
'<a href="/executions?open=' + encodeURIComponent(executionId) + '">view in Executions</a>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderSingleFailure(message) {
|
||||
const wrap = document.getElementById('qc-result');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML =
|
||||
'<div class="lt-alert lt-alert--error">' +
|
||||
'<span class="lt-alert-icon">✕</span>' +
|
||||
'<div class="lt-alert-body">' +
|
||||
'<div class="lt-alert-title">Command failed</div>' +
|
||||
'<div class="lt-alert-msg">' + esc(message) + '</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderMultiResult(results, successCount, failCount) {
|
||||
const wrap = document.getElementById('qc-result');
|
||||
if (!wrap) return;
|
||||
const rows = results.map(r =>
|
||||
'<tr>' +
|
||||
'<td data-label="Worker">' + esc(r.worker) + '</td>' +
|
||||
'<td data-label="Result">' +
|
||||
(r.success
|
||||
? '<span class="lt-status lt-status-completed">✓ Sent (' + esc(String(r.executionId || '').slice(0, 8)) + '…)</span>'
|
||||
: '<span class="lt-status lt-status-failed">✕ ' + esc(r.error) + '</span>') +
|
||||
'</td>' +
|
||||
'</tr>'
|
||||
).join('');
|
||||
wrap.innerHTML =
|
||||
'<div class="lt-alert' + (failCount === 0 ? ' lt-alert--success' : (successCount === 0 ? ' lt-alert--error' : ' lt-alert--warning')) + '">' +
|
||||
'<div class="lt-alert-body">' +
|
||||
'<div class="lt-alert-title">Multi-worker execution complete</div>' +
|
||||
'<div class="lt-alert-msg">Success: ' + successCount + ' | Failed: ' + failCount + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<table class="lt-table lt-table-sm lt-table-responsive">' +
|
||||
'<thead><tr><th>Worker</th><th>Result</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
function renderExecuting(count) {
|
||||
const wrap = document.getElementById('qc-result');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Executing' +
|
||||
(count > 1 ? ' on ' + count + ' worker(s)' : '') + '…</div></div>';
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
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 });
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '<span class="lt-text-dim">—</span>';
|
||||
return ids.map(id => {
|
||||
const w = _workers.find(worker => worker.id === id);
|
||||
const label = w ? w.name : String(id).slice(0, 8);
|
||||
return '<span class="lt-badge">' + esc(label) + '</span>';
|
||||
}).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 ? ' <span class="sc-countdown" data-next-run="' + esc(s.next_run) + '">(in ' + esc(countdown) + ')</span>' : '')
|
||||
: 'Not scheduled';
|
||||
const statusBadge = s.enabled
|
||||
? '<span class="lt-badge lt-badge-green">ENABLED</span>'
|
||||
: '<span class="lt-badge lt-badge-red">DISABLED</span>';
|
||||
const adminActions = Pulse.isAdmin
|
||||
? '<div class="lt-btn-group">' +
|
||||
'<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="sc:toggle" data-id="' + esc(s.id) + '" data-enabled="' + (s.enabled ? '1' : '0') + '">' +
|
||||
(s.enabled ? '⏸ Disable' : '▶ Enable') +
|
||||
'</button>' +
|
||||
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="sc:delete" data-id="' + esc(s.id) + '" data-name="' + esc(s.name || '') + '">🗑 Delete</button>' +
|
||||
'</div>'
|
||||
: '';
|
||||
return (
|
||||
'<tr class="' + (s.enabled ? '' : 'sc-row-disabled') + '">' +
|
||||
'<td data-label="Name">' + esc(s.name || '') + '</td>' +
|
||||
'<td data-label="Command"><code>' + esc(s.command || '') + '</code></td>' +
|
||||
'<td data-label="Schedule">' + scheduleDescription(s) + '</td>' +
|
||||
'<td data-label="Workers">' + workerNamesHtml(workerIds) + '</td>' +
|
||||
'<td data-label="Last run">' + lastRun + '</td>' +
|
||||
'<td data-label="Next run">' + nextRun + '</td>' +
|
||||
'<td data-label="Status">' + statusBadge + '</td>' +
|
||||
'<td data-label="Actions">' + adminActions + '</td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const wrap = document.getElementById('sc-wrap');
|
||||
if (!wrap) return;
|
||||
if (_schedules.length === 0) {
|
||||
wrap.innerHTML = '<div class="lt-empty-state"><div class="lt-empty-state-title">No scheduled commands yet</div></div>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML =
|
||||
'<table class="lt-table lt-table-responsive">' +
|
||||
'<thead><tr><th>Name</th><th>Command</th><th>Schedule</th><th>Workers</th><th>Last run</th><th>Next run</th><th>Status</th><th>Actions</th></tr></thead>' +
|
||||
'<tbody>' + _schedules.map(rowHtml).join('') + '</tbody>' +
|
||||
'</table>';
|
||||
}
|
||||
|
||||
function renderError() {
|
||||
const wrap = document.getElementById('sc-wrap');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML =
|
||||
'<div class="lt-alert lt-alert--error">' +
|
||||
'<span class="lt-alert-icon">✕</span>' +
|
||||
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load schedules</div></div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
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 = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No workers available</div></div>';
|
||||
return;
|
||||
}
|
||||
wrap.innerHTML = _workers.map(w =>
|
||||
'<label class="qc-worker-row' + (w.status === 'online' ? ' qc-worker-row--online' : '') + '">' +
|
||||
'<input type="checkbox" class="lt-checkbox" name="sc-worker-cb" value="' + esc(w.id) + '">' +
|
||||
'<span class="' + fmt.status(w.status) + '">' + (w.status === 'online' ? '●' : '○') + '</span>' +
|
||||
'<strong>' + esc(w.name) + '</strong>' +
|
||||
'</label>'
|
||||
).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 =
|
||||
'<label class="lt-label" for="sc-value">Interval (minutes)</label>' +
|
||||
'<input type="number" id="sc-value" class="lt-input" placeholder="e.g. 30" min="1">';
|
||||
} else if (type === 'hourly') {
|
||||
container.innerHTML =
|
||||
'<label class="lt-label" for="sc-value">Every X Hours</label>' +
|
||||
'<input type="number" id="sc-value" class="lt-input" placeholder="e.g. 2" min="1" max="24">';
|
||||
} else if (type === 'daily') {
|
||||
container.innerHTML =
|
||||
'<label class="lt-label" for="sc-value">Time (HH:MM)</label>' +
|
||||
'<input type="time" id="sc-value" class="lt-input">';
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
'<div class="lt-card-footer">' +
|
||||
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="wk:delete" ' +
|
||||
'data-worker-id="' + esc(worker.id) + '" data-worker-name="' + esc(worker.name) + '">Delete</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function cardHtml(worker) {
|
||||
const meta = parseMeta(worker);
|
||||
const online = worker.status === 'online';
|
||||
const pct = memPct(meta).toFixed(1);
|
||||
return (
|
||||
'<div class="wk-card-header">' +
|
||||
'<span class="lt-dot ' + (online ? 'lt-dot-up' : 'lt-dot-down') + '" data-wk-dot></span>' +
|
||||
'<span class="wk-name" data-wk-name>' + esc(worker.name) + '</span>' +
|
||||
'<span class="' + fmt.status(worker.status) + '" data-wk-status>' + esc(String(worker.status || '').toUpperCase()) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="wk-lastseen" data-wk-lastseen data-last-heartbeat="' + esc(worker.last_heartbeat || '') + '">' +
|
||||
'Last seen: ' + esc(fmt.ago(worker.last_heartbeat)) +
|
||||
'</div>' +
|
||||
'<div class="lt-kv-grid">' +
|
||||
'<div class="lt-kv-key">System</div>' +
|
||||
'<div class="lt-kv-val" data-wk-system>' + (meta ? esc((meta.platform || 'N/A') + ' ' + (meta.arch || '') + ' | ' + (meta.cpus || '?') + ' CPU cores') : 'N/A') + '</div>' +
|
||||
'<div class="lt-kv-key">Memory</div>' +
|
||||
'<div class="lt-kv-val" data-wk-memory>' +
|
||||
(meta ? esc(fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + ' (' + pct + '% used)') : 'N/A') +
|
||||
'<div class="lt-progress"><div class="lt-progress-bar" data-wk-membar style="width:' + (meta ? pct : 0) + '%"></div></div>' +
|
||||
'</div>' +
|
||||
'<div class="lt-kv-key">Load Avg</div>' +
|
||||
'<div class="lt-kv-val" data-wk-load>' + esc(loadAvgText(meta)) + '</div>' +
|
||||
'<div class="lt-kv-key">Uptime</div>' +
|
||||
'<div class="lt-kv-val" data-wk-uptime>' + esc(fmt.uptime(meta && meta.uptime)) + '</div>' +
|
||||
'<div class="lt-kv-key">Active Tasks</div>' +
|
||||
'<div class="lt-kv-val" data-wk-tasks>' + esc((meta && meta.activeTasks || 0) + ' / ' + (meta && meta.maxConcurrentTasks || 0)) + '</div>' +
|
||||
'</div>' +
|
||||
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 =
|
||||
'<div class="lt-empty-state">' +
|
||||
'<div class="lt-empty-state-icon">⚙</div>' +
|
||||
'<div class="lt-empty-state-title">No workers connected</div>' +
|
||||
'<div class="lt-empty-state-body">Workers appear here once they connect and send a heartbeat.</div>' +
|
||||
'</div>';
|
||||
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 = '<div class="lt-alert lt-alert--error"><div class="lt-alert-body"><div class="lt-alert-msg">Failed to load workers</div></div></div>';
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------
|
||||
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 });
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ' <span class="lt-badge lt-badge-sm">' + n + ' param' + (n > 1 ? 's' : '') + '</span>';
|
||||
}
|
||||
|
||||
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 (
|
||||
'<div class="lt-empty-state">' +
|
||||
'<div class="lt-empty-state-icon">⚙</div>' +
|
||||
'<div class="lt-empty-state-title">No workflows yet</div>' +
|
||||
'<div class="lt-empty-state-body">Workflows let you run multi-step jobs across one or more workers.</div>' +
|
||||
'<button type="button" class="lt-btn lt-btn-primary" data-action="wf:create-open">Create your first workflow</button>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
function renderRow(w) {
|
||||
var def = _registry[w.id] || {};
|
||||
var isAdmin = window.Pulse.isAdmin;
|
||||
var esc = window.Pulse.esc;
|
||||
var actions =
|
||||
'<button type="button" class="lt-btn lt-btn-sm lt-btn-primary" data-action="wf:execute" data-workflow-id="' +
|
||||
esc(w.id) + '">Execute</button>';
|
||||
if (isAdmin) {
|
||||
actions +=
|
||||
' <button type="button" class="lt-btn lt-btn-sm lt-btn-secondary" data-action="wf:edit-open" data-workflow-id="' +
|
||||
esc(w.id) + '">Edit</button>' +
|
||||
' <button type="button" class="lt-btn lt-btn-sm lt-btn-danger" data-action="wf:delete" data-workflow-id="' +
|
||||
esc(w.id) + '" data-workflow-name="' + esc(w.name) + '">Delete</button>';
|
||||
}
|
||||
var createdAt = window.Pulse.fmt.dateTime(w.created_at);
|
||||
return (
|
||||
'<tr>' +
|
||||
'<td data-label="Name">' + esc(w.name) + paramBadge(def) + '</td>' +
|
||||
'<td data-label="Description">' + esc(w.description || 'No description') + '</td>' +
|
||||
'<td data-label="Created">' + esc(w.created_by || 'Unknown') + ' · ' + esc(createdAt) + '</td>' +
|
||||
'<td data-label="Actions"><div class="lt-btn-group">' + actions + '</div></td>' +
|
||||
'</tr>'
|
||||
);
|
||||
}
|
||||
|
||||
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 =
|
||||
'<div class="lt-table-wrap">' +
|
||||
'<table class="lt-table lt-table-responsive">' +
|
||||
'<thead><tr><th>Name</th><th>Description</th><th>Created</th><th>Actions</th></tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderLoadError(message) {
|
||||
var el = document.getElementById('wf-list');
|
||||
if (!el) return;
|
||||
el.innerHTML =
|
||||
'<div class="lt-alert lt-alert--error" role="alert">' +
|
||||
'<span class="lt-alert-icon">⚠</span>' +
|
||||
'<span class="lt-alert-msg">' + window.Pulse.esc(message || 'Failed to load workflows') + '</span>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
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 ? ' <span class="wf-required">*</span>' : '';
|
||||
return (
|
||||
'<div class="lt-form-group">' +
|
||||
'<label class="lt-label" for="wf-run-param-' + esc(p.name) + '">' + label + required + '</label>' +
|
||||
'<input type="text" id="wf-run-param-' + esc(p.name) + '" class="lt-input" ' +
|
||||
'data-param-name="' + esc(p.name) + '" data-required="' + (p.required ? '1' : '0') + '" ' +
|
||||
'placeholder="' + esc(p.placeholder || '') + '">' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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.
|
||||
%>
|
||||
<div class="lt-page-header">
|
||||
<div>
|
||||
<h1 class="lt-page-title">Executions</h1>
|
||||
<p class="lt-page-subtitle">Workflow and quick-command run history</p>
|
||||
</div>
|
||||
<div class="lt-page-actions">
|
||||
<div class="lt-btn-group">
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:refresh">↻ Refresh</button>
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" id="ex-compare-toggle"
|
||||
data-action="ex:compare-toggle" aria-pressed="false">▤ Compare Mode</button>
|
||||
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm is-hidden" id="ex-compare-run"
|
||||
data-action="ex:compare-run">⚖ Compare Selected</button>
|
||||
<% if (user && user.isAdmin) { %>
|
||||
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="ex:clear-completed">✖ Clear Completed</button>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lt-frame">
|
||||
<span class="lt-frame-bl"></span><span class="lt-frame-br"></span>
|
||||
|
||||
<!-- Manual / Automated sub-tabs. base.js initTabs wires the click/keyboard
|
||||
behaviour and persists lt_activeTab_/executions; the ex:view-tab action
|
||||
keeps pulse_executionView (the authoritative store) in sync. -->
|
||||
<div class="lt-tab-bar" role="tablist" aria-label="Execution view">
|
||||
<button type="button" class="lt-tab active" role="tab" id="ex-tabbtn-manual"
|
||||
aria-selected="true" aria-controls="ex-tab-manual"
|
||||
data-tab="ex-tab-manual" data-action="ex:view-tab" data-view="manual">
|
||||
👤 Manual Runs <span class="lt-badge lt-badge-sm" id="ex-count-manual"></span>
|
||||
</button>
|
||||
<button type="button" class="lt-tab" role="tab" id="ex-tabbtn-automated"
|
||||
aria-selected="false" aria-controls="ex-tab-automated"
|
||||
data-tab="ex-tab-automated" data-action="ex:view-tab" data-view="automated">
|
||||
🤖 Automated <span class="lt-badge lt-badge-sm" id="ex-count-automated"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="lt-alert lt-alert--warning is-hidden" id="ex-compare-hint">
|
||||
<span class="lt-alert-icon">▤</span>
|
||||
<div class="lt-alert-body">
|
||||
<div class="lt-alert-title">Compare mode</div>
|
||||
<div class="lt-alert-msg">Select 2–5 executions to compare their outputs. Click a row to toggle selection.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lt-toolbar">
|
||||
<div class="lt-toolbar-left">
|
||||
<div class="lt-search">
|
||||
<input type="search" id="ex-search" class="lt-input lt-search-input"
|
||||
placeholder="Search by command, execution ID, or workflow name…"
|
||||
autocomplete="off" aria-label="Search executions"
|
||||
data-input-action="ex:search">
|
||||
</div>
|
||||
<select id="ex-status" class="lt-select" aria-label="Status filter" data-change-action="ex:filter-status">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="waiting">Waiting</option>
|
||||
</select>
|
||||
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-action="ex:clear-filters">Clear Filters</button>
|
||||
</div>
|
||||
<div class="lt-toolbar-right">
|
||||
<span id="ex-filter-stats" class="pulse-meta"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lt-tab-panels">
|
||||
<div class="lt-tab-panel active" id="ex-tab-manual" role="tabpanel" aria-labelledby="ex-tabbtn-manual" tabindex="-1">
|
||||
<div id="ex-list-manual" class="ex-list">
|
||||
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-tab-panel" id="ex-tab-automated" role="tabpanel" aria-labelledby="ex-tabbtn-automated" tabindex="-1">
|
||||
<div id="ex-list-automated" class="ex-list">
|
||||
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== EXECUTION DETAIL MODAL ===================== -->
|
||||
<div class="lt-modal-overlay" id="ex-detail-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ex-detail-title">
|
||||
<div class="lt-modal lt-modal-lg">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="ex-detail-title">Execution Details</span>
|
||||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body" id="ex-detail-body"></div>
|
||||
<div class="lt-modal-footer" id="ex-detail-footer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== EXECUTION COMPARE MODAL ==================== -->
|
||||
<div class="lt-modal-overlay" id="ex-compare-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="ex-compare-title">
|
||||
<div class="lt-modal lt-modal-lg">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="ex-compare-title">⚖ Execution Comparison</span>
|
||||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body" id="ex-compare-body"></div>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-modal-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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: '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:templates-open">📋 Templates</button>' +
|
||||
'<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:history-open">🕐 History</button>'
|
||||
}) %>
|
||||
|
||||
<div class="lt-frame">
|
||||
<form id="qc-form" data-submit-action="qc:execute">
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label">Execution Mode</label>
|
||||
<div class="qc-mode-row">
|
||||
<label class="qc-radio-label">
|
||||
<input type="radio" class="lt-radio" name="qc-exec-mode" id="qc-mode-single" value="single" checked data-change-action="qc:mode">
|
||||
<span>Single Worker</span>
|
||||
</label>
|
||||
<label class="qc-radio-label">
|
||||
<input type="radio" class="lt-radio" name="qc-exec-mode" id="qc-mode-multi" value="multi" data-change-action="qc:mode">
|
||||
<span>Multiple Workers</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lt-form-group" id="qc-single-mode">
|
||||
<label class="lt-label" for="qc-worker">Select Worker</label>
|
||||
<select class="lt-select" id="qc-worker">
|
||||
<option value="">Loading workers…</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="lt-form-group" id="qc-multi-mode" hidden>
|
||||
<label class="lt-label">Select Workers</label>
|
||||
<div id="qc-worker-list" class="qc-worker-list">
|
||||
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading…</div></div>
|
||||
</div>
|
||||
<div class="lt-btn-group qc-worker-actions">
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:select-all">Select All</button>
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:select-online">Online Only</button>
|
||||
<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:clear-all">Clear All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="qc-command">Command</label>
|
||||
<textarea class="lt-textarea" id="qc-command" rows="4" placeholder="Enter command to execute (e.g. 'uptime' or 'df -h')"></textarea>
|
||||
<div class="lt-field-hint">Ctrl+Enter to execute</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="lt-btn lt-btn-primary" id="qc-execute-btn">▶ Execute Command</button>
|
||||
</form>
|
||||
|
||||
<div id="qc-result" class="qc-result"></div>
|
||||
</div>
|
||||
|
||||
<!-- Command Templates Modal -->
|
||||
<div class="lt-modal-overlay" id="qc-templates-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="qc-templates-title">
|
||||
<div class="lt-modal lt-modal-lg">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="qc-templates-title">Command Templates</span>
|
||||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">
|
||||
<div id="qc-templates-list" class="qc-scroll-list"></div>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command History Modal -->
|
||||
<div class="lt-modal-overlay" id="qc-history-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="qc-history-title">
|
||||
<div class="lt-modal lt-modal-lg">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="qc-history-title">Command History</span>
|
||||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<div class="lt-modal-body">
|
||||
<div id="qc-history-list" class="qc-scroll-list"></div>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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: '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="app:refresh">↻ Refresh</button>' +
|
||||
(pageConfig && pageConfig.isAdmin ? '<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" data-action="sc:create-open">➕ Create Schedule</button>' : '')
|
||||
}) %>
|
||||
|
||||
<div class="lt-frame">
|
||||
<div id="sc-wrap">
|
||||
<div class="lt-empty-state" id="sc-loading">
|
||||
<div class="lt-empty-state-title">Loading…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Schedule Modal -->
|
||||
<div class="lt-modal-overlay" id="sc-create-modal" aria-hidden="true" role="dialog" aria-modal="true" aria-labelledby="sc-create-title">
|
||||
<div class="lt-modal">
|
||||
<div class="lt-modal-header">
|
||||
<span class="lt-modal-title" id="sc-create-title">Create Scheduled Command</span>
|
||||
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">✕</button>
|
||||
</div>
|
||||
<form id="sc-create-form" data-submit-action="sc:create-submit">
|
||||
<div class="lt-modal-body">
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="sc-name">Schedule Name</label>
|
||||
<input type="text" id="sc-name" class="lt-input" placeholder="e.g. Daily System Check">
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="sc-command">Command</label>
|
||||
<textarea id="sc-command" class="lt-textarea" rows="3" placeholder="Enter command to execute"></textarea>
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label">Target Workers</label>
|
||||
<div id="sc-worker-list" class="qc-worker-list">
|
||||
<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="sc-type">Schedule Type</label>
|
||||
<select id="sc-type" class="lt-select" data-change-action="sc:type">
|
||||
<option value="interval">Every X Minutes</option>
|
||||
<option value="hourly">Every X Hours</option>
|
||||
<option value="daily">Daily at Time</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lt-form-group" id="sc-value-container">
|
||||
<label class="lt-label" for="sc-value">Interval (minutes)</label>
|
||||
<input type="number" id="sc-value" class="lt-input" placeholder="e.g. 30" min="1">
|
||||
</div>
|
||||
</div>
|
||||
<div class="lt-modal-footer">
|
||||
<button type="submit" class="lt-btn lt-btn-primary">Create Schedule</button>
|
||||
<button type="button" class="lt-btn lt-btn-ghost" data-modal-close>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="wf-create-definition">Definition (JSON)</label>
|
||||
<textarea id="wf-create-definition" name="definition" class="lt-textarea wf-json-textarea" required></textarea>
|
||||
<span class="lt-field-hint">Steps run in order against the selected targets.</span>
|
||||
<span class="lt-form-hint">Steps run in order against the selected targets.</span>
|
||||
</div>
|
||||
<div class="lt-form-group">
|
||||
<label class="lt-label" for="wf-create-webhook">Webhook URL (optional)</label>
|
||||
|
||||
@@ -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 })
|
||||
%>
|
||||
<div class="lt-page-header">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user