Files
jaredandClaude Fable 5.1 68d0a906a6 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
2026-09-08 21:57:33 -04:00

366 lines
15 KiB
JavaScript

/* =====================================================================
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 });
})();