From 1191f091c47c870cb4740f1dd3e0a4309dfd8a8b Mon Sep 17 00:00:00 2001 From: janis steiner Date: Thu, 7 May 2026 18:14:43 +0200 Subject: [PATCH] initial commit --- .gitignore | 4 + backend/api/index.php | 231 +++++++++++++++++ backend/config/database.php | 15 ++ docker-compose.yml | 47 ++++ docker/Dockerfile.php | 5 + docker/init.sql | 56 +++++ docker/nginx.conf | 20 ++ frontend/assets/css/style.css | 215 ++++++++++++++++ frontend/assets/js/app.js | 454 ++++++++++++++++++++++++++++++++++ frontend/index.html | 278 +++++++++++++++++++++ 10 files changed, 1325 insertions(+) create mode 100644 .gitignore create mode 100644 backend/api/index.php create mode 100644 backend/config/database.php create mode 100644 docker-compose.yml create mode 100644 docker/Dockerfile.php create mode 100644 docker/init.sql create mode 100644 docker/nginx.conf create mode 100644 frontend/assets/css/style.css create mode 100644 frontend/assets/js/app.js create mode 100644 frontend/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ccf797d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +vendor/ +.env +mysql/ \ No newline at end of file diff --git a/backend/api/index.php b/backend/api/index.php new file mode 100644 index 0000000..44fa1cf --- /dev/null +++ b/backend/api/index.php @@ -0,0 +1,231 @@ + 'Not found']); + } +} catch (Exception $e) { + http_response_code(500); + echo json_encode(['error' => $e->getMessage()]); +} + +function handleTeams($method, $id, $db) { + switch ($method) { + case 'GET': + if ($id) { + $stmt = $db->prepare("SELECT * FROM teams WHERE id = ?"); + $stmt->execute([$id]); + echo json_encode($stmt->fetch(PDO::FETCH_ASSOC)); + } else { + echo json_encode($db->query("SELECT * FROM teams ORDER BY name")->fetchAll(PDO::FETCH_ASSOC)); + } + break; + case 'POST': + $data = json_decode(file_get_contents('php://input'), true); + $stmt = $db->prepare("INSERT INTO teams (name, color) VALUES (?, ?)"); + $stmt->execute([$data['name'], $data['color'] ?? '#0d6efd']); + echo json_encode(['id' => $db->lastInsertId()]); + break; + } +} + +function handleEvents($method, $id, $db) { + switch ($method) { + case 'GET': + if ($id) { + $stmt = $db->prepare(" + SELECT e.*, t.name AS team_name, t.color AS team_color + FROM events e JOIN teams t ON e.team_id = t.id + WHERE e.id = ? + "); + $stmt->execute([$id]); + $event = $stmt->fetch(PDO::FETCH_ASSOC); + if ($event) { + $cstmt = $db->prepare("SELECT * FROM comments WHERE event_id = ? ORDER BY created_at ASC"); + $cstmt->execute([$id]); + $event['comments'] = $cstmt->fetchAll(PDO::FETCH_ASSOC); + } + echo json_encode($event); + } else { + $teamFilter = $_GET['team_id'] ?? null; + $sql = " + SELECT e.*, t.name AS team_name, t.color AS team_color + FROM events e JOIN teams t ON e.team_id = t.id + "; + $params = []; + if ($teamFilter) { + $sql .= " WHERE e.team_id = ?"; + $params[] = $teamFilter; + } + $sql .= " ORDER BY e.occurred_at DESC"; + $stmt = $db->prepare($sql); + $stmt->execute($params); + echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + break; + case 'POST': + $data = json_decode(file_get_contents('php://input'), true); + $stmt = $db->prepare(" + INSERT INTO events (team_id, title, description, severity, event_type, occurred_at) + VALUES (?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $data['team_id'], + $data['title'], + $data['description'] ?? '', + $data['severity'] ?? 'info', + $data['event_type'] ?? 'general', + $data['occurred_at'] ?? date('Y-m-d H:i:s') + ]); + echo json_encode(['id' => $db->lastInsertId()]); + break; + case 'DELETE': + if ($id) { + $stmt = $db->prepare("DELETE FROM events WHERE id = ?"); + $stmt->execute([$id]); + echo json_encode(['deleted' => true]); + } + break; + } +} + +function handleComments($method, $id, $db) { + switch ($method) { + case 'GET': + $eventId = $_GET['event_id'] ?? null; + if ($eventId) { + $stmt = $db->prepare("SELECT * FROM comments WHERE event_id = ? ORDER BY created_at ASC"); + $stmt->execute([$eventId]); + echo json_encode($stmt->fetchAll(PDO::FETCH_ASSOC)); + } + break; + case 'POST': + $data = json_decode(file_get_contents('php://input'), true); + $stmt = $db->prepare("INSERT INTO comments (event_id, author, body) VALUES (?, ?, ?)"); + $stmt->execute([$data['event_id'], $data['author'], $data['body']]); + echo json_encode(['id' => $db->lastInsertId()]); + break; + } +} + +function handleNodes($method, $id, $db) { + switch ($method) { + case 'GET': + echo json_encode($db->query("SELECT * FROM network_nodes ORDER BY group_name, label")->fetchAll(PDO::FETCH_ASSOC)); + break; + case 'POST': + $data = json_decode(file_get_contents('php://input'), true); + $stmt = $db->prepare(" + INSERT INTO network_nodes (label, ip_address, node_type, status, group_name, pos_x, pos_y) + VALUES (?, ?, ?, ?, ?, ?, ?) + "); + $stmt->execute([ + $data['label'], + $data['ip_address'] ?? '', + $data['node_type'] ?? 'host', + $data['status'] ?? 'unknown', + $data['group_name'] ?? 'default', + $data['pos_x'] ?? 0, + $data['pos_y'] ?? 0 + ]); + echo json_encode(['id' => $db->lastInsertId()]); + break; + case 'PUT': + if ($id) { + $data = json_decode(file_get_contents('php://input'), true); + $fields = []; + $params = []; + foreach (['label','ip_address','node_type','status','group_name','pos_x','pos_y'] as $f) { + if (isset($data[$f])) { + $fields[] = "$f = ?"; + $params[] = $data[$f]; + } + } + if ($fields) { + $params[] = $id; + $stmt = $db->prepare("UPDATE network_nodes SET " . implode(', ', $fields) . " WHERE id = ?"); + $stmt->execute($params); + } + echo json_encode(['updated' => true]); + } + break; + case 'DELETE': + if ($id) { + $db->prepare("DELETE FROM network_nodes WHERE id = ?")->execute([$id]); + echo json_encode(['deleted' => true]); + } + break; + } +} + +function handleLinks($method, $id, $db) { + switch ($method) { + case 'GET': + echo json_encode($db->query(" + SELECT l.*, s.label AS source_label, t.label AS target_label + FROM network_links l + JOIN network_nodes s ON l.source_id = s.id + JOIN network_nodes t ON l.target_id = t.id + ")->fetchAll(PDO::FETCH_ASSOC)); + break; + case 'POST': + $data = json_decode(file_get_contents('php://input'), true); + $stmt = $db->prepare(" + INSERT INTO network_links (source_id, target_id, link_type, label) + VALUES (?, ?, ?, ?) + "); + $stmt->execute([ + $data['source_id'], + $data['target_id'], + $data['link_type'] ?? 'direct', + $data['label'] ?? '' + ]); + echo json_encode(['id' => $db->lastInsertId()]); + break; + case 'DELETE': + if ($id) { + $db->prepare("DELETE FROM network_links WHERE id = ?")->execute([$id]); + echo json_encode(['deleted' => true]); + } + break; + } +} \ No newline at end of file diff --git a/backend/config/database.php b/backend/config/database.php new file mode 100644 index 0000000..d7e3453 --- /dev/null +++ b/backend/config/database.php @@ -0,0 +1,15 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC + ]); + } + return $db; +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1587964 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,47 @@ +services: + nginx: + image: nginx:alpine + ports: + - "8080:80" + volumes: + - ./frontend:/var/www/html + - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf + depends_on: + - php + networks: + - neptune + + php: + build: + context: . + dockerfile: docker/Dockerfile.php + volumes: + - ./backend:/var/www/backend + depends_on: + mysql: + condition: service_healthy + networks: + - neptune + + mysql: + image: mysql:8.0 + environment: + MYSQL_ROOT_PASSWORD: neptune_root_pass + MYSQL_DATABASE: neptune + MYSQL_USER: neptune + MYSQL_PASSWORD: neptune_pass + volumes: + - mysql_data:/var/lib/mysql + - ./docker/init.sql:/docker-entrypoint-initdb.d/init.sql + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 10s + retries: 10 + networks: + - neptune + +volumes: + mysql_data: + +networks: + neptune: \ No newline at end of file diff --git a/docker/Dockerfile.php b/docker/Dockerfile.php new file mode 100644 index 0000000..fe1f913 --- /dev/null +++ b/docker/Dockerfile.php @@ -0,0 +1,5 @@ +FROM php:8.2-fpm + +RUN docker-php-ext-install pdo pdo_mysql + +WORKDIR /var/www/backend \ No newline at end of file diff --git a/docker/init.sql b/docker/init.sql new file mode 100644 index 0000000..8ed4bfd --- /dev/null +++ b/docker/init.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS teams ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + color VARCHAR(7) DEFAULT '#0d6efd', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS events ( + id INT AUTO_INCREMENT PRIMARY KEY, + team_id INT NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + severity ENUM('info','low','medium','high','critical') DEFAULT 'info', + event_type VARCHAR(50) DEFAULT 'general', + occurred_at DATETIME NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS comments ( + id INT AUTO_INCREMENT PRIMARY KEY, + event_id INT NOT NULL, + author VARCHAR(100) NOT NULL, + body TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS network_nodes ( + id INT AUTO_INCREMENT PRIMARY KEY, + label VARCHAR(255) NOT NULL, + ip_address VARCHAR(45), + node_type ENUM('host','server','router','firewall','switch','cloud','endpoint','other') DEFAULT 'host', + status ENUM('online','offline','unknown','compromised','monitoring') DEFAULT 'unknown', + group_name VARCHAR(100) DEFAULT 'default', + pos_x FLOAT DEFAULT 0, + pos_y FLOAT DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS network_links ( + id INT AUTO_INCREMENT PRIMARY KEY, + source_id INT NOT NULL, + target_id INT NOT NULL, + link_type ENUM('direct','vpn','wireless','monitored') DEFAULT 'direct', + label VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (source_id) REFERENCES network_nodes(id) ON DELETE CASCADE, + FOREIGN KEY (target_id) REFERENCES network_nodes(id) ON DELETE CASCADE +); + +INSERT IGNORE INTO teams (name, color) VALUES + ('Blue Team', '#0d6efd'), + ('Red Team', '#dc3545'), + ('SOC', '#ffc107'), + ('Threat Intel', '#198754'); \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..09c4517 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,20 @@ +server { + listen 80; + root /var/www/html; + index index.html; + + location /api/ { + fastcgi_pass php:9000; + fastcgi_param SCRIPT_FILENAME /var/www/backend/api/index.php; + fastcgi_param REQUEST_URI $request_uri; + fastcgi_param QUERY_STRING $query_string; + fastcgi_param REQUEST_METHOD $request_method; + fastcgi_param CONTENT_TYPE $content_type; + fastcgi_param CONTENT_LENGTH $content_length; + include fastcgi_params; + } + + location / { + try_files $uri $uri/ =404; + } +} \ No newline at end of file diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css new file mode 100644 index 0000000..854a152 --- /dev/null +++ b/frontend/assets/css/style.css @@ -0,0 +1,215 @@ +:root { + --neptune-bg: #0a0e1a; + --neptune-card: #131a2b; + --neptune-border: #1e2a45; + --neptune-accent: #3b82f6; +} + +body { + background-color: var(--neptune-bg); + color: #e2e8f0; + font-size: .9rem; +} + +.navbar { + background: linear-gradient(135deg, #0a0e1a 0%, #121828 100%) !important; +} + +.card, .modal-content, .list-group-item { + background-color: var(--neptune-card); + border-color: var(--neptune-border); +} + +.form-control, .form-select { + background-color: #0d1117; + border-color: var(--neptune-border); + color: #e2e8f0; +} + +.form-control:focus, .form-select:focus { + background-color: #0d1117; + border-color: var(--neptune-accent); + color: #e2e8f0; + box-shadow: 0 0 0 .2rem rgba(59,130,246,.15); +} + +.btn-primary { + background-color: var(--neptune-accent); + border-color: var(--neptune-accent); +} + +.btn-primary:hover { + background-color: #2563eb; + border-color: #2563eb; +} + +/* Timeline */ +.timeline-item { + position: relative; + padding-left: 2rem; + margin-bottom: 1.5rem; +} + +.timeline-item::before { + content: ''; + position: absolute; + left: .5rem; + top: 0; + bottom: -1.5rem; + width: 2px; + background: var(--neptune-border); +} + +.timeline-item:last-child::before { + display: none; +} + +.timeline-dot { + position: absolute; + left: .25rem; + top: .35rem; + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid; + background: var(--neptune-card); + z-index: 1; +} + +.severity-critical { border-color: #ef4444; color: #ef4444; } +.severity-high { border-color: #f97316; color: #f97316; } +.severity-medium { border-color: #eab308; color: #eab308; } +.severity-low { border-color: #22c55e; color: #22c55e; } +.severity-info { border-color: #3b82f6; color: #3b82f6; } + +.timeline-card { + border-left: 3px solid var(--neptune-accent); + transition: border-color .2s; +} + +.timeline-card:hover { + border-left-color: #60a5fa; +} + +.severity-badge { + font-size: .65rem; + text-transform: uppercase; + letter-spacing: .05em; +} + +.comment-box { + background: #0d1117; + border-radius: .375rem; + padding: .5rem .75rem; + margin-top: .5rem; +} + +.comment-box .comment-author { + color: var(--neptune-accent); + font-weight: 600; + font-size: .8rem; +} + +.comment-box .comment-body { + font-size: .85rem; + margin-top: .15rem; +} + +/* Network Map */ +.node-tooltip { + position: absolute; + background: #131a2b; + border: 1px solid var(--neptune-border); + border-radius: .375rem; + padding: .5rem .75rem; + font-size: .8rem; + pointer-events: none; + z-index: 1000; + display: none; +} + +#networkCanvas { + cursor: grab; +} + +#networkCanvas:active { + cursor: grabbing; +} + +/* Scrollbar */ +::-webkit-scrollbar { width: 6px; } +::-webkit-scrollbar-track { background: var(--neptune-bg); } +::-webkit-scrollbar-thumb { background: var(--neptune-border); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #2a3a60; } + +/* Nav tabs in navbar */ +.nav-tabs .nav-link { + color: #94a3b8; + border: none; + padding: .5rem 1rem; +} + +.nav-tabs .nav-link.active { + color: #fff; + background: transparent; + border-bottom: 2px solid var(--neptune-accent); +} + +.nav-tabs .nav-link:hover { + color: #fff; + border-bottom: 2px solid transparent; +} + +/* Node list items */ +.node-list-item { + cursor: pointer; + transition: background .15s; + border-left: 3px solid transparent; +} + +.node-list-item:hover { + background: rgba(59,130,246,.1); +} + +.node-list-item.active { + border-left-color: var(--neptune-accent); + background: rgba(59,130,246,.15); +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + margin-right: .4rem; +} + +.status-online { background: #22c55e; } +.status-offline { background: #6b7280; } +.status-unknown { background: #9ca3af; } +.status-compromised { background: #ef4444; animation: pulse 1.5s infinite; } +.status-monitoring { background: #eab308; } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: .4; } +} + +.event-meta { + font-size: .75rem; + color: #64748b; +} + +.event-title { + color: #f1f5f9; +} + +.comment-input-group .btn { + border-color: var(--neptune-border); + color: var(--neptune-accent); +} + +.comment-input-group .btn:hover { + background: var(--neptune-accent); + color: #fff; +} \ No newline at end of file diff --git a/frontend/assets/js/app.js b/frontend/assets/js/app.js new file mode 100644 index 0000000..8dcc3df --- /dev/null +++ b/frontend/assets/js/app.js @@ -0,0 +1,454 @@ +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)}

` : ''} +
+ ${renderComments(e)} +
+ + +
+
+
+
+
`; + }).join(''); +} + +function renderComments(event) { + if (!event.comments || !event.comments.length) return ''; + return event.comments.map(c => ` +
+
${esc(c.author)} · ${new Date(c.created_at).toLocaleString()}
+
${esc(c.body)}
+
+ `).join(''); +} + +async function addComment(eventId, btn) { + const input = btn.parentElement.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: document.getElementById('eventTime').value || 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; +} \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..3e82e7e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,278 @@ + + + + + + Neptune - Cybersecurity Incident Journal + + + + + + + + +
+
+ + +
+ +
+
+

Incident Timeline

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+

Loading events...

+
+
+
+ + +
+ +
+
+

Network Map

+
+
+ + +
+
+ +
+
+
+
+ +
+
+
+
+
+
+ Node Details +
+
+ Click a node to see details +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + \ No newline at end of file