104 lines
4.1 KiB
TypeScript
104 lines
4.1 KiB
TypeScript
/**
|
|
* Flow Graph — container-to-container traffic visualization
|
|
* Uses a simple SVG force-directed layout (D3-free for bundle size)
|
|
* Ball Studios <https://git.balls.studio>
|
|
*/
|
|
|
|
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<SVGSVGElement>(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<string>();
|
|
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<string, { x: number; y: number }> = {};
|
|
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 <div className='addon-empty'>No inter-container flows detected in the last 24h.</div>;
|
|
}
|
|
|
|
return <svg ref={svgRef} className='addon-flow-graph' width='100%' height='280' />;
|
|
}
|