const API = '/api/'; let teams = []; let events = []; let nodes = []; let links = []; let selectedNodeId = null; // Network canvas state let canvas, ctx; let canvasNodes = []; let canvasLinks = []; let isDragging = false; let dragNode = null; let offsetX, offsetY; let panX = 0, panY = 0; let isPanning = false; let panStartX, panStartY; document.addEventListener('DOMContentLoaded', () => { canvas = document.getElementById('networkCanvas'); ctx = canvas.getContext('2d'); resizeCanvas(); loadTeams().then(() => { loadEvents(); }); loadNetworkData(); document.getElementById('saveEvent').addEventListener('click', saveEvent); document.getElementById('saveNode').addEventListener('click', saveNode); document.getElementById('saveLink').addEventListener('click', saveLink); document.getElementById('teamFilter').addEventListener('change', renderTimeline); document.getElementById('searchEvents').addEventListener('input', renderTimeline); canvas.addEventListener('mousedown', onCanvasMouseDown); canvas.addEventListener('mousemove', onCanvasMouseMove); canvas.addEventListener('mouseup', onCanvasMouseUp); canvas.addEventListener('dblclick', onCanvasDblClick); window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); }); document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => { tab.addEventListener('shown.bs.tab', () => { if (tab.id === 'network-tab') { resizeCanvas(); renderNetwork(); } }); }); // Bootstrap dark mode default document.documentElement.setAttribute('data-bs-theme', 'dark'); }); function resizeCanvas() { const wrapper = document.getElementById('networkCanvasWrapper'); canvas.width = wrapper.clientWidth; canvas.height = wrapper.clientHeight; } // ==================== API HELPERS ==================== async function apiFetch(path, options = {}) { const res = await fetch(API + path, { headers: { 'Content-Type': 'application/json' }, ...options }); return res.json(); } // ==================== TEAMS ==================== async function loadTeams() { teams = await apiFetch('teams'); const selTeam = document.getElementById('eventTeam'); const filter = document.getElementById('teamFilter'); selTeam.innerHTML = ''; filter.innerHTML = ''; teams.forEach(t => { selTeam.innerHTML += ``; filter.innerHTML += ``; }); } // ==================== EVENTS / TIMELINE ==================== async function loadEvents() { events = await apiFetch('events'); renderTimeline(); } function renderTimeline() { const container = document.getElementById('timelineContainer'); const teamFilter = document.getElementById('teamFilter').value; const search = document.getElementById('searchEvents').value.toLowerCase(); let filtered = events; if (teamFilter) filtered = filtered.filter(e => e.team_id == teamFilter); if (search) filtered = filtered.filter(e => e.title.toLowerCase().includes(search) || (e.description && e.description.toLowerCase().includes(search)) ); if (!filtered.length) { container.innerHTML = `

No events yet. Create your first incident entry!

`; return; } container.innerHTML = filtered.map(e => { const sevClass = 'severity-' + e.severity; const date = new Date(e.occurred_at).toLocaleString(); return `
${e.team_name} ${e.severity} ${e.event_type}
${date}
${esc(e.title)}
${e.description ? `

${esc(e.description)}

` : ''}
Comments ${e.comments && e.comments.length ? `(${e.comments.length})` : ''}
${renderComments(e)}
`; }).join(''); } function renderComments(event) { if (!event.comments || !event.comments.length) return '
No comments yet
'; return event.comments.map(c => `
${esc(c.author)} ${new Date(c.created_at).toLocaleString()}
${esc(c.body)}
`).join(''); } async function addComment(eventId, el) { const container = el.closest('.comment-input-group') || el.parentElement; const input = container.querySelector('.comment-input'); const body = input.value.trim(); if (!body) return; const author = prompt('Your name:') || 'Anonymous'; await apiFetch('comments', { method: 'POST', body: JSON.stringify({ event_id: eventId, author, body }) }); input.value = ''; loadEvents(); } async function saveEvent() { const data = { team_id: document.getElementById('eventTeam').value, title: document.getElementById('eventTitle').value, description: document.getElementById('eventDescription').value, severity: document.getElementById('eventSeverity').value, event_type: document.getElementById('eventType').value, occurred_at: new Date().toISOString().slice(0, 16) }; if (!data.title) return alert('Title required'); await apiFetch('events', { method: 'POST', body: JSON.stringify(data) }); bootstrap.Modal.getInstance(document.getElementById('eventModal')).hide(); document.getElementById('eventForm').reset(); loadEvents(); } // ==================== NETWORK MAP ==================== async function loadNetworkData() { nodes = await apiFetch('nodes'); links = await apiFetch('links'); populateNodeSelects(); renderNodeList(); renderNetwork(); } function populateNodeSelects() { const html = nodes.map(n => ``).join(''); document.getElementById('linkSource').innerHTML = html; document.getElementById('linkTarget').innerHTML = html; } function renderNodeList() { const list = document.getElementById('nodeList'); list.innerHTML = nodes.map(n => `
${esc(n.label)}
${n.ip_address || '—'} · ${n.node_type} · ${n.group_name}
`).join(''); } function selectNode(id) { selectedNodeId = id; const n = nodes.find(x => x.id == id); if (n) { document.getElementById('nodeDetails').innerHTML = `
${esc(n.label)}
IP: ${n.ip_address || '—'}
Type: ${n.node_type}
Status: ${n.status}
Group: ${n.group_name}
`; } renderNodeList(); renderNetwork(); } async function saveNode() { const data = { label: document.getElementById('nodeLabel').value, ip_address: document.getElementById('nodeIp').value, node_type: document.getElementById('nodeType').value, status: document.getElementById('nodeStatus').value, group_name: document.getElementById('nodeGroup').value || 'default', pos_x: Math.random() * canvas.width * 0.6 + canvas.width * 0.2, pos_y: Math.random() * canvas.height * 0.6 + canvas.height * 0.2 }; if (!data.label) return alert('Label required'); await apiFetch('nodes', { method: 'POST', body: JSON.stringify(data) }); bootstrap.Modal.getInstance(document.getElementById('nodeModal')).hide(); document.getElementById('nodeForm').reset(); loadNetworkData(); } async function saveLink() { const data = { source_id: document.getElementById('linkSource').value, target_id: document.getElementById('linkTarget').value, link_type: document.getElementById('linkType').value, label: document.getElementById('linkLabel').value }; if (data.source_id === data.target_id) return alert('Source and target must differ'); await apiFetch('links', { method: 'POST', body: JSON.stringify(data) }); bootstrap.Modal.getInstance(document.getElementById('linkModal')).hide(); document.getElementById('linkForm').reset(); loadNetworkData(); } // ==================== CANVAS RENDERING ==================== function buildCanvasGraph() { canvasNodes = nodes.map(n => ({ id: n.id, label: n.label, ip: n.ip_address, type: n.node_type, status: n.status, group: n.group_name, x: parseFloat(n.pos_x) || (Math.random() * canvas.width * 0.6 + canvas.width * 0.2), y: parseFloat(n.pos_y) || (Math.random() * canvas.height * 0.6 + canvas.height * 0.2), radius: n.node_type === 'router' || n.node_type === 'firewall' ? 22 : n.node_type === 'server' ? 28 : 18 })); canvasLinks = links.map(l => ({ source: canvasNodes.find(n => n.id == l.source_id), target: canvasNodes.find(n => n.id == l.target_id), type: l.link_type, label: l.label })).filter(l => l.source && l.target); } function getNodeColor(node) { const colors = { host: '#3b82f6', server: '#8b5cf6', router: '#f59e0b', firewall: '#ef4444', switch: '#06b6d4', cloud: '#22c55e', endpoint: '#ec4899', other: '#6b7280' }; return colors[node.type] || '#6b7280'; } function renderNetwork() { buildCanvasGraph(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(panX, panY); // Draw links canvasLinks.forEach(l => { ctx.beginPath(); ctx.moveTo(l.source.x, l.source.y); ctx.lineTo(l.target.x, l.target.y); const colors = { direct: '#334155', vpn: '#eab308', wireless: '#22c55e', monitored: '#3b82f6' }; ctx.strokeStyle = colors[l.type] || '#334155'; ctx.lineWidth = l.type === 'vpn' ? 2.5 : l.type === 'monitored' ? 1.5 : 1.5; if (l.type === 'vpn' || l.type === 'wireless') { ctx.setLineDash([6, 4]); } else { ctx.setLineDash([]); } ctx.stroke(); ctx.setLineDash([]); // Link label if (l.label) { const mx = (l.source.x + l.target.x) / 2; const my = (l.source.y + l.target.y) / 2; ctx.fillStyle = '#94a3b8'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(l.label, mx, my - 6); } }); // Draw nodes canvasNodes.forEach(n => { const isSelected = selectedNodeId == n.id; const color = getNodeColor(n); // Glow for selected if (isSelected) { ctx.shadowColor = color; ctx.shadowBlur = 20; } // Shape by type ctx.beginPath(); if (n.type === 'router' || n.type === 'firewall') { // Diamond ctx.moveTo(n.x, n.y - n.radius); ctx.lineTo(n.x + n.radius, n.y); ctx.lineTo(n.x, n.y + n.radius); ctx.lineTo(n.x - n.radius, n.y); ctx.closePath(); } else if (n.type === 'cloud') { // Cloud-like ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); } else if (n.type === 'server') { // Square ctx.rect(n.x - n.radius * 0.8, n.y - n.radius * 0.8, n.radius * 1.6, n.radius * 1.6); } else { // Circle (host, endpoint, etc) ctx.arc(n.x, n.y, n.radius, 0, Math.PI * 2); } ctx.fillStyle = color + '30'; ctx.fill(); ctx.strokeStyle = isSelected ? '#fff' : color; ctx.lineWidth = isSelected ? 3 : 2; ctx.stroke(); // Status indicator ctx.shadowBlur = 0; ctx.beginPath(); ctx.arc(n.x + n.radius * 0.6, n.y - n.radius * 0.6, 5, 0, Math.PI * 2); const statusColors = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' }; ctx.fillStyle = statusColors[n.status] || '#9ca3af'; ctx.fill(); // Label ctx.fillStyle = '#e2e8f0'; ctx.font = isSelected ? 'bold 11px sans-serif' : '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(n.label, n.x, n.y + n.radius + 14); if (n.ip) { ctx.fillStyle = '#64748b'; ctx.font = '9px sans-serif'; ctx.fillText(n.ip, n.x, n.y + n.radius + 26); } }); ctx.restore(); } // ==================== CANVAS EVENTS ==================== function getCanvasNode(mx, my) { return canvasNodes.find(n => { const dx = mx - n.x, dy = my - n.y; return Math.sqrt(dx * dx + dy * dy) < n.radius + 8; }); } function onCanvasMouseDown(e) { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; const node = getCanvasNode(mx, my); if (node) { isDragging = true; dragNode = node; offsetX = mx - node.x; offsetY = my - node.y; canvas.style.cursor = 'grabbing'; } else { isPanning = true; panStartX = e.clientX - panX; panStartY = e.clientY - panY; canvas.style.cursor = 'grabbing'; } } function onCanvasMouseMove(e) { const rect = canvas.getBoundingClientRect(); if (isDragging && dragNode) { dragNode.x = e.clientX - rect.left - panX - offsetX; dragNode.y = e.clientY - rect.top - panY - offsetY; renderNetwork(); } else if (isPanning) { panX = e.clientX - panStartX; panY = e.clientY - panStartY; renderNetwork(); } else { const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; canvas.style.cursor = getCanvasNode(mx, my) ? 'pointer' : 'grab'; } } function onCanvasMouseUp(e) { if (isDragging && dragNode) { // Save position apiFetch(`nodes/${dragNode.id}`, { method: 'PUT', body: JSON.stringify({ pos_x: dragNode.x, pos_y: dragNode.y }) }); } isDragging = false; dragNode = null; isPanning = false; canvas.style.cursor = 'grab'; } function onCanvasDblClick(e) { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; const node = getCanvasNode(mx, my); if (node) { selectNode(node.id); } } function esc(s) { if (!s) return ''; const div = document.createElement('div'); div.textContent = s; return div.innerHTML; }