feat: decision tree visualization with React Flow + Dagre auto-layout

This commit is contained in:
Vectry
2026-02-09 23:58:41 +00:00
parent 21b4f9f316
commit 867e1e9eb1
6 changed files with 1151 additions and 24 deletions

View File

@@ -38,7 +38,44 @@ export async function GET(
return NextResponse.json({ error: "Trace not found" }, { status: 404 });
}
return NextResponse.json({ trace }, { status: 200 });
// Transform data to match frontend expectations
const transformedTrace = {
...trace,
decisionPoints: trace.decisionPoints.map((dp) => ({
id: dp.id,
type: dp.type,
chosenAction: typeof dp.chosen === "string" ? dp.chosen : JSON.stringify(dp.chosen),
alternatives: dp.alternatives.map((alt) => (typeof alt === "string" ? alt : JSON.stringify(alt))),
reasoning: dp.reasoning,
contextSnapshot: dp.contextSnapshot as Record<string, unknown> | null,
confidence: null, // Not in schema, default to null
timestamp: dp.timestamp.toISOString(),
parentSpanId: dp.parentSpanId,
})),
spans: trace.spans.map((span) => ({
id: span.id,
name: span.name,
type: span.type,
status: span.status === "COMPLETED" ? "OK" : span.status === "ERROR" ? "ERROR" : "CANCELLED",
startedAt: span.startedAt.toISOString(),
endedAt: span.endedAt?.toISOString() ?? null,
durationMs: span.durationMs,
input: span.input,
output: span.output,
metadata: (span.metadata as Record<string, unknown>) ?? {},
parentSpanId: span.parentSpanId,
})),
events: trace.events.map((event) => ({
id: event.id,
type: event.type,
name: event.name,
timestamp: event.timestamp.toISOString(),
metadata: (event.metadata as Record<string, unknown>) ?? {},
spanId: event.spanId,
})),
};
return NextResponse.json({ trace: transformedTrace }, { status: 200 });
} catch (error) {
console.error("Error retrieving trace:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });

View File

@@ -1,18 +1,17 @@
import { notFound } from "next/navigation";
import { TraceDetail } from "@/components/trace-detail";
interface TraceResponse {
trace: {
id: string;
name: string;
status: "RUNNING" | "COMPLETED" | "ERROR";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
costUsd: number | null;
};
interface TraceData {
id: string;
name: string;
status: "RUNNING" | "COMPLETED" | "ERROR";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
costUsd: number | null;
totalCost: number | null;
decisionPoints: Array<{
id: string;
type: string;
@@ -22,6 +21,7 @@ interface TraceResponse {
contextSnapshot: Record<string, unknown> | null;
confidence: number | null;
timestamp: string;
parentSpanId: string | null;
}>;
spans: Array<{
id: string;
@@ -34,6 +34,7 @@ interface TraceResponse {
input: unknown;
output: unknown;
metadata: Record<string, unknown>;
parentSpanId: string | null;
}>;
events: Array<{
id: string;
@@ -41,9 +42,14 @@ interface TraceResponse {
name: string;
timestamp: string;
metadata: Record<string, unknown>;
spanId: string | null;
}>;
}
interface TraceResponse {
trace: TraceData;
}
async function getTrace(id: string): Promise<TraceResponse | null> {
try {
const res = await fetch(`http://localhost:3000/api/traces/${id}`, {
@@ -76,12 +82,24 @@ export default async function TraceDetailPage({ params }: TraceDetailPageProps)
notFound();
}
const { trace } = data;
return (
<TraceDetail
trace={data.trace}
decisionPoints={data.decisionPoints}
spans={data.spans}
events={data.events}
trace={{
id: trace.id,
name: trace.name,
status: trace.status,
startedAt: trace.startedAt,
endedAt: trace.endedAt,
durationMs: trace.durationMs,
tags: trace.tags,
metadata: trace.metadata,
costUsd: trace.costUsd ?? trace.totalCost,
}}
decisionPoints={trace.decisionPoints}
spans={trace.spans}
events={trace.events}
/>
);
}

View File

@@ -0,0 +1,797 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {
ReactFlow,
useNodesState,
useEdgesState,
addEdge,
type Connection,
type Edge,
type Node,
Handle,
Position,
MiniMap,
Controls,
Background,
BackgroundVariant,
ConnectionLineType,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import dagre from "@dagrejs/dagre";
import {
GitBranch,
Activity,
Zap,
Terminal,
AlertCircle,
CheckCircle,
XCircle,
Clock,
ChevronDown,
ChevronRight,
Lightbulb,
Layers,
} from "lucide-react";
import { cn, formatDuration } from "@/lib/utils";
// Types matching trace-detail.tsx
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
interface DecisionPoint {
id: string;
type: string;
chosenAction: string;
alternatives: string[];
reasoning: string | null;
contextSnapshot: Record<string, unknown> | null;
confidence: number | null;
timestamp: string;
parentSpanId?: string | null;
}
interface Span {
id: string;
name: string;
type: string;
status: "OK" | "ERROR" | "CANCELLED";
startedAt: string;
endedAt: string | null;
durationMs: number | null;
input: unknown;
output: unknown;
metadata: Record<string, unknown>;
parentSpanId?: string | null;
}
interface Event {
id: string;
type: string;
name: string;
timestamp: string;
metadata: Record<string, unknown>;
spanId?: string | null;
}
interface Trace {
id: string;
name: string;
status: TraceStatus;
startedAt: string;
endedAt: string | null;
durationMs: number | null;
tags: string[];
metadata: Record<string, unknown>;
costUsd: number | null;
}
interface DecisionTreeProps {
trace: Trace;
decisionPoints: DecisionPoint[];
spans: Span[];
events: Event[];
}
// Color configurations
const spanTypeColors: Record<string, { bg: string; text: string; border: string }> = {
LLM_CALL: {
bg: "bg-purple-500/10",
text: "text-purple-400",
border: "border-purple-500/20",
},
TOOL_CALL: {
bg: "bg-blue-500/10",
text: "text-blue-400",
border: "border-blue-500/20",
},
CHAIN: {
bg: "bg-amber-500/10",
text: "text-amber-400",
border: "border-amber-500/20",
},
AGENT: {
bg: "bg-cyan-500/10",
text: "text-cyan-400",
border: "border-cyan-500/20",
},
MEMORY_OP: {
bg: "bg-pink-500/10",
text: "text-pink-400",
border: "border-pink-500/20",
},
CUSTOM: {
bg: "bg-neutral-700/30",
text: "text-neutral-400",
border: "border-neutral-600/30",
},
};
const spanStatusColors: Record<string, { dot: string; pulse?: boolean }> = {
OK: { dot: "bg-emerald-400" },
ERROR: { dot: "bg-red-400" },
CANCELLED: { dot: "bg-amber-400", pulse: true },
};
const decisionTypeColors: Record<string, { bg: string; text: string; border: string }> = {
TOOL_SELECTION: {
bg: "bg-blue-500/10",
text: "text-blue-400",
border: "border-blue-500/20",
},
PATH_SELECTION: {
bg: "bg-purple-500/10",
text: "text-purple-400",
border: "border-purple-500/20",
},
RESPONSE_FORMULATION: {
bg: "bg-emerald-500/10",
text: "text-emerald-400",
border: "border-emerald-500/20",
},
DEFAULT: {
bg: "bg-neutral-700/30",
text: "text-neutral-400",
border: "border-neutral-600/30",
},
};
const eventTypeIcons: Record<string, React.ComponentType<{ className?: string }>> = {
LLM_CALL: Zap,
TOOL_CALL: Terminal,
ERROR: AlertCircle,
DEFAULT: Activity,
};
// Node data types
type TraceNodeData = {
trace: Trace;
} & Record<string, unknown>;
type SpanNodeData = {
span: Span;
maxDuration: number;
} & Record<string, unknown>;
type DecisionNodeData = {
decision: DecisionPoint;
} & Record<string, unknown>;
type EventNodeData = {
event: Event;
} & Record<string, unknown>;
// Custom Node Components
function TraceNode({ data, selected }: { data: TraceNodeData; selected?: boolean }) {
const { trace } = data;
const statusConfig: Record<TraceStatus, { icon: React.ComponentType<{ className?: string }>; color: string }> = {
RUNNING: { icon: Activity, color: "text-amber-400" },
COMPLETED: { icon: CheckCircle, color: "text-emerald-400" },
ERROR: { icon: XCircle, color: "text-red-400" },
};
const status = statusConfig[trace.status];
const StatusIcon = status.icon;
return (
<div
className={cn(
"w-[300px] p-4 rounded-xl bg-neutral-900 border-2 transition-all",
selected
? "border-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]"
: "border-emerald-500/50 hover:border-emerald-400/70"
)}
>
<Handle type="target" position={Position.Top} className="!bg-emerald-500" />
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-emerald-500/10">
<GitBranch className="w-5 h-5 text-emerald-400" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-neutral-100 truncate">{trace.name}</h3>
<div className="flex items-center gap-2 mt-1">
<StatusIcon className={cn("w-3 h-3", status.color)} />
<span className={cn("text-xs", status.color)}>{trace.status}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-neutral-700">
<Clock className="w-3 h-3 text-neutral-500" />
<span className="text-xs text-neutral-400">{formatDuration(trace.durationMs)}</span>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-emerald-500" />
</div>
);
}
function SpanNode({ data, selected }: { data: SpanNodeData; selected?: boolean }) {
const { span, maxDuration } = data;
const colors = spanTypeColors[span.type] || spanTypeColors.CUSTOM;
const statusColor = spanStatusColors[span.status] || spanStatusColors.CANCELLED;
const durationPercent = maxDuration > 0 ? ((span.durationMs || 0) / maxDuration) * 100 : 0;
return (
<div
className={cn(
"w-[260px] p-3 rounded-xl bg-neutral-900 border transition-all",
selected
? "border-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]"
: "border-neutral-700 hover:border-neutral-600"
)}
>
<Handle type="target" position={Position.Top} className="!bg-neutral-500" />
<div className="flex items-start gap-2">
<div className={cn("w-2 h-2 rounded-full mt-1.5", statusColor.dot, statusColor.pulse && "animate-pulse")} />
<div className="flex-1 min-w-0">
<h4 className="font-medium text-neutral-100 text-sm truncate">{span.name}</h4>
<div
className={cn(
"inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium mt-1 border",
colors.bg,
colors.text,
colors.border
)}
>
{span.type}
</div>
</div>
</div>
<div className="mt-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
<div
className="h-full bg-emerald-500 rounded-full transition-all"
style={{ width: `${durationPercent}%` }}
/>
</div>
<span className="text-xs text-neutral-400">{formatDuration(span.durationMs)}</span>
</div>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-neutral-500" />
</div>
);
}
function DecisionNode({ data, selected }: { data: DecisionNodeData; selected?: boolean }) {
const { decision } = data;
const [expanded, setExpanded] = useState(false);
const colors = decisionTypeColors[decision.type] || decisionTypeColors.DEFAULT;
return (
<div
className={cn(
"w-[240px] rounded-xl bg-neutral-900 border transition-all overflow-hidden",
selected
? "border-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]"
: "border-neutral-700 hover:border-neutral-600"
)}
>
<Handle type="target" position={Position.Top} className="!bg-neutral-500" />
<div className="p-3">
<div className="flex items-center gap-2 mb-2">
<div
className={cn(
"px-1.5 py-0.5 rounded text-[10px] font-medium border",
colors.bg,
colors.text,
colors.border
)}
>
{decision.type}
</div>
{decision.confidence !== null && (
<span className="text-[10px] text-emerald-400 font-medium">
{Math.round(decision.confidence * 100)}%
</span>
)}
</div>
<p className="text-sm text-neutral-100 truncate">{decision.chosenAction}</p>
{decision.alternatives.length > 0 && (
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-1 mt-2 text-[10px] text-neutral-400 hover:text-neutral-200 transition-colors"
>
{expanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
{decision.alternatives.length} alternative{decision.alternatives.length !== 1 ? "s" : ""}
</button>
)}
{expanded && decision.alternatives.length > 0 && (
<div className="mt-2 space-y-1">
{decision.alternatives.map((alt, idx) => (
<div
key={idx}
className="flex items-center gap-1 text-[10px] text-neutral-500 px-2 py-1 bg-neutral-800/50 rounded"
>
<span className="text-neutral-600">#{idx + 1}</span>
<span className="truncate">{alt}</span>
</div>
))}
</div>
)}
{decision.reasoning && (
<div className="mt-2 pt-2 border-t border-neutral-800">
<div className="flex items-center gap-1 text-[10px] text-neutral-500">
<Lightbulb className="w-3 h-3" />
<span className="truncate">{decision.reasoning}</span>
</div>
</div>
)}
</div>
<Handle type="source" position={Position.Bottom} className="!bg-neutral-500" />
</div>
);
}
function EventNode({ data, selected }: { data: EventNodeData; selected?: boolean }) {
const { event } = data;
const Icon = eventTypeIcons[event.type] || eventTypeIcons.DEFAULT;
return (
<div
className={cn(
"w-[180px] p-2 rounded-xl bg-neutral-900 border transition-all",
selected
? "border-emerald-500 shadow-[0_0_20px_rgba(16,185,129,0.3)]"
: "border-neutral-700 hover:border-neutral-600"
)}
>
<Handle type="target" position={Position.Top} className="!bg-neutral-500" />
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-neutral-800">
<Icon className="w-3 h-3 text-neutral-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-neutral-100 truncate">{event.name}</p>
<p className="text-[10px] text-neutral-500">{event.type}</p>
</div>
</div>
<Handle type="source" position={Position.Bottom} className="!bg-neutral-500" />
</div>
);
}
// Node types must be defined outside the component to avoid re-renders
const nodeTypes = {
trace: TraceNode,
span: SpanNode,
decision: DecisionNode,
event: EventNode,
};
// Build the tree layout using Dagre
function buildTreeLayout(
trace: Trace,
spans: Span[],
decisionPoints: DecisionPoint[],
events: Event[]
): { nodes: Node[]; edges: Edge[] } {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: "TB",
ranksep: 80,
nodesep: 40,
});
const nodes: Node[] = [];
const edges: Edge[] = [];
// Add root trace node
const traceNodeId = `trace-${trace.id}`;
g.setNode(traceNodeId, { width: 300, height: 100 });
nodes.push({
id: traceNodeId,
type: "trace",
data: { trace } as TraceNodeData,
position: { x: 0, y: 0 },
});
// Calculate max duration for span bars
const maxDuration = Math.max(...spans.map((s) => s.durationMs || 0), 1);
// Add span nodes and build parent-child relationships
const spanMap = new Map<string, string>(); // spanId -> nodeId
const childSpans = new Map<string, string[]>(); // parentSpanId -> spanIds
spans.forEach((span) => {
const nodeId = `span-${span.id}`;
spanMap.set(span.id, nodeId);
if (span.parentSpanId) {
const siblings = childSpans.get(span.parentSpanId) || [];
siblings.push(span.id);
childSpans.set(span.parentSpanId, siblings);
}
g.setNode(nodeId, { width: 260, height: 80 });
nodes.push({
id: nodeId,
type: "span",
data: { span, maxDuration } as SpanNodeData,
position: { x: 0, y: 0 },
});
});
// Connect spans to their parents or to trace root
spans.forEach((span) => {
const nodeId = spanMap.get(span.id)!;
if (span.parentSpanId && spanMap.has(span.parentSpanId)) {
const parentId = spanMap.get(span.parentSpanId)!;
g.setEdge(parentId, nodeId);
edges.push({
id: `e-${parentId}-${nodeId}`,
source: parentId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: span.status === "ERROR" ? "#f87171" : "#34d399" },
});
} else {
// Top-level span - connect to trace root
g.setEdge(traceNodeId, nodeId);
edges.push({
id: `e-${traceNodeId}-${nodeId}`,
source: traceNodeId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: span.status === "ERROR" ? "#f87171" : "#34d399" },
});
}
});
// Add decision nodes
decisionPoints.forEach((decision) => {
const nodeId = `decision-${decision.id}`;
g.setNode(nodeId, { width: 240, height: 90 });
nodes.push({
id: nodeId,
type: "decision",
data: { decision } as DecisionNodeData,
position: { x: 0, y: 0 },
});
// Connect to parent span or trace root
if (decision.parentSpanId && spanMap.has(decision.parentSpanId)) {
const parentId = spanMap.get(decision.parentSpanId)!;
g.setEdge(parentId, nodeId);
edges.push({
id: `e-${parentId}-${nodeId}`,
source: parentId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: "#60a5fa" },
});
} else {
g.setEdge(traceNodeId, nodeId);
edges.push({
id: `e-${traceNodeId}-${nodeId}`,
source: traceNodeId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: "#60a5fa" },
});
}
});
// Add event nodes
events.forEach((event) => {
const nodeId = `event-${event.id}`;
g.setNode(nodeId, { width: 180, height: 50 });
nodes.push({
id: nodeId,
type: "event",
data: { event } as EventNodeData,
position: { x: 0, y: 0 },
});
// Connect to span or trace root
if (event.spanId && spanMap.has(event.spanId)) {
const parentId = spanMap.get(event.spanId)!;
g.setEdge(parentId, nodeId);
edges.push({
id: `e-${parentId}-${nodeId}`,
source: parentId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: "#a78bfa" },
});
} else {
g.setEdge(traceNodeId, nodeId);
edges.push({
id: `e-${traceNodeId}-${nodeId}`,
source: traceNodeId,
target: nodeId,
type: ConnectionLineType.SmoothStep,
animated: true,
style: { stroke: "#a78bfa" },
});
}
});
// Calculate layout
dagre.layout(g);
// Update node positions from Dagre
nodes.forEach((node) => {
const dagreNode = g.node(node.id);
node.position = {
x: dagreNode.x - dagreNode.width / 2,
y: dagreNode.y - dagreNode.height / 2,
};
});
return { nodes, edges };
}
export function DecisionTree({ trace, spans, decisionPoints, events }: DecisionTreeProps) {
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => buildTreeLayout(trace, spans, decisionPoints, events),
[trace, spans, decisionPoints, events]
);
const [nodes, _, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const onConnect = useCallback(
(params: Connection) => {
setEdges((eds) => addEdge(params, eds));
},
[setEdges]
);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNode((prev) => (prev?.id === node.id ? null : node));
}, []);
// Empty state
if (spans.length === 0 && decisionPoints.length === 0 && events.length === 0) {
return (
<div className="flex flex-col items-center justify-center min-h-[600px] text-center">
<Layers className="w-12 h-12 text-neutral-600 mb-4" />
<h3 className="text-lg font-medium text-neutral-300 mb-2">No trace data available</h3>
<p className="text-sm text-neutral-500 max-w-md">
This trace does not contain any spans, decisions, or events to visualize.
</p>
</div>
);
}
return (
<div className="flex gap-4 h-[600px]">
<div className="flex-1 rounded-xl border border-neutral-800 overflow-hidden bg-neutral-950">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
connectionLineType={ConnectionLineType.SmoothStep}
fitView
minZoom={0.1}
maxZoom={2}
colorMode="dark"
attributionPosition="bottom-right"
>
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#404040" />
<MiniMap
nodeColor={(node) => {
switch (node.type) {
case "trace":
return "#10b981";
case "span":
return "#3b82f6";
case "decision":
return "#8b5cf6";
case "event":
return "#f59e0b";
default:
return "#6b7280";
}
}}
maskColor="rgba(23, 23, 23, 0.8)"
className="!bg-neutral-900/80 !border-neutral-700"
/>
<Controls className="!bg-neutral-900 !border-neutral-700 [&>button]:!bg-neutral-800 [&>button]:!border-neutral-700 [&>button]:!text-neutral-300 [&>button:hover]:!bg-neutral-700" />
</ReactFlow>
</div>
{/* Details Panel */}
{selectedNode && (
<div className="w-80 rounded-xl border border-neutral-800 bg-neutral-900 p-4 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-neutral-100">Details</h3>
<button
onClick={() => setSelectedNode(null)}
className="text-neutral-400 hover:text-neutral-200 transition-colors"
>
<XCircle className="w-5 h-5" />
</button>
</div>
<NodeDetails node={selectedNode} />
</div>
)}
</div>
);
}
function NodeDetails({ node }: { node: Node }) {
switch (node.type) {
case "trace": {
const data = node.data as unknown as TraceNodeData;
return (
<div className="space-y-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Name</span>
<p className="text-sm text-neutral-100 mt-1">{data.trace.name}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Status</span>
<p className="text-sm text-neutral-100 mt-1">{data.trace.status}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Duration</span>
<p className="text-sm text-neutral-100 mt-1">{formatDuration(data.trace.durationMs)}</p>
</div>
{data.trace.costUsd !== null && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Cost</span>
<p className="text-sm text-neutral-100 mt-1">${data.trace.costUsd.toFixed(4)}</p>
</div>
)}
{data.trace.tags.length > 0 && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Tags</span>
<div className="flex flex-wrap gap-2 mt-1">
{data.trace.tags.map((tag) => (
<span key={tag} className="px-2 py-1 rounded bg-neutral-800 text-xs text-neutral-300">
{tag}
</span>
))}
</div>
</div>
)}
</div>
);
}
case "span": {
const data = node.data as unknown as SpanNodeData;
return (
<div className="space-y-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Name</span>
<p className="text-sm text-neutral-100 mt-1">{data.span.name}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Type</span>
<p className="text-sm text-neutral-100 mt-1">{data.span.type}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Status</span>
<p className="text-sm text-neutral-100 mt-1">{data.span.status}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Duration</span>
<p className="text-sm text-neutral-100 mt-1">{formatDuration(data.span.durationMs)}</p>
</div>
{Boolean(data.span.input) && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Input</span>
<pre className="mt-1 p-2 bg-neutral-950 rounded text-xs text-neutral-300 overflow-x-auto">
{JSON.stringify(data.span.input, null, 2)}
</pre>
</div>
)}
{Boolean(data.span.output) && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Output</span>
<pre className="mt-1 p-2 bg-neutral-950 rounded text-xs text-neutral-300 overflow-x-auto">
{JSON.stringify(data.span.output, null, 2)}
</pre>
</div>
)}
</div>
);
}
case "decision": {
const data = node.data as unknown as DecisionNodeData;
return (
<div className="space-y-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Type</span>
<p className="text-sm text-neutral-100 mt-1">{data.decision.type}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Chosen Action</span>
<p className="text-sm text-neutral-100 mt-1">{data.decision.chosenAction}</p>
</div>
{data.decision.confidence !== null && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Confidence</span>
<p className="text-sm text-emerald-400 mt-1">{Math.round(data.decision.confidence * 100)}%</p>
</div>
)}
{data.decision.alternatives.length > 0 && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">
Alternatives ({data.decision.alternatives.length})
</span>
<div className="space-y-1 mt-1">
{data.decision.alternatives.map((alt, idx) => (
<p key={idx} className="text-xs text-neutral-400">
{idx + 1}. {alt}
</p>
))}
</div>
</div>
)}
{data.decision.reasoning && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Reasoning</span>
<p className="text-sm text-neutral-300 mt-1 italic">&ldquo;{data.decision.reasoning}&rdquo;</p>
</div>
)}
</div>
);
}
case "event": {
const data = node.data as unknown as EventNodeData;
return (
<div className="space-y-4">
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Name</span>
<p className="text-sm text-neutral-100 mt-1">{data.event.name}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Type</span>
<p className="text-sm text-neutral-100 mt-1">{data.event.type}</p>
</div>
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Timestamp</span>
<p className="text-sm text-neutral-100 mt-1">{new Date(data.event.timestamp).toLocaleString()}</p>
</div>
{Object.keys(data.event.metadata).length > 0 && (
<div>
<span className="text-xs text-neutral-500 uppercase tracking-wide">Metadata</span>
<pre className="mt-1 p-2 bg-neutral-950 rounded text-xs text-neutral-300 overflow-x-auto">
{JSON.stringify(data.event.metadata, null, 2)}
</pre>
</div>
)}
</div>
);
}
default:
return null;
}
}

View File

@@ -20,9 +20,10 @@ import {
Terminal,
} from "lucide-react";
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
import { DecisionTree } from "./decision-tree";
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
type TabType = "decisions" | "spans" | "events";
type TabType = "tree" | "decisions" | "spans" | "events";
interface DecisionPoint {
id: string;
@@ -33,6 +34,7 @@ interface DecisionPoint {
contextSnapshot: Record<string, unknown> | null;
confidence: number | null;
timestamp: string;
parentSpanId?: string | null;
}
interface Span {
@@ -46,6 +48,7 @@ interface Span {
input: unknown;
output: unknown;
metadata: Record<string, unknown>;
parentSpanId?: string | null;
}
interface Event {
@@ -54,6 +57,7 @@ interface Event {
name: string;
timestamp: string;
metadata: Record<string, unknown>;
spanId?: string | null;
}
interface Trace {
@@ -138,7 +142,7 @@ export function TraceDetail({
spans,
events,
}: TraceDetailProps) {
const [activeTab, setActiveTab] = useState<TabType>("decisions");
const [activeTab, setActiveTab] = useState<TabType>("tree");
const status = statusConfig[trace.status];
const StatusIcon = status.icon;
@@ -243,6 +247,12 @@ export function TraceDetail({
{/* Tabs */}
<div className="border-b border-neutral-800">
<div className="flex gap-1">
<TabButton
active={activeTab === "tree"}
onClick={() => setActiveTab("tree")}
icon={GitBranch}
label="Tree"
/>
<TabButton
active={activeTab === "decisions"}
onClick={() => setActiveTab("decisions")}
@@ -265,7 +275,15 @@ export function TraceDetail({
</div>
{/* Tab Content */}
<div className="min-h-[400px]">
<div className="min-h-[600px]">
{activeTab === "tree" && (
<DecisionTree
trace={trace}
spans={spans}
decisionPoints={decisionPoints}
events={events}
/>
)}
{activeTab === "decisions" && (
<DecisionsTab decisionPoints={decisionPoints} />
)}