const API = '/api/'; let teams = []; let events = []; let documents = []; let editingDocId = null; let linkingEventId = null; 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 dragNodeIds = null; let dragShapeId = null; let dragOrigShape = null; let selectStartX, selectStartY; let selectRect = null; let nextShapeZ = 0; let copyBuffer = null; let editingNodeId = null; let editingShapeId = null; let syncInterval = null; let syncHashes = { events: null, documents: null, network: null }; let pendingCanvasCreate = null; function startSync() { if (syncInterval) return; syncInterval = setInterval(async () => { if (dragType || dragTarget || isPanning) return; const openModals = document.querySelectorAll('.modal.show'); if (openModals.length > 0) return; try { const [eventsData, docsData, nodesData, linksData, shapesData] = await Promise.all([ apiFetch('events'), apiFetch('documents'), apiFetch('nodes'), apiFetch('links'), apiFetch('shapes'), ]); const eventsHash = JSON.stringify(eventsData); if (eventsHash !== syncHashes.events) { syncHashes.events = eventsHash; events = Array.isArray(eventsData) ? eventsData : []; loadAllTags(); renderTimeline(); } const docsHash = JSON.stringify(docsData); if (docsHash !== syncHashes.documents) { syncHashes.documents = docsHash; documents = Array.isArray(docsData) ? docsData : []; renderDocuments(); } const netHash = JSON.stringify({ n: nodesData, l: linksData, s: shapesData }); if (netHash !== syncHashes.network) { syncHashes.network = netHash; nodes = Array.isArray(nodesData) ? nodesData : []; links = Array.isArray(linksData) ? linksData : []; shapes = Array.isArray(shapesData) ? shapesData : []; populateNodeSelects(); renderNodeList(); renderShapeList(); if (shapes.length) nextShapeZ = Math.max(...shapes.map(x => x.z_index)) + 1; buildCanvasGraph(); renderNetwork(); } } catch (e) {} }, 5000); } function setupCanvasClickCreate() { const wrapper = document.getElementById('networkCanvasWrapper'); const bar = document.getElementById('nodeToolbar'); wrapper.addEventListener('click', async (e) => { if (!pendingCanvasCreate) return; const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; const type = pendingCanvasCreate; pendingCanvasCreate = null; if (bar) bar.querySelectorAll('.d-inline-flex').forEach(s => s.style.outline = 'none'); if (type.startsWith('shape:')) { const shapeType = type.split(':')[1]; await apiFetch('shapes', { method: 'POST', body: JSON.stringify({ label: shapeType === 'rectangle' ? 'Box' : 'Ellipse', shape_type: shapeType, pos_x: mx - 100, pos_y: my - 75, width: 200, height: 150, color: '#1e3a5f', border_color: '#3b82f6', opacity: 0.15, z_index: nextShapeZ++ })}); } else { await apiFetch('nodes', { method: 'POST', body: JSON.stringify({ label: type.charAt(0).toUpperCase() + type.slice(1), ip_address: '', node_type: type || 'host', status: 'unknown', group_name: 'default', notes: '', pos_x: mx, pos_y: my })}); } await loadNetworkData(); }); } const DOC_TYPE_ICONS = { deployment: { icon: 'fa-server', color: '#06b6d4' }, attack: { icon: 'fa-bolt', color: '#ef4444' }, 'incident-report': { icon: 'fa-exclamation-triangle', color: '#f59e0b' }, remediation: { icon: 'fa-wrench', color: '#22c55e' }, exercise: { icon: 'fa-dumbbell', color: '#3b82f6' } }; const DOC_TYPE_LABELS = { deployment: 'Deployment', attack: 'Attack', 'incident-report': 'Incident Report', remediation: 'Remediation', exercise: 'Exercise' }; // ==================== 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 text = await res.text(); let data; try { data = JSON.parse(text); } catch (e) { errEl.textContent = 'Server returned: ' + text.substring(0, 100); errEl.style.display = 'block'; return; } 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(async () => { await Promise.all([loadEvents(), loadDocuments()]); }); 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('saveLinkDocs').addEventListener('click', saveLinkDocs); document.getElementById('addUserBtn').addEventListener('click', addUser); document.getElementById('addUserBtn').addEventListener('click', addUser); document.getElementById('logoutBtn').addEventListener('click', logout); document.getElementById('teamFilter').addEventListener('change', renderTimeline); document.getElementById('tagFilter').addEventListener('change', renderTimeline); document.getElementById('searchEvents').addEventListener('input', renderTimeline); document.getElementById('saveDocument').addEventListener('click', saveDocument); document.getElementById('documentModal').addEventListener('hidden.bs.modal', () => { editingDocId = null; window._editDocData = null; document.getElementById('documentForm').reset(); }); document.getElementById('docTeamFilter').addEventListener('change', renderDocuments); document.getElementById('docTypeFilter').addEventListener('change', renderDocuments); document.getElementById('searchDocs').addEventListener('input', renderDocuments); document.getElementById('shapeOpacity').addEventListener('input', (e) => { document.getElementById('opacityVal').textContent = parseFloat(e.target.value).toFixed(2); }); const ALLOWED_EXTENSIONS = ['pdf', 'md', 'txt', 'docx', 'xlsx', 'csv', 'pptx', 'evidence']; document.getElementById('eventFileInput').addEventListener('change', (e) => { const preview = document.getElementById('eventFilePreview'); const files = Array.from(e.target.files); const invalid = files.filter(f => { const ext = f.name.split('.').pop().toLowerCase(); return !ALLOWED_EXTENSIONS.includes(ext); }); if (invalid.length) { preview.innerHTML = '
Invalid type(s): ' + invalid.map(f => f.name).join(', ') + '. Allowed: ' + ALLOWED_EXTENSIONS.join(', ') + '
'; e.target.value = ''; return; } if (!files.length) { preview.innerHTML = ''; return; } preview.innerHTML = files.map(f => `
${esc(f.name)} (${formatFileSize(f.size)})
` ).join(''); }); document.getElementById('eventTagsInput').addEventListener('input', renderEventTagsPreview); document.getElementById('eventModal').addEventListener('hidden.bs.modal', () => { document.getElementById('eventTagsDisplay').innerHTML = ''; document.getElementById('eventTagsInput').value = ''; }); 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(); } if (tab.id === 'documents-tab') { loadDocuments(); } }); }); 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'); syncHashes.events = JSON.stringify(events); loadAllTags(); renderTimeline(); } let allTags = []; const PAGE_SIZE = 25; let renderedCount = 0; let filteredEvents = []; let isLoadingMore = false; async function loadAllTags() { try { allTags = await apiFetch('tags'); const sel = document.getElementById('tagFilter'); if (!sel) return; const current = sel.value; sel.innerHTML = '' + allTags.map(t => ``).join(''); sel.value = current; } catch (e) {} } function renderTimeline() { const container = document.getElementById('timelineContainer'); const teamFilter = document.getElementById('teamFilter').value; const tagFilter = document.getElementById('tagFilter').value; const search = document.getElementById('searchEvents').value.toLowerCase(); filteredEvents = events; if (teamFilter) filteredEvents = filteredEvents.filter(e => e.team_id == teamFilter); if (tagFilter) filteredEvents = filteredEvents.filter(e => e.tags && e.tags.includes(tagFilter)); if (search) filteredEvents = filteredEvents.filter(e => e.title.toLowerCase().includes(search) || (e.description && e.description.toLowerCase().includes(search)) || (e.tags && e.tags.some(t => t.includes(search))) ); renderedCount = 0; container.innerHTML = ''; container.removeAttribute('style'); renderMoreEvents(); } function renderMoreEvents() { const container = document.getElementById('timelineContainer'); if (!filteredEvents.length) { container.innerHTML = '

No events yet. Create your first incident entry!

'; return; } const next = Math.min(renderedCount + PAGE_SIZE, filteredEvents.length); const batch = filteredEvents.slice(renderedCount, next); const html = batch.map(e => { const date = new Date(e.occurred_at).toLocaleString(); return `
${esc(e.team_name)} ${e.severity} ${e.event_type} ${(e.tags && e.tags.length) ? e.tags.map(t => `${esc(t)}`).join('') : ''}
${date}
${esc(e.title)}
${e.description ? '

' + renderDocLinks(e.description) + '

' : ''}
${(e.comments && e.comments.length) ? `
Comments (${e.comments.length})
` : ''}
${renderComments(e)}
`; }).join(''); const wrapper = document.createElement('div'); wrapper.innerHTML = html; const fragment = document.createDocumentFragment(); while (wrapper.firstChild) fragment.appendChild(wrapper.firstChild); container.appendChild(fragment); renderedCount = next; batch.forEach(e => { const el = document.querySelector(`.timeline-item[data-event-id="${e.id}"] .attachments-list`); if (el) renderAttachments(e.id, el); }); if (renderedCount < filteredEvents.length) { const sentinel = document.createElement('div'); sentinel.id = 'timeline-sentinel'; sentinel.className = 'text-center py-3'; sentinel.innerHTML = ''; container.appendChild(sentinel); observeSentinel(); } } let sentinelObserver = null; let suppressNextMouse = 0; function observeSentinel() { if (sentinelObserver) sentinelObserver.disconnect(); const sentinel = document.getElementById('timeline-sentinel'); if (!sentinel) return; sentinelObserver = new IntersectionObserver((entries) => { if (entries[0].isIntersecting && !isLoadingMore) { isLoadingMore = true; renderMoreEvents(); isLoadingMore = false; } }, { rootMargin: '200px' }); sentinelObserver.observe(sentinel); } function renderDocLinks(text) { const parts = text.split(/(\[doc:\d+\].*?\[\/doc\])/g); return parts.map(p => { const m = p.match(/\[doc:(\d+)\](.*?)\[\/doc\]/); if (m) { return `${esc(m[2])}`; } return esc(p); }).join(''); } function renderInlineDocLinks(desc) { const re = /\[doc:(\d+)\](.*?)\[\/doc\]/g; const labels = []; let m; while ((m = re.exec(desc)) !== null) { const id = parseInt(m[1]); const title = m[2]; labels.push(`${esc(title)}`); } return labels.length ? '' + labels.join('') : ''; } function openDocument(id) { const tab = document.getElementById('documents-tab'); if (tab) tab.click(); setTimeout(() => { const container = document.getElementById('documentContainer'); if (!container) return; const cards = container.querySelectorAll('.doc-card'); cards.forEach(c => { c.style.transition = 'box-shadow .3s, border-color .3s'; c.style.boxShadow = ''; c.style.borderColor = ''; }); const target = container.querySelector(`[data-doc-id="${id}"]`); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'center' }); target.style.boxShadow = '0 0 20px rgba(59,130,246,.5)'; target.style.borderColor = 'var(--neptune-accent)'; setTimeout(() => { target.style.boxShadow = ''; target.style.borderColor = ''; }, 3000); } else { loadDocuments(); setTimeout(() => { const el = document.getElementById('documentContainer').querySelector(`[data-doc-id="${id}"]`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); el.style.boxShadow = '0 0 20px rgba(59,130,246,.5)'; el.style.borderColor = 'var(--neptune-accent)'; setTimeout(() => { el.style.boxShadow = ''; el.style.borderColor = ''; }, 3000); } }, 500); } const filter = document.getElementById('searchDocs'); if (filter) filter.value = ''; document.getElementById('docTeamFilter').value = ''; document.getElementById('docTypeFilter').value = ''; }, 300); } function linkDocsToEvent(eventId) { linkingEventId = eventId; const list = document.getElementById('linkDocsList'); if (!documents.length) { list.innerHTML = '
No documents available. Create some first.
'; } else { const alreadyLinked = extractDocIdsFromDesc(events.find(e => e.id == eventId)?.description || ''); list.innerHTML = documents.map(d => { const checked = alreadyLinked.includes(d.id) ? 'checked' : ''; return `
`; }).join(''); } new bootstrap.Modal(document.getElementById('linkDocsModal')).show(); } function extractDocIdsFromDesc(desc) { const ids = []; const re = /\[doc:(\d+)\]/g; let m; while ((m = re.exec(desc)) !== null) ids.push(parseInt(m[1])); return ids; } async function saveLinkDocs() { const modal = bootstrap.Modal.getInstance(document.getElementById('linkDocsModal')); if (!linkingEventId) { modal.hide(); return; } const checks = document.querySelectorAll('#linkDocsList input[type="checkbox"]:checked'); const selectedIds = Array.from(checks).map(c => parseInt(c.value)); const event = events.find(e => e.id == linkingEventId); if (!event) { modal.hide(); return; } let desc = event.description || ''; desc = desc.replace(/\[doc:\d+\].*?\[\/doc\]\s*/g, '').trim(); const docParts = selectedIds.map(id => { const d = documents.find(x => x.id == id); return d ? `[doc:${d.id}]${d.title}[/doc]` : ''; }).filter(Boolean); if (docParts.length) desc = (desc ? desc + '\n' : '') + 'Linked: ' + docParts.join(', '); await apiFetch('events/' + linkingEventId, { method: 'PUT', body: JSON.stringify({ description: desc }) }); modal.hide(); linkingEventId = null; loadEvents(); } function renderComments(event) { if (!event.comments || !event.comments.length) return ''; 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 uploadAttachment(eventId, file) { const formData = new FormData(); formData.append('file', file); formData.append('event_id', eventId); await fetch('/api/attachments', { method: 'POST', body: formData }); } async function loadAttachments(eventId) { try { const res = await fetch('/api/attachments?event_id=' + eventId); return await res.json(); } catch (e) { return []; } } async function deleteAttachment(id, el) { if (!confirm('Delete this file?')) return; await apiFetch('attachments/' + id, { method: 'DELETE' }); const container = el.closest('.attachments-list'); if (container) { const eventId = container.dataset.eventId; renderAttachments(eventId, container); } } async function renderAttachments(eventId, container) { container.innerHTML = 'Loading...'; const attachments = await loadAttachments(eventId); if (!attachments.length) { container.innerHTML = ''; return; } container.innerHTML = attachments.map(a => { const icon = getFileIcon(a.mime_type, a.original_name); const size = formatFileSize(a.file_size); const ext = a.original_name.split('.').pop().toLowerCase(); const viewable = ['txt', 'md', 'csv'].includes(ext); const href = viewable ? '#' : '/download/?file=' + encodeURIComponent(a.stored_name) + '&mode=download'; const onclick = viewable ? ` onclick="event.preventDefault();openFileViewer('${esc(a.stored_name)}','${esc(a.original_name)}')"` : ''; return `
${esc(a.original_name)} (${size})
`; }).join(''); } function formatFileSize(bytes) { if (!bytes) return '0 B'; const units = ['B', 'KB', 'MB', 'GB']; let i = 0; let size = bytes; while (size >= 1024 && i < units.length - 1) { size /= 1024; i++; } return size.toFixed(1) + ' ' + units[i]; } function getFileIcon(mime, name) { if (mime.startsWith('image/')) return 'fa-file-image'; if (mime === 'application/pdf') return 'fa-file-pdf'; if (mime.startsWith('text/')) return 'fa-file-alt'; if (mime.includes('spreadsheet') || mime.includes('excel') || name.endsWith('.csv') || name.endsWith('.xls') || name.endsWith('.xlsx')) return 'fa-file-excel'; if (mime.includes('document') || mime.includes('word') || name.endsWith('.doc') || name.endsWith('.docx')) return 'fa-file-word'; if (name.endsWith('.md')) return 'fa-file-alt'; return 'fa-file'; } function openFileViewer(storedName, originalName) { const modalEl = document.getElementById('fileViewerModal'); document.getElementById('fileViewerName').textContent = originalName; document.getElementById('fileViewerDownloadBtn').onclick = () => { window.open('/download/?file=' + encodeURIComponent(storedName) + '&mode=download', '_blank'); }; const body = document.getElementById('fileViewerBody'); body.innerHTML = '

Loading file...

'; const modal = new bootstrap.Modal(modalEl); modal.show(); const ext = originalName.split('.').pop().toLowerCase(); if (ext === 'pdf') { body.innerHTML = ``; return; } fetch('/download/?file=' + encodeURIComponent(storedName) + '&mode=view') .then(r => { if (!r.ok) throw new Error('Failed to load'); return r.text(); }) .then(text => { if (ext === 'md') { body.innerHTML = '
' + esc(text) + '
'; } else { body.innerHTML = '
' + esc(text) + '
'; } }) .catch(() => { body.innerHTML = '

Failed to load file

'; }); } async function saveEvent() { const tags = parseEventTags(); 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), tags: tags }; if (!data.title) return alert('Title required'); const result = await apiFetch('events', { method: 'POST', body: JSON.stringify(data) }); const eventId = result.id; const fileInput = document.getElementById('eventFileInput'); if (fileInput.files.length > 0) { for (const file of fileInput.files) { await uploadAttachment(eventId, file); } } bootstrap.Modal.getInstance(document.getElementById('eventModal')).hide(); document.getElementById('eventForm').reset(); document.getElementById('eventFilePreview').innerHTML = ''; document.getElementById('eventTagsDisplay').innerHTML = ''; document.getElementById('eventTagsInput').value = ''; fileInput.value = ''; loadEvents(); } function parseEventTags() { const input = document.getElementById('eventTagsInput'); if (!input) return []; return input.value.split(',').map(t => t.trim().toLowerCase()).filter(t => t.length > 0); } function renderEventTagsPreview() { const tags = parseEventTags(); const container = document.getElementById('eventTagsDisplay'); if (!container) return; container.innerHTML = tags.map(t => `${esc(t)}`).join(''); } function renderTimelineTags(tags) { if (!tags || !tags.length) return ''; return '
' + tags.map(t => `${esc(t)}` ).join('') + '
'; } // ==================== 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(); renderNodeToolbar(); setupCanvasClickCreate(); startSync(); 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) + '
' : (typeof n.notes !== 'undefined' ? '
No 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 renderNodeToolbar() { const bar = document.getElementById('nodeToolbar'); if (!bar) return; const iconMap = [ { type: 'host', icon: 'fa-desktop', color: '#3b82f6', label: 'Host' }, { type: 'server', icon: 'fa-server', color: '#8b5cf6', label: 'Server' }, { type: 'router', icon: 'fa-route', color: '#f59e0b', label: 'Router' }, { type: 'firewall', icon: 'fa-shield-halved', color: '#ef4444', label: 'Firewall' }, { type: 'switch', icon: 'fa-network-wired', color: '#06b6d4', label: 'Switch' }, { type: 'cloud', icon: 'fa-cloud', color: '#22c55e', label: 'Cloud' }, { type: 'endpoint', icon: 'fa-laptop', color: '#ec4899', label: 'Endpoint' }, { type: 'other', icon: 'fa-circle', color: '#6b7280', label: 'Other' }, { type: 'shape:rectangle', icon: 'fa-vector-square', color: '#3b82f6', label: 'Box', isShape: true }, { type: 'shape:ellipse', icon: 'fa-circle', color: '#3b82f6', label: 'Ellipse', isShape: true }, ]; bar.innerHTML = 'Click item then click canvas:'; iconMap.forEach(t => { const el = document.createElement('span'); el.className = 'd-inline-flex align-items-center gap-1 px-2 py-1 rounded'; el.style.cssText = 'cursor:pointer;font-size:.75rem;color:' + t.color + ';background:' + t.color + '12;border:1px solid ' + t.color + '30;'; el.innerHTML = '' + t.label + ''; el.addEventListener('click', () => { bar.querySelectorAll('.d-inline-flex').forEach(s => s.style.outline = 'none'); el.style.outline = '2px solid ' + t.color; pendingCanvasCreate = t.type; }); bar.appendChild(el); }); } function setupCanvasClickCreate() { const wrapper = document.getElementById('networkCanvasWrapper'); if (wrapper.dataset.clickCreate) return; wrapper.dataset.clickCreate = '1'; const bar = document.getElementById('nodeToolbar'); wrapper.addEventListener('click', async (e) => { if (!pendingCanvasCreate) return; if (e.target !== canvas && e.target !== wrapper) return; const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left - panX; const my = e.clientY - rect.top - panY; const type = pendingCanvasCreate; pendingCanvasCreate = null; if (bar) bar.querySelectorAll('.d-inline-flex').forEach(s => s.style.outline = 'none'); if (type.startsWith('shape:')) { const shapeType = type.split(':')[1]; await apiFetch('shapes', { method: 'POST', body: JSON.stringify({ label: shapeType === 'rectangle' ? 'Box' : 'Ellipse', shape_type: shapeType, pos_x: mx - 100, pos_y: my - 75, width: 200, height: 150, color: '#1e3a5f', border_color: '#3b82f6', opacity: 0.15, z_index: nextShapeZ++ })}); } else { await apiFetch('nodes', { method: 'POST', body: JSON.stringify({ label: type.charAt(0).toUpperCase() + type.slice(1), ip_address: '', node_type: type || 'host', status: 'unknown', group_name: 'default', notes: '', pos_x: mx, pos_y: my })}); } await loadNetworkData(); }); } 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) { if (e.button === 2) { isPanning = true; panStartX = e.clientX; panStartY = e.clientY; canvas.style.cursor = 'grabbing'; return; } if (e.button !== 0) return; if (Date.now() < suppressNextMouse) return; 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; dragNodeIds = [...selectedNodeIds]; 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; dragShapeId = shape.id; dragOffX = mx - shape.x; dragOffY = my - shape.y; renderNodeList(); renderShapeList(); const resizeNow = getShapeResizeHandleAt(mx, my); if (resizeNow) { dragType = 'resize'; dragTarget = resizeNow.shape; dragShapeId = resizeNow.shape.id; dragOrigShape = { x: resizeNow.shape.x, y: resizeNow.shape.y, w: resizeNow.shape.w, h: resizeNow.shape.h, cx: resizeNow.cx, cy: resizeNow.cy }; dragOffX = mx; dragOffY = my; } 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' && dragNodeIds) { 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 (dragNodeIds.includes(c.id)) { c.x += dx; c.y += dy; } } renderNetwork(); return; } if (dragType === 'shape' && dragShapeId) { const s = canvasShapes.find(x => x.id === dragShapeId); if (!s) return; s.x = e.clientX - rect.left - panX - dragOffX; s.y = e.clientY - rect.top - panY - dragOffY; renderNetwork(); return; } if (dragType === 'resize' && dragShapeId) { const s = canvasShapes.find(x => x.id === dragShapeId); if (!s) return; const dx = e.clientX - rect.left - panX - dragOffX; const dy = e.clientY - rect.top - panY - dragOffY; const o = dragOrigShape; 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; panStartX = e.clientX; panStartY = e.clientY; 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' && dragNodeIds) { for (const id of dragNodeIds) { const c = canvasNodes.find(n => n.id === id); if (c) apiFetch('nodes/' + id, { method: 'PUT', body: JSON.stringify({ pos_x: c.x, pos_y: c.y }) }); } } else if (dragShapeId && (dragType === 'shape' || dragType === 'resize')) { const s = canvasShapes.find(x => x.id === dragShapeId); if (s) apiFetch('shapes/' + dragShapeId, { method: 'PUT', body: JSON.stringify({ pos_x: s.x, pos_y: s.y, width: s.w, height: s.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 foundNodes = canvasNodes.filter(n => n.x >= rx && n.x <= rx + rw && n.y >= ry && n.y <= ry + rh); const foundShapes = canvasShapes.filter(s => { const sx = s.x, sy = s.y, ex = s.x + s.w, ey = s.y + s.h; return sx < rx + rw && ex > rx && sy < ry + rh && ey > ry; }); selectedNodeIds = foundNodes.map(n => n.id); selectedNodeId = foundNodes.length > 0 ? foundNodes[0].id : null; selectedShapeId = foundShapes.length > 0 ? foundShapes[foundShapes.length - 1].id : null; renderNodeList(); renderShapeList(); renderNetwork(); } } dragType = null; dragTarget = null; dragOrig = null; dragNodeIds = null; dragShapeId = null; dragOrigShape = 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) { editSelectedNode(node.id); return; } const shape = getShapeAt(mx, my); if (shape) { editSelectedShape(shape.id); } } function esc(s) { if (!s) return ''; const div = document.createElement('div'); div.textContent = s; return div.innerHTML; } async function loadRegistrationSetting() { try { const res = await apiFetch('registration'); const toggle = document.getElementById('registrationToggle'); if (toggle) toggle.checked = res.registration_enabled === true; } catch (e) {} } async function saveRegistrationSetting() { const toggle = document.getElementById('registrationToggle'); if (!toggle) return; const enabled = toggle.checked; try { await apiFetch('registration', { method: 'POST', body: JSON.stringify({ registration_enabled: enabled }) }); } catch (e) { alert('Failed to update registration setting'); } } 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(); loadRegistrationSetting(); }); document.getElementById('registrationToggle').addEventListener('change', saveRegistrationSetting); // ==================== DOCUMENTS ==================== async function loadDocuments() { try { documents = await apiFetch('documents'); syncHashes.documents = JSON.stringify(documents); } catch (e) { documents = []; } populateDocTeamFilter(); renderDocuments(); } function populateDocTeamFilter() { const sel = document.getElementById('docTeamFilter'); const teamSel = document.getElementById('docTeam'); sel.innerHTML = ''; teamSel.innerHTML = ''; teams.forEach(t => { sel.innerHTML += ``; teamSel.innerHTML += ``; }); } function renderDocuments() { const container = document.getElementById('documentContainer'); if (!container) { console.error('documentContainer not found'); return; } console.log('renderDocuments called, documents count:', documents.length); const teamFilter = document.getElementById('docTeamFilter').value; const typeFilter = document.getElementById('docTypeFilter').value; const search = document.getElementById('searchDocs').value.toLowerCase(); let filtered = documents; if (teamFilter) filtered = filtered.filter(d => d.team_id == teamFilter); if (typeFilter) filtered = filtered.filter(d => d.doc_type == typeFilter); if (search) filtered = filtered.filter(d => d.title.toLowerCase().includes(search) || (d.content && d.content.toLowerCase().includes(search)) ); if (!filtered.length) { container.innerHTML = '

No documents yet. Create a deployment, attack report, or more!

'; return; } container.innerHTML = '
' + filtered.map(d => { const meta = DOC_TYPE_ICONS[d.doc_type] || DOC_TYPE_ICONS.deployment; const label = DOC_TYPE_LABELS[d.doc_type] || d.doc_type; const date = new Date(d.occurred_at).toLocaleString(); let contentPreview = ''; if (d.content) { try { const p = JSON.parse(d.content); contentPreview = p.notes || ''; } catch (e) { contentPreview = d.content; } if (contentPreview.length > 150) contentPreview = contentPreview.substring(0, 150) + '...'; } return `
${label} ${esc(d.team_name)}
${date}
${esc(d.title)}
${contentPreview ? '

' + esc(contentPreview) + '

' : ''}
`; }).join('') + '
'; } const DOC_FIELD_TEMPLATES = { deployment: [ { id: 'docTarget', label: 'Target System / Host', type: 'network-node' }, { id: 'docVersion', label: 'Version / Build', type: 'text', placeholder: 'e.g. v2.1.0, build 42' }, { id: 'docStatus', label: 'Status', type: 'select', options: ['Planned', 'In Progress', 'Completed', 'Rolled Back', 'Failed'] } ], attack: [ { id: 'docSource', label: 'Attack Source / IP', type: 'network-node' }, { id: 'docVector', label: 'Attack Vector', type: 'select', options: ['Phishing', 'Brute Force', 'Malware', 'DDoS', 'SQL Injection', 'XSS', 'Social Engineering', 'Physical', 'Other'] }, { id: 'docImpact', label: 'Impact', type: 'select', options: ['None', 'Low', 'Medium', 'High', 'Critical'] } ], 'incident-report': [ { id: 'docSeverity', label: 'Severity', type: 'select', options: ['Info', 'Low', 'Medium', 'High', 'Critical'] }, { id: 'docDetectedBy', label: 'Detected By', type: 'text', placeholder: 'e.g. SOC, Automated Alert, User Report' }, { id: 'docContainment', label: 'Containment Status', type: 'select', options: ['Not Contained', 'In Progress', 'Contained', 'Resolved'] } ], remediation: [ { id: 'docAffected', label: 'Affected Systems', type: 'text', placeholder: 'e.g. mail-server-02, all endpoints' }, { id: 'docAction', label: 'Action Taken', type: 'select', options: ['Patch Applied', 'Config Change', 'Rule Updated', 'Access Revoked', 'Network Isolated', 'Other'] }, { id: 'docVerified', label: 'Verification', type: 'select', options: ['Pending', 'Verified', 'Failed Verification'] } ], exercise: [ { id: 'docScenario', label: 'Scenario', type: 'text', placeholder: 'e.g. Ransomware simulation, phishing drill' }, { id: 'docParticipants', label: 'Participants', type: 'text', placeholder: 'e.g. Blue Team, SOC, external red team' }, { id: 'docOutcome', label: 'Outcome', type: 'select', options: ['Pass', 'Partial Pass', 'Fail', 'Inconclusive', 'Not Evaluated'] } ] }; function updateDocFormFields() { const docType = document.getElementById('docType').value; const container = document.getElementById('dynamicDocFields'); const templates = DOC_FIELD_TEMPLATES[docType] || []; container.innerHTML = templates.map(f => { if (f.type === 'network-node') { const opts = nodes.map(n => ``).join(''); return `
`; } if (f.type === 'select') { const opts = f.options.map(o => ``).join(''); return `
`; } return `
`; }).join(''); if (editingDocId && window._editDocData) { const data = window._editDocData; templates.forEach(f => { const el = document.getElementById(f.id); if (el && data._parsedFields?.[f.id]) el.value = data._parsedFields[f.id]; }); } } function collectDocFields() { const docType = document.getElementById('docType').value; const templates = DOC_FIELD_TEMPLATES[docType] || []; const fields = {}; templates.forEach(f => { const el = document.getElementById(f.id); if (el) fields[f.id] = el.value; }); return fields; } async function saveDocument() { const docType = document.getElementById('docType').value; const teamId = document.getElementById('docTeam').value; const title = document.getElementById('docTitle').value.trim(); const notes = document.getElementById('docContent').value.trim(); const fields = collectDocFields(); if (!title) return alert('Title is required'); if (!teamId) return alert('Team is required'); const templates = DOC_FIELD_TEMPLATES[docType] || []; const fieldsObj = {}; templates.forEach(f => { const v = fields[f.id]; if (v) fieldsObj[f.id] = v; }); const data = { doc_type: docType, team_id: teamId, title: title, content: JSON.stringify({ fields: fieldsObj, notes: notes }), occurred_at: new Date().toISOString().slice(0, 16) }; if (editingDocId) { await apiFetch('documents/' + editingDocId, { method: 'PUT', body: JSON.stringify(data) }); editingDocId = null; } else { await apiFetch('documents', { method: 'POST', body: JSON.stringify(data) }); } bootstrap.Modal.getInstance(document.getElementById('documentModal')).hide(); document.getElementById('documentForm').reset(); window._editDocData = null; loadDocuments(); loadEvents(); } function openNewDocument(type) { editingDocId = null; document.getElementById('documentModalLabel').textContent = 'New ' + (DOC_TYPE_LABELS[type] || type) + ' Document'; document.getElementById('saveDocument').innerHTML = ' Save Document'; document.getElementById('docType').value = type; document.getElementById('docTitle').value = ''; document.getElementById('docContent').value = ''; window._editDocData = null; updateDocFormFields(); new bootstrap.Modal(document.getElementById('documentModal')).show(); } async function editDocument(id) { const d = documents.find(x => x.id == id); if (!d) return; editingDocId = id; window._editDocData = d; let parsedNotes = ''; const parsedFields = {}; if (d.content) { try { const parsed = JSON.parse(d.content); if (parsed.fields) Object.assign(parsedFields, parsed.fields); parsedNotes = parsed.notes || ''; } catch (e) { parsedNotes = d.content; } } document.getElementById('documentModalLabel').textContent = 'Edit ' + (DOC_TYPE_LABELS[d.doc_type] || d.doc_type) + ' Document'; document.getElementById('saveDocument').innerHTML = ' Update Document'; document.getElementById('docType').value = d.doc_type; document.getElementById('docTeam').value = d.team_id; document.getElementById('docTitle').value = d.title; document.getElementById('docContent').value = parsedNotes; d._parsedFields = parsedFields; updateDocFormFields(); new bootstrap.Modal(document.getElementById('documentModal')).show(); } async function deleteDocument(id) { if (!confirm('Delete this document?')) return; await apiFetch('documents/' + id, { method: 'DELETE' }); loadDocuments(); loadEvents(); } 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; }; }