diff --git a/frontend/assets/css/style.css b/frontend/assets/css/style.css index 64cb0aa..9cdf815 100644 --- a/frontend/assets/css/style.css +++ b/frontend/assets/css/style.css @@ -293,4 +293,15 @@ kbd { margin: 0; white-space: pre-wrap; word-break: break-word; +} + +/* Network toolbar */ +.toolbar-node-item:hover, +.toolbar-shape-item:hover { + filter: brightness(1.3); +} + +.toolbar-node-item:active, +.toolbar-shape-item:active { + cursor: grabbing; } \ No newline at end of file diff --git a/frontend/assets/js/app.js b/frontend/assets/js/app.js index 9f35088..d28e22c 100644 --- a/frontend/assets/js/app.js +++ b/frontend/assets/js/app.js @@ -149,6 +149,7 @@ function initApp() { 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); @@ -725,6 +726,9 @@ async function loadNetworkData() { populateNodeSelects(); renderNodeList(); renderShapeList(); + renderNodeToolbar(); + setupCanvasDrop(); + startSync(); if (shapes.length) nextShapeZ = Math.max(...shapes.map(x => x.z_index)) + 1; buildCanvasGraph(); renderNetwork(); @@ -1143,6 +1147,111 @@ function getShapeHandles(s) { ]; } +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' }, + ]; + bar.innerHTML = 'Drag to canvas:'; + iconMap.forEach(t => { + const el = document.createElement('span'); + el.className = 'toolbar-node-item d-inline-flex align-items-center gap-1 px-2 py-1 rounded'; + el.draggable = true; + el.dataset.nodeType = t.type; + el.style.cssText = 'cursor:grab;font-size:.75rem;color:' + t.color + ';background:' + t.color + '12;border:1px solid ' + t.color + '30;'; + el.innerHTML = '' + t.label + ''; + el.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('text/plain', t.type); + e.dataTransfer.effectAllowed = 'copy'; + }); + bar.appendChild(el); + }); + bar.innerHTML += 'Box'; + bar.innerHTML += 'Ellipse'; + + bar.querySelectorAll('.toolbar-shape-item').forEach(el => { + el.addEventListener('dragstart', (e) => { + e.dataTransfer.setData('text/plain', 'shape:' + el.dataset.shape); + e.dataTransfer.effectAllowed = 'copy'; + }); + }); +} + +function setupCanvasDrop() { + const wrapper = document.getElementById('networkCanvasWrapper'); + wrapper.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; }); + wrapper.addEventListener('drop', async (e) => { + e.preventDefault(); + const data = e.dataTransfer.getData('text/plain'); + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left - panX; + const my = e.clientY - rect.top - panY; + + if (data.startsWith('shape:')) { + const shapeType = data.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++ + })}); + loadNetworkData(); + } else { + await apiFetch('nodes', { method: 'POST', body: JSON.stringify({ + label: data.charAt(0).toUpperCase() + data.slice(1), + ip_address: '', node_type: data, status: 'unknown', + group_name: 'default', notes: '', + pos_x: mx, pos_y: my + })}); + loadNetworkData(); + } + }); +} + +// Real-time sync: poll backend every 5 seconds only when network tab is visible +let syncInterval = null; +let lastSyncData = null; + +function startSync() { + if (syncInterval) return; + syncInterval = setInterval(async () => { + const netTab = document.getElementById('network-tab'); + if (!netTab || !netTab.classList.contains('active')) return; + try { + const [n, l, s] = await Promise.all([ + apiFetch('nodes'), apiFetch('links'), apiFetch('shapes') + ]); + const hash = JSON.stringify({ n, l, s }); + if (hash !== lastSyncData) { + lastSyncData = hash; + 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(); + } + } catch (e) {} + }, 5000); +} + +function stopSync() { + if (syncInterval) { clearInterval(syncInterval); syncInterval = null; } +} + function getCanvasNodeAt(mx, my) { return canvasNodes.find(n => Math.hypot(mx - n.x, my - n.y) < 28); } @@ -1248,7 +1357,10 @@ function onMouseMove(e) { renderNetwork(); return; } if (isPanning) { - panX = e.clientX - panStartX; panY = e.clientY - panStartY; + panX += e.clientX - panStartX; + panY += e.clientY - panStartY; + panStartX = e.clientX; + panStartY = e.clientY; renderNetwork(); return; } const mx = e.clientX - rect.left - panX; diff --git a/frontend/index.html b/frontend/index.html index 701174a..85926b6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -90,7 +90,7 @@

Network Map

- Drag nodes & shapes · Resize via corner handles · Del to delete + Drag nodes & shapes · Resize via corner handles · Del to delete · Right-click to pan