- 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
258 lines
10 KiB
JavaScript
258 lines
10 KiB
JavaScript
/* =====================================================================
|
|
PULSE — Workers page (WP-C)
|
|
Owns DOM id prefix `wk-` and action namespace `wk:*`.
|
|
===================================================================== */
|
|
'use strict';
|
|
|
|
(function () {
|
|
const esc = Pulse.esc;
|
|
const fmt = Pulse.fmt;
|
|
|
|
function parseMeta(worker) {
|
|
if (!worker || !worker.metadata) return null;
|
|
if (typeof worker.metadata === 'string') {
|
|
try { return JSON.parse(worker.metadata); } catch (e) { return null; }
|
|
}
|
|
return worker.metadata;
|
|
}
|
|
|
|
function memPct(meta) {
|
|
if (!meta || !meta.totalMem) return 0;
|
|
return ((meta.totalMem - meta.freeMem) / meta.totalMem * 100);
|
|
}
|
|
|
|
function loadAvgText(meta) {
|
|
if (!meta || !meta.loadavg) return 'N/A';
|
|
return meta.loadavg.map(l => Number(l).toFixed(2)).join(', ');
|
|
}
|
|
|
|
/* -------------------------------------------------------------------
|
|
Card construction
|
|
------------------------------------------------------------------- */
|
|
function footerHtml(worker) {
|
|
if (!Pulse.isAdmin) return '';
|
|
return (
|
|
'<div class="lt-card-footer">' +
|
|
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm" data-action="wk:delete" ' +
|
|
'data-worker-id="' + esc(worker.id) + '" data-worker-name="' + esc(worker.name) + '">Delete</button>' +
|
|
'</div>'
|
|
);
|
|
}
|
|
|
|
function cardHtml(worker) {
|
|
const meta = parseMeta(worker);
|
|
const online = worker.status === 'online';
|
|
const pct = memPct(meta).toFixed(1);
|
|
return (
|
|
'<div class="wk-card-header">' +
|
|
'<span class="lt-dot ' + (online ? 'lt-dot-up' : 'lt-dot-down') + '" data-wk-dot></span>' +
|
|
'<span class="wk-name" data-wk-name>' + esc(worker.name) + '</span>' +
|
|
'<span class="' + fmt.status(worker.status) + '" data-wk-status>' + esc(String(worker.status || '').toUpperCase()) + '</span>' +
|
|
'</div>' +
|
|
'<div class="wk-lastseen" data-wk-lastseen data-last-heartbeat="' + esc(worker.last_heartbeat || '') + '">' +
|
|
'Last seen: ' + esc(fmt.ago(worker.last_heartbeat)) +
|
|
'</div>' +
|
|
'<div class="lt-kv-grid">' +
|
|
'<div class="lt-kv-key">System</div>' +
|
|
'<div class="lt-kv-val" data-wk-system>' + (meta ? esc((meta.platform || 'N/A') + ' ' + (meta.arch || '') + ' | ' + (meta.cpus || '?') + ' CPU cores') : 'N/A') + '</div>' +
|
|
'<div class="lt-kv-key">Memory</div>' +
|
|
'<div class="lt-kv-val" data-wk-memory>' +
|
|
(meta ? esc(fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + ' (' + pct + '% used)') : 'N/A') +
|
|
'<div class="lt-progress"><div class="lt-progress-bar" data-wk-membar style="width:' + (meta ? pct : 0) + '%"></div></div>' +
|
|
'</div>' +
|
|
'<div class="lt-kv-key">Load Avg</div>' +
|
|
'<div class="lt-kv-val" data-wk-load>' + esc(loadAvgText(meta)) + '</div>' +
|
|
'<div class="lt-kv-key">Uptime</div>' +
|
|
'<div class="lt-kv-val" data-wk-uptime>' + esc(fmt.uptime(meta && meta.uptime)) + '</div>' +
|
|
'<div class="lt-kv-key">Active Tasks</div>' +
|
|
'<div class="lt-kv-val" data-wk-tasks>' + esc((meta && meta.activeTasks || 0) + ' / ' + (meta && meta.maxConcurrentTasks || 0)) + '</div>' +
|
|
'</div>' +
|
|
footerHtml(worker)
|
|
);
|
|
}
|
|
|
|
function createCard(worker) {
|
|
const div = document.createElement('div');
|
|
div.className = 'lt-card wk-card';
|
|
div.setAttribute('data-worker-id', worker.id);
|
|
div.classList.add(worker.status === 'online' ? 'wk-card-online' : 'wk-card-offline');
|
|
div.innerHTML = cardHtml(worker);
|
|
return div;
|
|
}
|
|
|
|
/* Update an existing card's fields in place — never replaces the card
|
|
node itself, so identity is preserved across refreshes. */
|
|
function patchCard(card, worker) {
|
|
const meta = parseMeta(worker);
|
|
const online = worker.status === 'online';
|
|
|
|
card.classList.toggle('wk-card-online', online);
|
|
card.classList.toggle('wk-card-offline', !online);
|
|
|
|
const dot = card.querySelector('[data-wk-dot]');
|
|
if (dot) dot.className = 'lt-dot ' + (online ? 'lt-dot-up' : 'lt-dot-down');
|
|
|
|
const name = card.querySelector('[data-wk-name]');
|
|
if (name) name.textContent = worker.name;
|
|
|
|
const status = card.querySelector('[data-wk-status]');
|
|
if (status) {
|
|
status.className = fmt.status(worker.status);
|
|
status.textContent = String(worker.status || '').toUpperCase();
|
|
}
|
|
|
|
const lastSeen = card.querySelector('[data-wk-lastseen]');
|
|
if (lastSeen) {
|
|
lastSeen.setAttribute('data-last-heartbeat', worker.last_heartbeat || '');
|
|
lastSeen.textContent = 'Last seen: ' + fmt.ago(worker.last_heartbeat);
|
|
}
|
|
|
|
const system = card.querySelector('[data-wk-system]');
|
|
if (system) system.textContent = meta ? ((meta.platform || 'N/A') + ' ' + (meta.arch || '') + ' | ' + (meta.cpus || '?') + ' CPU cores') : 'N/A';
|
|
|
|
const pct = memPct(meta).toFixed(1);
|
|
const memory = card.querySelector('[data-wk-memory]');
|
|
if (memory) {
|
|
const bar = memory.querySelector('[data-wk-membar]');
|
|
const text = meta ? (fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + ' (' + pct + '% used)') : 'N/A';
|
|
if (memory.firstChild && memory.firstChild.nodeType === Node.TEXT_NODE) {
|
|
memory.firstChild.textContent = text;
|
|
} else {
|
|
memory.insertBefore(document.createTextNode(text), memory.firstChild);
|
|
}
|
|
if (bar) bar.style.width = (meta ? pct : 0) + '%';
|
|
}
|
|
|
|
const load = card.querySelector('[data-wk-load]');
|
|
if (load) load.textContent = loadAvgText(meta);
|
|
|
|
const uptime = card.querySelector('[data-wk-uptime]');
|
|
if (uptime) uptime.textContent = fmt.uptime(meta && meta.uptime);
|
|
|
|
const tasks = card.querySelector('[data-wk-tasks]');
|
|
if (tasks) tasks.textContent = (meta && meta.activeTasks || 0) + ' / ' + (meta && meta.maxConcurrentTasks || 0);
|
|
|
|
// Admin footer can appear/disappear if isAdmin changes mid-session (rare,
|
|
// but keep it correct rather than assuming it's static).
|
|
const hasFooter = !!card.querySelector('.lt-card-footer');
|
|
if (Pulse.isAdmin && !hasFooter) {
|
|
card.insertAdjacentHTML('beforeend', footerHtml(worker));
|
|
} else if (!Pulse.isAdmin && hasFooter) {
|
|
const f = card.querySelector('.lt-card-footer');
|
|
if (f) f.remove();
|
|
} else if (hasFooter) {
|
|
const btn = card.querySelector('[data-action="wk:delete"]');
|
|
if (btn) btn.setAttribute('data-worker-name', worker.name);
|
|
}
|
|
}
|
|
|
|
/* -------------------------------------------------------------------
|
|
Grid rendering — patches existing cards, adds/removes as needed.
|
|
------------------------------------------------------------------- */
|
|
function renderGrid(workers) {
|
|
const grid = document.getElementById('wk-grid');
|
|
if (!grid) return;
|
|
|
|
if (workers.length === 0) {
|
|
grid.innerHTML =
|
|
'<div class="lt-empty-state">' +
|
|
'<div class="lt-empty-state-icon">⚙</div>' +
|
|
'<div class="lt-empty-state-title">No workers connected</div>' +
|
|
'<div class="lt-empty-state-body">Workers appear here once they connect and send a heartbeat.</div>' +
|
|
'</div>';
|
|
return;
|
|
}
|
|
|
|
const loading = document.getElementById('wk-loading');
|
|
if (loading) loading.remove();
|
|
|
|
const seen = new Set();
|
|
const order = [];
|
|
workers.forEach(worker => {
|
|
seen.add(String(worker.id));
|
|
let card = grid.querySelector('.wk-card[data-worker-id="' + cssEscape(worker.id) + '"]');
|
|
if (!card) {
|
|
card = createCard(worker);
|
|
} else {
|
|
patchCard(card, worker);
|
|
}
|
|
order.push(card);
|
|
});
|
|
|
|
// Remove cards for workers no longer present.
|
|
Array.prototype.slice.call(grid.querySelectorAll('.wk-card')).forEach(card => {
|
|
if (!seen.has(String(card.getAttribute('data-worker-id')))) card.remove();
|
|
});
|
|
|
|
// Ensure DOM order matches API order without detaching untouched nodes
|
|
// unnecessarily (appendChild on an already-positioned node is a no-op
|
|
// move, not a re-create, so identity is preserved either way).
|
|
order.forEach(card => grid.appendChild(card));
|
|
}
|
|
|
|
function cssEscape(v) {
|
|
if (window.CSS && CSS.escape) return CSS.escape(String(v));
|
|
return String(v).replace(/["\\]/g, '\\$&');
|
|
}
|
|
|
|
function renderError() {
|
|
const grid = document.getElementById('wk-grid');
|
|
if (!grid) return;
|
|
grid.innerHTML = '<div class="lt-alert lt-alert--error"><div class="lt-alert-body"><div class="lt-alert-msg">Failed to load workers</div></div></div>';
|
|
}
|
|
|
|
/* -------------------------------------------------------------------
|
|
Data loading + actions
|
|
------------------------------------------------------------------- */
|
|
async function loadWorkers() {
|
|
try {
|
|
const workers = await Pulse.api.get('/api/workers') || [];
|
|
renderGrid(workers);
|
|
} catch (e) {
|
|
console.error('[Pulse:workers] failed to load workers', e);
|
|
renderError();
|
|
}
|
|
}
|
|
|
|
async function deleteWorker(el) {
|
|
const id = el.getAttribute('data-worker-id');
|
|
const name = el.getAttribute('data-worker-name') || 'this worker';
|
|
const ok = await Pulse.confirm({
|
|
title: 'Delete worker',
|
|
message: 'Delete worker ' + name + '?',
|
|
type: 'error',
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await Pulse.api.delete('/api/workers/' + encodeURIComponent(id));
|
|
Pulse.toast.success('Worker deleted');
|
|
await loadWorkers();
|
|
} catch (e) {
|
|
Pulse.toast.error((e && e.message) || 'Failed to delete worker');
|
|
}
|
|
}
|
|
|
|
function onTick() {
|
|
document.querySelectorAll('#wk-grid [data-wk-lastseen][data-last-heartbeat]').forEach(el => {
|
|
const v = el.getAttribute('data-last-heartbeat');
|
|
if (v) el.textContent = 'Last seen: ' + fmt.ago(v);
|
|
});
|
|
}
|
|
|
|
function init() {
|
|
Pulse.actions.register('wk:delete', deleteWorker);
|
|
Pulse.events.on('tick', onTick);
|
|
return loadWorkers();
|
|
}
|
|
|
|
function onEvent(type) {
|
|
if (type === 'worker_update') {
|
|
loadWorkers();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Pulse.registerPage({ name: 'workers', init, refresh: loadWorkers, onEvent });
|
|
})();
|