239 lines
9.7 KiB
JavaScript
239 lines
9.7 KiB
JavaScript
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 '<span style="color:var(--text-muted);font-size:0.7rem">no history</span>';
|
|
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 `<svg width="${w}" height="${h}" class="sparkline"><polyline points="${pts}" fill="none" stroke="${color}" stroke-width="1.5"/></svg>`;
|
|
}
|
|
|
|
// 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 = '<i class="fa-solid fa-terminal"></i>';
|
|
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 = '<option value="all">All nodes</option>' +
|
|
(g.groups || []).map(x => `<option value="${escapeHtml(x.tag)}">${escapeHtml(x.tag)} (${x.count})</option>`).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 = '<div class="empty-state" style="padding:1rem"><p>No scheduled tasks yet. Add one below.</p></div>';
|
|
return;
|
|
}
|
|
el.innerHTML = d.schedules.map(s => `
|
|
<div class="schedule-item">
|
|
<div>
|
|
<strong>${escapeHtml(s.name)}</strong>
|
|
<span class="sched-meta">${escapeHtml(s.command)} • every ${s.intervalSec}s • group: ${escapeHtml(s.tag)}</span>
|
|
</div>
|
|
<div class="node-actions">
|
|
<button class="btn-icon" title="${s.enabled ? 'Pause' : 'Resume'}" onclick="toggleSchedule('${s.id}')">
|
|
<i class="fa-solid fa-${s.enabled ? 'pause' : 'play'}"></i>
|
|
</button>
|
|
<button class="btn-icon" title="Delete" onclick="deleteSchedule('${s.id}')">
|
|
<i class="fa-solid fa-trash-can"></i>
|
|
</button>
|
|
</div>
|
|
</div>`).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 = '<option value="all">All nodes</option>' +
|
|
(g.groups || []).map(x => `<option value="${escapeHtml(x.tag)}">${escapeHtml(x.tag)} (${x.count})</option>`).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 => `
|
|
<div class="log-entry error">
|
|
[${new Date(a.ts).toLocaleTimeString()}] ⚠️ ${escapeHtml(a.message)}
|
|
</div>`).join('')
|
|
: '<div class="log-entry system">No alerts. All nodes checking in.</div>';
|
|
}).catch(() => {});
|
|
}
|
|
setInterval(renderAlerts, 15000);
|
|
|
|
console.log('[v2] UI additions loaded');
|