async function api(path, opts = {}) { opts.headers = Object.assign({ 'Content-Type': 'application/json' }, opts.headers || {}); const r = await fetch(path, opts); if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); } /* ═══ NexusOps v2 UI: live console, sparklines, schedules, groups, alerts ═══ */ // ── Live Console (drawer) ── let consoleNode = null; let consoleEventSource = null; function openLiveConsole(nodeId, hostname) { consoleNode = { id: nodeId, hostname }; document.getElementById('consoleDrawerHostname').textContent = hostname; document.getElementById('consoleOutput').innerHTML = ''; document.getElementById('consoleDrawer').classList.add('active'); document.getElementById('consoleInput').focus(); // ask agent to fast-poll api(`/api/nodes/${nodeId}/fastpoll`, { method: 'POST', body: JSON.stringify({ slow: false }) }).catch(() => {}); const url = `/api/nodes/${nodeId}/console${nexusToken ? '?token=' + encodeURIComponent(nexusToken) : ''}`; const out = document.getElementById('consoleOutput'); appendConsoleLine('── console attached (fast-poll active) ──', 'sys'); consoleEventSource = new EventSource(url); consoleEventSource.onmessage = (ev) => { try { const d = JSON.parse(ev.data); if (d.type === 'output') appendConsoleLine(d.text, 'out'); } catch (e) {} }; consoleEventSource.onerror = () => appendConsoleLine('── stream interrupted, retrying… ──', 'sys'); } function closeLiveConsole() { if (consoleEventSource) { consoleEventSource.close(); consoleEventSource = null; } if (consoleNode) { // restore slow polling api(`/api/nodes/${consoleNode.id}/fastpoll`, { method: 'POST', body: JSON.stringify({ slow: true }) }).catch(() => {}); } document.getElementById('consoleDrawer').classList.remove('active'); consoleNode = null; } function appendConsoleLine(text, cls) { const out = document.getElementById('consoleOutput'); const div = document.createElement('div'); div.className = 'console-line ' + (cls || 'out'); div.textContent = text; out.appendChild(div); out.scrollTop = out.scrollHeight; } async function consoleRunCommand() { const input = document.getElementById('consoleInput'); const cmd = input.value.trim(); if (!cmd || !consoleNode) return; appendConsoleLine(`$ ${cmd}`, 'cmd'); input.value = ''; try { const r = await fetch(`/api/nodes/${consoleNode.id}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: cmd, actionType: 'raw_command' }) }); if (!r.ok) appendConsoleLine('! failed to queue command', 'sys'); } catch (e) { appendConsoleLine('! failed to queue command', 'sys'); } } // ── Sparklines (inline SVG from metricsHistory) ── function sparkline(values, color) { if (!values || values.length < 2) return 'no history'; const w = 90, h = 24, max = Math.max(...values, 100); const pts = values.map((v, i) => `${(i / (values.length - 1)) * w},${h - (v / max) * h}`).join(' '); return ``; } // Patch renderNodesGrid to add sparkline row + console button const _origRenderNodesGrid = renderNodesGrid; renderNodesGrid = function () { _origRenderNodesGrid(); // inject sparklines into each node card document.querySelectorAll('.node-card').forEach((card, idx) => { // cards render in filtered order; match by hostname text — safer: find by data via original data }); // Simpler: re-render footer metrics with sparkline data attribute injection }; // Simpler approach: monkey-patch the map output by wrapping string injection (function patchSparklines() { const orig = renderNodesGrid; renderNodesGrid = function () { orig(); // attach sparkline next to heartbeat label per card using nodesData order const cards = document.querySelectorAll('#nodesGrid .node-card'); const filtered = nodesData.filter(node => { const search = (document.getElementById('searchInput')?.value || '').toLowerCase(); const matchesFilter = currentFilter === 'all' || node.status === currentFilter; const matchesSearch = !search || node.hostname.toLowerCase().includes(search) || node.ip.toLowerCase().includes(search) || node.platform.toLowerCase().includes(search) || node.id.toLowerCase().includes(search); return matchesFilter && matchesSearch; }); cards.forEach((card, i) => { const node = filtered[i]; if (!node) return; const hist = (node.metricsHistory || []).map(m => m.cpu); const foot = card.querySelector('.node-actions'); if (foot) { const spark = document.createElement('span'); spark.innerHTML = sparkline(hist, node.status === 'online' ? '#4ade80' : '#f87171'); spark.title = 'CPU history (last 30 beats)'; spark.style.marginRight = '0.5rem'; foot.parentNode.insertBefore(spark, foot); // live console button const btn = document.createElement('button'); btn.className = 'btn-icon'; btn.title = 'Live Console'; btn.innerHTML = ''; btn.onclick = () => openLiveConsole(node.id, node.hostname.replace(/'/g, "\\'")); foot.insertBefore(btn, foot.firstChild); } }); }; })(); // ── Schedules panel ── function openSchedulesModal() { document.getElementById('schedulesModal').classList.add('active'); renderSchedules(); api('/api/groups').then(g => { const sel = document.getElementById('schedTag'); sel.innerHTML = '' + (g.groups || []).map(x => ``).join(''); }).catch(() => {}); } function closeSchedulesModal() { document.getElementById('schedulesModal').classList.remove('active'); } function renderSchedules() { api('/api/schedules').then(d => { const el = document.getElementById('schedulesList'); if (!d.schedules || !d.schedules.length) { el.innerHTML = '

No scheduled tasks yet. Add one below.

'; return; } el.innerHTML = d.schedules.map(s => `
${escapeHtml(s.name)} ${escapeHtml(s.command)} • every ${s.intervalSec}s • group: ${escapeHtml(s.tag)}
`).join(''); }).catch(() => {}); } async function createSchedule() { const name = document.getElementById('schedName').value.trim(); const command = document.getElementById('schedCommand').value.trim(); const interval = parseInt(document.getElementById('schedInterval').value, 10); const tag = document.getElementById('schedTag').value || 'all'; if (!command || !interval || interval < 15) { toast('Need a command and interval ≥ 15s', 'error'); return; } await api('/api/schedules', { method: 'POST', body: JSON.stringify({ name, command, intervalSec: interval, tag }) }); document.getElementById('schedName').value = ''; document.getElementById('schedCommand').value = ''; toast('Schedule created', 'success'); renderSchedules(); } async function toggleSchedule(id) { await api(`/api/schedules/${id}/toggle`, { method: 'POST' }); renderSchedules(); } async function deleteSchedule(id) { await api(`/api/schedules/${id}`, { method: 'DELETE' }); renderSchedules(); } // ── Group bulk command modal ── function openGroupCommandModal() { document.getElementById('groupCmdModal').classList.add('active'); api('/api/groups').then(g => { const sel = document.getElementById('groupCmdTag'); sel.innerHTML = '' + (g.groups || []).map(x => ``).join(''); }).catch(() => {}); } function closeGroupCommandModal() { document.getElementById('groupCmdModal').classList.remove('active'); } async function submitGroupCommand() { const command = document.getElementById('groupCmdInput').value.trim(); const tag = document.getElementById('groupCmdTag').value || 'all'; if (!command) { toast('Enter a command', 'error'); return; } try { const d = await api('/api/groups/command', { method: 'POST', body: JSON.stringify({ command, tag }) }); toast(`Queued on ${d.count} node(s) in "${tag}"`, 'success'); closeGroupCommandModal(); } catch (e) { toast('Failed to queue group command', 'error'); } } // ── Alerts feed ── function renderAlerts() { const el = document.getElementById('alertsFeed'); if (!el) return; api('/api/alerts').then(d => { const alerts = d.alerts || []; document.getElementById('alertCount').textContent = `${alerts.length}`; el.innerHTML = alerts.length ? alerts.slice().reverse().map(a => `
[${new Date(a.ts).toLocaleTimeString()}] ⚠️ ${escapeHtml(a.message)}
`).join('') : '
No alerts. All nodes checking in.
'; }).catch(() => {}); } setInterval(renderAlerts, 15000); console.log('[v2] UI additions loaded');