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)}
` : ''}
`;
}).join('');
}
function renderComments(event) {
if (!event.comments || !event.comments.length) return 'No comments yet
';
return event.comments.map(c => `
`).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;
};
}