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:
@@ -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">⚙</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 });
|
||||
})();
|
||||
Reference in New Issue
Block a user