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>
496 lines
17 KiB
TypeScript
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>
|
|
);
|
|
};
|