+441
-124
@@ -3,41 +3,69 @@ let teams = [];
|
|||||||
let events = [];
|
let events = [];
|
||||||
let nodes = [];
|
let nodes = [];
|
||||||
let links = [];
|
let links = [];
|
||||||
|
let shapes = [];
|
||||||
let selectedNodeId = null;
|
let selectedNodeId = null;
|
||||||
|
let selectedShapeId = null;
|
||||||
|
|
||||||
// Network canvas state
|
// Canvas state
|
||||||
let canvas, ctx;
|
let canvas, ctx;
|
||||||
let canvasNodes = [];
|
let canvasNodes = [];
|
||||||
let canvasLinks = [];
|
let canvasLinks = [];
|
||||||
let isDragging = false;
|
let canvasShapes = [];
|
||||||
let dragNode = null;
|
|
||||||
let offsetX, offsetY;
|
|
||||||
let panX = 0, panY = 0;
|
let panX = 0, panY = 0;
|
||||||
let isPanning = false;
|
let isPanning = false;
|
||||||
let panStartX, panStartY;
|
let panStartX, panStartY;
|
||||||
|
|
||||||
|
// Drag state
|
||||||
|
let dragTarget = null;
|
||||||
|
let dragType = null;
|
||||||
|
let dragOffX, dragOffY;
|
||||||
|
let dragHandle = null;
|
||||||
|
let dragOrig = null;
|
||||||
|
|
||||||
|
let nextShapeZ = 0;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
canvas = document.getElementById('networkCanvas');
|
canvas = document.getElementById('networkCanvas');
|
||||||
ctx = canvas.getContext('2d');
|
ctx = canvas.getContext('2d');
|
||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
|
|
||||||
loadTeams().then(() => {
|
loadTeams().then(() => loadEvents());
|
||||||
loadEvents();
|
|
||||||
});
|
|
||||||
loadNetworkData();
|
loadNetworkData();
|
||||||
|
|
||||||
document.getElementById('saveEvent').addEventListener('click', saveEvent);
|
document.getElementById('saveEvent').addEventListener('click', saveEvent);
|
||||||
document.getElementById('saveNode').addEventListener('click', saveNode);
|
document.getElementById('saveNode').addEventListener('click', saveNode);
|
||||||
document.getElementById('saveLink').addEventListener('click', saveLink);
|
document.getElementById('saveLink').addEventListener('click', saveLink);
|
||||||
|
document.getElementById('saveShape').addEventListener('click', saveShape);
|
||||||
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
|
document.getElementById('teamFilter').addEventListener('change', renderTimeline);
|
||||||
document.getElementById('searchEvents').addEventListener('input', 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', onCanvasMouseDown);
|
canvas.addEventListener('mousedown', onMouseDown);
|
||||||
canvas.addEventListener('mousemove', onCanvasMouseMove);
|
canvas.addEventListener('mousemove', onMouseMove);
|
||||||
canvas.addEventListener('mouseup', onCanvasMouseUp);
|
canvas.addEventListener('mouseup', onMouseUp);
|
||||||
canvas.addEventListener('dblclick', onCanvasDblClick);
|
canvas.addEventListener('dblclick', onDblClick);
|
||||||
|
canvas.addEventListener('contextmenu', onContextMenu);
|
||||||
window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); });
|
window.addEventListener('resize', () => { resizeCanvas(); renderNetwork(); });
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (document.activeElement === canvas || document.activeElement?.tagName !== 'INPUT') {
|
||||||
|
if (selectedNodeId) deleteSelectedNode();
|
||||||
|
else if (selectedShapeId) deleteSelectedShape();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
selectedNodeId = null;
|
||||||
|
selectedShapeId = null;
|
||||||
|
renderNetwork();
|
||||||
|
renderNodeList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
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') {
|
||||||
@@ -47,7 +75,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bootstrap dark mode default
|
|
||||||
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
document.documentElement.setAttribute('data-bs-theme', 'dark');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +84,6 @@ function resizeCanvas() {
|
|||||||
canvas.height = wrapper.clientHeight;
|
canvas.height = wrapper.clientHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== API HELPERS ====================
|
|
||||||
async function apiFetch(path, options = {}) {
|
async function apiFetch(path, options = {}) {
|
||||||
const res = await fetch(API + path, {
|
const res = await fetch(API + path, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -128,7 +154,7 @@ function renderTimeline() {
|
|||||||
${renderComments(e)}
|
${renderComments(e)}
|
||||||
</div>
|
</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..." data-event-id="${e.id}" 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="bi bi-send"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,10 +212,18 @@ async function saveEvent() {
|
|||||||
|
|
||||||
// ==================== NETWORK MAP ====================
|
// ==================== NETWORK MAP ====================
|
||||||
async function loadNetworkData() {
|
async function loadNetworkData() {
|
||||||
nodes = await apiFetch('nodes');
|
const [n, l, s] = await Promise.all([
|
||||||
links = await apiFetch('links');
|
apiFetch('nodes'),
|
||||||
|
apiFetch('links'),
|
||||||
|
apiFetch('shapes')
|
||||||
|
]);
|
||||||
|
nodes = n;
|
||||||
|
links = l;
|
||||||
|
shapes = s;
|
||||||
populateNodeSelects();
|
populateNodeSelects();
|
||||||
renderNodeList();
|
renderNodeList();
|
||||||
|
renderShapeList();
|
||||||
|
if (shapes.length) nextShapeZ = Math.max(...shapes.map(s => s.z_index)) + 1;
|
||||||
renderNetwork();
|
renderNetwork();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,15 +241,31 @@ function renderNodeList() {
|
|||||||
<span class="status-dot status-${n.status}"></span>
|
<span class="status-dot status-${n.status}"></span>
|
||||||
<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} · ${n.group_name}</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() {
|
||||||
|
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) {
|
function selectNode(id) {
|
||||||
selectedNodeId = id;
|
selectedNodeId = id;
|
||||||
|
selectedShapeId = null;
|
||||||
const n = nodes.find(x => x.id == id);
|
const n = nodes.find(x => x.id == id);
|
||||||
if (n) {
|
if (n) {
|
||||||
document.getElementById('nodeDetails').innerHTML = `
|
document.getElementById('nodeDetails').innerHTML = `
|
||||||
@@ -225,13 +275,41 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
renderNodeList();
|
renderNodeList();
|
||||||
|
renderShapeList();
|
||||||
renderNetwork();
|
renderNetwork();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectShape(id) {
|
||||||
|
selectedShapeId = id;
|
||||||
|
selectedNodeId = null;
|
||||||
|
renderNodeList();
|
||||||
|
renderShapeList();
|
||||||
|
renderNetwork();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedNode() {
|
||||||
|
if (!selectedNodeId) return;
|
||||||
|
if (!confirm('Delete this node and its connections?')) return;
|
||||||
|
const id = selectedNodeId;
|
||||||
|
selectedNodeId = null;
|
||||||
|
await apiFetch(`nodes/${id}`, { method: 'DELETE' });
|
||||||
|
loadNetworkData();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedShape() {
|
||||||
|
if (!selectedShapeId) return;
|
||||||
|
if (!confirm('Delete this shape?')) return;
|
||||||
|
const id = selectedShapeId;
|
||||||
|
selectedShapeId = null;
|
||||||
|
await apiFetch(`shapes/${id}`, { method: 'DELETE' });
|
||||||
|
loadNetworkData();
|
||||||
|
}
|
||||||
|
|
||||||
async function saveNode() {
|
async function saveNode() {
|
||||||
const data = {
|
const data = {
|
||||||
label: document.getElementById('nodeLabel').value,
|
label: document.getElementById('nodeLabel').value,
|
||||||
@@ -239,8 +317,8 @@ async function saveNode() {
|
|||||||
node_type: document.getElementById('nodeType').value,
|
node_type: document.getElementById('nodeType').value,
|
||||||
status: document.getElementById('nodeStatus').value,
|
status: document.getElementById('nodeStatus').value,
|
||||||
group_name: document.getElementById('nodeGroup').value || 'default',
|
group_name: document.getElementById('nodeGroup').value || 'default',
|
||||||
pos_x: Math.random() * canvas.width * 0.6 + canvas.width * 0.2,
|
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
|
pos_y: Math.random() * canvas.height * 0.6 + canvas.height * 0.2 - panY
|
||||||
};
|
};
|
||||||
if (!data.label) return alert('Label required');
|
if (!data.label) return alert('Label required');
|
||||||
await apiFetch('nodes', { method: 'POST', body: JSON.stringify(data) });
|
await apiFetch('nodes', { method: 'POST', body: JSON.stringify(data) });
|
||||||
@@ -263,19 +341,55 @@ async function saveLink() {
|
|||||||
loadNetworkData();
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== CANVAS RENDERING ====================
|
// ==================== CANVAS RENDERING ====================
|
||||||
|
const NODE_ICONS = {
|
||||||
|
host: ['M7,4 L17,4 L20,9 L20,18 L4,18 L4,9 Z', '#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'],
|
||||||
|
router: ['M12,3 L21,10 L18,20 L6,20 L3,10 Z M12,6 L12,17 M8,10 L16,10', '#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'],
|
||||||
|
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'],
|
||||||
|
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'],
|
||||||
|
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'],
|
||||||
|
other: ['M8,6 L16,6 L18,12 L16,18 L8,18 L6,12 Z', '#6b7280']
|
||||||
|
};
|
||||||
|
|
||||||
function buildCanvasGraph() {
|
function buildCanvasGraph() {
|
||||||
canvasNodes = nodes.map(n => ({
|
canvasNodes = nodes.map(n => {
|
||||||
id: n.id,
|
const icon = NODE_ICONS[n.node_type] || NODE_ICONS.other;
|
||||||
label: n.label,
|
return {
|
||||||
ip: n.ip_address,
|
id: n.id,
|
||||||
type: n.node_type,
|
label: n.label,
|
||||||
status: n.status,
|
ip: n.ip_address,
|
||||||
group: n.group_name,
|
type: n.node_type,
|
||||||
x: parseFloat(n.pos_x) || (Math.random() * canvas.width * 0.6 + canvas.width * 0.2),
|
status: n.status,
|
||||||
y: parseFloat(n.pos_y) || (Math.random() * canvas.height * 0.6 + canvas.height * 0.2),
|
group: n.group_name,
|
||||||
radius: n.node_type === 'router' || n.node_type === 'firewall' ? 22 : n.node_type === 'server' ? 28 : 18
|
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),
|
||||||
@@ -283,15 +397,20 @@ function buildCanvasGraph() {
|
|||||||
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);
|
||||||
}
|
|
||||||
|
|
||||||
function getNodeColor(node) {
|
canvasShapes = shapes.map(s => ({
|
||||||
const colors = {
|
id: s.id,
|
||||||
host: '#3b82f6', server: '#8b5cf6', router: '#f59e0b',
|
label: s.label,
|
||||||
firewall: '#ef4444', switch: '#06b6d4', cloud: '#22c55e',
|
type: s.shape_type,
|
||||||
endpoint: '#ec4899', other: '#6b7280'
|
x: parseFloat(s.pos_x),
|
||||||
};
|
y: parseFloat(s.pos_y),
|
||||||
return colors[node.type] || '#6b7280';
|
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() {
|
function renderNetwork() {
|
||||||
@@ -300,6 +419,9 @@ 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);
|
||||||
|
|
||||||
// Draw links
|
// Draw links
|
||||||
canvasLinks.forEach(l => {
|
canvasLinks.forEach(l => {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -308,120 +430,264 @@ function renderNetwork() {
|
|||||||
|
|
||||||
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 : l.type === 'monitored' ? 1.5 : 1.5;
|
ctx.lineWidth = l.type === 'vpn' ? 2.5 : 1.5;
|
||||||
|
|
||||||
|
if (l.type === 'vpn' || l.type === 'wireless') ctx.setLineDash([6, 4]);
|
||||||
|
else ctx.setLineDash([]);
|
||||||
|
|
||||||
if (l.type === 'vpn' || l.type === 'wireless') {
|
|
||||||
ctx.setLineDash([6, 4]);
|
|
||||||
} else {
|
|
||||||
ctx.setLineDash([]);
|
|
||||||
}
|
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
ctx.setLineDash([]);
|
ctx.setLineDash([]);
|
||||||
|
|
||||||
// Link label
|
|
||||||
if (l.label) {
|
if (l.label) {
|
||||||
const mx = (l.source.x + l.target.x) / 2;
|
const mx = (l.source.x + l.target.x) / 2;
|
||||||
const my = (l.source.y + l.target.y) / 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 - 6);
|
ctx.fillText(l.label, mx, my - 8);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Draw nodes
|
// Draw nodes
|
||||||
canvasNodes.forEach(n => {
|
canvasNodes.forEach(drawCanvasNode);
|
||||||
const isSelected = selectedNodeId == n.id;
|
ctx.restore();
|
||||||
const color = getNodeColor(n);
|
}
|
||||||
|
|
||||||
// Glow for selected
|
function drawShape(s) {
|
||||||
if (isSelected) {
|
ctx.save();
|
||||||
ctx.shadowColor = color;
|
const isSelected = selectedShapeId == s.id;
|
||||||
ctx.shadowBlur = 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shape by type
|
ctx.globalAlpha = s.opacity;
|
||||||
|
|
||||||
|
if (s.type === 'ellipse') {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
if (n.type === 'router' || n.type === 'firewall') {
|
ctx.ellipse(s.x + s.w / 2, s.y + s.h / 2, s.w / 2, s.h / 2, 0, 0, Math.PI * 2);
|
||||||
// Diamond
|
ctx.fillStyle = s.color;
|
||||||
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.fill();
|
||||||
ctx.strokeStyle = isSelected ? '#fff' : color;
|
ctx.globalAlpha = 1;
|
||||||
ctx.lineWidth = isSelected ? 3 : 2;
|
ctx.strokeStyle = isSelected ? '#ffffff' : s.borderColor;
|
||||||
|
ctx.lineWidth = isSelected ? 2.5 : 1.5;
|
||||||
|
ctx.setLineDash([5, 3]);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
ctx.setLineDash([]);
|
||||||
// Status indicator
|
} else {
|
||||||
ctx.shadowBlur = 0;
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(n.x + n.radius * 0.6, n.y - n.radius * 0.6, 5, 0, Math.PI * 2);
|
ctx.roundRect(s.x, s.y, s.w, s.h, 8);
|
||||||
const statusColors = { online: '#22c55e', offline: '#6b7280', unknown: '#9ca3af', compromised: '#ef4444', monitoring: '#eab308' };
|
ctx.fillStyle = s.color;
|
||||||
ctx.fillStyle = statusColors[n.status] || '#9ca3af';
|
|
||||||
ctx.fill();
|
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([]);
|
||||||
|
}
|
||||||
|
|
||||||
// Label
|
// Resize handles
|
||||||
ctx.fillStyle = '#e2e8f0';
|
if (isSelected) {
|
||||||
ctx.font = isSelected ? 'bold 11px sans-serif' : '10px sans-serif';
|
const handles = getShapeHandles(s);
|
||||||
ctx.textAlign = 'center';
|
handles.forEach(h => {
|
||||||
ctx.fillText(n.label, n.x, n.y + n.radius + 14);
|
ctx.beginPath();
|
||||||
if (n.ip) {
|
ctx.arc(h.x, h.y, 5, 0, Math.PI * 2);
|
||||||
ctx.fillStyle = '#64748b';
|
ctx.fillStyle = '#ffffff';
|
||||||
ctx.font = '9px sans-serif';
|
ctx.fill();
|
||||||
ctx.fillText(n.ip, n.x, n.y + n.radius + 26);
|
ctx.strokeStyle = '#3b82f6';
|
||||||
}
|
ctx.lineWidth = 2;
|
||||||
});
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label
|
||||||
|
ctx.globalAlpha = 1;
|
||||||
|
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();
|
ctx.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function drawCanvasNode(n) {
|
||||||
|
const isSelected = selectedNodeId == n.id;
|
||||||
|
const s = 1.8;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
if (isSelected) {
|
||||||
|
ctx.shadowColor = n.color;
|
||||||
|
ctx.shadowBlur = 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill background
|
||||||
|
ctx.translate(n.x, n.y);
|
||||||
|
ctx.scale(1, 1);
|
||||||
|
ctx.beginPath();
|
||||||
|
|
||||||
|
// 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.fill();
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = isSelected ? '#ffffff' : n.color;
|
||||||
|
ctx.lineWidth = isSelected ? 2.5 : 1.5;
|
||||||
|
ctx.stroke();
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
|
||||||
|
// Icon path
|
||||||
|
const scalePath = (path, cx, cy, sc) => {
|
||||||
|
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);
|
||||||
|
ctx.fillStyle = n.color;
|
||||||
|
ctx.fill(path);
|
||||||
|
|
||||||
|
// Status dot
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(17, -17, 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();
|
||||||
|
if (n.status === 'compromised') {
|
||||||
|
ctx.strokeStyle = '#ef4444';
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.translate(-n.x, -n.y);
|
||||||
|
|
||||||
|
// 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 + 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 ====================
|
// ==================== CANVAS EVENTS ====================
|
||||||
function getCanvasNode(mx, my) {
|
function getCanvasNodeRaw(mx, my) {
|
||||||
return canvasNodes.find(n => {
|
return canvasNodes.find(n => {
|
||||||
const dx = mx - n.x, dy = my - n.y;
|
const dx = mx - n.x, dy = my - n.y;
|
||||||
return Math.sqrt(dx * dx + dy * dy) < n.radius + 8;
|
return Math.sqrt(dx * dx + dy * dy) < 28;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasMouseDown(e) {
|
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 getShapeResizeHandle(mx, my) {
|
||||||
|
for (const s of canvasShapes) {
|
||||||
|
if (selectedShapeId != s.id) continue;
|
||||||
|
const handles = getShapeHandles(s);
|
||||||
|
for (const h of handles) {
|
||||||
|
const dx = mx - h.x, dy = my - h.y;
|
||||||
|
if (Math.sqrt(dx * dx + dy * dy) < 8) return { shape: s, handle: h };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMouseDown(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 = getCanvasNode(mx, my);
|
// Check shape resize handle first (only for selected shape)
|
||||||
if (node) {
|
const resize = getShapeResizeHandle(mx, my);
|
||||||
isDragging = true;
|
if (resize) {
|
||||||
dragNode = node;
|
dragType = 'resize';
|
||||||
offsetX = mx - node.x;
|
dragTarget = resize.shape;
|
||||||
offsetY = my - node.y;
|
dragHandle = resize.handle;
|
||||||
canvas.style.cursor = 'grabbing';
|
dragOrig = { x: resize.shape.x, y: resize.shape.y, w: resize.shape.w, h: resize.shape.h };
|
||||||
} else {
|
dragOffX = mx;
|
||||||
isPanning = true;
|
dragOffY = my;
|
||||||
panStartX = e.clientX - panX;
|
return;
|
||||||
panStartY = e.clientY - panY;
|
|
||||||
canvas.style.cursor = 'grabbing';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check node
|
||||||
|
const node = getCanvasNodeRaw(mx, my);
|
||||||
|
if (node) {
|
||||||
|
dragType = 'node';
|
||||||
|
dragTarget = node;
|
||||||
|
dragOffX = mx - node.x;
|
||||||
|
dragOffY = my - node.y;
|
||||||
|
selectNode(node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check shape body
|
||||||
|
const shape = getShapeAt(mx, my);
|
||||||
|
if (shape) {
|
||||||
|
selectedNodeId = null;
|
||||||
|
selectedShapeId = shape.id;
|
||||||
|
dragType = 'shape';
|
||||||
|
dragTarget = shape;
|
||||||
|
dragOffX = mx - shape.x;
|
||||||
|
dragOffY = my - shape.y;
|
||||||
|
renderShapeList();
|
||||||
|
renderNetwork();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pan
|
||||||
|
selectedNodeId = null;
|
||||||
|
selectedShapeId = null;
|
||||||
|
renderNodeList();
|
||||||
|
renderShapeList();
|
||||||
|
isPanning = true;
|
||||||
|
panStartX = e.clientX - panX;
|
||||||
|
panStartY = e.clientY - panY;
|
||||||
|
canvas.style.cursor = 'grabbing';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasMouseMove(e) {
|
function onMouseMove(e) {
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
if (isDragging && dragNode) {
|
|
||||||
dragNode.x = e.clientX - rect.left - panX - offsetX;
|
if (dragType === 'node' && dragTarget) {
|
||||||
dragNode.y = e.clientY - rect.top - panY - offsetY;
|
dragTarget.x = e.clientX - rect.left - panX - dragOffX;
|
||||||
|
dragTarget.y = e.clientY - rect.top - panY - dragOffY;
|
||||||
|
renderNetwork();
|
||||||
|
} else if (dragType === 'shape' && dragTarget) {
|
||||||
|
dragTarget.x = e.clientX - rect.left - panX - dragOffX;
|
||||||
|
dragTarget.y = e.clientY - rect.top - panY - dragOffY;
|
||||||
|
renderNetwork();
|
||||||
|
} else if (dragType === 'resize' && dragTarget && dragHandle) {
|
||||||
|
const dx = e.clientX - rect.left - panX - dragOffX;
|
||||||
|
const dy = e.clientY - rect.top - panY - dragOffY;
|
||||||
|
const h = dragHandle;
|
||||||
|
if (h.cx === 0) { dragTarget.x = dragOrig.x + dx; dragTarget.w = dragOrig.w - dx; }
|
||||||
|
else { dragTarget.w = dragOrig.w + dx; }
|
||||||
|
if (h.cy === 0) { dragTarget.y = dragOrig.y + dy; dragTarget.h = dragOrig.h - dy; }
|
||||||
|
else { dragTarget.h = dragOrig.h + dy; }
|
||||||
|
if (dragTarget.w < 50) dragTarget.w = 50;
|
||||||
|
if (dragTarget.h < 50) dragTarget.h = 50;
|
||||||
renderNetwork();
|
renderNetwork();
|
||||||
} else if (isPanning) {
|
} else if (isPanning) {
|
||||||
panX = e.clientX - panStartX;
|
panX = e.clientX - panStartX;
|
||||||
@@ -430,32 +696,65 @@ function onCanvasMouseMove(e) {
|
|||||||
} 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;
|
||||||
canvas.style.cursor = getCanvasNode(mx, my) ? 'pointer' : 'grab';
|
const node = getCanvasNodeRaw(mx, my);
|
||||||
|
const shape = getShapeAt(mx, my);
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasMouseUp(e) {
|
function onMouseUp(e) {
|
||||||
if (isDragging && dragNode) {
|
if (dragTarget) {
|
||||||
// Save position
|
if (dragType === 'node') {
|
||||||
apiFetch(`nodes/${dragNode.id}`, {
|
apiFetch(`nodes/${dragTarget.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ pos_x: dragNode.x, pos_y: dragNode.y })
|
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
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
isDragging = false;
|
|
||||||
dragNode = null;
|
dragType = null;
|
||||||
|
dragTarget = null;
|
||||||
|
dragHandle = null;
|
||||||
|
dragOrig = null;
|
||||||
isPanning = false;
|
isPanning = false;
|
||||||
canvas.style.cursor = 'grab';
|
canvas.style.cursor = 'grab';
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasDblClick(e) {
|
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 = getCanvasNode(mx, my);
|
|
||||||
|
const node = getCanvasNodeRaw(mx, my);
|
||||||
if (node) {
|
if (node) {
|
||||||
selectNode(node.id);
|
selectNode(node.id);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shape = getShapeAt(mx, my);
|
||||||
|
if (shape) {
|
||||||
|
selectedNodeId = null;
|
||||||
|
selectedShapeId = shape.id;
|
||||||
|
renderShapeList();
|
||||||
|
renderNetwork();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onContextMenu(e) {
|
||||||
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
@@ -464,3 +763,21 @@ function esc(s) {
|
|||||||
div.textContent = s;
|
div.textContent = s;
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// roundRect polyfill for older browsers
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
+87
-30
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Neptune - Cybersecurity Incident Journal</title>
|
<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@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="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.1/css/all.min.css" rel="stylesheet">
|
||||||
<link href="assets/css/style.css" rel="stylesheet">
|
<link href="assets/css/style.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand d-flex align-items-center" href="#">
|
<a class="navbar-brand d-flex align-items-center" href="#">
|
||||||
<i class="bi bi-globe2 me-2 text-primary"></i>
|
<i class="fas fa-globe me-2 text-primary"></i>
|
||||||
<span class="fw-bold">Neptune</span>
|
<span class="fw-bold">Neptune</span>
|
||||||
<span class="badge bg-secondary ms-2 small">Cybersecurity Journal</span>
|
<span class="badge bg-secondary ms-2 small">Cybersecurity Journal</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -24,12 +24,12 @@
|
|||||||
<ul class="nav nav-tabs border-0 ms-3" id="mainTabs" role="tablist">
|
<ul class="nav nav-tabs border-0 ms-3" id="mainTabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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
|
<i class="fas fa-clock me-1"></i>Timeline
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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
|
<i class="fas fa-project-diagram me-1"></i>Network Map
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -40,16 +40,14 @@
|
|||||||
<div class="container-fluid mt-3">
|
<div class="container-fluid mt-3">
|
||||||
<div class="tab-content" id="mainTabContent">
|
<div class="tab-content" id="mainTabContent">
|
||||||
|
|
||||||
<!-- ==================== TIMELINE TAB ==================== -->
|
|
||||||
<div class="tab-pane fade show active" id="timeline" role="tabpanel">
|
<div class="tab-pane fade show active" id="timeline" role="tabpanel">
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h4><i class="bi bi-clock-history text-primary"></i> Incident Timeline</h4>
|
<h4><i class="fas fa-clock text-primary"></i> Incident Timeline</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-end">
|
<div class="col-md-6 text-end">
|
||||||
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#eventModal">
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#eventModal">
|
||||||
<i class="bi bi-plus-lg"></i> New Event
|
<i class="fas fa-plus me-1"></i> New Event
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,19 +71,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ==================== NETWORK MAP TAB ==================== -->
|
|
||||||
<div class="tab-pane fade" id="network" role="tabpanel">
|
<div class="tab-pane fade" id="network" role="tabpanel">
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<h4><i class="bi bi-diagram-3 text-primary"></i> Network Map</h4>
|
<h4><i class="fas fa-project-diagram text-primary"></i> Network Map</h4>
|
||||||
|
<small class="text-secondary">Drag nodes & shapes · Resize via corner handles · <kbd class="bg-dark text-secondary">Del</kbd> to delete</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-end">
|
<div class="col-md-6 text-end">
|
||||||
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#nodeModal">
|
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#nodeModal">
|
||||||
<i class="bi bi-plus-lg"></i> Add Node
|
<i class="fas fa-plus me-1"></i> Add Node
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-info btn-sm text-dark" data-bs-toggle="modal" data-bs-target="#shapeModal">
|
||||||
|
<i class="fas fa-vector-square me-1"></i> Add Shape
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-outline-secondary btn-sm" data-bs-toggle="modal" data-bs-target="#linkModal">
|
<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
|
<i class="fas fa-link me-1"></i> Add Link
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,13 +101,33 @@
|
|||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<div class="card bg-dark border-secondary mb-2">
|
<div class="card bg-dark border-secondary mb-2">
|
||||||
<div class="card-header py-2">
|
<div class="card-header py-2">
|
||||||
<small class="fw-bold">Node Details</small>
|
<small class="fw-bold"><i class="fas fa-info-circle me-1"></i> Details</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body py-2" id="nodeDetails">
|
<div class="card-body py-2" id="nodeDetails">
|
||||||
<small class="text-secondary">Click a node to see details</small>
|
<small class="text-secondary">Click a node or shape</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group list-group-flush bg-dark border-secondary rounded" id="nodeList" style="max-height: 50vh; overflow-y: auto;">
|
|
||||||
|
<ul class="nav nav-pills nav-fill mb-2 gap-1" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active py-1 small" id="nodes-tab" data-bs-toggle="tab" data-bs-target="#nodesPanel" type="button">
|
||||||
|
<i class="fas fa-desktop me-1"></i> Nodes
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link py-1 small" id="shapes-tab" data-bs-toggle="tab" data-bs-target="#shapesPanel" type="button">
|
||||||
|
<i class="fas fa-vector-square me-1"></i> Shapes
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="nodesPanel">
|
||||||
|
<div class="list-group list-group-flush bg-dark border-secondary rounded" id="nodeList" style="max-height: 35vh; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="shapesPanel">
|
||||||
|
<div class="list-group list-group-flush bg-dark border-secondary rounded" id="shapeList" style="max-height: 35vh; overflow-y: auto;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,12 +135,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ==================== EVENT MODAL ==================== -->
|
|
||||||
<div class="modal fade" id="eventModal" tabindex="-1">
|
<div class="modal fade" id="eventModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content bg-dark">
|
<div class="modal-content bg-dark">
|
||||||
<div class="modal-header border-secondary">
|
<div class="modal-header border-secondary">
|
||||||
<h5 class="modal-title"><i class="bi bi-plus-circle text-primary"></i> New Event</h5>
|
<h5 class="modal-title"><i class="fas fa-plus-circle text-primary me-1"></i> New Event</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -160,26 +179,21 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-secondary">
|
<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-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>
|
<button type="button" class="btn btn-sm btn-primary" id="saveEvent"><i class="fas fa-save me-1"></i> Save Event</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ==================== NODE MODAL ==================== -->
|
|
||||||
<div class="modal fade" id="nodeModal" tabindex="-1">
|
<div class="modal fade" id="nodeModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content bg-dark">
|
<div class="modal-content bg-dark">
|
||||||
<div class="modal-header border-secondary">
|
<div class="modal-header border-secondary">
|
||||||
<h5 class="modal-title"><i class="bi bi-plus-circle text-primary"></i> Add Network Node</h5>
|
<h5 class="modal-title"><i class="fas fa-desktop text-primary me-1"></i> Add Network Node</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -225,18 +239,61 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-secondary">
|
<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-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>
|
<button type="button" class="btn btn-sm btn-primary" id="saveNode"><i class="fas fa-save me-1"></i> Add Node</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="shapeModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-sm">
|
||||||
|
<div class="modal-content bg-dark">
|
||||||
|
<div class="modal-header border-secondary">
|
||||||
|
<h5 class="modal-title"><i class="fas fa-vector-square text-info me-1"></i> Add Shape</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="shapeForm">
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Label</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" id="shapeLabel" placeholder="DMZ, Internal Net, ...">
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Type</label>
|
||||||
|
<select class="form-select form-select-sm" id="shapeType">
|
||||||
|
<option value="rectangle">Rectangle / Box</option>
|
||||||
|
<option value="ellipse">Ellipse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label small">Fill</label>
|
||||||
|
<input type="color" class="form-control form-control-color form-control-sm" id="shapeColor" value="#1e3a5f">
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<label class="form-label small">Border</label>
|
||||||
|
<input type="color" class="form-control form-control-color form-control-sm" id="shapeBorderColor" value="#3b82f6">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<label class="form-label small">Opacity <span id="opacityVal">0.15</span></label>
|
||||||
|
<input type="range" class="form-range" id="shapeOpacity" min="0.05" max="0.5" step="0.05" value="0.15">
|
||||||
|
</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="saveShape"><i class="fas fa-save me-1"></i> Add Shape</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ==================== LINK MODAL ==================== -->
|
|
||||||
<div class="modal fade" id="linkModal" tabindex="-1">
|
<div class="modal fade" id="linkModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
<div class="modal-content bg-dark">
|
<div class="modal-content bg-dark">
|
||||||
<div class="modal-header border-secondary">
|
<div class="modal-header border-secondary">
|
||||||
<h5 class="modal-title"><i class="bi bi-link-45deg text-primary"></i> Add Connection</h5>
|
<h5 class="modal-title"><i class="fas fa-link text-primary me-1"></i> Add Connection</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -259,14 +316,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label class="form-label small">Label (optional)</label>
|
<label class="form-label small">Label</label>
|
||||||
<input type="text" class="form-control form-control-sm" id="linkLabel" placeholder="e.g. 1 Gbps, SSH Tunnel">
|
<input type="text" class="form-control form-control-sm" id="linkLabel" placeholder="e.g. 1 Gbps, SSH Tunnel">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-secondary">
|
<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-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>
|
<button type="button" class="btn btn-sm btn-primary" id="saveLink"><i class="fas fa-save me-1"></i> Add Link</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user