initial commit

This commit is contained in:
2026-05-07 18:14:43 +02:00
commit 1191f091c4
10 changed files with 1325 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules/
vendor/
.env
mysql/
+231
View File
@@ -0,0 +1,231 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit;
}
require_once __DIR__ . '/../config/database.php';
$db = getDbConnection();
$requestUri = $_SERVER['REQUEST_URI'];
$method = $_SERVER['REQUEST_METHOD'];
$path = parse_url($requestUri, PHP_URL_PATH);
$path = str_replace('/api/', '', $path);
$segments = explode('/', trim($path, '/'));
$resource = $segments[0] ?? '';
$id = $segments[1] ?? null;
try {
switch ($resource) {
case 'teams':
handleTeams($method, $id, $db);
break;
case 'events':
handleEvents($method, $id, $db);
break;
case 'comments':
handleComments($method, $id, $db);
break;
case 'nodes':
handleNodes($method, $id, $db);
break;
case 'links':
handleLinks($method, $id, $db);
break;
default:
http_response_code(404);
echo json_encode(['error' => '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;
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
function getDbConnection() {
static $db = null;
if ($db === null) {
$host = getenv('DB_HOST') ?: 'mysql';
$dbname = getenv('DB_NAME') ?: 'neptune';
$user = getenv('DB_USER') ?: 'neptune';
$pass = getenv('DB_PASS') ?: 'neptune_pass';
$db = new PDO("mysql:host=$host;dbname=$dbname;charset=utf8mb4", $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC
]);
}
return $db;
}
+47
View File
@@ -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:
+5
View File
@@ -0,0 +1,5 @@
FROM php:8.2-fpm
RUN docker-php-ext-install pdo pdo_mysql
WORKDIR /var/www/backend
+56
View File
@@ -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');
+20
View File
@@ -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;
}
}
+215
View File
@@ -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;
}
+454
View File
@@ -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 = '<option value="">All Teams</option>';
teams.forEach(t => {
selTeam.innerHTML += `<option value="${t.id}">${t.name}</option>`;
filter.innerHTML += `<option value="${t.id}">${t.name}</option>`;
});
}
// ==================== 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 = `<div class="text-center text-secondary py-5"><i class="bi bi-journal-text fs-1"></i><p class="mt-2">No events yet. Create your first incident entry!</p></div>`;
return;
}
container.innerHTML = filtered.map(e => {
const sevClass = 'severity-' + e.severity;
const date = new Date(e.occurred_at).toLocaleString();
return `
<div class="timeline-item">
<div class="timeline-dot ${sevClass}"></div>
<div class="card timeline-card bg-dark border-secondary" style="border-left-color: ${e.team_color}">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-start">
<div>
<span class="badge severity-badge me-1" style="background:${e.team_color}20;color:${e.team_color}">${e.team_name}</span>
<span class="badge bg-${e.severity === 'critical' ? 'danger' : e.severity === 'high' ? 'warning' : e.severity === 'medium' ? 'warning' : e.severity === 'low' ? 'success' : 'info'} severity-badge">${e.severity}</span>
<span class="badge bg-secondary severity-badge ms-1">${e.event_type}</span>
</div>
<small class="event-meta">${date}</small>
</div>
<h6 class="event-title mt-1 mb-1">${esc(e.title)}</h6>
${e.description ? `<p class="mb-1 small text-secondary">${esc(e.description)}</p>` : ''}
<div class="mt-2" id="comments-${e.id}">
${renderComments(e)}
<div class="input-group input-group-sm comment-input-group mt-1">
<input type="text" class="form-control form-control-sm comment-input" placeholder="Add comment..." data-event-id="${e.id}">
<button class="btn btn-outline-secondary btn-sm" onclick="addComment(${e.id}, this)"><i class="bi bi-send"></i></button>
</div>
</div>
</div>
</div>
</div>`;
}).join('');
}
function renderComments(event) {
if (!event.comments || !event.comments.length) return '';
return event.comments.map(c => `
<div class="comment-box">
<div class="comment-author"><i class="bi bi-person-circle me-1"></i>${esc(c.author)} <span class="text-secondary fw-normal">· ${new Date(c.created_at).toLocaleString()}</span></div>
<div class="comment-body">${esc(c.body)}</div>
</div>
`).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 => `<option value="${n.id}">${esc(n.label)} (${n.ip_address || 'no IP'})</option>`).join('');
document.getElementById('linkSource').innerHTML = html;
document.getElementById('linkTarget').innerHTML = html;
}
function renderNodeList() {
const list = document.getElementById('nodeList');
list.innerHTML = nodes.map(n => `
<div class="list-group-item bg-dark border-secondary node-list-item py-2 ${selectedNodeId == n.id ? 'active' : ''}" data-node-id="${n.id}" onclick="selectNode(${n.id})">
<div class="d-flex align-items-center">
<span class="status-dot status-${n.status}"></span>
<div>
<strong class="small">${esc(n.label)}</strong>
<div class="text-secondary" style="font-size:.7rem;">${n.ip_address || '—'} · ${n.node_type} · ${n.group_name}</div>
</div>
</div>
</div>
`).join('');
}
function selectNode(id) {
selectedNodeId = id;
const n = nodes.find(x => x.id == id);
if (n) {
document.getElementById('nodeDetails').innerHTML = `
<div class="small">
<div class="d-flex align-items-center mb-1"><span class="status-dot status-${n.status} me-2"></span><strong>${esc(n.label)}</strong></div>
<div><span class="text-secondary">IP:</span> ${n.ip_address || '—'}</div>
<div><span class="text-secondary">Type:</span> ${n.node_type}</div>
<div><span class="text-secondary">Status:</span> ${n.status}</div>
<div><span class="text-secondary">Group:</span> ${n.group_name}</div>
</div>
`;
}
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;
}
+278
View File
@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="en" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Neptune - Cybersecurity Incident Journal</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="assets/css/style.css" rel="stylesheet">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
<div class="container-fluid">
<a class="navbar-brand d-flex align-items-center" href="#">
<i class="bi bi-globe2 me-2 text-primary"></i>
<span class="fw-bold">Neptune</span>
<span class="badge bg-secondary ms-2 small">Cybersecurity Journal</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="nav nav-tabs border-0 ms-3" id="mainTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="timeline-tab" data-bs-toggle="tab" data-bs-target="#timeline" type="button" role="tab">
<i class="bi bi-clock-history me-1"></i>Timeline
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="network-tab" data-bs-toggle="tab" data-bs-target="#network" type="button" role="tab">
<i class="bi bi-diagram-3 me-1"></i>Network Map
</button>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid mt-3">
<div class="tab-content" id="mainTabContent">
<!-- ==================== TIMELINE TAB ==================== -->
<div class="tab-pane fade show active" id="timeline" role="tabpanel">
<div class="row mb-3">
<div class="col-md-6">
<h4><i class="bi bi-clock-history text-primary"></i> Incident Timeline</h4>
</div>
<div class="col-md-6 text-end">
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#eventModal">
<i class="bi bi-plus-lg"></i> New Event
</button>
</div>
</div>
<div class="row mb-3">
<div class="col-md-4">
<select class="form-select form-select-sm" id="teamFilter">
<option value="">All Teams</option>
</select>
</div>
<div class="col-md-8">
<input type="text" class="form-control form-control-sm" id="searchEvents" placeholder="Search events...">
</div>
</div>
<div id="timelineContainer">
<div class="text-center text-secondary py-5">
<div class="spinner-border" role="status"></div>
<p class="mt-2">Loading events...</p>
</div>
</div>
</div>
<!-- ==================== NETWORK MAP TAB ==================== -->
<div class="tab-pane fade" id="network" role="tabpanel">
<div class="row mb-3">
<div class="col-md-6">
<h4><i class="bi bi-diagram-3 text-primary"></i> Network Map</h4>
</div>
<div class="col-md-6 text-end">
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#nodeModal">
<i class="bi bi-plus-lg"></i> Add Node
</button>
<button class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#linkModal">
<i class="bi bi-link-45deg"></i> Add Link
</button>
</div>
</div>
<div class="row">
<div class="col-md-9">
<div class="card bg-dark border-secondary">
<div class="card-body p-0" id="networkCanvasWrapper" style="height: 70vh; position: relative; overflow: hidden;">
<canvas id="networkCanvas" style="width: 100%; height: 100%;"></canvas>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card bg-dark border-secondary mb-2">
<div class="card-header py-2">
<small class="fw-bold">Node Details</small>
</div>
<div class="card-body py-2" id="nodeDetails">
<small class="text-secondary">Click a node to see details</small>
</div>
</div>
<div class="list-group list-group-flush bg-dark border-secondary rounded" id="nodeList" style="max-height: 50vh; overflow-y: auto;">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ==================== EVENT MODAL ==================== -->
<div class="modal fade" id="eventModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark">
<div class="modal-header border-secondary">
<h5 class="modal-title"><i class="bi bi-plus-circle text-primary"></i> New Event</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="eventForm">
<div class="mb-2">
<label class="form-label small">Team</label>
<select class="form-select form-select-sm" id="eventTeam" required></select>
</div>
<div class="mb-2">
<label class="form-label small">Title</label>
<input type="text" class="form-control form-control-sm" id="eventTitle" required>
</div>
<div class="mb-2">
<label class="form-label small">Description</label>
<textarea class="form-control form-control-sm" id="eventDescription" rows="3"></textarea>
</div>
<div class="row mb-2">
<div class="col">
<label class="form-label small">Severity</label>
<select class="form-select form-select-sm" id="eventSeverity">
<option value="info">Info</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="col">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" id="eventType">
<option value="general">General</option>
<option value="detection">Detection</option>
<option value="incident">Incident</option>
<option value="remediation">Remediation</option>
<option value="intel">Threat Intel</option>
<option value="exercise">Exercise</option>
</select>
</div>
</div>
<div class="mb-2">
<label class="form-label small">Occurred At</label>
<input type="datetime-local" class="form-control form-control-sm" id="eventTime">
</div>
</form>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="saveEvent"><i class="bi bi-save"></i> Save Event</button>
</div>
</div>
</div>
</div>
<!-- ==================== NODE MODAL ==================== -->
<div class="modal fade" id="nodeModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark">
<div class="modal-header border-secondary">
<h5 class="modal-title"><i class="bi bi-plus-circle text-primary"></i> Add Network Node</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="nodeForm">
<div class="mb-2">
<label class="form-label small">Label</label>
<input type="text" class="form-control form-control-sm" id="nodeLabel" required>
</div>
<div class="mb-2">
<label class="form-label small">IP Address</label>
<input type="text" class="form-control form-control-sm" id="nodeIp">
</div>
<div class="row mb-2">
<div class="col">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" id="nodeType">
<option value="host">Host</option>
<option value="server">Server</option>
<option value="router">Router</option>
<option value="firewall">Firewall</option>
<option value="switch">Switch</option>
<option value="cloud">Cloud</option>
<option value="endpoint">Endpoint</option>
<option value="other">Other</option>
</select>
</div>
<div class="col">
<label class="form-label small">Status</label>
<select class="form-select form-select-sm" id="nodeStatus">
<option value="unknown">Unknown</option>
<option value="online">Online</option>
<option value="offline">Offline</option>
<option value="monitoring">Monitoring</option>
<option value="compromised">Compromised</option>
</select>
</div>
</div>
<div class="mb-2">
<label class="form-label small">Group</label>
<input type="text" class="form-control form-control-sm" id="nodeGroup" placeholder="e.g. DMZ, Internal, Cloud">
</div>
</form>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="saveNode"><i class="bi bi-save"></i> Add Node</button>
</div>
</div>
</div>
</div>
<!-- ==================== LINK MODAL ==================== -->
<div class="modal fade" id="linkModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content bg-dark">
<div class="modal-header border-secondary">
<h5 class="modal-title"><i class="bi bi-link-45deg text-primary"></i> Add Connection</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="linkForm">
<div class="mb-2">
<label class="form-label small">Source Node</label>
<select class="form-select form-select-sm" id="linkSource" required></select>
</div>
<div class="mb-2">
<label class="form-label small">Target Node</label>
<select class="form-select form-select-sm" id="linkTarget" required></select>
</div>
<div class="mb-2">
<label class="form-label small">Connection Type</label>
<select class="form-select form-select-sm" id="linkType">
<option value="direct">Direct</option>
<option value="vpn">VPN</option>
<option value="wireless">Wireless</option>
<option value="monitored">Monitored</option>
</select>
</div>
<div class="mb-2">
<label class="form-label small">Label (optional)</label>
<input type="text" class="form-control form-control-sm" id="linkLabel" placeholder="e.g. 1 Gbps, SSH Tunnel">
</div>
</form>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-sm btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-sm btn-primary" id="saveLink"><i class="bi bi-save"></i> Add Link</button>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="assets/js/app.js"></script>
</body>
</html>