const API = '/api/'; let teams = []; let events = []; let nodes = []; let links = []; let shapes = []; let selectedNodeId = null; let selectedNodeIds = []; 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 selectStartX, selectStartY; let selectRect = null; let nextShapeZ = 0; 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 true; } } catch (_) {} document.getElementById('loginOverlay').style.display = 'flex'; throw new Error('not logged in'); } 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({}, '', '/'); initApp(); } else { errEl.textContent = data.error || 'Login failed'; errEl.style.display = 'block'; } } catch (e) { errEl.textContent = 'Could not reach server. Try rebuilding Docker: docker compose down && docker compose build && docker compose up -d'; 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', () => { document.getElementById('loginBtn').addEventListener('click', () => { const callbackUrl = window.location.origin + '/?auth_callback=1'; window.location.href = 'https://auth.jakach.ch/?send_to=' + encodeURIComponent(callbackUrl); }); 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); } else { checkSession().then(initApp).catch(() => {}); } }); function initApp() { 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('addUserBtn').addEventListener('click', addUser); document.getElementById('logoutBtn').addEventListener('click', logout); 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.ctrlKey || e.metaKey) && e.key === 'a') { if (!document.activeElement || document.activeElement.tagName !== 'INPUT') { e.preventDefault(); selectedNodeIds = canvasNodes.map(n => n.id); selectedNodeId = selectedNodeIds.length > 0 ? selectedNodeIds[0] : null; selectedShapeId = null; renderNodeList(); renderShapeList(); renderNetwork(); } return; } if (e.key === 'Delete') { if (!document.activeElement || document.activeElement.tagName !== 'INPUT') { if (selectedNodeIds.length > 0) deleteSelectedNodes(); else if (selectedShapeId) deleteSelectedShape(selectedShapeId); } } if (e.key === 'Escape') { selectedNodeId = null; selectedNodeIds = []; 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(); } async function loadTeams() { teams = await apiFetch('teams'); const sel = document.getElementById('eventTeam'); const filter = document.getElementById('teamFilter'); sel.innerHTML = ''; filter.innerHTML = ''; teams.forEach(t => { sel.innerHTML += ``; filter.innerHTML += ``; }); } 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 = '

No events yet. Create your first incident entry!

'; return; } container.innerHTML = filtered.map(e => { const date = new Date(e.occurred_at).toLocaleString(); return `
${esc(e.team_name)} ${e.severity} ${e.event_type}
${date}
${esc(e.title)}
${e.description ? '

' + esc(e.description) + '

' : ''}
Comments ${e.comments && e.comments.length ? '(' + e.comments.length + ')' : ''}
${renderComments(e)}
`; }).join(''); } function renderComments(event) { if (!event.comments || !event.comments.length) return '
No comments yet
'; return event.comments.map(c => `
${esc(c.author)} ${new Date(c.created_at).toLocaleString()}
${esc(c.body)}
`).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 = currentUser || 'Anonymous'; await apiFetch('comments', { method: 'POST', body: JSON.stringify({ event_id: eventId, author, body }) }); input.value = ''; loadEvents(); } async function deleteEvent(id, btn) { if (!confirm('Delete this event?')) return; btn.disabled = true; await apiFetch('events/' + id, { method: 'DELETE' }); 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 => '').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 => { return '
' + '
' + '' + '' + '
' + '' + esc(n.label) + '' + '
' + (n.ip_address || '—') + ' · ' + n.node_type + '
' + '
' + '
' + '
'; }).join(''); } function renderShapeList() { const list = document.getElementById('shapeList'); list.innerHTML = shapes.map(s => { return '
' + '
' + '
' + '' + '' + esc(s.label || (s.shape_type === 'rectangle' ? 'Box' : 'Ellipse')) + '' + '
' + '
' + '' + s.shape_type + '' + '' + '
' + '
' + '
'; }).join(''); } 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]; } else { selectedNodeIds.push(id); selectedNodeId = id; } } else { selectedNodeId = id; selectedNodeIds = [id]; } selectedShapeId = null; const n = nodes.find(x => x.id == id); if (n) { document.getElementById('nodeDetails').innerHTML = '
' + '
' + esc(n.label) + '
' + '
IP: ' + (n.ip_address || '—') + '
' + '
Type: ' + n.node_type + '
' + '
Status: ' + n.status + '
' + '
Group: ' + n.group_name + '
' + (n.notes ? '
' + esc(n.notes) + '
' : '') + (selectedNodeIds.length > 1 ? '+' + (selectedNodeIds.length - 1) + ' more selected' : '') + '
' + '' + '' + '
' + '
'; } renderNodeList(); renderShapeList(); renderNetwork(); } function selectShape(id) { selectedShapeId = id; selectedNodeId = null; selectedNodeIds = []; renderNodeList(); renderShapeList(); renderNetwork(); } async function deleteSelectedNodes() { const ids = [...selectedNodeIds]; if (!ids.length) return; 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' }); 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: Object.assign({}, n) }; } function copyShape(id) { const s = shapes.find(x => x.id == id); if (!s) return; copyBuffer = { type: 'shape', data: Object.assign({}, 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 done = (val) => { if (resolved) return; resolved = true; modal.hide(); resolve(val); }; const onKey = (e) => { if (e.key === 'Enter') { e.preventDefault(); done(true); } }; btn.onclick = () => done(true); modalEl.addEventListener('hidden.bs.modal', () => { document.removeEventListener('keydown', onKey); if (!resolved) resolve(false); }); document.addEventListener('keydown', onKey); 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', notes: document.getElementById('nodeNotes').value, 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'); if (editingNodeId) { 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, notes: data.notes })}); editingNodeId = null; } else { await apiFetch('nodes', { method: 'POST', body: JSON.stringify(data) }); } bootstrap.Modal.getInstance(document.getElementById('nodeModal')).hide(); document.getElementById('nodeForm').reset(); document.getElementById('nodeModalLabel').textContent = 'Add Network Node'; document.getElementById('saveNode').innerHTML = ' Add Node'; loadNetworkData(); } function editSelectedNode(id) { const n = nodes.find(x => x.id == id); if (!n) return; editingNodeId = id; document.getElementById('nodeLabel').value = n.label; document.getElementById('nodeIp').value = n.ip_address || ''; document.getElementById('nodeType').value = n.node_type; document.getElementById('nodeStatus').value = n.status; document.getElementById('nodeGroup').value = n.group_name; document.getElementById('nodeNotes').value = n.notes || ''; document.getElementById('nodeModalLabel').textContent = 'Edit Network Node'; document.getElementById('saveNode').innerHTML = ' Update Node'; new bootstrap.Modal(document.getElementById('nodeModal')).show(); } function editSelectedShape(id) { const s = shapes.find(x => x.id == id); if (!s) return; editingShapeId = id; document.getElementById('shapeLabel').value = s.label; document.getElementById('shapeType').value = s.shape_type; document.getElementById('shapeColor').value = s.color; document.getElementById('shapeBorderColor').value = s.border_color; document.getElementById('shapeOpacity').value = s.opacity; document.getElementById('opacityVal').textContent = s.opacity; document.getElementById('shapeModalLabel').textContent = 'Edit Shape'; document.getElementById('saveShape').innerHTML = ' Update Shape'; new bootstrap.Modal(document.getElementById('shapeModal')).show(); } 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, color: document.getElementById('shapeColor').value, border_color: document.getElementById('shapeBorderColor').value, opacity: parseFloat(document.getElementById('shapeOpacity').value), }; 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) }); 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.z_index = nextShapeZ++; await apiFetch('shapes', { method: 'POST', body: JSON.stringify(data) }); } bootstrap.Modal.getInstance(document.getElementById('shapeModal')).hide(); document.getElementById('shapeForm').reset(); document.getElementById('shapeModalLabel').textContent = 'Add Shape'; document.getElementById('saveShape').innerHTML = ' Add Shape'; 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'; } const NODE_FA_ICONS = { host: { icon: '\uf108', color: '#3b82f6' }, server: { icon: '\uf233', color: '#8b5cf6' }, router: { icon: '\uf4d8', color: '#f59e0b' }, firewall: { icon: '\uf3ed', color: '#ef4444' }, switch: { icon: '\uf6ff', 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); 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); if (selectRect && (selectRect.w > 0 || selectRect.h > 0)) { ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]); ctx.strokeRect(selectRect.x, selectRect.y, selectRect.w, selectRect.h); ctx.fillStyle = 'rgba(59, 130, 246, 0.08)'; ctx.fillRect(selectRect.x, selectRect.y, selectRect.w, selectRect.h); ctx.setLineDash([]); } 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 = 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'; ctx.fill(); ctx.strokeStyle = sel ? '#ffffff' : n.color; ctx.lineWidth = sel ? 2.5 : 1.5; ctx.stroke(); ctx.shadowBlur = 0; 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(); 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 } ]; } 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) { 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(); const resizeNow = getShapeResizeHandleAt(mx, my); if (resizeNow) { dragType = 'resize'; dragTarget = resizeNow.shape; dragOffX = mx; dragOffY = my; dragOrig = { x: resizeNow.shape.x, y: resizeNow.shape.y, w: resizeNow.shape.w, h: resizeNow.shape.h, cx: resizeNow.cx, cy: resizeNow.cy }; } return; } selectedNodeId = null; selectedNodeIds = []; selectedShapeId = null; renderNodeList(); renderShapeList(); dragType = 'select'; selectStartX = mx; selectStartY = my; selectRect = { x: mx, y: my, w: 0, h: 0 }; } 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; 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; } 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 (dragType === 'select') { const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; selectRect.x = Math.min(selectStartX, mx); selectRect.y = Math.min(selectStartY, my); selectRect.w = Math.abs(mx - selectStartX); selectRect.h = Math.abs(my - selectStartY); 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 (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 }) }); } } 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 }) }); } else if (dragType === 'select') { selectRect = null; const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; const rx = Math.min(selectStartX, mx); const ry = Math.min(selectStartY, my); 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); selectedNodeIds = found.map(n => n.id); selectedNodeId = found.length > 0 ? found[0].id : null; selectedShapeId = null; renderNodeList(); renderShapeList(); renderNetwork(); } } dragType = null; dragTarget = null; dragOrig = null; selectRect = 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; } async function loadUsers() { const list = document.getElementById('userList'); try { const users = await apiFetch('settings'); list.innerHTML = users.map(u => { return '
' + '
' + esc(u.username) + '' + '' + u.role + '' + '
' + u.user_token.substring(0, 16) + '...
' + (u.role !== 'admin' ? '' : '') + '
'; }).join(''); } catch (e) { list.innerHTML = '
Failed to load users
'; } } async function addUser() { const token = document.getElementById('addUserToken').value.trim(); if (!token) return; try { const res = await apiFetch('settings', { method: 'POST', body: JSON.stringify({ user_token: token }) }); if (res.status === 'success') { document.getElementById('addUserToken').value = ''; loadUsers(); } else { alert(res.error || 'Failed to add user'); } } catch (e) { alert('Failed to add user'); } } async function removeUser(id) { if (!confirm('Remove this user?')) return; try { await apiFetch('settings', { method: 'DELETE', body: JSON.stringify({ id }) }); loadUsers(); } catch (e) { alert('Failed to remove user'); } } document.getElementById('settingsModal').addEventListener('show.bs.modal', loadUsers); 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; }; }