- 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
366 lines
15 KiB
JavaScript
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' ? '●' : '○') + '</span>' +
|
|
'<strong>' + esc(w.name) + '</strong>' +
|
|
'</label>'
|
|
);
|
|
}).join('');
|
|
}
|
|
|
|
function selectedWorkerCheckboxIds() {
|
|
return Array.from(document.querySelectorAll('input[name="qc-worker-cb"]:checked')).map(cb => cb.value);
|
|
}
|
|
|
|
async function loadWorkers() {
|
|
const select = document.getElementById('qc-worker');
|
|
const preserveSingle = select ? select.value : '';
|
|
const preserveMulti = new Set(selectedWorkerCheckboxIds());
|
|
try {
|
|
_workers = await Pulse.api.get('/api/workers') || [];
|
|
} catch (e) {
|
|
_workers = [];
|
|
console.error('[Pulse:quick] failed to load workers', e);
|
|
}
|
|
renderWorkerSelect(_workers, preserveSingle);
|
|
renderWorkerCheckboxes(_workers, preserveMulti);
|
|
return _workers;
|
|
}
|
|
|
|
/* -------------------------------------------------------------------
|
|
Templates modal
|
|
------------------------------------------------------------------- */
|
|
function renderTemplates() {
|
|
const list = document.getElementById('qc-templates-list');
|
|
if (!list) return;
|
|
list.innerHTML = TEMPLATES.map((t, i) =>
|
|
'<div class="qc-list-item" data-action="qc:template-use" data-index="' + i + '">' +
|
|
'<div class="qc-list-item-title">' + esc(t.name) + '</div>' +
|
|
'<div class="qc-list-item-code"><code>' + esc(t.cmd) + '</code></div>' +
|
|
'<div class="qc-list-item-meta">' + esc(t.desc) + '</div>' +
|
|
'</div>'
|
|
).join('');
|
|
}
|
|
|
|
function renderHistory() {
|
|
const list = document.getElementById('qc-history-list');
|
|
if (!list) return;
|
|
const history = loadHistory();
|
|
if (history.length === 0) {
|
|
list.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">No command history yet</div></div>';
|
|
return;
|
|
}
|
|
list.innerHTML = history.map((item, i) =>
|
|
'<div class="qc-list-item" data-action="qc:history-use" data-index="' + i + '">' +
|
|
'<div class="qc-list-item-code"><code>' + esc(item.command) + '</code></div>' +
|
|
'<div class="qc-list-item-meta">' + esc(Pulse.fmt.dateTime(item.timestamp)) + ' — ' + esc(item.worker) + '</div>' +
|
|
'</div>'
|
|
).join('');
|
|
}
|
|
|
|
/* -------------------------------------------------------------------
|
|
Result rendering
|
|
------------------------------------------------------------------- */
|
|
function renderSingleSuccess(executionId) {
|
|
const wrap = document.getElementById('qc-result');
|
|
if (!wrap) return;
|
|
wrap.innerHTML =
|
|
'<div class="lt-alert lt-alert--success">' +
|
|
'<span class="lt-alert-icon">✓</span>' +
|
|
'<div class="lt-alert-body">' +
|
|
'<div class="lt-alert-title">Command sent successfully</div>' +
|
|
'<div class="lt-alert-msg">Execution ID: <code>' + esc(executionId) + '</code> — ' +
|
|
'<a href="/executions?open=' + encodeURIComponent(executionId) + '">view in Executions</a>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
function renderSingleFailure(message) {
|
|
const wrap = document.getElementById('qc-result');
|
|
if (!wrap) return;
|
|
wrap.innerHTML =
|
|
'<div class="lt-alert lt-alert--error">' +
|
|
'<span class="lt-alert-icon">✕</span>' +
|
|
'<div class="lt-alert-body">' +
|
|
'<div class="lt-alert-title">Command failed</div>' +
|
|
'<div class="lt-alert-msg">' + esc(message) + '</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}
|
|
|
|
function renderMultiResult(results, successCount, failCount) {
|
|
const wrap = document.getElementById('qc-result');
|
|
if (!wrap) return;
|
|
const rows = results.map(r =>
|
|
'<tr>' +
|
|
'<td data-label="Worker">' + esc(r.worker) + '</td>' +
|
|
'<td data-label="Result">' +
|
|
(r.success
|
|
? '<span class="lt-status lt-status-completed">✓ Sent (' + esc(String(r.executionId || '').slice(0, 8)) + '…)</span>'
|
|
: '<span class="lt-status lt-status-failed">✕ ' + esc(r.error) + '</span>') +
|
|
'</td>' +
|
|
'</tr>'
|
|
).join('');
|
|
wrap.innerHTML =
|
|
'<div class="lt-alert' + (failCount === 0 ? ' lt-alert--success' : (successCount === 0 ? ' lt-alert--error' : ' lt-alert--warning')) + '">' +
|
|
'<div class="lt-alert-body">' +
|
|
'<div class="lt-alert-title">Multi-worker execution complete</div>' +
|
|
'<div class="lt-alert-msg">Success: ' + successCount + ' | Failed: ' + failCount + '</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<table class="lt-table lt-table-sm lt-table-responsive">' +
|
|
'<thead><tr><th>Worker</th><th>Result</th></tr></thead>' +
|
|
'<tbody>' + rows + '</tbody>' +
|
|
'</table>';
|
|
}
|
|
|
|
function renderExecuting(count) {
|
|
const wrap = document.getElementById('qc-result');
|
|
if (!wrap) return;
|
|
wrap.innerHTML = '<div class="lt-empty-state lt-empty-state--sm"><div class="lt-empty-state-title">Executing' +
|
|
(count > 1 ? ' on ' + count + ' worker(s)' : '') + '…</div></div>';
|
|
}
|
|
|
|
/* -------------------------------------------------------------------
|
|
Execute
|
|
------------------------------------------------------------------- */
|
|
async function execute() {
|
|
const commandEl = document.getElementById('qc-command');
|
|
const command = commandEl ? commandEl.value.trim() : '';
|
|
if (!command) {
|
|
Pulse.toast.error('Please enter a command');
|
|
return;
|
|
}
|
|
const mode = currentMode();
|
|
|
|
if (mode === 'single') {
|
|
const select = document.getElementById('qc-worker');
|
|
const workerId = select ? select.value : '';
|
|
if (!workerId) {
|
|
Pulse.toast.error('Please select a worker');
|
|
return;
|
|
}
|
|
const worker = _workers.find(w => w.id === workerId);
|
|
const workerName = worker ? worker.name : 'Unknown';
|
|
renderExecuting(1);
|
|
try {
|
|
const data = await Pulse.api.post('/api/workers/' + encodeURIComponent(workerId) + '/command', { command });
|
|
addToHistory(command, workerName);
|
|
renderSingleSuccess(data && data.execution_id);
|
|
Pulse.beep('success');
|
|
} catch (e) {
|
|
renderSingleFailure(e.message || 'Failed to execute command');
|
|
Pulse.beep('error');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const workerIds = selectedWorkerCheckboxIds();
|
|
if (workerIds.length === 0) {
|
|
Pulse.toast.error('Please select at least one worker');
|
|
return;
|
|
}
|
|
renderExecuting(workerIds.length);
|
|
const results = [];
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
for (const workerId of workerIds) {
|
|
const worker = _workers.find(w => w.id === workerId);
|
|
const workerLabel = worker ? worker.name : workerId;
|
|
try {
|
|
const data = await Pulse.api.post('/api/workers/' + encodeURIComponent(workerId) + '/command', { command });
|
|
results.push({ worker: workerLabel, success: true, executionId: data && data.execution_id });
|
|
successCount++;
|
|
} catch (e) {
|
|
results.push({ worker: workerLabel, success: false, error: e.message || 'Failed to execute' });
|
|
failCount++;
|
|
}
|
|
}
|
|
addToHistory(command, workerIds.length + ' workers');
|
|
renderMultiResult(results, successCount, failCount);
|
|
if (failCount === 0) Pulse.beep('success');
|
|
else if (successCount > 0) Pulse.beep('info');
|
|
else Pulse.beep('error');
|
|
}
|
|
|
|
/* -------------------------------------------------------------------
|
|
Cross-page re-run (from Executions page)
|
|
------------------------------------------------------------------- */
|
|
function consumeRerun() {
|
|
let raw;
|
|
try { raw = window.sessionStorage.getItem(RERUN_KEY); } catch (e) { return; }
|
|
if (!raw) return;
|
|
try { window.sessionStorage.removeItem(RERUN_KEY); } catch (e) { /* ignore */ }
|
|
let payload;
|
|
try { payload = JSON.parse(raw); } catch (e) { return; }
|
|
if (!payload) return;
|
|
|
|
const singleRadio = document.getElementById('qc-mode-single');
|
|
if (singleRadio) singleRadio.checked = true;
|
|
applyMode();
|
|
|
|
if (payload.worker_id) {
|
|
const select = document.getElementById('qc-worker');
|
|
const hasWorker = select && _workers.some(w => w.id === payload.worker_id);
|
|
if (hasWorker) {
|
|
select.value = payload.worker_id;
|
|
} else {
|
|
Pulse.toast.warning('Original worker is no longer available — select a worker to run this command.');
|
|
}
|
|
}
|
|
const commandEl = document.getElementById('qc-command');
|
|
if (commandEl) {
|
|
commandEl.value = payload.command || '';
|
|
commandEl.focus();
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------------------------------
|
|
Data loading / refresh
|
|
------------------------------------------------------------------- */
|
|
async function refresh() {
|
|
await loadWorkers();
|
|
}
|
|
|
|
function init() {
|
|
Pulse.actions.registerAll({
|
|
'qc:mode': applyMode,
|
|
'qc:templates-open': () => { renderTemplates(); if (window.lt && lt.modal) lt.modal.open('qc-templates-modal'); },
|
|
'qc:template-use': (el) => {
|
|
const idx = Number(el.getAttribute('data-index'));
|
|
const tpl = TEMPLATES[idx];
|
|
if (!tpl) return;
|
|
const commandEl = document.getElementById('qc-command');
|
|
if (commandEl) commandEl.value = tpl.cmd;
|
|
if (window.lt && lt.modal) lt.modal.close('qc-templates-modal');
|
|
},
|
|
'qc:history-open': () => { renderHistory(); if (window.lt && lt.modal) lt.modal.open('qc-history-modal'); },
|
|
'qc:history-use': (el) => {
|
|
const idx = Number(el.getAttribute('data-index'));
|
|
const history = loadHistory();
|
|
const item = history[idx];
|
|
if (!item) return;
|
|
const commandEl = document.getElementById('qc-command');
|
|
if (commandEl) commandEl.value = item.command;
|
|
if (window.lt && lt.modal) lt.modal.close('qc-history-modal');
|
|
},
|
|
'qc:select-all': () => {
|
|
document.querySelectorAll('input[name="qc-worker-cb"]').forEach(cb => { cb.checked = true; });
|
|
},
|
|
'qc:select-online': () => {
|
|
document.querySelectorAll('input[name="qc-worker-cb"]').forEach(cb => {
|
|
cb.checked = cb.getAttribute('data-status') === 'online';
|
|
});
|
|
},
|
|
'qc:clear-all': () => {
|
|
document.querySelectorAll('input[name="qc-worker-cb"]').forEach(cb => { cb.checked = false; });
|
|
},
|
|
'qc:execute': execute,
|
|
});
|
|
|
|
if (window.lt && lt.keys && typeof lt.keys.on === 'function') {
|
|
lt.keys.on('ctrl+enter', () => { execute(); });
|
|
}
|
|
|
|
applyMode();
|
|
return loadWorkers().then(() => { consumeRerun(); });
|
|
}
|
|
|
|
function onEvent(type) {
|
|
if (type === 'worker_update') {
|
|
loadWorkers();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Pulse.registerPage({ name: 'quick', init, refresh, onEvent });
|
|
})();
|