Files
go-otel/otel-ui/src/components/ServiceGraph.tsx
T
matsandClaude Sonnet 5 1e9482ed21
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 56s
Unify otel-ui on ds-* theme; restore redesigned Issues view
Restore IssuesVisualizer redesign reverted by merge 67ce419 and re-apply
Needle triage/test additions. Migrate all remaining screens off the MD3
palette, raw Tailwind colors, gradients and Material Symbols. Remove the
legacy theme block and fonts, drop the duplicate tab strip, rebuild
TelemetryFilters on FilterBar, and document status in AGENTS.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 08:56:34 +00:00

496 lines
17 KiB
TypeScript

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<string | null>(null);
const { nodes, edges, width, height } = useMemo(() => {
// 1. Map spans by ID for parent lookup
const spanMap = new Map<string, GraphSpan>();
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<string, number>();
uniqueServices.forEach((s) => inDegree.set(s, 0));
edgeMap.forEach((edge) => {
inDegree.set(edge.target, (inDegree.get(edge.target) || 0) + 1);
});
const layers = new Map<string, number>();
// 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<number, string[]>();
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<string, { x: number; y: number }>();
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 (
<div className="py-12 text-center text-sm text-ds-text-3">
No service nodes available to visualize.
</div>
);
}
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
return (
<div className="relative overflow-hidden rounded-2xl border border-ds-line bg-ds-surface-2 p-4 shadow-2xl backdrop-blur-md">
<div className="mb-4 flex flex-wrap items-center justify-between gap-4 border-b border-ds-line pb-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-ds-accent" />
<span className="text-xs font-bold uppercase tracking-widest text-ds-text-2">
Service Call Graph ({nodes.length} Services, {edges.length} Cross-Service Links)
</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-ds-text-2">
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-ds-ok" /> Healthy
</span>
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-ds-critical" /> Errors
</span>
{selectedService && (
<button
onClick={() => onSelectService?.(null)}
className="ml-2 rounded-lg bg-ds-accent-bg px-2 py-0.5 text-ds-accent hover:bg-ds-accent-bg transition-all font-semibold"
>
Clear filter ({selectedService})
</button>
)}
</div>
</div>
<div className="overflow-x-auto">
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
className="mx-auto select-none"
>
<defs>
<marker
id="arrow-normal"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--ds-accent)" />
</marker>
<marker
id="arrow-error"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 1.5 L 8 5 L 0 8.5 z" fill="var(--ds-critical)" />
</marker>
<linearGradient id="edge-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="var(--ds-accent)" stopOpacity="0.8" />
<stop offset="100%" stopColor="var(--ds-accent)" stopOpacity="0.8" />
</linearGradient>
<linearGradient id="edge-error-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="var(--ds-critical)" stopOpacity="0.9" />
<stop offset="100%" stopColor="var(--ds-critical)" stopOpacity="0.9" />
</linearGradient>
</defs>
{/* 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 (
<g key={edge.id} className="transition-opacity duration-300">
{/* Background path for hit testing */}
<path
d={pathD}
fill="none"
stroke="transparent"
strokeWidth={20}
className="cursor-pointer"
/>
{/* Visible animated curve */}
<path
d={pathD}
fill="none"
stroke={
edge.hasError
? "url(#edge-error-grad)"
: isEdgeHighlighted
? "var(--ds-accent)"
: "color-mix(in srgb, var(--ds-accent) 40%, transparent)"
}
strokeWidth={isEdgeHighlighted ? 3 : 2}
strokeDasharray={edge.hasError ? "4 2" : undefined}
markerEnd={edge.hasError ? "url(#arrow-error)" : "url(#arrow-normal)"}
/>
{/* Edge Call Badge */}
<g transform={`translate(${midX - 35}, ${midY - 12})`}>
<rect
width={70}
height={22}
rx={6}
fill="var(--ds-bg)"
stroke={edge.hasError ? "color-mix(in srgb, var(--ds-critical) 40%, transparent)" : "var(--ds-line-strong)"}
strokeWidth={1}
/>
<text
x={35}
y={14}
textAnchor="middle"
fill={edge.hasError ? "var(--ds-critical)" : "var(--ds-text-2)"}
fontSize={9}
fontWeight="bold"
fontFamily="monospace"
>
{edge.callCount}x · {formatDuration(edge.avgDuration)}
</text>
</g>
</g>
);
})}
{/* Render Service Nodes */}
{nodes.map((node) => {
const isSelected = selectedService === node.name;
const isHovered = hoveredNode === node.name;
const hasError = node.errorCount > 0;
return (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
onClick={() => onSelectService?.(isSelected ? null : node.name)}
onMouseEnter={() => setHoveredNode(node.name)}
onMouseLeave={() => setHoveredNode(null)}
className="cursor-pointer"
>
{/* Node Box */}
<rect
width={200}
height={84}
rx={16}
fill="var(--ds-bg)"
stroke={
isSelected
? "var(--ds-accent)"
: hasError
? "var(--ds-critical)"
: isHovered
? "var(--ds-line-strong)"
: "var(--ds-line-strong)"
}
strokeWidth={isSelected ? 2.5 : hasError ? 2 : 1}
filter={isSelected ? "drop-shadow(0 0 12px color-mix(in srgb, var(--ds-accent) 40%, transparent))" : undefined}
/>
{/* Left Accent Stripe */}
<rect
x={0}
y={0}
width={6}
height={84}
rx={3}
fill={hasError ? "var(--ds-critical)" : "var(--ds-accent)"}
/>
{/* Service Name Header */}
<g transform="translate(16, 24)">
<text
x={0}
y={0}
fill="var(--ds-text)"
fontSize={13}
fontWeight="bold"
fontFamily="sans-serif"
>
{node.name.length > 20 ? `${node.name.slice(0, 18)}...` : node.name}
</text>
</g>
{/* Spans Count & Latency Pills */}
<g transform="translate(16, 44)">
<text x={0} y={10} fill="var(--ds-text-3)" fontSize={10} fontFamily="monospace">
Spans: <tspan fill="var(--ds-text)">{node.spanCount}</tspan>
</text>
<text x={75} y={10} fill="var(--ds-text-3)" fontSize={10} fontFamily="monospace">
Time: <tspan fill="var(--ds-accent)">{formatDuration(node.totalDuration)}</tspan>
</text>
</g>
{/* Status Badges */}
<g transform="translate(16, 68)">
{hasError ? (
<g>
<rect width={80} height={16} rx={4} fill="color-mix(in srgb, var(--ds-critical) 15%, transparent)" />
<text
x={40}
y={11}
textAnchor="middle"
fill="var(--ds-critical)"
fontSize={9}
fontWeight="bold"
>
{node.errorCount} Error{node.errorCount > 1 ? "s" : ""}
</text>
</g>
) : (
<g>
<rect width={60} height={16} rx={4} fill="color-mix(in srgb, var(--ds-ok) 15%, transparent)" />
<text
x={30}
y={11}
textAnchor="middle"
fill="var(--ds-ok)"
fontSize={9}
fontWeight="bold"
>
Healthy
</text>
</g>
)}
{isSelected && (
<g transform="translate(90, 0)">
<rect width={55} height={16} rx={4} fill="color-mix(in srgb, var(--ds-accent) 20%, transparent)" />
<text
x={27.5}
y={11}
textAnchor="middle"
fill="var(--ds-accent)"
fontSize={9}
fontWeight="bold"
>
Active
</text>
</g>
)}
</g>
</g>
);
})}
</svg>
</div>
{nodes.length === 1 && (
<div className="mt-3 text-center text-xs text-ds-text-3">
This trace was executed inside a single service ({nodes[0].name}). No cross-service network calls were made.
</div>
)}
</div>
);
};