const API = '/api/'; let teams = []; let events = []; let nodes = []; let links = []; let shapes = []; let selectedNodeId = null; let selectedShapeId = null; // Canvas state let canvas, ctx; let canvasNodes = []; let canvasLinks = []; let canvasShapes = []; let panX = 0, panY = 0; let isPanning = false; let panStartX, panStartY; // Drag state 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', onContextMenu); window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); }); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.key === 'Delete' || e.key === 'Backspace') { if (document.activeElement === canvas || document.activeElement?.tagName !== 'INPUT') { if (selectedNodeId) deleteSelectedNode(); else if (selectedShapeId) deleteSelectedShape(); } } if (e.key === 'Escape') { selectedNodeId = null; selectedShapeId = null; renderNetwork(); renderNodeList(); } }); 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 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() { 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(s => s.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 => `
${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(); } // ==================== CANVAS RENDERING ==================== const NODE_ICONS = { host: ['M7,4 L17,4 L20,9 L20,18 L4,18 L4,9 Z', '#3b82f6'], server: ['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', '#8b5cf6'], router: ['M12,3 L21,10 L18,20 L6,20 L3,10 Z M12,6 L12,17 M8,10 L16,10', '#f59e0b'], firewall: ['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', '#ef4444'], switch: ['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', '#06b6d4'], cloud: ['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', '#22c55e'], endpoint: ['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', '#ec4899'], other: ['M8,6 L16,6 L18,12 L16,18 L8,18 L6,12 Z', '#6b7280'] }; function buildCanvasGraph() { canvasNodes = nodes.map(n => { const icon = NODE_ICONS[n.node_type] || NODE_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: icon[0], color: icon[1], 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); // Draw shapes (lowest layer) canvasShapes.sort((a, b) => a.z - b.z).forEach(drawShape); // 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 : 1.5; if (l.type === 'vpn' || l.type === 'wireless') ctx.setLineDash([6, 4]); else ctx.setLineDash([]); ctx.stroke(); ctx.setLineDash([]); 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 - 8); } }); // Draw nodes canvasNodes.forEach(drawCanvasNode); ctx.restore(); } function drawShape(s) { ctx.save(); const isSelected = 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); ctx.fillStyle = s.color; ctx.fill(); ctx.globalAlpha = 1; ctx.strokeStyle = isSelected ? '#ffffff' : s.borderColor; ctx.lineWidth = isSelected ? 2.5 : 1.5; ctx.setLineDash([5, 3]); ctx.stroke(); ctx.setLineDash([]); } 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 = isSelected ? '#ffffff' : s.borderColor; ctx.lineWidth = isSelected ? 2.5 : 1.5; ctx.setLineDash([5, 3]); ctx.stroke(); ctx.setLineDash([]); } // Resize handles if (isSelected) { const handles = getShapeHandles(s); handles.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(); }); } // Label ctx.globalAlpha = 1; 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 isSelected = selectedNodeId == n.id; const s = 1.8; ctx.save(); if (isSelected) { ctx.shadowColor = n.color; ctx.shadowBlur = 18; } // Fill background ctx.translate(n.x, n.y); ctx.scale(1, 1); ctx.beginPath(); // Icon background circle/box if (n.type === 'switch') { ctx.rect(-20, -20, 40, 40); } else { ctx.arc(0, 0, 22, 0, Math.PI * 2); } ctx.fillStyle = n.color + '18'; ctx.fill(); // Border ctx.strokeStyle = isSelected ? '#ffffff' : n.color; ctx.lineWidth = isSelected ? 2.5 : 1.5; ctx.stroke(); ctx.shadowBlur = 0; // Icon path const scalePath = (path, cx, cy, sc) => { return path.replace(/([\d.]+)/g, (m) => { const v = parseFloat(m); return ((v - 12) * sc + cx).toFixed(1); }); }; const scaled = scalePath(n.iconPath, 0, 0, 0.85); 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 statusColors = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' }; ctx.fillStyle = statusColors[n.status] || '#9ca3af'; ctx.fill(); if (n.status === 'compromised') { ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.stroke(); } ctx.translate(-n.x, -n.y); // 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 + 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, cx: 0, cy: 0 }, { x: s.x + s.w, y: s.y, cx: 1, cy: 0 }, { x: s.x + s.w, y: s.y + s.h, cx: 1, cy: 1 }, { x: s.x, y: s.y + s.h, cx: 0, cy: 1 } ]; } // ==================== CANVAS EVENTS ==================== function getCanvasNodeRaw(mx, my) { return canvasNodes.find(n => { const dx = mx - n.x, dy = my - n.y; return Math.sqrt(dx * dx + dy * dy) < 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 getShapeResizeHandle(mx, my) { for (const s of canvasShapes) { if (selectedShapeId != s.id) continue; const handles = getShapeHandles(s); for (const h of handles) { const dx = mx - h.x, dy = my - h.y; if (Math.sqrt(dx * dx + dy * dy) < 8) return { shape: s, handle: h }; } } return null; } function onMouseDown(e) { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; // Check shape resize handle first (only for selected shape) const resize = getShapeResizeHandle(mx, my); if (resize) { dragType = 'resize'; dragTarget = resize.shape; dragHandle = resize.handle; dragOrig = { x: resize.shape.x, y: resize.shape.y, w: resize.shape.w, h: resize.shape.h }; dragOffX = mx; dragOffY = my; return; } // Check node const node = getCanvasNodeRaw(mx, my); if (node) { dragType = 'node'; dragTarget = node; dragOffX = mx - node.x; dragOffY = my - node.y; selectNode(node.id); return; } // Check shape body const shape = getShapeAt(mx, my); if (shape) { selectedNodeId = null; selectedShapeId = shape.id; dragType = 'shape'; dragTarget = shape; dragOffX = mx - shape.x; dragOffY = my - shape.y; renderShapeList(); renderNetwork(); return; } // Pan 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 && dragHandle) { const dx = e.clientX - rect.left - panX - dragOffX; const dy = e.clientY - rect.top - panY - dragOffY; const h = dragHandle; if (h.cx === 0) { dragTarget.x = dragOrig.x + dx; dragTarget.w = dragOrig.w - dx; } else { dragTarget.w = dragOrig.w + dx; } if (h.cy === 0) { dragTarget.y = dragOrig.y + dy; dragTarget.h = dragOrig.h - dy; } else { dragTarget.h = dragOrig.h + dy; } if (dragTarget.w < 50) dragTarget.w = 50; if (dragTarget.h < 50) dragTarget.h = 50; 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; const node = getCanvasNodeRaw(mx, my); const shape = getShapeAt(mx, my); const resize = getShapeResizeHandle(mx, my); if (resize) canvas.style.cursor = 'nwse-resize'; else if (node || shape) canvas.style.cursor = 'pointer'; else canvas.style.cursor = 'grab'; } } function onMouseUp(e) { if (dragTarget) { if (dragType === 'node') { apiFetch(`nodes/${dragTarget.id}`, { method: 'PUT', body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y }) }); } else if (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; dragHandle = 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 = getCanvasNodeRaw(mx, my); if (node) { selectNode(node.id); return; } const shape = getShapeAt(mx, my); if (shape) { selectedNodeId = null; selectedShapeId = shape.id; renderShapeList(); renderNetwork(); } } function onContextMenu(e) { e.preventDefault(); } function esc(s) { if (!s) return ''; const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } // roundRect polyfill for older browsers 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; }; }