more otel
Build and Publish / Metadata (push) Successful in 2s
Build and Publish / BuildAndDeployAmd64 (push) Successful in 58s

This commit is contained in:
mats committed 2026-09-18 20:51:37 +00:00
1 parent 9620e3bfa6
commit 21f04d696b
88 files changed
+23419 -192

No files matched your search

+495
View File
@@ -0,0 +1,495 @@
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-slate-500">
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-white/10 bg-slate-950/60 p-4 shadow-2xl backdrop-blur-md">
<div className="mb-4 flex flex-wrap items-center justify-between gap-4 border-b border-white/5 pb-3">
<div className="flex items-center gap-2">
<Layers className="h-4 w-4 text-sky-400" />
<span className="text-xs font-bold uppercase tracking-widest text-slate-300">
Service Call Graph ({nodes.length} Services, {edges.length} Cross-Service Links)
</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-slate-400">
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-emerald-400" /> Healthy
</span>
<span className="inline-flex items-center gap-1">
<span className="h-2 w-2 rounded-full bg-rose-400" /> Errors
</span>
{selectedService && (
<button
onClick={() => onSelectService?.(null)}
className="ml-2 rounded-lg bg-sky-500/20 px-2 py-0.5 text-sky-300 hover:bg-sky-500/30 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="#38bdf8" />
</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="#f43f5e" />
</marker>
<linearGradient id="edge-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#38bdf8" stopOpacity="0.8" />
<stop offset="100%" stopColor="#818cf8" stopOpacity="0.8" />
</linearGradient>
<linearGradient id="edge-error-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#f43f5e" stopOpacity="0.9" />
<stop offset="100%" stopColor="#fb7185" 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
? "#38bdf8"
: "rgba(56, 189, 248, 0.4)"
}
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="#030712"
stroke={edge.hasError ? "rgba(244,63,94,0.4)" : "rgba(255,255,255,0.1)"}
strokeWidth={1}
/>
<text
x={35}
y={14}
textAnchor="middle"
fill={edge.hasError ? "#fb7185" : "#94a3b8"}
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="#090d16"
stroke={
isSelected
? "#38bdf8"
: hasError
? "#f43f5e"
: isHovered
? "rgba(255, 255, 255, 0.3)"
: "rgba(255, 255, 255, 0.1)"
}
strokeWidth={isSelected ? 2.5 : hasError ? 2 : 1}
filter={isSelected ? "drop-shadow(0 0 12px rgba(56,189,248,0.4))" : undefined}
/>
{/* Left Accent Stripe */}
<rect
x={0}
y={0}
width={6}
height={84}
rx={3}
fill={hasError ? "#f43f5e" : "#38bdf8"}
/>
{/* Service Name Header */}
<g transform="translate(16, 24)">
<text
x={0}
y={0}
fill="#ffffff"
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="#64748b" fontSize={10} fontFamily="monospace">
Spans: <tspan fill="#e2e8f0">{node.spanCount}</tspan>
</text>
<text x={75} y={10} fill="#64748b" fontSize={10} fontFamily="monospace">
Time: <tspan fill="#38bdf8">{formatDuration(node.totalDuration)}</tspan>
</text>
</g>
{/* Status Badges */}
<g transform="translate(16, 68)">
{hasError ? (
<g>
<rect width={80} height={16} rx={4} fill="rgba(244,63,94,0.15)" />
<text
x={40}
y={11}
textAnchor="middle"
fill="#fb7185"
fontSize={9}
fontWeight="bold"
>
{node.errorCount} Error{node.errorCount > 1 ? "s" : ""}
</text>
</g>
) : (
<g>
<rect width={60} height={16} rx={4} fill="rgba(16,185,129,0.15)" />
<text
x={30}
y={11}
textAnchor="middle"
fill="#34d399"
fontSize={9}
fontWeight="bold"
>
Healthy
</text>
</g>
)}
{isSelected && (
<g transform="translate(90, 0)">
<rect width={55} height={16} rx={4} fill="rgba(56,189,248,0.2)" />
<text
x={27.5}
y={11}
textAnchor="middle"
fill="#38bdf8"
fontSize={9}
fontWeight="bold"
>
Active
</text>
</g>
)}
</g>
</g>
);
})}
</svg>
</div>
{nodes.length === 1 && (
<div className="mt-3 text-center text-xs text-slate-500">
This trace was executed inside a single service ({nodes[0].name}). No cross-service network calls were made.
</div>
)}
</div>
);
};