feat: decision tree visualization with React Flow + Dagre auto-layout
This commit is contained in:
@@ -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 });
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
797
apps/web/src/components/decision-tree.tsx
Normal file
797
apps/web/src/components/decision-tree.tsx
Normal 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">“{data.decision.reasoning}”</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;
|
||||
}
|
||||
}
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user