fix
Deploy / deploy (push) Successful in 38s

This commit is contained in:
2026-05-07 18:40:25 +02:00
parent 77e345ba63
commit 0829db06f9
2 changed files with 143 additions and 258 deletions
+21 -12
View File
@@ -127,18 +127,6 @@ body {
} }
/* Network Map */ /* 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 { #networkCanvas {
cursor: grab; cursor: grab;
} }
@@ -147,6 +135,27 @@ body {
cursor: grabbing; cursor: grabbing;
} }
.node-list-item i {
width: 1rem;
text-align: center;
}
.nav-pills .nav-link {
color: #94a3b8;
}
.nav-pills .nav-link.active {
background: var(--neptune-accent);
color: #fff;
}
kbd {
border: 1px solid var(--neptune-border);
padding: 1px 5px;
border-radius: 3px;
font-size: .7rem;
}
/* Scrollbar */ /* Scrollbar */
::-webkit-scrollbar { width: 6px; } ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--neptune-bg); } ::-webkit-scrollbar-track { background: var(--neptune-bg); }
+122 -246
View File
@@ -7,7 +7,6 @@ let shapes = [];
let selectedNodeId = null; let selectedNodeId = null;
let selectedShapeId = null; let selectedShapeId = null;
// Canvas state
let canvas, ctx; let canvas, ctx;
let canvasNodes = []; let canvasNodes = [];
let canvasLinks = []; let canvasLinks = [];
@@ -16,7 +15,6 @@ let panX = 0, panY = 0;
let isPanning = false; let isPanning = false;
let panStartX, panStartY; let panStartX, panStartY;
// Drag state
let dragTarget = null; let dragTarget = null;
let dragType = null; let dragType = null;
let dragOffX, dragOffY; let dragOffX, dragOffY;
@@ -47,13 +45,12 @@ document.addEventListener('DOMContentLoaded', () => {
canvas.addEventListener('mousemove', onMouseMove); canvas.addEventListener('mousemove', onMouseMove);
canvas.addEventListener('mouseup', onMouseUp); canvas.addEventListener('mouseup', onMouseUp);
canvas.addEventListener('dblclick', onDblClick); canvas.addEventListener('dblclick', onDblClick);
canvas.addEventListener('contextmenu', onContextMenu); canvas.addEventListener('contextmenu', (e) => e.preventDefault());
window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); }); window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); });
// Keyboard shortcuts
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
if (document.activeElement === canvas || document.activeElement?.tagName !== 'INPUT') { if (!document.activeElement || document.activeElement.tagName !== 'INPUT') {
if (selectedNodeId) deleteSelectedNode(); if (selectedNodeId) deleteSelectedNode();
else if (selectedShapeId) deleteSelectedShape(); else if (selectedShapeId) deleteSelectedShape();
} }
@@ -63,15 +60,13 @@ document.addEventListener('DOMContentLoaded', () => {
selectedShapeId = null; selectedShapeId = null;
renderNetwork(); renderNetwork();
renderNodeList(); renderNodeList();
renderShapeList();
} }
}); });
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => { document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => {
tab.addEventListener('shown.bs.tab', () => { tab.addEventListener('shown.bs.tab', () => {
if (tab.id === 'network-tab') { if (tab.id === 'network-tab') { resizeCanvas(); renderNetwork(); }
resizeCanvas();
renderNetwork();
}
}); });
}); });
@@ -95,17 +90,17 @@ async function apiFetch(path, options = {}) {
// ==================== TEAMS ==================== // ==================== TEAMS ====================
async function loadTeams() { async function loadTeams() {
teams = await apiFetch('teams'); teams = await apiFetch('teams');
const selTeam = document.getElementById('eventTeam'); const sel = document.getElementById('eventTeam');
const filter = document.getElementById('teamFilter'); const filter = document.getElementById('teamFilter');
selTeam.innerHTML = ''; sel.innerHTML = '';
filter.innerHTML = '<option value="">All Teams</option>'; filter.innerHTML = '<option value="">All Teams</option>';
teams.forEach(t => { teams.forEach(t => {
selTeam.innerHTML += `<option value="${t.id}">${t.name}</option>`; sel.innerHTML += `<option value="${t.id}">${t.name}</option>`;
filter.innerHTML += `<option value="${t.id}">${t.name}</option>`; filter.innerHTML += `<option value="${t.id}">${t.name}</option>`;
}); });
} }
// ==================== EVENTS / TIMELINE ==================== // ==================== EVENTS ====================
async function loadEvents() { async function loadEvents() {
events = await apiFetch('events'); events = await apiFetch('events');
renderTimeline(); renderTimeline();
@@ -124,21 +119,20 @@ function renderTimeline() {
); );
if (!filtered.length) { 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>`; container.innerHTML = `<div class="text-center text-secondary py-5"><i class="fas fa-book-open fs-1 mb-2"></i><p>No events yet. Create your first incident entry!</p></div>`;
return; return;
} }
container.innerHTML = filtered.map(e => { container.innerHTML = filtered.map(e => {
const sevClass = 'severity-' + e.severity;
const date = new Date(e.occurred_at).toLocaleString(); const date = new Date(e.occurred_at).toLocaleString();
return ` return `
<div class="timeline-item"> <div class="timeline-item">
<div class="timeline-dot ${sevClass}"></div> <div class="timeline-dot severity-${e.severity}"></div>
<div class="card timeline-card bg-dark border-secondary" style="border-left-color: ${e.team_color}"> <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="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-start"> <div class="d-flex justify-content-between align-items-start">
<div> <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 severity-badge me-1" style="background:${e.team_color}20;color:${e.team_color}">${esc(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-${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> <span class="badge bg-secondary severity-badge ms-1">${e.event_type}</span>
</div> </div>
@@ -147,15 +141,11 @@ function renderTimeline() {
<h6 class="event-title mt-1 mb-1">${esc(e.title)}</h6> <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>` : ''} ${e.description ? `<p class="mb-1 small text-secondary">${esc(e.description)}</p>` : ''}
<div class="mt-2" id="comments-${e.id}"> <div class="mt-2" id="comments-${e.id}">
<div class="d-flex align-items-center mb-1"> <div class="d-flex align-items-center mb-1"><small class="text-secondary fw-bold"><i class="fas fa-comment-dots me-1"></i>Comments ${e.comments && e.comments.length ? `(${e.comments.length})` : ''}</small></div>
<small class="text-secondary fw-bold"><i class="bi bi-chat-dots me-1"></i>Comments ${e.comments && e.comments.length ? `(${e.comments.length})` : ''}</small> <div class="comment-log">${renderComments(e)}</div>
</div>
<div class="comment-log">
${renderComments(e)}
</div>
<div class="input-group input-group-sm comment-input-group mt-1"> <div class="input-group input-group-sm comment-input-group mt-1">
<input type="text" class="form-control form-control-sm comment-input" placeholder="Write a comment..." onkeydown="if(event.key==='Enter') addComment(${e.id}, this)"> <input type="text" class="form-control form-control-sm comment-input" placeholder="Write a comment..." onkeydown="if(event.key==='Enter') addComment(${e.id}, this)">
<button class="btn btn-outline-secondary btn-sm" onclick="addComment(${e.id}, this)"><i class="bi bi-send"></i></button> <button class="btn btn-outline-secondary btn-sm" onclick="addComment(${e.id}, this)"><i class="fas fa-paper-plane"></i></button>
</div> </div>
</div> </div>
</div> </div>
@@ -165,10 +155,10 @@ function renderTimeline() {
} }
function renderComments(event) { function renderComments(event) {
if (!event.comments || !event.comments.length) return '<div class="text-secondary small py-1"><i class="bi bi-chat-left-text me-1"></i>No comments yet</div>'; if (!event.comments || !event.comments.length) return '<div class="text-secondary small py-1"><i class="fas fa-comment me-1"></i>No comments yet</div>';
return event.comments.map(c => ` return event.comments.map(c => `
<div class="comment-box d-flex"> <div class="comment-box d-flex">
<div class="me-2 text-secondary" style="font-size:.7rem;"><i class="bi bi-person-circle"></i></div> <div class="me-2 text-secondary" style="font-size:.7rem;"><i class="fas fa-user-circle"></i></div>
<div class="flex-grow-1"> <div class="flex-grow-1">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<span class="comment-author">${esc(c.author)}</span> <span class="comment-author">${esc(c.author)}</span>
@@ -186,10 +176,7 @@ async function addComment(eventId, el) {
const body = input.value.trim(); const body = input.value.trim();
if (!body) return; if (!body) return;
const author = prompt('Your name:') || 'Anonymous'; const author = prompt('Your name:') || 'Anonymous';
await apiFetch('comments', { await apiFetch('comments', { method: 'POST', body: JSON.stringify({ event_id: eventId, author, body }) });
method: 'POST',
body: JSON.stringify({ event_id: eventId, author, body })
});
input.value = ''; input.value = '';
loadEvents(); loadEvents();
} }
@@ -210,7 +197,7 @@ async function saveEvent() {
loadEvents(); loadEvents();
} }
// ==================== NETWORK MAP ==================== // ==================== NETWORK MAP DATA ====================
async function loadNetworkData() { async function loadNetworkData() {
const [n, l, s] = await Promise.all([ const [n, l, s] = await Promise.all([
apiFetch('nodes'), apiFetch('nodes'),
@@ -223,7 +210,7 @@ async function loadNetworkData() {
populateNodeSelects(); populateNodeSelects();
renderNodeList(); renderNodeList();
renderShapeList(); renderShapeList();
if (shapes.length) nextShapeZ = Math.max(...shapes.map(s => s.z_index)) + 1; if (shapes.length) nextShapeZ = Math.max(...shapes.map(x => x.z_index)) + 1;
renderNetwork(); renderNetwork();
} }
@@ -235,17 +222,20 @@ function populateNodeSelects() {
function renderNodeList() { function renderNodeList() {
const list = document.getElementById('nodeList'); const list = document.getElementById('nodeList');
list.innerHTML = nodes.map(n => ` 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})"> 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 `
<div class="list-group-item bg-dark border-secondary node-list-item py-2 ${selectedNodeId == n.id ? 'active' : ''}" onclick="selectNode(${n.id})">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="status-dot status-${n.status}"></span> <span class="status-dot status-${n.status}"></span>
<i class="fas ${iconMap[n.node_type] || 'fa-circle'} me-2" style="color:${getNodeColorVal(n.node_type)};font-size:.85rem;"></i>
<div> <div>
<strong class="small">${esc(n.label)}</strong> <strong class="small">${esc(n.label)}</strong>
<div class="text-secondary" style="font-size:.7rem;">${n.ip_address || '—'} · ${n.node_type}</div> <div class="text-secondary" style="font-size:.7rem;">${n.ip_address || '—'} · ${n.node_type}</div>
</div> </div>
</div> </div>
</div> </div>`;
`).join(''); }).join('');
} }
function renderShapeList() { function renderShapeList() {
@@ -275,7 +265,7 @@ function selectNode(id) {
<div><span class="text-secondary">Type:</span> ${n.node_type}</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">Status:</span> ${n.status}</div>
<div><span class="text-secondary">Group:</span> ${n.group_name}</div> <div><span class="text-secondary">Group:</span> ${n.group_name}</div>
<button class="btn btn-outline-danger btn-sm mt-2" onclick="deleteSelectedNode()"><i class="bi bi-trash"></i> Delete</button> <button class="btn btn-outline-danger btn-sm mt-2" onclick="deleteSelectedNode()"><i class="fas fa-trash me-1"></i>Delete</button>
</div> </div>
`; `;
} }
@@ -360,56 +350,46 @@ async function saveShape() {
loadNetworkData(); 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 ==================== // ==================== CANVAS RENDERING ====================
const NODE_ICONS = { const NODE_FA_ICONS = {
host: ['M7,4 L17,4 L20,9 L20,18 L4,18 L4,9 Z', '#3b82f6'], host: { path: 'M7,4 L17,4 L20,9 L20,18 L4,18 L4,9 Z', color: '#3b82f6' },
server: ['M6,3 L18,3 L20,6 L20,20 L4,20 L4,6 Z M8,10 L16,10 M8,14 L16,14 M8,17 L12,17', '#8b5cf6'], 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: ['M12,3 L21,10 L18,20 L6,20 L3,10 Z M12,6 L12,17 M8,10 L16,10', '#f59e0b'], router: { path: 'M12,3 L21,10 L18,20 L6,20 L3,10 Z M12,6 L12,17 M8,10 L16,10', color: '#f59e0b' },
firewall: ['M6,3 L18,3 L21,8 L21,16 L18,21 L6,21 L3,16 L3,8 Z M9,10 L15,10 L15,14 L9,14 Z', '#ef4444'], 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: ['M4,8 L20,8 L20,16 L4,16 Z M7,11 L7,13 M10,11 L10,13 M13,11 L13,13 M16,11 L16,13', '#06b6d4'], 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: ['M10,4 C6,4 4,7 5,10 C3,11 2,14 4,16 L8,16 C10,18 14,18 16,16 L20,16 C22,14 21,10 19,9 C20,6 17,4 15,5 C14,4 12,4 10,4 Z', '#22c55e'], 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: ['M8,4 L16,4 L18,10 L18,16 L16,20 L8,20 L6,16 L6,10 Z M10,12 L14,12 M12,10 L12,14', '#ec4899'], 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: ['M8,6 L16,6 L18,12 L16,18 L8,18 L6,12 Z', '#6b7280'] other: { path: 'M8,6 L16,6 L18,12 L16,18 L8,18 L6,12 Z', color: '#6b7280' }
}; };
function buildCanvasGraph() { function buildCanvasGraph() {
canvasNodes = nodes.map(n => { canvasNodes = nodes.map(n => {
const icon = NODE_ICONS[n.node_type] || NODE_ICONS.other; const fa = NODE_FA_ICONS[n.node_type] || NODE_FA_ICONS.other;
return { return {
id: n.id, id: n.id, label: n.label, ip: n.ip_address,
label: n.label, type: n.node_type, status: n.status, group: n.group_name,
ip: n.ip_address, x: parseFloat(n.pos_x) || 100, y: parseFloat(n.pos_y) || 100,
type: n.node_type, iconPath: fa.path, color: fa.color, w: 36, h: 36
status: n.status,
group: n.group_name,
x: parseFloat(n.pos_x) || 100,
y: parseFloat(n.pos_y) || 100,
iconPath: icon[0],
color: icon[1],
w: 36,
h: 36
}; };
}); });
canvasLinks = links.map(l => ({ canvasLinks = links.map(l => ({
source: canvasNodes.find(n => n.id == l.source_id), source: canvasNodes.find(n => n.id == l.source_id),
target: canvasNodes.find(n => n.id == l.target_id), target: canvasNodes.find(n => n.id == l.target_id),
type: l.link_type, type: l.link_type, label: l.label
label: l.label
})).filter(l => l.source && l.target); })).filter(l => l.source && l.target);
canvasShapes = shapes.map(s => ({ canvasShapes = shapes.map(s => ({
id: s.id, id: s.id, label: s.label, type: s.shape_type,
label: s.label, x: parseFloat(s.pos_x), y: parseFloat(s.pos_y),
type: s.shape_type, w: parseFloat(s.width), h: parseFloat(s.height),
x: parseFloat(s.pos_x), color: s.color, borderColor: s.border_color,
y: parseFloat(s.pos_y), opacity: parseFloat(s.opacity), z: parseInt(s.z_index)
w: parseFloat(s.width),
h: parseFloat(s.height),
color: s.color,
borderColor: s.border_color,
opacity: parseFloat(s.opacity),
z: parseInt(s.z_index)
})); }));
} }
@@ -419,74 +399,54 @@ function renderNetwork() {
ctx.save(); ctx.save();
ctx.translate(panX, panY); ctx.translate(panX, panY);
// Draw shapes (lowest layer)
canvasShapes.sort((a, b) => a.z - b.z).forEach(drawShape); canvasShapes.sort((a, b) => a.z - b.z).forEach(drawShape);
// Draw links
canvasLinks.forEach(l => { canvasLinks.forEach(l => {
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(l.source.x, l.source.y); ctx.moveTo(l.source.x, l.source.y);
ctx.lineTo(l.target.x, l.target.y); ctx.lineTo(l.target.x, l.target.y);
const colors = { direct: '#334155', vpn: '#eab308', wireless: '#22c55e', monitored: '#3b82f6' }; const colors = { direct: '#334155', vpn: '#eab308', wireless: '#22c55e', monitored: '#3b82f6' };
ctx.strokeStyle = colors[l.type] || '#334155'; ctx.strokeStyle = colors[l.type] || '#334155';
ctx.lineWidth = l.type === 'vpn' ? 2.5 : 1.5; ctx.lineWidth = l.type === 'vpn' ? 2.5 : 1.5;
if (l.type === 'vpn' || l.type === 'wireless') ctx.setLineDash([6, 4]); if (l.type === 'vpn' || l.type === 'wireless') ctx.setLineDash([6, 4]);
else ctx.setLineDash([]); else ctx.setLineDash([]);
ctx.stroke(); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
if (l.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.fillStyle = '#94a3b8';
ctx.font = '10px sans-serif'; ctx.font = '10px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(l.label, mx, my - 8); ctx.fillText(l.label, (l.source.x + l.target.x) / 2, (l.source.y + l.target.y) / 2 - 8);
} }
}); });
// Draw nodes
canvasNodes.forEach(drawCanvasNode); canvasNodes.forEach(drawCanvasNode);
ctx.restore(); ctx.restore();
} }
function drawShape(s) { function drawShape(s) {
ctx.save(); ctx.save();
const isSelected = selectedShapeId == s.id; const sel = selectedShapeId == s.id;
ctx.globalAlpha = s.opacity; ctx.globalAlpha = s.opacity;
if (s.type === 'ellipse') { if (s.type === 'ellipse') {
ctx.beginPath(); ctx.beginPath();
ctx.ellipse(s.x + s.w / 2, s.y + s.h / 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2); ctx.ellipse(s.x + s.w / 2, s.y + s.h / 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2);
ctx.fillStyle = s.color;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = isSelected ? '#ffffff' : s.borderColor;
ctx.lineWidth = isSelected ? 2.5 : 1.5;
ctx.setLineDash([5, 3]);
ctx.stroke();
ctx.setLineDash([]);
} else { } else {
ctx.beginPath(); ctx.beginPath();
ctx.roundRect(s.x, s.y, s.w, s.h, 8); ctx.roundRect(s.x, s.y, s.w, s.h, 8);
ctx.fillStyle = s.color;
ctx.fill();
ctx.globalAlpha = 1;
ctx.strokeStyle = isSelected ? '#ffffff' : s.borderColor;
ctx.lineWidth = isSelected ? 2.5 : 1.5;
ctx.setLineDash([5, 3]);
ctx.stroke();
ctx.setLineDash([]);
} }
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([]);
// Resize handles if (sel) {
if (isSelected) { getShapeHandles(s).forEach(h => {
const handles = getShapeHandles(s);
handles.forEach(h => {
ctx.beginPath(); ctx.beginPath();
ctx.arc(h.x, h.y, 5, 0, Math.PI * 2); ctx.arc(h.x, h.y, 5, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff'; ctx.fillStyle = '#ffffff';
@@ -497,54 +457,30 @@ function drawShape(s) {
}); });
} }
// Label
ctx.globalAlpha = 1;
ctx.fillStyle = '#94a3b8'; ctx.fillStyle = '#94a3b8';
ctx.font = '12px sans-serif'; ctx.font = '12px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(s.label, s.x + s.w / 2, s.y - 8); ctx.fillText(s.label || '', s.x + s.w / 2, s.y - 8);
ctx.restore(); ctx.restore();
} }
function drawCanvasNode(n) { function drawCanvasNode(n) {
const isSelected = selectedNodeId == n.id; const sel = selectedNodeId == n.id;
const s = 1.8;
ctx.save(); ctx.save();
if (isSelected) {
ctx.shadowColor = n.color;
ctx.shadowBlur = 18;
}
// Fill background if (sel) { ctx.shadowColor = n.color; ctx.shadowBlur = 18; }
ctx.translate(n.x, n.y);
ctx.scale(1, 1);
ctx.beginPath(); ctx.beginPath();
ctx.arc(n.x, n.y, 22, 0, Math.PI * 2);
// Icon background circle/box
if (n.type === 'switch') {
ctx.rect(-20, -20, 40, 40);
} else {
ctx.arc(0, 0, 22, 0, Math.PI * 2);
}
ctx.fillStyle = n.color + '18'; ctx.fillStyle = n.color + '18';
ctx.fill(); ctx.fill();
ctx.strokeStyle = sel ? '#ffffff' : n.color;
// Border ctx.lineWidth = sel ? 2.5 : 1.5;
ctx.strokeStyle = isSelected ? '#ffffff' : n.color;
ctx.lineWidth = isSelected ? 2.5 : 1.5;
ctx.stroke(); ctx.stroke();
ctx.shadowBlur = 0; ctx.shadowBlur = 0;
// Icon path // Icon path scaled
const scalePath = (path, cx, cy, sc) => { const scaled = n.iconPath.replace(/([\d.]+)/g, m => ((parseFloat(m) - 12) * 0.85 + 0).toFixed(1));
return path.replace(/([\d.]+)/g, (m) => {
const v = parseFloat(m);
return ((v - 12) * sc + cx).toFixed(1);
});
};
const scaled = scalePath(n.iconPath, 0, 0, 0.85);
const path = new Path2D(scaled); const path = new Path2D(scaled);
ctx.fillStyle = n.color; ctx.fillStyle = n.color;
ctx.fill(path); ctx.fill(path);
@@ -552,20 +488,15 @@ function drawCanvasNode(n) {
// Status dot // Status dot
ctx.beginPath(); ctx.beginPath();
ctx.arc(17, -17, 5, 0, Math.PI * 2); ctx.arc(17, -17, 5, 0, Math.PI * 2);
const statusColors = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' }; const sc = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' };
ctx.fillStyle = statusColors[n.status] || '#9ca3af'; ctx.fillStyle = sc[n.status] || '#9ca3af';
ctx.fill(); ctx.fill();
if (n.status === 'compromised') { if (n.status === 'compromised') {
ctx.strokeStyle = '#ef4444'; ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.stroke();
ctx.lineWidth = 2;
ctx.stroke();
} }
ctx.translate(-n.x, -n.y);
// Label
ctx.fillStyle = '#e2e8f0'; ctx.fillStyle = '#e2e8f0';
ctx.font = isSelected ? 'bold 11px sans-serif' : '10px sans-serif'; ctx.font = sel ? 'bold 11px sans-serif' : '10px sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(n.label, n.x, n.y + 34); ctx.fillText(n.label, n.x, n.y + 34);
if (n.ip) { if (n.ip) {
@@ -573,25 +504,21 @@ function drawCanvasNode(n) {
ctx.font = '9px sans-serif'; ctx.font = '9px sans-serif';
ctx.fillText(n.ip, n.x, n.y + 46); ctx.fillText(n.ip, n.x, n.y + 46);
} }
ctx.restore(); ctx.restore();
} }
function getShapeHandles(s) { function getShapeHandles(s) {
return [ return [
{ x: s.x, y: s.y, cx: 0, cy: 0 }, { x: s.x, y: s.y },
{ x: s.x + s.w, y: s.y, cx: 1, cy: 0 }, { x: s.x + s.w, y: s.y },
{ x: s.x + s.w, y: s.y + s.h, cx: 1, cy: 1 }, { x: s.x + s.w, y: s.y + s.h },
{ x: s.x, y: s.y + s.h, cx: 0, cy: 1 } { x: s.x, y: s.y + s.h }
]; ];
} }
// ==================== CANVAS EVENTS ==================== // ==================== CANVAS EVENTS ====================
function getCanvasNodeRaw(mx, my) { function getCanvasNodeAt(mx, my) {
return canvasNodes.find(n => { return canvasNodes.find(n => Math.hypot(mx - n.x, my - n.y) < 28);
const dx = mx - n.x, dy = my - n.y;
return Math.sqrt(dx * dx + dy * dy) < 28;
});
} }
function getShapeAt(mx, my) { function getShapeAt(mx, my) {
@@ -602,13 +529,11 @@ function getShapeAt(mx, my) {
return null; return null;
} }
function getShapeResizeHandle(mx, my) { function getShapeResizeHandleAt(mx, my) {
for (const s of canvasShapes) { for (const s of canvasShapes) {
if (selectedShapeId != s.id) continue; if (selectedShapeId != s.id) continue;
const handles = getShapeHandles(s); for (const h of getShapeHandles(s)) {
for (const h of handles) { if (Math.hypot(mx - h.x, my - h.y) < 8) return { shape: s, hx: h.x, hy: h.y };
const dx = mx - h.x, dy = my - h.y;
if (Math.sqrt(dx * dx + dy * dy) < 8) return { shape: s, handle: h };
} }
} }
return null; return null;
@@ -619,51 +544,36 @@ function onMouseDown(e) {
const mx = e.clientX - rect.left - panX; const mx = e.clientX - rect.left - panX;
const my = e.clientY - rect.top - panY; const my = e.clientY - rect.top - panY;
// Check shape resize handle first (only for selected shape) const resize = getShapeResizeHandleAt(mx, my);
const resize = getShapeResizeHandle(mx, my);
if (resize) { if (resize) {
dragType = 'resize'; const s = resize.shape;
dragTarget = resize.shape; dragType = 'resize'; dragTarget = s;
dragHandle = resize.handle; dragOffX = mx; dragOffY = my;
dragOrig = { x: resize.shape.x, y: resize.shape.y, w: resize.shape.w, h: resize.shape.h }; dragOrig = { x: s.x, y: s.y, w: s.w, h: s.h };
dragOffX = mx;
dragOffY = my;
return; return;
} }
// Check node const node = getCanvasNodeAt(mx, my);
const node = getCanvasNodeRaw(mx, my);
if (node) { if (node) {
dragType = 'node'; dragType = 'node'; dragTarget = node;
dragTarget = node; dragOffX = mx - node.x; dragOffY = my - node.y;
dragOffX = mx - node.x;
dragOffY = my - node.y;
selectNode(node.id); selectNode(node.id);
return; return;
} }
// Check shape body
const shape = getShapeAt(mx, my); const shape = getShapeAt(mx, my);
if (shape) { if (shape) {
selectedNodeId = null; selectedNodeId = null; selectedShapeId = shape.id;
selectedShapeId = shape.id; dragType = 'shape'; dragTarget = shape;
dragType = 'shape'; dragOffX = mx - shape.x; dragOffY = my - shape.y;
dragTarget = shape; renderNodeList(); renderShapeList(); renderNetwork();
dragOffX = mx - shape.x;
dragOffY = my - shape.y;
renderShapeList();
renderNetwork();
return; return;
} }
// Pan selectedNodeId = null; selectedShapeId = null;
selectedNodeId = null; renderNodeList(); renderShapeList();
selectedShapeId = null;
renderNodeList();
renderShapeList();
isPanning = true; isPanning = true;
panStartX = e.clientX - panX; panStartX = e.clientX - panX; panStartY = e.clientY - panY;
panStartY = e.clientY - panY;
canvas.style.cursor = 'grabbing'; canvas.style.cursor = 'grabbing';
} }
@@ -678,57 +588,38 @@ function onMouseMove(e) {
dragTarget.x = e.clientX - rect.left - panX - dragOffX; dragTarget.x = e.clientX - rect.left - panX - dragOffX;
dragTarget.y = e.clientY - rect.top - panY - dragOffY; dragTarget.y = e.clientY - rect.top - panY - dragOffY;
renderNetwork(); renderNetwork();
} else if (dragType === 'resize' && dragTarget && dragHandle) { } else if (dragType === 'resize' && dragTarget) {
const dx = e.clientX - rect.left - panX - dragOffX; const dx = e.clientX - rect.left - panX - dragOffX;
const dy = e.clientY - rect.top - panY - dragOffY; const dy = e.clientY - rect.top - panY - dragOffY;
const h = dragHandle; const s = dragTarget;
if (h.cx === 0) { dragTarget.x = dragOrig.x + dx; dragTarget.w = dragOrig.w - dx; } let nx = dragOrig.x, ny = dragOrig.y, nw = dragOrig.w, nh = dragOrig.h;
else { dragTarget.w = dragOrig.w + dx; } if (dragOffX < dragOrig.x + dragOrig.w / 2) { nx = dragOrig.x + dx; nw = dragOrig.w - dx; }
if (h.cy === 0) { dragTarget.y = dragOrig.y + dy; dragTarget.h = dragOrig.h - dy; } else { nw = dragOrig.w + dx; }
else { dragTarget.h = dragOrig.h + dy; } if (dragOffY < dragOrig.y + dragOrig.h / 2) { ny = dragOrig.y + dy; nh = dragOrig.h - dy; }
if (dragTarget.w < 50) dragTarget.w = 50; else { nh = dragOrig.h + dy; }
if (dragTarget.h < 50) dragTarget.h = 50; if (nw < 50) nw = 50;
if (nh < 50) nh = 50;
s.x = nx; s.y = ny; s.w = nw; s.h = nh;
renderNetwork(); renderNetwork();
} else if (isPanning) { } else if (isPanning) {
panX = e.clientX - panStartX; panX = e.clientX - panStartX; panY = e.clientY - panStartY;
panY = e.clientY - panStartY;
renderNetwork(); renderNetwork();
} else { } else {
const mx = e.clientX - rect.left - panX; const mx = e.clientX - rect.left - panX;
const my = e.clientY - rect.top - panY; const my = e.clientY - rect.top - panY;
const node = getCanvasNodeRaw(mx, my); if (getShapeResizeHandleAt(mx, my)) canvas.style.cursor = 'nwse-resize';
const shape = getShapeAt(mx, my); else if (getCanvasNodeAt(mx, my) || getShapeAt(mx, my)) canvas.style.cursor = 'pointer';
const resize = getShapeResizeHandle(mx, my);
if (resize) canvas.style.cursor = 'nwse-resize';
else if (node || shape) canvas.style.cursor = 'pointer';
else canvas.style.cursor = 'grab'; else canvas.style.cursor = 'grab';
} }
} }
function onMouseUp(e) { function onMouseUp(e) {
if (dragTarget) { if (dragTarget && dragType === 'node') {
if (dragType === 'node') { apiFetch(`nodes/${dragTarget.id}`, { method: 'PUT', body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y }) });
apiFetch(`nodes/${dragTarget.id}`, { } else if (dragTarget && (dragType === 'shape' || dragType === 'resize')) {
method: 'PUT', apiFetch(`shapes/${dragTarget.id}`, { method: 'PUT', body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y, width: dragTarget.w, height: dragTarget.h }) });
body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y })
});
} else if (dragType === 'shape' || dragType === 'resize') {
apiFetch(`shapes/${dragTarget.id}`, {
method: 'PUT',
body: JSON.stringify({
pos_x: dragTarget.x,
pos_y: dragTarget.y,
width: dragTarget.w,
height: dragTarget.h
})
});
}
} }
dragType = null; dragTarget = null; dragOrig = null;
dragType = null;
dragTarget = null;
dragHandle = null;
dragOrig = null;
isPanning = false; isPanning = false;
canvas.style.cursor = 'grab'; canvas.style.cursor = 'grab';
} }
@@ -737,24 +628,10 @@ function onDblClick(e) {
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left - panX; const mx = e.clientX - rect.left - panX;
const my = e.clientY - rect.top - panY; const my = e.clientY - rect.top - panY;
const node = getCanvasNodeAt(mx, my);
const node = getCanvasNodeRaw(mx, my); if (node) { selectNode(node.id); return; }
if (node) {
selectNode(node.id);
return;
}
const shape = getShapeAt(mx, my); const shape = getShapeAt(mx, my);
if (shape) { if (shape) { selectedNodeId = null; selectedShapeId = shape.id; renderShapeList(); renderNetwork(); }
selectedNodeId = null;
selectedShapeId = shape.id;
renderShapeList();
renderNetwork();
}
}
function onContextMenu(e) {
e.preventDefault();
} }
function esc(s) { function esc(s) {
@@ -764,7 +641,6 @@ function esc(s) {
return div.innerHTML; return div.innerHTML;
} }
// roundRect polyfill for older browsers
if (!CanvasRenderingContext2D.prototype.roundRect) { if (!CanvasRenderingContext2D.prototype.roundRect) {
CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) { CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
if (r > w / 2) r = w / 2; if (r > w / 2) r = w / 2;