454 lines
17 KiB
JavaScript
454 lines
17 KiB
JavaScript
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;
|
|
} |