const API = '/api/'; let teams = []; let events = []; let nodes = []; let links = []; let shapes = []; let selectedNodeId = null; let selectedShapeId = null; let canvas, ctx; let canvasNodes = []; let canvasLinks = []; let canvasShapes = []; let panX = 0, panY = 0; let isPanning = false; let panStartX, panStartY; let dragTarget = null; let dragType = null; let dragOffX, dragOffY; let dragHandle = null; let dragOrig = null; let nextShapeZ = 0; 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('saveShape').addEventListener('click', saveShape); document.getElementById('teamFilter').addEventListener('change', renderTimeline); document.getElementById('searchEvents').addEventListener('input', renderTimeline); document.getElementById('shapeOpacity').addEventListener('input', (e) => { document.getElementById('opacityVal').textContent = parseFloat(e.target.value).toFixed(2); }); canvas.addEventListener('mousedown', onMouseDown); canvas.addEventListener('mousemove', onMouseMove); canvas.addEventListener('mouseup', onMouseUp); canvas.addEventListener('dblclick', onDblClick); canvas.addEventListener('contextmenu', (e) => e.preventDefault()); window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Delete' || e.key === 'Backspace') { if (!document.activeElement || document.activeElement.tagName !== 'INPUT') { if (selectedNodeId) deleteSelectedNode(); else if (selectedShapeId) deleteSelectedShape(); } } if (e.key === 'Escape') { selectedNodeId = null; selectedShapeId = null; renderNetwork(); renderNodeList(); renderShapeList(); } }); document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => { tab.addEventListener('shown.bs.tab', () => { if (tab.id === 'network-tab') { resizeCanvas(); renderNetwork(); } }); }); document.documentElement.setAttribute('data-bs-theme', 'dark'); }); function resizeCanvas() { const wrapper = document.getElementById('networkCanvasWrapper'); canvas.width = wrapper.clientWidth; canvas.height = wrapper.clientHeight; } 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 sel = document.getElementById('eventTeam'); const filter = document.getElementById('teamFilter'); sel.innerHTML = ''; filter.innerHTML = ''; teams.forEach(t => { sel.innerHTML += ``; filter.innerHTML += ``; }); } // ==================== EVENTS ==================== 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 date = new Date(e.occurred_at).toLocaleString(); return `
${esc(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 DATA ==================== async function loadNetworkData() { const [n, l, s] = await Promise.all([ apiFetch('nodes'), apiFetch('links'), apiFetch('shapes') ]); nodes = n; links = l; shapes = s; populateNodeSelects(); renderNodeList(); renderShapeList(); if (shapes.length) nextShapeZ = Math.max(...shapes.map(x => x.z_index)) + 1; 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 => { const iconMap = { host:'fa-desktop', server:'fa-server', router:'fa-route', firewall:'fa-shield-halved', switch:'fa-network-wired', cloud:'fa-cloud', endpoint:'fa-laptop', other:'fa-circle' }; return `
${esc(n.label)}
${n.ip_address || '—'} · ${n.node_type}
`; }).join(''); } function renderShapeList() { const list = document.getElementById('shapeList'); list.innerHTML = shapes.map(s => `
${esc(s.label) || (s.shape_type === 'rectangle' ? 'Box' : 'Ellipse')}
${s.shape_type}
`).join(''); } function selectNode(id) { selectedNodeId = id; selectedShapeId = null; 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(); renderShapeList(); renderNetwork(); } function selectShape(id) { selectedShapeId = id; selectedNodeId = null; renderNodeList(); renderShapeList(); renderNetwork(); } async function deleteSelectedNode() { if (!selectedNodeId) return; if (!confirm('Delete this node and its connections?')) return; const id = selectedNodeId; selectedNodeId = null; await apiFetch(`nodes/${id}`, { method: 'DELETE' }); loadNetworkData(); } async function deleteSelectedShape() { if (!selectedShapeId) return; if (!confirm('Delete this shape?')) return; const id = selectedShapeId; selectedShapeId = null; await apiFetch(`shapes/${id}`, { method: 'DELETE' }); loadNetworkData(); } 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 - panX, pos_y: Math.random() * canvas.height * 0.6 + canvas.height * 0.2 - panY }; 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(); } async function saveShape() { const data = { label: document.getElementById('shapeLabel').value, shape_type: document.getElementById('shapeType').value, pos_x: canvas.width / 2 - 100 - panX, pos_y: canvas.height / 2 - 75 - panY, width: 200, height: 150, color: document.getElementById('shapeColor').value, border_color: document.getElementById('shapeBorderColor').value, opacity: parseFloat(document.getElementById('shapeOpacity').value), z_index: nextShapeZ++ }; await apiFetch('shapes', { method: 'POST', body: JSON.stringify(data) }); bootstrap.Modal.getInstance(document.getElementById('shapeModal')).hide(); document.getElementById('shapeForm').reset(); loadNetworkData(); } function getNodeColorVal(type) { const c = { host:'#3b82f6', server:'#8b5cf6', router:'#f59e0b', firewall:'#ef4444', switch:'#06b6d4', cloud:'#22c55e', endpoint:'#ec4899', other:'#6b7280' }; return c[type] || '#6b7280'; } // ==================== CANVAS RENDERING ==================== const NODE_FA_ICONS = { host: { path: 'M7,4 L17,4 L20,9 L20,18 L4,18 L4,9 Z', color: '#3b82f6' }, server: { path: 'M6,3 L18,3 L20,6 L20,20 L4,20 L4,6 Z M8,10 L16,10 M8,14 L16,14 M8,17 L12,17', color: '#8b5cf6' }, router: { path: 'M12,3 L21,10 L18,20 L6,20 L3,10 Z M12,6 L12,17 M8,10 L16,10', color: '#f59e0b' }, firewall: { path: 'M6,3 L18,3 L21,8 L21,16 L18,21 L6,21 L3,16 L3,8 Z M9,10 L15,10 L15,14 L9,14 Z', color: '#ef4444' }, switch: { path: 'M4,8 L20,8 L20,16 L4,16 Z M7,11 L7,13 M10,11 L10,13 M13,11 L13,13 M16,11 L16,13', color: '#06b6d4' }, cloud: { path: 'M10,4 C6,4 4,7 5,10 C3,11 2,14 4,16 L8,16 C10,18 14,18 16,16 L20,16 C22,14 21,10 19,9 C20,6 17,4 15,5 C14,4 12,4 10,4 Z', color: '#22c55e' }, endpoint: { path: 'M8,4 L16,4 L18,10 L18,16 L16,20 L8,20 L6,16 L6,10 Z M10,12 L14,12 M12,10 L12,14', color: '#ec4899' }, other: { path: 'M8,6 L16,6 L18,12 L16,18 L8,18 L6,12 Z', color: '#6b7280' } }; function buildCanvasGraph() { canvasNodes = nodes.map(n => { const fa = NODE_FA_ICONS[n.node_type] || NODE_FA_ICONS.other; return { 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) || 100, y: parseFloat(n.pos_y) || 100, iconPath: fa.path, color: fa.color, w: 36, h: 36 }; }); 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); canvasShapes = shapes.map(s => ({ id: s.id, label: s.label, type: s.shape_type, x: parseFloat(s.pos_x), y: parseFloat(s.pos_y), w: parseFloat(s.width), h: parseFloat(s.height), color: s.color, borderColor: s.border_color, opacity: parseFloat(s.opacity), z: parseInt(s.z_index) })); } function renderNetwork() { buildCanvasGraph(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.save(); ctx.translate(panX, panY); canvasShapes.sort((a, b) => a.z - b.z).forEach(drawShape); 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 : 1.5; if (l.type === 'vpn' || l.type === 'wireless') ctx.setLineDash([6, 4]); else ctx.setLineDash([]); ctx.stroke(); ctx.setLineDash([]); if (l.label) { ctx.fillStyle = '#94a3b8'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(l.label, (l.source.x + l.target.x) / 2, (l.source.y + l.target.y) / 2 - 8); } }); canvasNodes.forEach(drawCanvasNode); ctx.restore(); } function drawShape(s) { ctx.save(); const sel = selectedShapeId == s.id; ctx.globalAlpha = s.opacity; if (s.type === 'ellipse') { ctx.beginPath(); ctx.ellipse(s.x + s.w / 2, s.y + s.h / 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2); } else { ctx.beginPath(); ctx.roundRect(s.x, s.y, s.w, s.h, 8); } ctx.fillStyle = s.color; ctx.fill(); ctx.globalAlpha = 1; ctx.strokeStyle = sel ? '#ffffff' : s.borderColor; ctx.lineWidth = sel ? 2.5 : 1.5; ctx.setLineDash([5, 3]); ctx.stroke(); ctx.setLineDash([]); if (sel) { getShapeHandles(s).forEach(h => { ctx.beginPath(); ctx.arc(h.x, h.y, 5, 0, Math.PI * 2); ctx.fillStyle = '#ffffff'; ctx.fill(); ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; ctx.stroke(); }); } ctx.fillStyle = '#94a3b8'; ctx.font = '12px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(s.label || '', s.x + s.w / 2, s.y - 8); ctx.restore(); } function drawCanvasNode(n) { const sel = selectedNodeId == n.id; ctx.save(); if (sel) { ctx.shadowColor = n.color; ctx.shadowBlur = 18; } ctx.beginPath(); ctx.arc(n.x, n.y, 22, 0, Math.PI * 2); ctx.fillStyle = n.color + '18'; ctx.fill(); ctx.strokeStyle = sel ? '#ffffff' : n.color; ctx.lineWidth = sel ? 2.5 : 1.5; ctx.stroke(); ctx.shadowBlur = 0; // Icon path scaled const scaled = n.iconPath.replace(/([\d.]+)/g, m => ((parseFloat(m) - 12) * 0.85 + 0).toFixed(1)); const path = new Path2D(scaled); ctx.fillStyle = n.color; ctx.fill(path); // Status dot ctx.beginPath(); ctx.arc(17, -17, 5, 0, Math.PI * 2); const sc = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' }; ctx.fillStyle = sc[n.status] || '#9ca3af'; ctx.fill(); if (n.status === 'compromised') { ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.stroke(); } ctx.fillStyle = '#e2e8f0'; ctx.font = sel ? 'bold 11px sans-serif' : '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(n.label, n.x, n.y + 34); if (n.ip) { ctx.fillStyle = '#64748b'; ctx.font = '9px sans-serif'; ctx.fillText(n.ip, n.x, n.y + 46); } ctx.restore(); } function getShapeHandles(s) { return [ { x: s.x, y: s.y }, { x: s.x + s.w, y: s.y }, { x: s.x + s.w, y: s.y + s.h }, { x: s.x, y: s.y + s.h } ]; } // ==================== CANVAS EVENTS ==================== function getCanvasNodeAt(mx, my) { return canvasNodes.find(n => Math.hypot(mx - n.x, my - n.y) < 28); } function getShapeAt(mx, my) { for (let i = canvasShapes.length - 1; i >= 0; i--) { const s = canvasShapes[i]; if (mx >= s.x && mx <= s.x + s.w && my >= s.y && my <= s.y + s.h) return s; } return null; } function getShapeResizeHandleAt(mx, my) { for (const s of canvasShapes) { if (selectedShapeId != s.id) continue; for (const h of getShapeHandles(s)) { if (Math.hypot(mx - h.x, my - h.y) < 8) return { shape: s, hx: h.x, hy: h.y }; } } return null; } function onMouseDown(e) { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; const resize = getShapeResizeHandleAt(mx, my); if (resize) { const s = resize.shape; dragType = 'resize'; dragTarget = s; dragOffX = mx; dragOffY = my; dragOrig = { x: s.x, y: s.y, w: s.w, h: s.h }; return; } const node = getCanvasNodeAt(mx, my); if (node) { dragType = 'node'; dragTarget = node; dragOffX = mx - node.x; dragOffY = my - node.y; selectNode(node.id); return; } const shape = getShapeAt(mx, my); if (shape) { selectedNodeId = null; selectedShapeId = shape.id; dragType = 'shape'; dragTarget = shape; dragOffX = mx - shape.x; dragOffY = my - shape.y; renderNodeList(); renderShapeList(); renderNetwork(); return; } selectedNodeId = null; selectedShapeId = null; renderNodeList(); renderShapeList(); isPanning = true; panStartX = e.clientX - panX; panStartY = e.clientY - panY; canvas.style.cursor = 'grabbing'; } function onMouseMove(e) { const rect = canvas.getBoundingClientRect(); if (dragType === 'node' && dragTarget) { dragTarget.x = e.clientX - rect.left - panX - dragOffX; dragTarget.y = e.clientY - rect.top - panY - dragOffY; renderNetwork(); } else if (dragType === 'shape' && dragTarget) { dragTarget.x = e.clientX - rect.left - panX - dragOffX; dragTarget.y = e.clientY - rect.top - panY - dragOffY; renderNetwork(); } else if (dragType === 'resize' && dragTarget) { const dx = e.clientX - rect.left - panX - dragOffX; const dy = e.clientY - rect.top - panY - dragOffY; const s = dragTarget; let nx = dragOrig.x, ny = dragOrig.y, nw = dragOrig.w, nh = dragOrig.h; if (dragOffX < dragOrig.x + dragOrig.w / 2) { nx = dragOrig.x + dx; nw = dragOrig.w - dx; } else { nw = dragOrig.w + dx; } if (dragOffY < dragOrig.y + dragOrig.h / 2) { ny = dragOrig.y + dy; nh = dragOrig.h - dy; } else { nh = dragOrig.h + dy; } if (nw < 50) nw = 50; if (nh < 50) nh = 50; s.x = nx; s.y = ny; s.w = nw; s.h = nh; 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; if (getShapeResizeHandleAt(mx, my)) canvas.style.cursor = 'nwse-resize'; else if (getCanvasNodeAt(mx, my) || getShapeAt(mx, my)) canvas.style.cursor = 'pointer'; else canvas.style.cursor = 'grab'; } } function onMouseUp(e) { if (dragTarget && dragType === 'node') { apiFetch(`nodes/${dragTarget.id}`, { method: 'PUT', body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y }) }); } else if (dragTarget && (dragType === 'shape' || dragType === 'resize')) { apiFetch(`shapes/${dragTarget.id}`, { method: 'PUT', body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y, width: dragTarget.w, height: dragTarget.h }) }); } dragType = null; dragTarget = null; dragOrig = null; isPanning = false; canvas.style.cursor = 'grab'; } function onDblClick(e) { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; const node = getCanvasNodeAt(mx, my); if (node) { selectNode(node.id); return; } const shape = getShapeAt(mx, my); if (shape) { selectedNodeId = null; selectedShapeId = shape.id; renderShapeList(); renderNetwork(); } } function esc(s) { if (!s) return ''; const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } if (!CanvasRenderingContext2D.prototype.roundRect) { CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) { if (r > w / 2) r = w / 2; if (r > h / 2) r = h / 2; this.moveTo(x + r, y); this.lineTo(x + w - r, y); this.quadraticCurveTo(x + w, y, x + w, y + r); this.lineTo(x + w, y + h - r); this.quadraticCurveTo(x + w, y + h, x + w - r, y + h); this.lineTo(x + r, y + h); this.quadraticCurveTo(x, y + h, x, y + h - r); this.lineTo(x, y + r); this.quadraticCurveTo(x, y, x + r, y); return this; }; }