/* ===================================================================== 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 ( '' ); } function cardHtml(worker) { const meta = parseMeta(worker); const online = worker.status === 'online'; const pct = memPct(meta).toFixed(1); return ( '
' + '' + '' + esc(worker.name) + '' + '' + esc(String(worker.status || '').toUpperCase()) + '' + '
' + '
' + 'Last seen: ' + esc(fmt.ago(worker.last_heartbeat)) + '
' + '
' + '
System
' + '
' + (meta ? esc((meta.platform || 'N/A') + ' ' + (meta.arch || '') + ' | ' + (meta.cpus || '?') + ' CPU cores') : 'N/A') + '
' + '
Memory
' + '
' + (meta ? esc(fmt.bytes(meta.totalMem - meta.freeMem) + ' / ' + fmt.bytes(meta.totalMem) + ' (' + pct + '% used)') : 'N/A') + '
' + '
' + '
Load Avg
' + '
' + esc(loadAvgText(meta)) + '
' + '
Uptime
' + '
' + esc(fmt.uptime(meta && meta.uptime)) + '
' + '
Active Tasks
' + '
' + esc((meta && meta.activeTasks || 0) + ' / ' + (meta && meta.maxConcurrentTasks || 0)) + '
' + '
' + 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 = '
' + '
' + '
No workers connected
' + '
Workers appear here once they connect and send a heartbeat.
' + '
'; 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 = '
Failed to load workers
'; } /* ------------------------------------------------------------------- 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 }); })();