more otel
This commit is contained in:
1 parent
9620e3bfa6
commit
21f04d696b
88 files changed
+23419
-192
No files matched your search
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user