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:
2026-09-08 21:57:33 -04:00
co-authored by Claude Fable 5.1
parent cbb91a306d
commit 68d0a906a6
16 changed files with 2845 additions and 3 deletions
+36
View File
@@ -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;
}
+257
View File
@@ -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); }
+854
View File
@@ -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 ? '&#x2713;' : '') + '</span></td>'
: '';
const completed = e.completed_at
? esc(Pulse.fmt.dateTime(e.completed_at))
: (running
? '<span class="ex-elapsed" data-ex-elapsed="' + esc(e.started_at) + '">' +
esc(Pulse.fmt.elapsed(e.started_at)) + '</span>'
: '&mdash;');
return '<tr class="' + cls.join(' ') + '" data-action="' + action + '"' +
' data-execution-id="' + esc(e.id) + '" tabindex="0">' +
check +
'<td data-label="Status"><span class="' + Pulse.fmt.status(e.status) + '">' + esc(e.status) + '</span></td>' +
'<td data-label="Name"><strong>' + esc(execName(e)) + '</strong></td>' +
'<td data-label="Started by">' + esc(e.started_by || '') + '</td>' +
'<td data-label="Started">' + esc(Pulse.fmt.dateTime(e.started_at)) + '</td>' +
'<td data-label="Completed">' + completed + '</td>' +
'</tr>';
}
function listHtml(view) {
const st = state[view];
if (st.error) {
return '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">&#x26a0;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load executions</div>' +
'<div class="lt-alert-msg">' + esc(st.error) + '</div></div></div>';
}
if (!st.loaded) {
return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>';
}
const rows = filteredRows(view);
if (!rows.length) {
const filtering = !!(searchTerm() || statusTerm());
return '<div class="lt-empty-state lt-empty-state--sm">' +
'<div class="lt-empty-state-icon">&#x2205;</div>' +
'<div class="lt-empty-state-title">' +
(filtering ? 'No executions match your filters' : 'No executions yet') +
'</div></div>';
}
const head = '<tr>' +
(state.compareMode ? '<th scope="col" class="ex-col-check">&#x2713;</th>' : '') +
'<th scope="col">Status</th><th scope="col">Name</th><th scope="col">Started by</th>' +
'<th scope="col">Started</th><th scope="col">Completed / Elapsed</th></tr>';
let html = '<div class="lt-table-wrap"><table class="lt-table lt-table-responsive">' +
'<thead>' + head + '</thead><tbody>' + rows.map(rowHtml).join('') + '</tbody></table></div>';
if (st.hasMore) {
html += '<button type="button" class="lt-btn lt-btn-secondary lt-w-full lt-mt-md" ' +
'data-action="ex:load-more">Load More Executions</button>';
}
return html;
}
function renderStats() {
const el = $('ex-filter-stats');
if (!el) return;
const st = state[state.view];
if (!st.loaded) { el.textContent = ''; return; }
const shown = filteredRows(state.view).length;
el.textContent = 'Showing ' + shown + ' of ' + st.rows.length +
(st.hasMore ? '+' : '') + ' loaded execution' + (st.rows.length === 1 ? '' : 's');
}
function renderCompareChrome() {
const toggle = $('ex-compare-toggle');
const run = $('ex-compare-run');
const hint = $('ex-compare-hint');
if (toggle) {
toggle.textContent = state.compareMode ? '✗ Exit Compare Mode' : '▤ Compare Mode';
toggle.setAttribute('aria-pressed', state.compareMode ? 'true' : 'false');
toggle.classList.toggle('is-active', state.compareMode);
}
if (hint) hint.classList.toggle('is-hidden', !state.compareMode);
if (run) {
run.classList.toggle('is-hidden', !state.compareMode);
run.textContent = state.selected.size >= 2
? '⚖ Compare Selected (' + state.selected.size + ')'
: '⚖ Compare Selected';
}
}
function render() {
['manual', 'automated'].forEach((view) => {
const host = $('ex-list-' + view);
if (host) host.innerHTML = listHtml(view);
const badge = $('ex-count-' + view);
if (badge) badge.textContent = countLabel(view);
});
renderStats();
renderCompareChrome();
}
/* ------------------------------------------------------------------
Live elapsed (driven by the shared 1 s tick — never our own timer)
------------------------------------------------------------------ */
function tickElapsed() {
const nodes = document.querySelectorAll('[data-ex-elapsed]');
for (let i = 0; i < nodes.length; i++) {
nodes[i].textContent = Pulse.fmt.elapsed(nodes[i].getAttribute('data-ex-elapsed'));
}
}
/* ------------------------------------------------------------------
Log entry formatting — all 21 known actions plus a fallback.
Colour semantics map onto base.css: .success (green), .warning (amber),
.error (red), plus the page-local .ex-log-dim (grey) and .ex-log-info
(cyan) modifiers defined in executions.css.
------------------------------------------------------------------ */
function entry(kind, ts, title, details) {
const cls = kind ? 'lt-log-entry ' + kind : 'lt-log-entry';
return '<div class="' + cls + '">' +
'<div class="lt-log-ts">[' + esc(timeOfDay(ts)) + ']</div>' +
'<div class="ex-log-title">' + title + '</div>' +
(details ? '<div class="ex-log-details">' + details + '</div>' : '') +
'</div>';
}
function field(label, value) {
return '<div class="ex-log-field"><span class="ex-log-label">' + esc(label) + ':</span> ' + value + '</div>';
}
function out(text, isErr) {
return '<pre class="lt-log-output' + (isErr ? ' ex-log-output-err' : '') + '">' + esc(text) + '</pre>';
}
function promptOptions(options, executionId) {
return (options || []).map((opt) => {
if (executionId) {
return '<button type="button" class="lt-btn lt-btn-primary lt-btn-sm ex-opt" ' +
'data-action="ex:respond" data-response="' + esc(opt) + '">' + esc(opt) + '</button>';
}
return '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm ex-opt is-answered" disabled>' +
esc(opt) + '</button>';
}).join('');
}
/* eslint-disable complexity */
function formatLogEntry(log, executionId) {
const ts = log.timestamp;
const a = log.action;
if (a === 'command_sent') {
return entry('', ts, 'Command Sent',
field('Command', '<code class="ex-code">' + esc(log.command) + '</code>') +
(log.worker_id ? field('Worker', esc(log.worker_id)) : ''));
}
if (a === 'command_result') {
const ok = !!log.success;
return entry(ok ? 'success' : 'error', ts,
(ok ? '&#x2713;' : '&#x2717;') + ' Command Result',
field('Status', ok ? 'Success' : 'Failed') +
(log.duration ? field('Duration', esc(log.duration) + 'ms') : '') +
(log.stdout ? field('Output', out(log.stdout, false)) : '') +
(log.stderr ? field('Errors', out(log.stderr, true)) : '') +
(log.error ? field('Error', esc(log.error)) : ''));
}
if (a === 'step_started') {
return entry('warning', ts, '&#x25b6; Step ' + esc(log.step) + ': ' + esc(log.step_name || ''), '');
}
if (a === 'step_completed') {
return entry('success', ts, '&#x2713; Step ' + esc(log.step) + ' Completed: ' + esc(log.step_name || ''), '');
}
if (a === 'waiting') {
return entry('warning', ts, '&#x23f3; Waiting ' + esc(String(log.duration || 0)) + ' seconds…', '');
}
if (a === 'parse_complete') {
const pairs = log.parsed || {};
const keys = Object.keys(pairs);
const rows = keys.map((k) =>
'<div class="ex-parse-key">' + esc(k) + '</div><div class="ex-parse-val">' + esc(pairs[k]) + '</div>'
).join('');
return entry('ex-log-dim', ts,
'&#x2699; Parsed ' + keys.length + ' variable' + (keys.length !== 1 ? 's' : ''),
keys.length ? '<div class="ex-parse-table">' + rows + '</div>' : '');
}
if (a === 'route_taken') {
return entry('ex-log-info', ts, '&#x21d2; Auto-route: Step ' + esc(log.step),
log.label
? '<div class="ex-route-label">' + esc(log.label) + '</div>' +
(log.goto ? '<div class="ex-route-goto">&rarr; ' + esc(log.goto) + '</div>' : '')
: '');
}
if (a === 'no_workers') {
return entry('error', ts, '&#x2717; Step ' + esc(log.step) + ': No Workers Available',
'<div class="ex-log-field">' + esc(log.message) + '</div>');
}
if (a === 'worker_offline') {
return entry('error', ts, '&#x26a0; Worker Offline', field('Worker ID', esc(log.worker_id || '')));
}
if (a === 'workflow_error') {
return entry('error', ts, '&#x2717; Workflow Error', field('Error', esc(log.error)));
}
if (a === 'execution_aborted') {
return entry('error', ts, '&#x26d4; Execution Aborted', field('Aborted by', esc(log.aborted_by)));
}
if (a === 'prompt') {
return entry('ex-log-info', ts,
'&#x2753; Step ' + esc(log.step) + ': ' + esc(log.step_name || 'Prompt'),
(log.output ? out(log.output, false) : '') +
'<div class="ex-prompt-msg">' + esc(log.message || '') + '</div>' +
'<div class="ex-opt-row">' + promptOptions(log.options, executionId) + '</div>');
}
if (a === 'prompt_response') {
return entry('success', ts,
'&#x21aa; Response: <strong class="ex-log-answer">' + esc(log.response || '') + '</strong>' +
(log.responded_by ? '<span class="ex-log-by">by ' + esc(log.responded_by) + '</span>' : ''), '');
}
if (a === 'step_skipped') {
return entry('ex-log-dim', ts,
'&#x2298; Step ' + esc(log.step) + ' Skipped' + (log.reason ? ': ' + esc(log.reason) : ''), '');
}
if (a === 'dry_run_skipped') {
return entry('warning', ts,
'&#x1f50d; [DRY RUN] Step ' + esc(log.step) + ' Skipped: ' + esc(log.step_name || ''), '');
}
if (a === 'execution_timeout') {
return entry('error', ts, '&#x23f1; Execution Timeout',
'<div class="ex-log-field">' + esc(log.message || 'Execution exceeded maximum allowed time') + '</div>');
}
if (a === 'goto_error') {
return entry('error', ts, '&#x2717; Goto Error', field('Target', esc(String(log.target || ''))));
}
if (a === 'step_error') {
return entry('error', ts, '&#x2717; Step ' + esc(log.step) + ' Error: ' + esc(log.step_name || ''),
field('Error', esc(log.error || '')));
}
if (a === 'workflow_result') {
const ok = !!log.success;
return entry(ok ? 'success' : 'error', ts,
(ok ? '&#x2713;' : '&#x2717;') + ' Workflow Result: ' + (ok ? 'Success' : 'Failed'),
log.message ? '<div class="ex-log-field">' + esc(log.message) + '</div>' : '');
}
if (a === 'params') {
const p = log.params || {};
const str = Object.keys(p).map((k) => esc(k) + '=' + esc(String(p[k]))).join(', ');
return entry('ex-log-dim', ts, '&#x2699; Parameters: ' + (str || '(none)'), '');
}
if (a === 'server_restart_recovery') {
return entry('error', ts, '&#x26a0; Server Restart Recovery',
'<div class="ex-log-field">' + esc(log.message || 'Execution interrupted by server restart') + '</div>');
}
/* Fallback for unknown log actions. */
return entry('ex-log-dim', ts, esc(log.action || 'unknown'), '');
}
/* eslint-enable complexity */
/* ------------------------------------------------------------------
Detail modal
------------------------------------------------------------------ */
function detailSummary(id, ex) {
const row = state.rowIndex.get(id);
const name = ex.workflow_name || (row && row.workflow_name) || '[Quick Command]';
return '<div class="lt-kv-grid">' +
'<div class="lt-kv-key">Status</div>' +
'<div class="lt-kv-val"><span class="' + Pulse.fmt.status(ex.status) + '">' + esc(ex.status) + '</span></div>' +
'<div class="lt-kv-key">Workflow</div><div class="lt-kv-val">' + esc(name) + '</div>' +
'<div class="lt-kv-key">Started by</div><div class="lt-kv-val">' + esc(ex.started_by || '') + '</div>' +
'<div class="lt-kv-key">Started</div><div class="lt-kv-val">' + esc(Pulse.fmt.dateTime(ex.started_at)) + '</div>' +
'<div class="lt-kv-key">Completed</div><div class="lt-kv-val">' +
(ex.completed_at ? esc(Pulse.fmt.dateTime(ex.completed_at))
: (ex.status === 'running' ? esc(Pulse.fmt.elapsed(ex.started_at)) + ' elapsed' : '&mdash;')) +
'</div>' +
'<div class="lt-kv-key">Execution ID</div><div class="lt-kv-val ex-id-cell">' +
'<code class="ex-code">' + esc(id) + '</code>' +
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-copy="' + esc(id) + '" data-copy-toast>COPY</button>' +
'</div>' +
'</div>';
}
function detailPrompt(ex) {
if (!ex.waiting_for_input || !ex.prompt) return '';
const p = ex.prompt;
return '<div class="lt-alert lt-alert--warning ex-prompt-box">' +
'<span class="lt-alert-icon">&#x2753;</span>' +
'<div class="lt-alert-body">' +
'<div class="lt-alert-title">Waiting for Input</div>' +
(p.output ? out(p.output, false) : '') +
'<div class="lt-alert-msg">' + esc(p.message || '') + '</div>' +
'<div class="ex-opt-row">' + promptOptions(p.options, true) + '</div>' +
'</div></div>';
}
function detailLogs(id, ex) {
const logs = Array.isArray(ex.logs) ? ex.logs : [];
if (!logs.length) {
return '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No logs recorded</div></div>';
}
const body = logs.map((log, idx) => {
/* Only the last unanswered prompt stays interactive. */
let promptExecId = null;
if (log.action === 'prompt' && ex.waiting_for_input) {
const answered = logs.slice(idx + 1).some((l) => l.action === 'prompt_response');
if (!answered) promptExecId = id;
}
return formatLogEntry(log, promptExecId);
}).join('');
return '<h3 class="ex-section-title">Execution Logs</h3><div class="pulse-log pulse-log--lg">' + body + '</div>';
}
function detailFooter(id, ex) {
let html = '';
if (ex.status === 'running') {
html += '<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="ex:abort" ' +
'data-execution-id="' + esc(id) + '">&#x26d4; Abort Execution</button>';
}
const cmdLog = (Array.isArray(ex.logs) ? ex.logs : []).find((l) => l.action === 'command_sent' && l.command);
if (cmdLog) {
html += '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:rerun" ' +
'data-command="' + esc(cmdLog.command) + '" data-worker-id="' + esc(cmdLog.worker_id || '') + '">' +
'&#x21bb; Re-run Command</button>';
}
html += '<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="ex:download" ' +
'data-execution-id="' + esc(id) + '">&#x1f4be; Download Logs</button>';
html += '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" data-modal-close>Close</button>';
return html;
}
async function openDetail(id, reopen) {
const modal = $('ex-detail-modal');
const body = $('ex-detail-body');
const footer = $('ex-detail-footer');
if (!modal || !body) return;
if (!reopen) {
body.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>';
if (footer) footer.innerHTML = '';
modal.dataset.executionId = id;
if (lt.modal) lt.modal.open(modal);
}
let ex;
try {
ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
} catch (e) {
body.innerHTML = '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">&#x26a0;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Error loading execution details</div>' +
'<div class="lt-alert-msg">' + esc(e && e.message ? e.message : String(e)) + '</div></div></div>';
return;
}
modal.dataset.executionId = id;
body.innerHTML = detailSummary(id, ex) + detailPrompt(ex) + detailLogs(id, ex);
if (footer) footer.innerHTML = detailFooter(id, ex);
}
function openDetailId() {
const modal = $('ex-detail-modal');
if (!modal || !modal.classList.contains('is-open')) return null;
return modal.dataset.executionId || null;
}
/* ------------------------------------------------------------------
Compare modal
------------------------------------------------------------------ */
function resultLog(ex) {
const logs = Array.isArray(ex.logs) ? ex.logs : [];
return logs.find((l) => l.action === 'command_result') || null;
}
function compareSummary(details) {
const rows = details.map((ex, idx) => {
const start = Pulse.fmt.safeDate(ex.started_at);
const end = Pulse.fmt.safeDate(ex.completed_at);
const duration = (start && end) ? Math.round((end.getTime() - start.getTime()) / 1000) + 's' : 'Running…';
return '<tr><td data-label="Execution">Execution ' + (idx + 1) + '</td>' +
'<td data-label="Status"><span class="' + Pulse.fmt.status(ex.status) + '">' + esc(ex.status) + '</span></td>' +
'<td data-label="Started">' + esc(Pulse.fmt.dateTime(ex.started_at)) + '</td>' +
'<td data-label="Duration">' + esc(duration) + '</td></tr>';
}).join('');
return '<h3 class="ex-section-title">Comparison Summary</h3>' +
'<div class="lt-table-wrap"><table class="lt-table lt-table-sm lt-table-responsive"><thead><tr>' +
'<th scope="col">Execution</th><th scope="col">Status</th><th scope="col">Started</th><th scope="col">Duration</th>' +
'</tr></thead><tbody>' + rows + '</tbody></table></div>';
}
function compareOutputs(details) {
const cols = details.map((ex, idx) => {
const r = resultLog(ex) || {};
const stdout = r.stdout || '';
const stderr = r.stderr || '';
const name = ex.workflow_name || (state.rowIndex.get(ex.id) || {}).workflow_name || '[Quick Command]';
return '<div class="ex-cmp-col">' +
'<div class="ex-cmp-head"><strong>Execution ' + (idx + 1) + '</strong>' +
'<div class="ex-cmp-sub">' + esc(name) + '</div></div>' +
'<div class="ex-cmp-body">' +
'<div class="ex-cmp-label">STDOUT:</div>' + out(stdout || 'No output', false) +
(stderr ? '<div class="ex-cmp-label ex-cmp-label--err">STDERR:</div>' + out(stderr, true) : '') +
'</div></div>';
}).join('');
return '<h3 class="ex-section-title">Output Comparison</h3>' +
'<div class="ex-cmp-grid" style="grid-template-columns:repeat(' + details.length + ',1fr);">' + cols + '</div>';
}
function compareDiff(details) {
if (details.length !== 2) return '';
const a = (resultLog(details[0]) || {}).stdout || '';
const b = (resultLog(details[1]) || {}).stdout || '';
const la = a.split('\n');
const lb = b.split('\n');
const max = Math.max(la.length, lb.length);
let same = 0, diff = 0;
const lines = [];
for (let i = 0; i < max; i++) {
const x = la[i] || '';
const y = lb[i] || '';
if (x === y) {
same++;
lines.push('<div class="ex-diff-same">' + (i + 1) + ': ' + (esc(x) || '(empty)') + '</div>');
} else {
diff++;
lines.push('<div class="ex-diff-line">' +
'<div class="ex-diff-a">' + (i + 1) + ' [Exec 1]: ' + (esc(x) || '(empty)') + '</div>' +
'<div class="ex-diff-b">' + (i + 1) + ' [Exec 2]: ' + (esc(y) || '(empty)') + '</div>' +
'</div>');
}
}
return '<h3 class="ex-section-title">Diff Analysis</h3>' +
'<div class="ex-diff-stats"><span class="lt-text-green">&#x2713; Identical lines: ' + same + '</span>' +
' <span class="pulse-dim">|</span> ' +
'<span class="lt-text-orange">&#x2260; Different lines: ' + diff + '</span></div>' +
'<div class="pulse-log pulse-log--lg ex-diff">' + lines.join('') + '</div>';
}
async function runCompare() {
if (state.selected.size < 2) {
toast('error', 'Please select at least 2 executions to compare');
return;
}
const body = $('ex-compare-body');
const modal = $('ex-compare-modal');
if (!body || !modal) return;
body.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Loading&hellip;</div></div>';
if (lt.modal) lt.modal.open(modal);
const ids = Array.from(state.selected);
const settled = await Promise.all(ids.map(async (id) => {
try {
const ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
ex.id = ex.id || id;
return ex;
} catch (e) { return null; }
}));
const details = settled.filter(Boolean);
if (details.length < 2) {
body.innerHTML = '<div class="lt-alert lt-alert--error"><span class="lt-alert-icon">&#x26a0;</span>' +
'<div class="lt-alert-body"><div class="lt-alert-title">Failed to load execution details</div></div></div>';
toast('error', 'Failed to load execution details');
return;
}
body.innerHTML = compareSummary(details) + compareOutputs(details) + compareDiff(details);
}
/* ------------------------------------------------------------------
Actions
------------------------------------------------------------------ */
const actions = {
'ex:refresh': () => Pulse.refreshNow(),
'ex:view-tab': (el) => {
const view = el.getAttribute('data-view');
if (!view || !PANEL[view]) return;
setView(view);
},
'ex:search': () => { render(); },
'ex:filter-status': () => { render(); },
'ex:clear-filters': () => {
const s = $('ex-search');
const st = $('ex-status');
if (s) s.value = '';
if (st) st.value = '';
render();
},
'ex:load-more': async (el) => {
el.disabled = true;
el.textContent = 'Loading…';
await loadView(state.view, true);
render();
},
'ex:view': (el) => {
const id = el.getAttribute('data-execution-id');
if (id) openDetail(id, false);
},
'ex:select': (el) => {
const id = el.getAttribute('data-execution-id');
if (!id) return;
if (state.selected.has(id)) {
state.selected.delete(id);
} else {
if (state.selected.size >= 5) {
toast('error', 'Maximum 5 executions can be compared');
return;
}
state.selected.add(id);
}
render();
},
'ex:compare-toggle': () => {
state.compareMode = !state.compareMode;
state.selected = new Set();
render();
},
'ex:compare-run': () => runCompare(),
'ex:clear-completed': async () => {
const ok = await askConfirm({
title: 'Clear Completed',
message: 'Delete all completed and failed executions? This cannot be undone.',
type: 'error',
confirmLabel: 'DELETE',
});
if (!ok) return;
try {
const data = await Pulse.api.delete('/api/executions/completed');
toast('success', 'Deleted ' + (data && data.deleted !== undefined ? data.deleted : 0) + ' execution(s)');
} catch (e) {
toast('error', (e && e.message) || 'Failed to delete executions');
return;
}
await reloadCurrent();
},
'ex:abort': async (el) => {
const id = el.getAttribute('data-execution-id') || openDetailId();
if (!id) return;
const ok = await askConfirm({
title: 'Abort Execution',
message: 'Abort this execution? It will be marked as failed.',
type: 'error',
confirmLabel: 'ABORT',
});
if (!ok) return;
try {
await Pulse.api.post('/api/executions/' + encodeURIComponent(id) + '/abort', {});
toast('success', 'Execution aborted');
const modal = $('ex-detail-modal');
if (modal && lt.modal) lt.modal.close(modal);
} catch (e) {
toast('error', (e && e.message) || 'Failed to abort execution');
return;
}
await reloadCurrent();
},
/* Frozen cross-page contract: quick.js consumes and clears pulse_rerun. */
'ex:rerun': async (el) => {
const command = el.getAttribute('data-command') || '';
const workerId = el.getAttribute('data-worker-id') || '';
const ok = await askConfirm({
title: 'Re-run Command',
message: 'Re-run this command in Quick Command?\n\n' + command,
type: 'warning',
confirmLabel: 'RE-RUN',
});
if (!ok) return;
try {
sessionStorage.setItem('pulse_rerun', JSON.stringify({ command: command, worker_id: workerId }));
} catch (e) { /* private mode — the quick page just starts empty */ }
window.location.href = '/quick';
},
'ex:download': async (el) => {
const id = el.getAttribute('data-execution-id') || openDetailId();
if (!id) return;
try {
const ex = await Pulse.api.get('/api/executions/' + encodeURIComponent(id));
const row = state.rowIndex.get(id) || {};
const payload = {
execution_id: id,
workflow_name: ex.workflow_name || row.workflow_name || '[Quick Command]',
status: ex.status,
started_by: ex.started_by,
started_at: ex.started_at,
completed_at: ex.completed_at,
logs: ex.logs,
};
const stamp = new Date().toISOString().split('T')[0];
Pulse.util.download('execution-' + id + '-' + stamp + '.json',
JSON.stringify(payload, null, 2), 'application/json');
} catch (e) {
toast('error', (e && e.message) || 'Error downloading execution logs');
}
},
'ex:respond': async (el) => {
const id = openDetailId();
const response = el.getAttribute('data-response');
if (!id || response === null) return;
try {
await Pulse.api.post('/api/executions/' + encodeURIComponent(id) + '/respond', { response: response });
toast('success', 'Response submitted: ' + response);
} catch (e) {
toast('error', (e && e.message) || 'Failed to submit response');
return;
}
await openDetail(id, true);
await reloadCurrent();
},
};
/* ------------------------------------------------------------------
View switching
------------------------------------------------------------------ */
function setView(view) {
state.view = view;
Pulse.util.storage.set(VIEW_KEY, view);
if (lt.tabs) lt.tabs.switch(PANEL[view]);
if (!state[view].loaded) {
loadView(view, false).then(render);
} else {
render();
}
}
/* ------------------------------------------------------------------
Deep link: /executions?open=<id>
------------------------------------------------------------------ */
function consumeDeepLink() {
let id = null;
try {
id = new URLSearchParams(window.location.search).get('open');
} catch (e) { return; }
if (!id) return;
try {
const url = new URL(window.location.href);
url.searchParams.delete('open');
window.history.replaceState({}, '', url.pathname + (url.search || '') + url.hash);
} catch (e) { /* non-fatal */ }
openDetail(id, false);
}
/* ------------------------------------------------------------------
Page registration
------------------------------------------------------------------ */
Pulse.actions.registerAll(actions);
Pulse.registerPage({
name: 'executions',
async init() {
const stored = Pulse.util.storage.get(VIEW_KEY, 'manual');
state.view = (stored === 'automated') ? 'automated' : 'manual';
if (lt.tabs) lt.tabs.switch(PANEL[state.view]);
Pulse.events.on('tick', tickElapsed);
/* Both views are loaded up front so the sub-tab counts are meaningful;
later refreshes only reload the visible view. */
await Promise.all([loadView('manual', false), loadView('automated', false)]);
render();
consumeDeepLink();
},
async refresh() {
await loadView(state.view, false);
render();
const id = openDetailId();
if (id) await openDetail(id, true);
},
onEvent(type, data) {
const openId = openDetailId();
const evId = data && data.execution_id;
if (type === 'command_result' || type === 'workflow_result' || type === 'execution_prompt') {
if (openId && evId && openId === evId) openDetail(openId, true);
reloadCurrent();
return true;
}
if (type === 'execution_started' || type === 'execution_status' || type === 'executions_bulk_deleted') {
reloadCurrent();
return true;
}
return false;
},
});
})();
+85
View File
@@ -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;
}
+365
View File
@@ -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' ? '&#x25cf;' : '&#x25cb;') + '</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)) + ' &mdash; ' + 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">&#x2713;</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> &mdash; ' +
'<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">&#x2715;</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">&#x2713; Sent (' + esc(String(r.executionId || '').slice(0, 8)) + '&hellip;)</span>'
: '<span class="lt-status lt-status-failed">&#x2715; ' + 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 + ' &nbsp;|&nbsp; 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)' : '') + '&hellip;</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 });
})();
+10
View File
@@ -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;
}
+286
View File
@@ -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">&mdash;</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 ? '&#x23f8; Disable' : '&#x25b6; 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 || '') + '">&#x1f5d1; 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">&#x2715;</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' ? '&#x25cf;' : '&#x25cb;') + '</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 });
})();
+32
View File
@@ -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;
}
+257
View File
@@ -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">&#x2699;</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 });
})();
+37
View File
@@ -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;
}
+355
View File
@@ -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">&#x2699;</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') + ' &middot; ' + 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">&#x26A0;</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;
}
});
})();
+118
View File
@@ -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">&#x21bb; 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">&#x25a4; 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">&#x2696; Compare Selected</button>
<% if (user && user.isAdmin) { %>
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="ex:clear-completed">&#x2716; 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">
&#x1f464; 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">
&#x1f916; 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">&#x25a4;</span>
<div class="lt-alert-body">
<div class="lt-alert-title">Compare mode</div>
<div class="lt-alert-msg">Select 2&ndash;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&hellip;</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&hellip;</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">&#x2715;</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">&#x2696; Execution Comparison</span>
<button type="button" class="lt-modal-close" data-modal-close aria-label="Close">&#x2715;</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>
+89
View File
@@ -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">&#x1f4cb; Templates</button>' +
'<button type="button" class="lt-btn lt-btn-secondary lt-btn-sm" data-action="qc:history-open">&#x1f550; 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&hellip;</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&hellip;</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">&#x25b6; 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">&#x2715;</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">&#x2715;</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>
+62
View File
@@ -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">&#x21bb; Refresh</button>' +
(pageConfig && pageConfig.isAdmin ? '<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" data-action="sc:create-open">&#x2795; 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&hellip;</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">&#x2715;</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&hellip;</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>
+1 -1
View File
@@ -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 -2
View File
@@ -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>