754 lines
28 KiB
JavaScript
754 lines
28 KiB
JavaScript
const API = '/api/';
|
|
let teams = [];
|
|
let events = [];
|
|
let nodes = [];
|
|
let links = [];
|
|
let shapes = [];
|
|
let selectedNodeId = null;
|
|
let selectedShapeId = null;
|
|
|
|
let canvas, ctx;
|
|
let canvasNodes = [];
|
|
let canvasLinks = [];
|
|
let canvasShapes = [];
|
|
let panX = 0, panY = 0;
|
|
let isPanning = false;
|
|
let panStartX, panStartY;
|
|
|
|
let dragTarget = null;
|
|
let dragType = null;
|
|
let dragOffX, dragOffY;
|
|
let dragHandle = null;
|
|
let dragOrig = null;
|
|
|
|
let nextShapeZ = 0;
|
|
let copyBuffer = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
canvas = document.getElementById('networkCanvas');
|
|
ctx = canvas.getContext('2d');
|
|
resizeCanvas();
|
|
|
|
loadTeams().then(() => loadEvents());
|
|
loadNetworkData();
|
|
|
|
document.getElementById('saveEvent').addEventListener('click', saveEvent);
|
|
document.getElementById('saveNode').addEventListener('click', saveNode);
|
|
document.getElementById('saveLink').addEventListener('click', saveLink);
|
|
document.getElementById('saveShape').addEventListener('click', saveShape);
|
|
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
|
|
document.getElementById('searchEvents').addEventListener('input', renderTimeline);
|
|
document.getElementById('shapeOpacity').addEventListener('input', (e) => {
|
|
document.getElementById('opacityVal').textContent = parseFloat(e.target.value).toFixed(2);
|
|
});
|
|
|
|
canvas.addEventListener('mousedown', onMouseDown);
|
|
canvas.addEventListener('mousemove', onMouseMove);
|
|
canvas.addEventListener('mouseup', onMouseUp);
|
|
canvas.addEventListener('dblclick', onDblClick);
|
|
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
|
|
window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); });
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
|
if (!document.activeElement || document.activeElement.tagName !== 'INPUT') {
|
|
if (selectedNodeId) copyNode(selectedNodeId);
|
|
else if (selectedShapeId) copyShape(selectedShapeId);
|
|
}
|
|
return;
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
|
if (!document.activeElement || document.activeElement.tagName !== 'INPUT') {
|
|
if (copyBuffer) pasteItem();
|
|
}
|
|
return;
|
|
}
|
|
if (e.key === 'Delete') {
|
|
if (!document.activeElement || document.activeElement.tagName !== 'INPUT') {
|
|
if (selectedNodeId) deleteSelectedNode(selectedNodeId);
|
|
else if (selectedShapeId) deleteSelectedShape(selectedShapeId);
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
selectedNodeId = null;
|
|
selectedShapeId = null;
|
|
renderNetwork();
|
|
renderNodeList();
|
|
renderShapeList();
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('[data-bs-toggle="tab"]').forEach(tab => {
|
|
tab.addEventListener('shown.bs.tab', () => {
|
|
if (tab.id === 'network-tab') { resizeCanvas(); renderNetwork(); }
|
|
});
|
|
});
|
|
|
|
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
|
});
|
|
|
|
function resizeCanvas() {
|
|
const wrapper = document.getElementById('networkCanvasWrapper');
|
|
canvas.width = wrapper.clientWidth;
|
|
canvas.height = wrapper.clientHeight;
|
|
}
|
|
|
|
async function apiFetch(path, options = {}) {
|
|
const res = await fetch(API + path, {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...options
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
// ==================== TEAMS ====================
|
|
async function loadTeams() {
|
|
teams = await apiFetch('teams');
|
|
const sel = document.getElementById('eventTeam');
|
|
const filter = document.getElementById('teamFilter');
|
|
sel.innerHTML = '';
|
|
filter.innerHTML = '<option value="">All Teams</option>';
|
|
teams.forEach(t => {
|
|
sel.innerHTML += `<option value="${t.id}">${t.name}</option>`;
|
|
filter.innerHTML += `<option value="${t.id}">${t.name}</option>`;
|
|
});
|
|
}
|
|
|
|
// ==================== EVENTS ====================
|
|
async function loadEvents() {
|
|
events = await apiFetch('events');
|
|
renderTimeline();
|
|
}
|
|
|
|
function renderTimeline() {
|
|
const container = document.getElementById('timelineContainer');
|
|
const teamFilter = document.getElementById('teamFilter').value;
|
|
const search = document.getElementById('searchEvents').value.toLowerCase();
|
|
|
|
let filtered = events;
|
|
if (teamFilter) filtered = filtered.filter(e => e.team_id == teamFilter);
|
|
if (search) filtered = filtered.filter(e =>
|
|
e.title.toLowerCase().includes(search) ||
|
|
(e.description && e.description.toLowerCase().includes(search))
|
|
);
|
|
|
|
if (!filtered.length) {
|
|
container.innerHTML = `<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;
|
|
}
|
|
|
|
container.innerHTML = filtered.map(e => {
|
|
const date = new Date(e.occurred_at).toLocaleString();
|
|
return `
|
|
<div class="timeline-item">
|
|
<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-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}">${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-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}">
|
|
<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>
|
|
<div class="comment-log">${renderComments(e)}</div>
|
|
<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)">
|
|
<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>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderComments(event) {
|
|
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 => `
|
|
<div class="comment-box d-flex">
|
|
<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="d-flex justify-content-between">
|
|
<span class="comment-author">${esc(c.author)}</span>
|
|
<span class="text-secondary" style="font-size:.7rem;">${new Date(c.created_at).toLocaleString()}</span>
|
|
</div>
|
|
<div class="comment-body">${esc(c.body)}</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function addComment(eventId, el) {
|
|
const container = el.closest('.comment-input-group') || el.parentElement;
|
|
const input = container.querySelector('.comment-input');
|
|
const body = input.value.trim();
|
|
if (!body) return;
|
|
const author = prompt('Your name:') || 'Anonymous';
|
|
await apiFetch('comments', { method: 'POST', body: JSON.stringify({ event_id: eventId, author, body }) });
|
|
input.value = '';
|
|
loadEvents();
|
|
}
|
|
|
|
async function saveEvent() {
|
|
const data = {
|
|
team_id: document.getElementById('eventTeam').value,
|
|
title: document.getElementById('eventTitle').value,
|
|
description: document.getElementById('eventDescription').value,
|
|
severity: document.getElementById('eventSeverity').value,
|
|
event_type: document.getElementById('eventType').value,
|
|
occurred_at: new Date().toISOString().slice(0, 16)
|
|
};
|
|
if (!data.title) return alert('Title required');
|
|
await apiFetch('events', { method: 'POST', body: JSON.stringify(data) });
|
|
bootstrap.Modal.getInstance(document.getElementById('eventModal')).hide();
|
|
document.getElementById('eventForm').reset();
|
|
loadEvents();
|
|
}
|
|
|
|
// ==================== NETWORK MAP DATA ====================
|
|
async function loadNetworkData() {
|
|
const [n, l, s] = await Promise.all([
|
|
apiFetch('nodes'),
|
|
apiFetch('links'),
|
|
apiFetch('shapes')
|
|
]);
|
|
nodes = Array.isArray(n) ? n : [];
|
|
links = Array.isArray(l) ? l : [];
|
|
shapes = Array.isArray(s) ? s : [];
|
|
populateNodeSelects();
|
|
renderNodeList();
|
|
renderShapeList();
|
|
if (shapes.length) nextShapeZ = Math.max(...shapes.map(x => x.z_index)) + 1;
|
|
buildCanvasGraph();
|
|
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 => {
|
|
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">
|
|
<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>
|
|
<strong class="small">${esc(n.label)}</strong>
|
|
<div class="text-secondary" style="font-size:.7rem;">${n.ip_address || '—'} · ${n.node_type}</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderShapeList() {
|
|
const list = document.getElementById('shapeList');
|
|
list.innerHTML = shapes.map(s => `
|
|
<div class="list-group-item bg-dark border-secondary py-2 ${selectedShapeId == s.id ? 'active' : ''}" onclick="selectShape(${s.id})">
|
|
<div class="d-flex align-items-center justify-content-between">
|
|
<div>
|
|
<span style="display:inline-block;width:12px;height:12px;border-radius:2px;background:${s.border_color};margin-right:.4rem;"></span>
|
|
<strong class="small">${esc(s.label) || (s.shape_type === 'rectangle' ? 'Box' : 'Ellipse')}</strong>
|
|
</div>
|
|
<small class="text-secondary">${s.shape_type}</small>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function selectNode(id) {
|
|
selectedNodeId = id;
|
|
selectedShapeId = null;
|
|
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>
|
|
<button class="btn btn-outline-danger btn-sm mt-2" onclick="deleteSelectedNode(${n.id})"><i class="fas fa-trash me-1"></i>Delete</button>
|
|
</div>
|
|
`;
|
|
}
|
|
renderNodeList();
|
|
renderShapeList();
|
|
renderNetwork();
|
|
}
|
|
|
|
function selectShape(id) {
|
|
selectedShapeId = id;
|
|
selectedNodeId = null;
|
|
renderNodeList();
|
|
renderShapeList();
|
|
renderNetwork();
|
|
}
|
|
|
|
async function deleteSelectedNode(id) {
|
|
if (!id) return;
|
|
const ok = await showConfirm('Delete this node and its connections?');
|
|
if (!ok) return;
|
|
selectedNodeId = null;
|
|
await apiFetch(`nodes/${id}`, { method: 'DELETE' });
|
|
loadNetworkData();
|
|
}
|
|
|
|
async function deleteSelectedShape(id) {
|
|
if (!id) return;
|
|
const ok = await showConfirm('Delete this shape?');
|
|
if (!ok) return;
|
|
selectedShapeId = null;
|
|
await apiFetch(`shapes/${id}`, { method: 'DELETE' });
|
|
loadNetworkData();
|
|
}
|
|
|
|
function copyNode(id) {
|
|
const n = nodes.find(x => x.id == id);
|
|
if (!n) return;
|
|
copyBuffer = { type: 'node', data: { ...n } };
|
|
}
|
|
|
|
function copyShape(id) {
|
|
const s = shapes.find(x => x.id == id);
|
|
if (!s) return;
|
|
copyBuffer = { type: 'shape', data: { ...s } };
|
|
}
|
|
|
|
async function pasteItem() {
|
|
if (!copyBuffer) return;
|
|
const offset = 30;
|
|
if (copyBuffer.type === 'node') {
|
|
const d = copyBuffer.data;
|
|
await apiFetch('nodes', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
label: d.label + ' (copy)',
|
|
ip_address: d.ip_address,
|
|
node_type: d.node_type,
|
|
status: d.status,
|
|
group_name: d.group_name,
|
|
pos_x: (parseFloat(d.pos_x) || 100) + offset,
|
|
pos_y: (parseFloat(d.pos_y) || 100) + offset
|
|
})
|
|
});
|
|
loadNetworkData();
|
|
} else if (copyBuffer.type === 'shape') {
|
|
const d = copyBuffer.data;
|
|
await apiFetch('shapes', {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
label: d.label + ' (copy)',
|
|
shape_type: d.shape_type,
|
|
pos_x: (parseFloat(d.pos_x) || 100) + offset,
|
|
pos_y: (parseFloat(d.pos_y) || 100) + offset,
|
|
width: d.width || 200,
|
|
height: d.height || 150,
|
|
color: d.color || '#1e3a5f',
|
|
border_color: d.border_color || '#3b82f6',
|
|
opacity: parseFloat(d.opacity) || 0.15,
|
|
z_index: nextShapeZ++
|
|
})
|
|
});
|
|
loadNetworkData();
|
|
}
|
|
}
|
|
|
|
function showConfirm(msg) {
|
|
return new Promise((resolve) => {
|
|
const modalEl = document.getElementById('confirmModal');
|
|
const modal = new bootstrap.Modal(modalEl);
|
|
document.getElementById('confirmMsg').textContent = msg;
|
|
const btn = document.getElementById('confirmBtn');
|
|
let resolved = false;
|
|
const cleanup = () => {
|
|
btn.removeEventListener('click', onClick);
|
|
modalEl.removeEventListener('hidden.bs.modal', onHidden);
|
|
};
|
|
const onClick = () => { resolved = true; cleanup(); modal.hide(); resolve(true); };
|
|
const onHidden = () => { if (!resolved) { cleanup(); resolve(false); } };
|
|
btn.addEventListener('click', onClick);
|
|
modalEl.addEventListener('hidden.bs.modal', onHidden);
|
|
modal.show();
|
|
});
|
|
}
|
|
|
|
async function saveNode() {
|
|
const data = {
|
|
label: document.getElementById('nodeLabel').value,
|
|
ip_address: document.getElementById('nodeIp').value,
|
|
node_type: document.getElementById('nodeType').value,
|
|
status: document.getElementById('nodeStatus').value,
|
|
group_name: document.getElementById('nodeGroup').value || 'default',
|
|
pos_x: Math.random() * canvas.width * 0.6 + canvas.width * 0.2 - panX,
|
|
pos_y: Math.random() * canvas.height * 0.6 + canvas.height * 0.2 - panY
|
|
};
|
|
if (!data.label) return alert('Label required');
|
|
await apiFetch('nodes', { method: 'POST', body: JSON.stringify(data) });
|
|
bootstrap.Modal.getInstance(document.getElementById('nodeModal')).hide();
|
|
document.getElementById('nodeForm').reset();
|
|
loadNetworkData();
|
|
}
|
|
|
|
async function saveLink() {
|
|
const data = {
|
|
source_id: document.getElementById('linkSource').value,
|
|
target_id: document.getElementById('linkTarget').value,
|
|
link_type: document.getElementById('linkType').value,
|
|
label: document.getElementById('linkLabel').value
|
|
};
|
|
if (data.source_id === data.target_id) return alert('Source and target must differ');
|
|
await apiFetch('links', { method: 'POST', body: JSON.stringify(data) });
|
|
bootstrap.Modal.getInstance(document.getElementById('linkModal')).hide();
|
|
document.getElementById('linkForm').reset();
|
|
loadNetworkData();
|
|
}
|
|
|
|
async function saveShape() {
|
|
const data = {
|
|
label: document.getElementById('shapeLabel').value,
|
|
shape_type: document.getElementById('shapeType').value,
|
|
pos_x: canvas.width / 2 - 100 - panX,
|
|
pos_y: canvas.height / 2 - 75 - panY,
|
|
width: 200,
|
|
height: 150,
|
|
color: document.getElementById('shapeColor').value,
|
|
border_color: document.getElementById('shapeBorderColor').value,
|
|
opacity: parseFloat(document.getElementById('shapeOpacity').value),
|
|
z_index: nextShapeZ++
|
|
};
|
|
await apiFetch('shapes', { method: 'POST', body: JSON.stringify(data) });
|
|
bootstrap.Modal.getInstance(document.getElementById('shapeModal')).hide();
|
|
document.getElementById('shapeForm').reset();
|
|
loadNetworkData();
|
|
}
|
|
|
|
function getNodeColorVal(type) {
|
|
const c = { host:'#3b82f6', server:'#8b5cf6', router:'#f59e0b', firewall:'#ef4444', switch:'#06b6d4', cloud:'#22c55e', endpoint:'#ec4899', other:'#6b7280' };
|
|
return c[type] || '#6b7280';
|
|
}
|
|
|
|
// ==================== CANVAS RENDERING ====================
|
|
const NODE_FA_ICONS = {
|
|
host: { icon: '\uf108', color: '#3b82f6' },
|
|
server: { icon: '\uf233', color: '#8b5cf6' },
|
|
router: { icon: '\uf4d8', color: '#f59e0b' },
|
|
firewall: { icon: '\uf6ed', color: '#ef4444' },
|
|
switch: { icon: '\uf0e8', color: '#06b6d4' },
|
|
cloud: { icon: '\uf0c2', color: '#22c55e' },
|
|
endpoint: { icon: '\uf109', color: '#ec4899' },
|
|
other: { icon: '\uf111', color: '#6b7280' }
|
|
};
|
|
|
|
function buildCanvasGraph() {
|
|
canvasNodes = nodes.map(n => {
|
|
const fa = NODE_FA_ICONS[n.node_type] || NODE_FA_ICONS.other;
|
|
return {
|
|
id: n.id, label: n.label, ip: n.ip_address,
|
|
type: n.node_type, status: n.status, group: n.group_name,
|
|
x: parseFloat(n.pos_x) || 100, y: parseFloat(n.pos_y) || 100,
|
|
icon: fa.icon, color: fa.color, w: 36, h: 36
|
|
};
|
|
});
|
|
|
|
canvasLinks = links.map(l => ({
|
|
source: canvasNodes.find(n => n.id == l.source_id),
|
|
target: canvasNodes.find(n => n.id == l.target_id),
|
|
type: l.link_type, label: l.label
|
|
})).filter(l => l.source && l.target);
|
|
|
|
canvasShapes = shapes.map(s => ({
|
|
id: s.id, label: s.label, type: s.shape_type,
|
|
x: parseFloat(s.pos_x), y: parseFloat(s.pos_y),
|
|
w: parseFloat(s.width), h: parseFloat(s.height),
|
|
color: s.color, borderColor: s.border_color,
|
|
opacity: parseFloat(s.opacity), z: parseInt(s.z_index)
|
|
}));
|
|
}
|
|
|
|
function renderNetwork() {
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.save();
|
|
ctx.translate(panX, panY);
|
|
|
|
// Use canvasShapes / canvasNodes / canvasLinks arrays directly (no rebuild)
|
|
canvasShapes.sort((a, b) => a.z - b.z).forEach(drawShape);
|
|
|
|
canvasLinks.forEach(l => {
|
|
ctx.beginPath();
|
|
ctx.moveTo(l.source.x, l.source.y);
|
|
ctx.lineTo(l.target.x, l.target.y);
|
|
const colors = { direct: '#334155', vpn: '#eab308', wireless: '#22c55e', monitored: '#3b82f6' };
|
|
ctx.strokeStyle = colors[l.type] || '#334155';
|
|
ctx.lineWidth = l.type === 'vpn' ? 2.5 : 1.5;
|
|
if (l.type === 'vpn' || l.type === 'wireless') ctx.setLineDash([6, 4]);
|
|
else ctx.setLineDash([]);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
if (l.label) {
|
|
ctx.fillStyle = '#94a3b8';
|
|
ctx.font = '10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(l.label, (l.source.x + l.target.x) / 2, (l.source.y + l.target.y) / 2 - 8);
|
|
}
|
|
});
|
|
|
|
canvasNodes.forEach(drawCanvasNode);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawShape(s) {
|
|
ctx.save();
|
|
const sel = selectedShapeId == s.id;
|
|
ctx.globalAlpha = s.opacity;
|
|
|
|
if (s.type === 'ellipse') {
|
|
ctx.beginPath();
|
|
ctx.ellipse(s.x + s.w / 2, s.y + s.h / 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2);
|
|
} else {
|
|
ctx.beginPath();
|
|
ctx.roundRect(s.x, s.y, s.w, s.h, 8);
|
|
}
|
|
ctx.fillStyle = s.color;
|
|
ctx.fill();
|
|
ctx.globalAlpha = 1;
|
|
ctx.strokeStyle = sel ? '#ffffff' : s.borderColor;
|
|
ctx.lineWidth = sel ? 2.5 : 1.5;
|
|
ctx.setLineDash([5, 3]);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
|
|
if (sel) {
|
|
getShapeHandles(s).forEach(h => {
|
|
ctx.beginPath();
|
|
ctx.arc(h.x, h.y, 5, 0, Math.PI * 2);
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#3b82f6';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
});
|
|
}
|
|
|
|
ctx.fillStyle = '#94a3b8';
|
|
ctx.font = '12px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(s.label || '', s.x + s.w / 2, s.y - 8);
|
|
ctx.restore();
|
|
}
|
|
|
|
function drawCanvasNode(n) {
|
|
const sel = selectedNodeId == n.id;
|
|
ctx.save();
|
|
|
|
if (sel) { ctx.shadowColor = n.color; ctx.shadowBlur = 18; }
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(n.x, n.y, 22, 0, Math.PI * 2);
|
|
ctx.fillStyle = n.color + '18';
|
|
ctx.fill();
|
|
ctx.strokeStyle = sel ? '#ffffff' : n.color;
|
|
ctx.lineWidth = sel ? 2.5 : 1.5;
|
|
ctx.stroke();
|
|
ctx.shadowBlur = 0;
|
|
|
|
// Draw icon using Font Awesome
|
|
ctx.save();
|
|
ctx.font = '900 22px "Font Awesome 6 Free", "FontAwesome", "Font Awesome 5 Free"';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillStyle = n.color;
|
|
ctx.fillText(n.icon, n.x, n.y);
|
|
ctx.restore();
|
|
|
|
// Status dot
|
|
ctx.beginPath();
|
|
ctx.arc(n.x + 17, n.y - 17, 5, 0, Math.PI * 2);
|
|
const sc = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' };
|
|
ctx.fillStyle = sc[n.status] || '#9ca3af';
|
|
ctx.fill();
|
|
if (n.status === 'compromised') {
|
|
ctx.strokeStyle = '#ef4444'; ctx.lineWidth = 2; ctx.stroke();
|
|
}
|
|
|
|
ctx.fillStyle = '#e2e8f0';
|
|
ctx.font = sel ? 'bold 11px sans-serif' : '10px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(n.label, n.x, n.y + 34);
|
|
if (n.ip) {
|
|
ctx.fillStyle = '#64748b';
|
|
ctx.font = '9px sans-serif';
|
|
ctx.fillText(n.ip, n.x, n.y + 46);
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
function getShapeHandles(s) {
|
|
return [
|
|
{ x: s.x, y: s.y, cx: 0, cy: 0 },
|
|
{ x: s.x + s.w, y: s.y, cx: 1, cy: 0 },
|
|
{ x: s.x + s.w, y: s.y + s.h, cx: 1, cy: 1 },
|
|
{ x: s.x, y: s.y + s.h, cx: 0, cy: 1 }
|
|
];
|
|
}
|
|
|
|
// ==================== CANVAS EVENTS ====================
|
|
function getCanvasNodeAt(mx, my) {
|
|
return canvasNodes.find(n => Math.hypot(mx - n.x, my - n.y) < 28);
|
|
}
|
|
|
|
function getShapeAt(mx, my) {
|
|
for (let i = canvasShapes.length - 1; i >= 0; i--) {
|
|
const s = canvasShapes[i];
|
|
if (mx >= s.x && mx <= s.x + s.w && my >= s.y && my <= s.y + s.h) return s;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getShapeResizeHandleAt(mx, my) {
|
|
for (const s of canvasShapes) {
|
|
if (selectedShapeId != s.id) continue;
|
|
for (const h of getShapeHandles(s)) {
|
|
if (Math.hypot(mx - h.x, my - h.y) < 8) return { shape: s, cx: h.cx, cy: h.cy, sx: h.x, sy: h.y };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function onMouseDown(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left - panX;
|
|
const my = e.clientY - rect.top - panY;
|
|
|
|
const resize = getShapeResizeHandleAt(mx, my);
|
|
if (resize) {
|
|
dragType = 'resize'; dragTarget = resize.shape;
|
|
dragOffX = mx; dragOffY = my;
|
|
dragOrig = { x: resize.shape.x, y: resize.shape.y, w: resize.shape.w, h: resize.shape.h, cx: resize.cx, cy: resize.cy };
|
|
return;
|
|
}
|
|
|
|
const node = getCanvasNodeAt(mx, my);
|
|
if (node) {
|
|
dragType = 'node'; dragTarget = node;
|
|
dragOffX = mx - node.x; dragOffY = my - node.y;
|
|
selectNode(node.id);
|
|
return;
|
|
}
|
|
|
|
const shape = getShapeAt(mx, my);
|
|
if (shape) {
|
|
selectedNodeId = null; selectedShapeId = shape.id;
|
|
dragType = 'shape'; dragTarget = shape;
|
|
dragOffX = mx - shape.x; dragOffY = my - shape.y;
|
|
renderNodeList(); renderShapeList();
|
|
return;
|
|
}
|
|
|
|
selectedNodeId = null; selectedShapeId = null;
|
|
renderNodeList(); renderShapeList();
|
|
isPanning = true;
|
|
panStartX = e.clientX - panX; panStartY = e.clientY - panY;
|
|
canvas.style.cursor = 'grabbing';
|
|
}
|
|
|
|
function onMouseMove(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
|
|
if (dragType === 'node' && dragTarget) {
|
|
dragTarget.x = e.clientX - rect.left - panX - dragOffX;
|
|
dragTarget.y = e.clientY - rect.top - panY - dragOffY;
|
|
renderNetwork();
|
|
return;
|
|
}
|
|
if (dragType === 'shape' && dragTarget) {
|
|
dragTarget.x = e.clientX - rect.left - panX - dragOffX;
|
|
dragTarget.y = e.clientY - rect.top - panY - dragOffY;
|
|
renderNetwork();
|
|
return;
|
|
}
|
|
if (dragType === 'resize' && dragTarget) {
|
|
const dx = e.clientX - rect.left - panX - dragOffX;
|
|
const dy = e.clientY - rect.top - panY - dragOffY;
|
|
const s = dragTarget;
|
|
const o = dragOrig;
|
|
let nx = o.x, ny = o.y, nw = o.w, nh = o.h;
|
|
if (o.cx === 0) { nx = o.x + dx; nw = o.w - dx; }
|
|
else { nw = o.w + dx; }
|
|
if (o.cy === 0) { ny = o.y + dy; nh = o.h - dy; }
|
|
else { nh = o.h + dy; }
|
|
if (nw < 50) { if (o.cx === 0) nx = o.x + o.w - 50; nw = 50; }
|
|
if (nh < 50) { if (o.cy === 0) ny = o.y + o.h - 50; nh = 50; }
|
|
s.x = nx; s.y = ny; s.w = nw; s.h = nh;
|
|
renderNetwork();
|
|
return;
|
|
}
|
|
if (isPanning) {
|
|
panX = e.clientX - panStartX; panY = e.clientY - panStartY;
|
|
renderNetwork();
|
|
return;
|
|
}
|
|
|
|
const mx = e.clientX - rect.left - panX;
|
|
const my = e.clientY - rect.top - panY;
|
|
if (getShapeResizeHandleAt(mx, my)) canvas.style.cursor = 'nwse-resize';
|
|
else if (getCanvasNodeAt(mx, my) || getShapeAt(mx, my)) canvas.style.cursor = 'pointer';
|
|
else canvas.style.cursor = 'grab';
|
|
}
|
|
|
|
function onMouseUp(e) {
|
|
if (dragTarget && dragType === 'node') {
|
|
apiFetch(`nodes/${dragTarget.id}`, { method: 'PUT', body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y }) });
|
|
} else if (dragTarget && (dragType === 'shape' || dragType === 'resize')) {
|
|
apiFetch(`shapes/${dragTarget.id}`, { method: 'PUT', body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y, width: dragTarget.w, height: dragTarget.h }) });
|
|
}
|
|
dragType = null; dragTarget = null; dragOrig = null;
|
|
isPanning = false;
|
|
canvas.style.cursor = 'grab';
|
|
}
|
|
|
|
function onDblClick(e) {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const mx = e.clientX - rect.left - panX;
|
|
const my = e.clientY - rect.top - panY;
|
|
const node = getCanvasNodeAt(mx, my);
|
|
if (node) { selectNode(node.id); return; }
|
|
const shape = getShapeAt(mx, my);
|
|
if (shape) { selectedNodeId = null; selectedShapeId = shape.id; renderShapeList(); renderNetwork(); }
|
|
}
|
|
|
|
function esc(s) {
|
|
if (!s) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
if (!CanvasRenderingContext2D.prototype.roundRect) {
|
|
CanvasRenderingContext2D.prototype.roundRect = function(x, y, w, h, r) {
|
|
if (r > w / 2) r = w / 2;
|
|
if (r > h / 2) r = h / 2;
|
|
this.moveTo(x + r, y);
|
|
this.lineTo(x + w - r, y);
|
|
this.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
this.lineTo(x + w, y + h - r);
|
|
this.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
this.lineTo(x + r, y + h);
|
|
this.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
this.lineTo(x, y + r);
|
|
this.quadraticCurveTo(x, y, x + r, y);
|
|
return this;
|
|
};
|
|
} |