+172
-269
@@ -29,7 +29,77 @@ let copyBuffer = null;
|
||||
let editingNodeId = null;
|
||||
let editingShapeId = null;
|
||||
|
||||
// ==================== AUTH / SESSION ====================
|
||||
let currentUser = null;
|
||||
let currentRole = null;
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
const res = await fetch('/api/session');
|
||||
const data = await res.json();
|
||||
if (data.loggedin) {
|
||||
currentUser = data.username;
|
||||
currentRole = data.role;
|
||||
document.getElementById('userDisplay').textContent = data.username;
|
||||
if (data.role === 'admin') document.getElementById('settingsBtn').classList.remove('d-none');
|
||||
document.getElementById('loginOverlay').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
document.getElementById('loginOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function performLogin(authToken) {
|
||||
const errEl = document.getElementById('loginError');
|
||||
const sucEl = document.getElementById('loginSuccess');
|
||||
errEl.style.display = 'none';
|
||||
sucEl.style.display = 'none';
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auth_token: authToken })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
currentUser = data.username;
|
||||
currentRole = data.role;
|
||||
document.getElementById('userDisplay').textContent = data.username;
|
||||
if (data.role === 'admin') document.getElementById('settingsBtn').classList.remove('d-none');
|
||||
document.getElementById('loginOverlay').style.display = 'none';
|
||||
window.history.replaceState({}, '', '/');
|
||||
loadTeams().then(() => loadEvents());
|
||||
loadNetworkData();
|
||||
} else {
|
||||
errEl.textContent = data.error || 'Login failed';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Connection error';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await apiFetch('logout', { method: 'POST' });
|
||||
currentUser = null;
|
||||
currentRole = null;
|
||||
document.getElementById('settingsBtn').classList.add('d-none');
|
||||
document.getElementById('userDisplay').textContent = '';
|
||||
document.getElementById('loginOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const authToken = params.get('auth');
|
||||
if (authToken) {
|
||||
document.getElementById('loginOverlay').style.display = 'flex';
|
||||
const btn = document.querySelector('#loginOverlay .btn');
|
||||
if (btn) btn.textContent = 'Authenticating...';
|
||||
performLogin(authToken);
|
||||
}
|
||||
|
||||
checkSession().then(() => {
|
||||
canvas = document.getElementById('networkCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
@@ -38,6 +108,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadTeams().then(() => loadEvents());
|
||||
loadNetworkData();
|
||||
|
||||
document.getElementById('loginBtn').addEventListener('click', () => {
|
||||
const callbackUrl = window.location.origin + '/?auth_callback=1';
|
||||
window.location.href = 'https://auth.jakach.ch/?send_to=' + encodeURIComponent(callbackUrl);
|
||||
});
|
||||
document.getElementById('saveEvent').addEventListener('click', saveEvent);
|
||||
document.getElementById('saveNode').addEventListener('click', saveNode);
|
||||
document.getElementById('saveLink').addEventListener('click', saveLink);
|
||||
@@ -88,7 +162,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
selectedNodeId = null;
|
||||
selectedNodeId = null; selectedNodeIds = [];
|
||||
selectedShapeId = null;
|
||||
renderNetwork();
|
||||
renderNodeList();
|
||||
@@ -120,7 +194,6 @@ async function apiFetch(path, options = {}) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ==================== TEAMS ====================
|
||||
async function loadTeams() {
|
||||
teams = await apiFetch('teams');
|
||||
const sel = document.getElementById('eventTeam');
|
||||
@@ -133,7 +206,6 @@ async function loadTeams() {
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== EVENTS ====================
|
||||
async function loadEvents() {
|
||||
events = await apiFetch('events');
|
||||
renderTimeline();
|
||||
@@ -152,7 +224,7 @@ function renderTimeline() {
|
||||
);
|
||||
|
||||
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>`;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -172,12 +244,12 @@ function renderTimeline() {
|
||||
<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>` : ''}
|
||||
${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="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)">
|
||||
<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>
|
||||
@@ -249,48 +321,47 @@ async function loadNetworkData() {
|
||||
}
|
||||
|
||||
function populateNodeSelects() {
|
||||
const html = nodes.map(n => `<option value="${n.id}">${esc(n.label)} (${n.ip_address || 'no IP'})</option>`).join('');
|
||||
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');
|
||||
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' };
|
||||
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 ${selectedNodeIds.includes(n.id) ? 'active' : ''}" onclick="selectNode(${n.id}, event.shiftKey)">
|
||||
<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>`;
|
||||
return '<div class="list-group-item bg-dark border-secondary node-list-item py-2 ' + (selectedNodeIds.includes(n.id) ? 'active' : '') + '" onclick="selectNode(' + n.id + ', event.shiftKey)">' +
|
||||
'<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>
|
||||
<div>
|
||||
<small class="text-secondary me-2">${s.shape_type}</small>
|
||||
<button class="btn btn-sm btn-outline-primary py-0 px-1" onclick="event.stopPropagation();editSelectedShape(${s.id})" title="Edit"><i class="fas fa-pen" style="font-size:.7rem;"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
list.innerHTML = shapes.map(s => {
|
||||
return '<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>' +
|
||||
'<div>' +
|
||||
'<small class="text-secondary me-2">' + s.shape_type + '</small>' +
|
||||
'<button class="btn btn-sm btn-outline-primary py-0 px-1" onclick="event.stopPropagation();editSelectedShape(' + s.id + ')" title="Edit"><i class="fas fa-pen" style="font-size:.7rem;"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function selectNode(id, add = false) {
|
||||
function selectNode(id, add) {
|
||||
if (add) {
|
||||
const idx = selectedNodeIds.indexOf(id);
|
||||
if (idx >= 0) { selectedNodeIds.splice(idx, 1); if (selectedNodeIds.length === 0) selectedNodeId = null; else selectedNodeId = selectedNodeIds[0]; }
|
||||
@@ -302,20 +373,19 @@ function selectNode(id, add = false) {
|
||||
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>
|
||||
${selectedNodeIds.length > 1 ? `<small class="text-secondary">+${selectedNodeIds.length - 1} more selected</small>` : ''}
|
||||
<div class="mt-2 d-flex gap-1">
|
||||
<button class="btn btn-outline-primary btn-sm" onclick="editSelectedNode(${n.id})"><i class="fas fa-pen me-1"></i>Edit</button>
|
||||
<button class="btn btn-outline-danger btn-sm" onclick="deleteSelectedNodes()"><i class="fas fa-trash me-1"></i>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
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>' +
|
||||
(selectedNodeIds.length > 1 ? '<small class="text-secondary">+' + (selectedNodeIds.length - 1) + ' more selected</small>' : '') +
|
||||
'<div class="mt-2 d-flex gap-1">' +
|
||||
'<button class="btn btn-outline-primary btn-sm" onclick="editSelectedNode(' + n.id + ')"><i class="fas fa-pen me-1"></i>Edit</button>' +
|
||||
'<button class="btn btn-outline-danger btn-sm" onclick="deleteSelectedNodes()"><i class="fas fa-trash me-1"></i>Delete</button>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
renderNodeList();
|
||||
renderShapeList();
|
||||
@@ -334,11 +404,11 @@ function selectShape(id) {
|
||||
async function deleteSelectedNodes() {
|
||||
const ids = [...selectedNodeIds];
|
||||
if (!ids.length) return;
|
||||
const ok = await showConfirm(`Delete ${ids.length} node(s) and their connections?`);
|
||||
const ok = await showConfirm('Delete ' + ids.length + ' node(s) and their connections?');
|
||||
if (!ok) return;
|
||||
selectedNodeId = null;
|
||||
selectedNodeIds = [];
|
||||
for (const id of ids) await apiFetch(`nodes/${id}`, { method: 'DELETE' });
|
||||
for (const id of ids) await apiFetch('nodes/' + id, { method: 'DELETE' });
|
||||
loadNetworkData();
|
||||
}
|
||||
|
||||
@@ -347,20 +417,20 @@ async function deleteSelectedShape(id) {
|
||||
const ok = await showConfirm('Delete this shape?');
|
||||
if (!ok) return;
|
||||
selectedShapeId = null;
|
||||
await apiFetch(`shapes/${id}`, { method: 'DELETE' });
|
||||
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 } };
|
||||
copyBuffer = { type: 'node', data: Object.assign({}, n) };
|
||||
}
|
||||
|
||||
function copyShape(id) {
|
||||
const s = shapes.find(x => x.id == id);
|
||||
if (!s) return;
|
||||
copyBuffer = { type: 'shape', data: { ...s } };
|
||||
copyBuffer = { type: 'shape', data: Object.assign({}, s) };
|
||||
}
|
||||
|
||||
async function pasteItem() {
|
||||
@@ -368,36 +438,23 @@ async function pasteItem() {
|
||||
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
|
||||
})
|
||||
});
|
||||
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++
|
||||
})
|
||||
});
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -430,14 +487,10 @@ async function saveNode() {
|
||||
};
|
||||
if (!data.label) return alert('Label required');
|
||||
if (editingNodeId) {
|
||||
const updates = {
|
||||
label: data.label,
|
||||
ip_address: data.ip_address,
|
||||
node_type: data.node_type,
|
||||
status: data.status,
|
||||
group_name: data.group_name
|
||||
};
|
||||
await apiFetch(`nodes/${editingNodeId}`, { method: 'PUT', body: JSON.stringify(updates) });
|
||||
await apiFetch('nodes/' + editingNodeId, { method: 'PUT', body: JSON.stringify({
|
||||
label: data.label, ip_address: data.ip_address,
|
||||
node_type: data.node_type, status: data.status, group_name: data.group_name
|
||||
})});
|
||||
editingNodeId = null;
|
||||
} else {
|
||||
await apiFetch('nodes', { method: 'POST', body: JSON.stringify(data) });
|
||||
@@ -503,13 +556,12 @@ async function saveShape() {
|
||||
if (editingShapeId) {
|
||||
const s = shapes.find(x => x.id == editingShapeId);
|
||||
data.pos_x = s.pos_x; data.pos_y = s.pos_y; data.width = s.width; data.height = s.height; data.z_index = s.z_index;
|
||||
await apiFetch(`shapes/${editingShapeId}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
await apiFetch('shapes/' + editingShapeId, { method: 'PUT', body: JSON.stringify(data) });
|
||||
editingShapeId = null;
|
||||
} else {
|
||||
data.pos_x = canvas.width / 2 - 100 - panX;
|
||||
data.pos_y = canvas.height / 2 - 75 - panY;
|
||||
data.width = 200;
|
||||
data.height = 150;
|
||||
data.width = 200; data.height = 150;
|
||||
data.z_index = nextShapeZ++;
|
||||
await apiFetch('shapes', { method: 'POST', body: JSON.stringify(data) });
|
||||
}
|
||||
@@ -525,7 +577,6 @@ function getNodeColorVal(type) {
|
||||
return c[type] || '#6b7280';
|
||||
}
|
||||
|
||||
// ==================== CANVAS RENDERING ====================
|
||||
const NODE_FA_ICONS = {
|
||||
host: { icon: '\uf108', color: '#3b82f6' },
|
||||
server: { icon: '\uf233', color: '#8b5cf6' },
|
||||
@@ -540,20 +591,16 @@ const NODE_FA_ICONS = {
|
||||
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,
|
||||
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
|
||||
};
|
||||
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),
|
||||
@@ -567,10 +614,7 @@ 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);
|
||||
@@ -589,10 +633,7 @@ function renderNetwork() {
|
||||
ctx.fillText(l.label, (l.source.x + l.target.x) / 2, (l.source.y + l.target.y) / 2 - 8);
|
||||
}
|
||||
});
|
||||
|
||||
canvasNodes.forEach(drawCanvasNode);
|
||||
|
||||
// Selection rectangle
|
||||
if (selectRect && (selectRect.w > 0 || selectRect.h > 0)) {
|
||||
ctx.strokeStyle = '#3b82f6';
|
||||
ctx.lineWidth = 1.5;
|
||||
@@ -602,7 +643,6 @@ function renderNetwork() {
|
||||
ctx.fillRect(selectRect.x, selectRect.y, selectRect.w, selectRect.h);
|
||||
ctx.setLineDash([]);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
@@ -610,7 +650,6 @@ 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);
|
||||
@@ -626,7 +665,6 @@ function drawShape(s) {
|
||||
ctx.setLineDash([5, 3]);
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
|
||||
if (sel) {
|
||||
getShapeHandles(s).forEach(h => {
|
||||
ctx.beginPath();
|
||||
@@ -638,7 +676,6 @@ function drawShape(s) {
|
||||
ctx.stroke();
|
||||
});
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#94a3b8';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
@@ -649,9 +686,7 @@ function drawShape(s) {
|
||||
function drawCanvasNode(n) {
|
||||
const sel = selectedNodeIds.includes(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';
|
||||
@@ -660,8 +695,6 @@ function drawCanvasNode(n) {
|
||||
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';
|
||||
@@ -669,17 +702,12 @@ function drawCanvasNode(n) {
|
||||
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();
|
||||
}
|
||||
|
||||
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';
|
||||
@@ -701,7 +729,6 @@ function getShapeHandles(s) {
|
||||
];
|
||||
}
|
||||
|
||||
// ==================== CANVAS EVENTS ====================
|
||||
function getCanvasNodeAt(mx, my) {
|
||||
return canvasNodes.find(n => Math.hypot(mx - n.x, my - n.y) < 28);
|
||||
}
|
||||
@@ -728,7 +755,6 @@ 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;
|
||||
@@ -736,31 +762,20 @@ function onMouseDown(e) {
|
||||
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) {
|
||||
if (e.shiftKey) {
|
||||
selectNode(node.id, true);
|
||||
// Start multi-drag for all selected
|
||||
dragType = 'node'; dragTarget = node;
|
||||
dragOffX = mx - node.x; dragOffY = my - node.y;
|
||||
} else {
|
||||
if (!selectedNodeIds.includes(node.id)) {
|
||||
selectNode(node.id);
|
||||
}
|
||||
dragType = 'node'; dragTarget = node;
|
||||
dragOffX = mx - node.x; dragOffY = my - node.y;
|
||||
}
|
||||
if (e.shiftKey) { selectNode(node.id, true); }
|
||||
else if (!selectedNodeIds.includes(node.id)) { selectNode(node.id); }
|
||||
dragType = 'node'; dragTarget = node;
|
||||
dragOffX = mx - node.x; dragOffY = my - node.y;
|
||||
return;
|
||||
}
|
||||
|
||||
const shape = getShapeAt(mx, my);
|
||||
if (shape) {
|
||||
selectedNodeId = null; selectedNodeIds = []; selectedShapeId = shape.id;
|
||||
dragType = 'shape'; dragTarget = shape;
|
||||
dragOffX = mx - shape.x; dragOffY = my - shape.y;
|
||||
renderNodeList(); renderShapeList();
|
||||
// Re-check resize handles now that shape is selected
|
||||
const resizeNow = getShapeResizeHandleAt(mx, my);
|
||||
if (resizeNow) {
|
||||
dragType = 'resize'; dragTarget = resizeNow.shape;
|
||||
@@ -769,7 +784,6 @@ function onMouseDown(e) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
selectedNodeId = null; selectedNodeIds = []; selectedShapeId = null;
|
||||
renderNodeList(); renderShapeList();
|
||||
dragType = 'select';
|
||||
@@ -779,38 +793,28 @@ function onMouseDown(e) {
|
||||
|
||||
function onMouseMove(e) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
|
||||
if (dragType === 'node' && dragTarget) {
|
||||
const dx = e.clientX - rect.left - panX - dragOffX - dragTarget.x;
|
||||
const dy = e.clientY - rect.top - panY - dragOffY - dragTarget.y;
|
||||
// Move all selected nodes by delta
|
||||
for (const c of canvasNodes) {
|
||||
if (selectedNodeIds.includes(c.id)) { c.x += dx; c.y += dy; }
|
||||
}
|
||||
renderNetwork();
|
||||
return;
|
||||
for (const c of canvasNodes) { if (selectedNodeIds.includes(c.id)) { c.x += dx; c.y += dy; } }
|
||||
renderNetwork(); return;
|
||||
}
|
||||
if (dragType === 'shape' && dragTarget) {
|
||||
dragTarget.x = e.clientX - rect.left - panX - dragOffX;
|
||||
dragTarget.y = e.clientY - rect.top - panY - dragOffY;
|
||||
renderNetwork();
|
||||
return;
|
||||
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;
|
||||
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 (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;
|
||||
renderNetwork(); return;
|
||||
}
|
||||
if (dragType === 'select') {
|
||||
const mx = e.clientX - rect.left - panX;
|
||||
@@ -819,15 +823,12 @@ function onMouseMove(e) {
|
||||
selectRect.y = Math.min(selectStartY, my);
|
||||
selectRect.w = Math.abs(mx - selectStartX);
|
||||
selectRect.h = Math.abs(my - selectStartY);
|
||||
renderNetwork();
|
||||
return;
|
||||
renderNetwork(); return;
|
||||
}
|
||||
if (isPanning) {
|
||||
panX = e.clientX - panStartX; panY = e.clientY - panStartY;
|
||||
renderNetwork();
|
||||
return;
|
||||
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';
|
||||
@@ -837,13 +838,9 @@ function onMouseMove(e) {
|
||||
|
||||
function onMouseUp(e) {
|
||||
if (dragType === 'node') {
|
||||
for (const c of canvasNodes) {
|
||||
if (selectedNodeIds.includes(c.id)) {
|
||||
apiFetch(`nodes/${c.id}`, { method: 'PUT', body: JSON.stringify({ pos_x: c.x, pos_y: c.y }) });
|
||||
}
|
||||
}
|
||||
for (const c of canvasNodes) { if (selectedNodeIds.includes(c.id)) apiFetch('nodes/' + c.id, { method: 'PUT', body: JSON.stringify({ pos_x: c.x, pos_y: c.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 }) });
|
||||
apiFetch('shapes/' + dragTarget.id, { method: 'PUT', body: JSON.stringify({ pos_x: dragTarget.x, pos_y: dragTarget.y, width: dragTarget.w, height: dragTarget.h }) });
|
||||
} else if (dragType === 'select') {
|
||||
selectRect = null;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
@@ -854,9 +851,7 @@ function onMouseUp(e) {
|
||||
const rw = Math.abs(mx - selectStartX);
|
||||
const rh = Math.abs(my - selectStartY);
|
||||
if (rw > 5 || rh > 5) {
|
||||
const found = canvasNodes.filter(n =>
|
||||
n.x >= rx && n.x <= rx + rw && n.y >= ry && n.y <= ry + rh
|
||||
);
|
||||
const found = canvasNodes.filter(n => n.x >= rx && n.x <= rx + rw && n.y >= ry && n.y <= ry + rh);
|
||||
selectedNodeIds = found.map(n => n.id);
|
||||
selectedNodeId = found.length > 0 ? found[0].id : null;
|
||||
selectedShapeId = null;
|
||||
@@ -886,110 +881,18 @@ function esc(s) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// ==================== AUTH / SESSION ====================
|
||||
let currentUser = null;
|
||||
let currentRole = null;
|
||||
|
||||
async function checkSession() {
|
||||
try {
|
||||
const res = await fetch('/api/session');
|
||||
const data = await res.json();
|
||||
if (data.loggedin) {
|
||||
currentUser = data.username;
|
||||
currentRole = data.role;
|
||||
document.getElementById('userDisplay').textContent = data.username;
|
||||
if (data.role === 'admin') {
|
||||
document.getElementById('settingsBtn').classList.remove('d-none');
|
||||
}
|
||||
document.getElementById('loginOverlay').style.display = 'none';
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
// Show login overlay
|
||||
document.getElementById('loginOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function performLogin(authToken) {
|
||||
const errEl = document.getElementById('loginError');
|
||||
const sucEl = document.getElementById('loginSuccess');
|
||||
errEl.style.display = 'none';
|
||||
sucEl.style.display = 'none';
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auth_token: authToken })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.status === 'success') {
|
||||
currentUser = data.username;
|
||||
currentRole = data.role;
|
||||
document.getElementById('userDisplay').textContent = data.username;
|
||||
if (data.role === 'admin') document.getElementById('settingsBtn').classList.remove('d-none');
|
||||
document.getElementById('loginOverlay').style.display = 'none';
|
||||
// Clean URL
|
||||
window.history.replaceState({}, '', '/');
|
||||
// Reload data
|
||||
loadTeams().then(() => loadEvents());
|
||||
loadNetworkData();
|
||||
} else {
|
||||
errEl.textContent = data.error || 'Login failed';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Connection error';
|
||||
errEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await apiFetch('logout', { method: 'POST' });
|
||||
currentUser = null;
|
||||
currentRole = null;
|
||||
document.getElementById('settingsBtn').classList.add('d-none');
|
||||
document.getElementById('userDisplay').textContent = '';
|
||||
document.getElementById('loginOverlay').style.display = 'flex';
|
||||
}
|
||||
|
||||
// Check for auth token in URL on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const authToken = params.get('auth');
|
||||
if (authToken) {
|
||||
// Show a loading state on overlay
|
||||
document.getElementById('loginOverlay').style.display = 'flex';
|
||||
document.querySelector('#loginOverlay .btn').textContent = 'Authenticating...';
|
||||
performLogin(authToken);
|
||||
}
|
||||
|
||||
checkSession().then(() => {
|
||||
// Init canvas and load data
|
||||
canvas = document.getElementById('networkCanvas');
|
||||
ctx = canvas.getContext('2d');
|
||||
resizeCanvas();
|
||||
|
||||
loadTeams().then(() => loadEvents());
|
||||
loadNetworkData();
|
||||
|
||||
document.getElementById('loginBtn').addEventListener('click', () => {
|
||||
const callbackUrl = window.location.origin + '/?auth_callback=1';
|
||||
window.location.href = 'https://auth.jakach.ch/?send_to=' + encodeURIComponent(callbackUrl);
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
const list = document.getElementById('userList');
|
||||
try {
|
||||
const users = await apiFetch('settings');
|
||||
list.innerHTML = users.map(u => `
|
||||
<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-secondary">
|
||||
<div>
|
||||
<strong class="small">${esc(u.username)}</strong>
|
||||
<span class="badge bg-${u.role === 'admin' ? 'warning' : 'secondary'} ms-1" style="font-size:.6rem;">${u.role}</span>
|
||||
<div class="text-secondary" style="font-size:.7rem;">${u.user_token.substring(0, 16)}...</div>
|
||||
</div>
|
||||
${u.role !== 'admin' ? `<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="removeUser(${u.id})"><i class="fas fa-times" style="font-size:.7rem;"></i></button>` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
list.innerHTML = users.map(u => {
|
||||
return '<div class="d-flex justify-content-between align-items-center py-1 border-bottom border-secondary">' +
|
||||
'<div><strong class="small">' + esc(u.username) + '</strong>' +
|
||||
'<span class="badge bg-' + (u.role === 'admin' ? 'warning' : 'secondary') + ' ms-1" style="font-size:.6rem;">' + u.role + '</span>' +
|
||||
'<div class="text-secondary" style="font-size:.7rem;">' + u.user_token.substring(0, 16) + '...</div></div>' +
|
||||
(u.role !== 'admin' ? '<button class="btn btn-outline-danger btn-sm py-0 px-1" onclick="removeUser(' + u.id + ')"><i class="fas fa-times" style="font-size:.7rem;"></i></button>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
list.innerHTML = '<div class="text-danger small">Failed to load users</div>';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user