const statusEl = document.querySelector('#status'); const svg = d3.select('#topology'); const width = 1000; const height = 600; const linkGroup = svg.append('g').attr('class', 'links'); const nodeGroup = svg.append('g').attr('class', 'nodes'); const simulation = d3.forceSimulation() .force('link', d3.forceLink().id(d => d.ip).distance(140)) .force('charge', d3.forceManyBody().strength(-200)) .force('center', d3.forceCenter(width / 2, height / 2)); function colorForNode(node) { if (node.comment && node.comment.includes('gateway')) return '#ffb347'; if (node.comment && node.comment.includes('scanner')) return '#4db8ff'; return node.via_ssh ? '#7fbea6' : '#d4d4d4'; } function render(data) { const edges = data.edges.map(edge => ({ ...edge, source: edge.source, target: edge.target, })); const link = linkGroup.selectAll('line').data(edges, d => `${d.source}|${d.target}|${d.relation}`); link.join( enter => enter.append('line').attr('stroke-width', 2), update => update, exit => exit.remove() ).attr('stroke', '#999'); const node = nodeGroup.selectAll('g').data(data.nodes, d => d.ip); const nodeEnter = node.enter().append('g').call(d3.drag() .on('start', dragstarted) .on('drag', dragged) .on('end', dragended)); nodeEnter.append('circle').attr('r', 26); nodeEnter.append('text') .attr('text-anchor', 'middle') .attr('dy', '0.35em') .text(d => d.ip); nodeEnter.append('title'); const nodeMerged = nodeEnter.merge(node); nodeMerged.select('circle').attr('fill', colorForNode); nodeMerged.select('title').text(d => `${d.ip}\n${d.dns_name || 'no reverse host'}\nvia SSH: ${d.via_ssh}`); node.exit().remove(); simulation.nodes(data.nodes).on('tick', ticked); simulation.force('link').links(edges); simulation.alpha(1).restart(); } function ticked() { linkGroup.selectAll('line') .attr('x1', d => d.source.x) .attr('y1', d => d.source.y) .attr('x2', d => d.target.x) .attr('y2', d => d.target.y); nodeGroup.selectAll('g') .attr('transform', d => `translate(${d.x},${d.y})`); } function dragstarted(event, d) { if (!event.active) simulation.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function dragended(event, d) { if (!event.active) simulation.alphaTarget(0); d.fx = null; d.fy = null; } async function refresh() { try { statusEl.textContent = 'Scanning local LAN…'; const response = await fetch('/api/scan'); if (!response.ok) { throw new Error(`scan failed: ${response.status}`); } const payload = await response.json(); render(payload); statusEl.textContent = `Last scanned ${new Date().toLocaleTimeString()}`; } catch (error) { statusEl.textContent = `Error: ${error.message}`; } } document.querySelector('#refresh').addEventListener('click', refresh); refresh();