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

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">&#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 });
})();