/** * Flow Graph — container-to-container traffic visualization * Uses a simple SVG force-directed layout (D3-free for bundle size) * Ball Studios */ import React, { useEffect, useRef } from 'react'; interface Flow { src_server_id: number | null; dst_server_id: number | null; bytes: number; packets: number; } interface Props { flows: Flow[]; } export default function FlowGraph({ flows }: Props) { const svgRef = useRef(null); useEffect(() => { if (!svgRef.current || flows.length === 0) return; const svg = svgRef.current; const W = svg.clientWidth || 600; const H = 280; svg.setAttribute('viewBox', `0 0 ${W} ${H}`); // Collect unique server IDs + "Internet" (null src) const serverIds = new Set(); flows.forEach(f => { serverIds.add(f.src_server_id ? `s${f.src_server_id}` : 'internet'); serverIds.add(f.dst_server_id ? `s${f.dst_server_id}` : 'internet'); }); const nodes = Array.from(serverIds); // Layout: evenly spaced horizontally const positions: Record = {}; nodes.forEach((id, i) => { positions[id] = { x: (W / (nodes.length + 1)) * (i + 1), y: H / 2 }; }); const maxBytes = Math.max(...flows.map(f => f.bytes), 1); // Clear while (svg.firstChild) svg.removeChild(svg.firstChild); // Draw edges flows.forEach(f => { const srcId = f.src_server_id ? `s${f.src_server_id}` : 'internet'; const dstId = f.dst_server_id ? `s${f.dst_server_id}` : 'internet'; if (!positions[srcId] || !positions[dstId]) return; const src = positions[srcId]; const dst = positions[dstId]; const strokeW = 1 + (f.bytes / maxBytes) * 5; const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); line.setAttribute('x1', String(src.x)); line.setAttribute('y1', String(src.y)); line.setAttribute('x2', String(dst.x)); line.setAttribute('y2', String(dst.y)); line.setAttribute('stroke', '#6366f1'); line.setAttribute('stroke-width', String(strokeW)); line.setAttribute('stroke-opacity', '0.6'); svg.appendChild(line); // Arrow marker const arrow = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); const midX = (src.x + dst.x) / 2; const midY = (src.y + dst.y) / 2; arrow.setAttribute('points', `${midX},${midY - 4} ${midX + 8},${midY} ${midX},${midY + 4}`); arrow.setAttribute('fill', '#818cf8'); svg.appendChild(arrow); }); // Draw nodes nodes.forEach(id => { const pos = positions[id]; const isNet = id === 'internet'; const label = isNet ? '🌐 Internet' : `Server ${id.slice(1)}`; const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', String(pos.x)); circle.setAttribute('cy', String(pos.y)); circle.setAttribute('r', '28'); circle.setAttribute('fill', isNet ? '#1e3a5f' : '#1e1b4b'); circle.setAttribute('stroke', isNet ? '#60a5fa' : '#818cf8'); circle.setAttribute('stroke-width', '2'); svg.appendChild(circle); const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', String(pos.x)); text.setAttribute('y', String(pos.y + 5)); text.setAttribute('text-anchor', 'middle'); text.setAttribute('fill', '#e5e7eb'); text.setAttribute('font-size', '11'); text.textContent = label; svg.appendChild(text); }); }, [flows]); if (flows.length === 0) { return
No inter-container flows detected in the last 24h.
; } return ; }