import { useMemo, useState } from "react"; import { Layers } from "lucide-react"; import { formatDuration } from "../lib/timeUtils"; export interface GraphSpan { spanId: string; parentSpanId?: string; service: string; duration: number; hasError?: boolean; } interface ServiceNode { id: string; name: string; spanCount: number; errorCount: number; totalDuration: number; layer: number; x: number; y: number; } interface ServiceEdge { id: string; source: string; target: string; callCount: number; avgDuration: number; hasError: boolean; } interface ServiceGraphProps { spans: GraphSpan[]; onSelectService?: (serviceName: string | null) => void; selectedService?: string | null; } export const ServiceGraph = ({ spans, onSelectService, selectedService, }: ServiceGraphProps) => { const [hoveredNode, setHoveredNode] = useState(null); const { nodes, edges, width, height } = useMemo(() => { // 1. Map spans by ID for parent lookup const spanMap = new Map(); spans.forEach((s) => spanMap.set(s.spanId, s)); // 2. Aggregate service metrics const serviceStats = new Map< string, { spanCount: number; errorCount: number; totalDuration: number } >(); spans.forEach((span) => { const current = serviceStats.get(span.service) || { spanCount: 0, errorCount: 0, totalDuration: 0, }; current.spanCount += 1; if (span.hasError) current.errorCount += 1; current.totalDuration += span.duration; serviceStats.set(span.service, current); }); // 3. Aggregate cross-service edges const edgeMap = new Map< string, { source: string; target: string; count: number; totalDur: number; errors: number } >(); spans.forEach((span) => { if (span.parentSpanId && spanMap.has(span.parentSpanId)) { const parentSpan = spanMap.get(span.parentSpanId)!; if (parentSpan.service !== span.service) { const edgeKey = `${parentSpan.service}->${span.service}`; const currentEdge = edgeMap.get(edgeKey) || { source: parentSpan.service, target: span.service, count: 0, totalDur: 0, errors: 0, }; currentEdge.count += 1; currentEdge.totalDur += span.duration; if (span.hasError) currentEdge.errors += 1; edgeMap.set(edgeKey, currentEdge); } } }); const uniqueServices = Array.from(serviceStats.keys()); // 4. Determine layer depth for each service (Topological ordering) const inDegree = new Map(); uniqueServices.forEach((s) => inDegree.set(s, 0)); edgeMap.forEach((edge) => { inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1); }); const layers = new Map(); // Roots have 0 in-degree const queue: string[] = []; uniqueServices.forEach((s) => { if ((inDegree.get(s) || 0) === 0) { layers.set(s, 0); queue.push(s); } }); // If there's a cycle or no zero-in-degree root, fallback to first service if (queue.length === 0 && uniqueServices.length > 0) { layers.set(uniqueServices[0], 0); queue.push(uniqueServices[0]); } while (queue.length > 0) { const current = queue.shift()!; const currentLayer = layers.get(current) || 0; edgeMap.forEach((edge) => { if (edge.source === current) { const targetLayer = Math.max(layers.get(edge.target) || 0, currentLayer + 1); layers.set(edge.target, targetLayer); if (!queue.includes(edge.target)) { queue.push(edge.target); } } }); } // Ensure all services have a layer uniqueServices.forEach((s) => { if (!layers.has(s)) layers.set(s, 0); }); // Group services by layer const layerGroups = new Map(); uniqueServices.forEach((s) => { const l = layers.get(s) || 0; const group = layerGroups.get(l) || []; group.push(s); layerGroups.set(l, group); }); const totalLayers = Math.max(1, layerGroups.size); const nodeWidth = 200; const nodeHeight = 84; const xGap = 160; const yGap = 36; let maxNodesInLayer = 1; layerGroups.forEach((group) => { if (group.length > maxNodesInLayer) maxNodesInLayer = group.length; }); const canvasWidth = Math.max(700, totalLayers * (nodeWidth + xGap)); const canvasHeight = Math.max(300, maxNodesInLayer * (nodeHeight + yGap) + 60); // Compute positions const computedNodes: ServiceNode[] = []; const nodePosMap = new Map(); Array.from(layerGroups.keys()) .sort((a, b) => a - b) .forEach((layerIdx) => { const group = layerGroups.get(layerIdx) || []; const layerX = 40 + layerIdx * (nodeWidth + xGap); const groupHeight = group.length * nodeHeight + (group.length - 1) * yGap; const startY = Math.max(40, (canvasHeight - groupHeight) / 2); group.forEach((serviceName, idxInGroup) => { const layerY = startY + idxInGroup * (nodeHeight + yGap); const stats = serviceStats.get(serviceName)!; computedNodes.push({ id: serviceName, name: serviceName, spanCount: stats.spanCount, errorCount: stats.errorCount, totalDuration: stats.totalDuration, layer: layerIdx, x: layerX, y: layerY, }); nodePosMap.set(serviceName, { x: layerX, y: layerY }); }); }); // Computed edges with coordinates const computedEdges: ServiceEdge[] = []; edgeMap.forEach((edge, key) => { computedEdges.push({ id: key, source: edge.source, target: edge.target, callCount: edge.count, avgDuration: edge.totalDur / edge.count, hasError: edge.errors > 0, }); }); return { nodes: computedNodes, edges: computedEdges, width: canvasWidth, height: canvasHeight, }; }, [spans]); if (nodes.length === 0) { return (
No service nodes available to visualize.
); } const nodeMap = new Map(nodes.map((n) => [n.id, n])); return (
Service Call Graph ({nodes.length} Services, {edges.length} Cross-Service Links)
Healthy Errors {selectedService && ( )}
{/* Render Connecting Edges */} {edges.map((edge) => { const src = nodeMap.get(edge.source); const tgt = nodeMap.get(edge.target); if (!src || !tgt) return null; const startX = src.x + 200; const startY = src.y + 42; const endX = tgt.x; const endY = tgt.y + 42; const dx = endX - startX; const cpx1 = startX + dx * 0.45; const cpy1 = startY; const cpx2 = endX - dx * 0.45; const cpy2 = endY; const pathD = `M ${startX} ${startY} C ${cpx1} ${cpy1}, ${cpx2} ${cpy2}, ${endX} ${endY}`; const midX = (startX + endX) / 2; const midY = (startY + endY) / 2; const isEdgeHighlighted = hoveredNode === edge.source || hoveredNode === edge.target || selectedService === edge.source || selectedService === edge.target; return ( {/* Background path for hit testing */} {/* Visible animated curve */} {/* Edge Call Badge */} {edge.callCount}x ยท {formatDuration(edge.avgDuration)} ); })} {/* Render Service Nodes */} {nodes.map((node) => { const isSelected = selectedService === node.name; const isHovered = hoveredNode === node.name; const hasError = node.errorCount > 0; return ( onSelectService?.(isSelected ? null : node.name)} onMouseEnter={() => setHoveredNode(node.name)} onMouseLeave={() => setHoveredNode(null)} className="cursor-pointer" > {/* Node Box */} {/* Left Accent Stripe */} {/* Service Name Header */} {node.name.length > 20 ? `${node.name.slice(0, 18)}...` : node.name} {/* Spans Count & Latency Pills */} Spans: {node.spanCount} Time: {formatDuration(node.totalDuration)} {/* Status Badges */} {hasError ? ( {node.errorCount} Error{node.errorCount > 1 ? "s" : ""} ) : ( Healthy )} {isSelected && ( Active )} ); })}
{nodes.length === 1 && (
This trace was executed inside a single service ({nodes[0].name}). No cross-service network calls were made.
)}
); };