feat: analytics tab with timeline waterfall, cost breakdown, token gauge
This commit is contained in:
563
apps/web/src/components/trace-analytics.tsx
Normal file
563
apps/web/src/components/trace-analytics.tsx
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Activity, DollarSign } from "lucide-react";
|
||||||
|
import { cn, formatDuration } from "@/lib/utils";
|
||||||
|
|
||||||
|
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: "RUNNING" | "COMPLETED" | "ERROR";
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
tags: string[];
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
costUsd: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TraceAnalyticsProps {
|
||||||
|
trace: Trace;
|
||||||
|
spans: Span[];
|
||||||
|
decisionPoints: DecisionPoint[];
|
||||||
|
events: Event[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const spanTypeColors: Record<string, { bg: string; text: string }> = {
|
||||||
|
LLM_CALL: { bg: "bg-purple-500/10", text: "text-purple-400" },
|
||||||
|
TOOL_CALL: { bg: "bg-blue-500/10", text: "text-blue-400" },
|
||||||
|
DEFAULT: { bg: "bg-neutral-700/30", text: "text-neutral-400" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBarColors: Record<string, string> = {
|
||||||
|
OK: "bg-emerald-500",
|
||||||
|
ERROR: "bg-red-500",
|
||||||
|
CANCELLED: "bg-amber-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TraceAnalytics({
|
||||||
|
trace,
|
||||||
|
spans,
|
||||||
|
decisionPoints,
|
||||||
|
}: TraceAnalyticsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Section A: Execution Timeline */}
|
||||||
|
<section className="p-5 bg-neutral-900 border border-neutral-800 rounded-xl">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-300 uppercase tracking-wider mb-4">
|
||||||
|
Execution Timeline
|
||||||
|
</h2>
|
||||||
|
<ExecutionTimeline trace={trace} spans={spans} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section B: Cost Breakdown */}
|
||||||
|
<section className="p-5 bg-neutral-900 border border-neutral-800 rounded-xl">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-300 uppercase tracking-wider mb-4">
|
||||||
|
Cost Breakdown
|
||||||
|
</h2>
|
||||||
|
<CostBreakdown trace={trace} spans={spans} decisionPoints={decisionPoints} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section C: Token Usage Gauge */}
|
||||||
|
<section className="p-5 bg-neutral-900 border border-neutral-800 rounded-xl">
|
||||||
|
<h2 className="text-sm font-semibold text-neutral-300 uppercase tracking-wider mb-4">
|
||||||
|
Token Usage
|
||||||
|
</h2>
|
||||||
|
<TokenUsageGauge trace={trace} />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section A: Execution Timeline (Waterfall Chart)
|
||||||
|
function ExecutionTimeline({ trace, spans }: { trace: Trace; spans: Span[] }) {
|
||||||
|
const timelineData = useMemo(() => {
|
||||||
|
if (!trace.startedAt || spans.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const traceStartTime = new Date(trace.startedAt).getTime();
|
||||||
|
const traceEndTime = trace.endedAt
|
||||||
|
? new Date(trace.endedAt).getTime()
|
||||||
|
: Date.now();
|
||||||
|
const totalDuration = traceEndTime - traceStartTime;
|
||||||
|
|
||||||
|
// Build span hierarchy for nesting
|
||||||
|
const spanMap = new Map<string, Span & { depth: number }>();
|
||||||
|
const rootSpans: (Span & { depth: number })[] = [];
|
||||||
|
|
||||||
|
// Sort spans by start time first
|
||||||
|
const sortedSpans = [...spans].sort(
|
||||||
|
(a, b) => new Date(a.startedAt).getTime() - new Date(b.startedAt).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate depth for each span
|
||||||
|
sortedSpans.forEach((span) => {
|
||||||
|
let depth = 0;
|
||||||
|
let parentId = span.parentSpanId;
|
||||||
|
while (parentId) {
|
||||||
|
depth++;
|
||||||
|
const parent = spans.find((s) => s.id === parentId);
|
||||||
|
parentId = parent?.parentSpanId;
|
||||||
|
}
|
||||||
|
const spanWithDepth = { ...span, depth };
|
||||||
|
spanMap.set(span.id, spanWithDepth);
|
||||||
|
if (!span.parentSpanId) {
|
||||||
|
rootSpans.push(spanWithDepth);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build nested display order
|
||||||
|
const displaySpans: (Span & { depth: number })[] = [];
|
||||||
|
const addSpanAndChildren = (spanId: string) => {
|
||||||
|
const span = spanMap.get(spanId);
|
||||||
|
if (span) {
|
||||||
|
displaySpans.push(span);
|
||||||
|
// Find children
|
||||||
|
const children = sortedSpans.filter((s) => s.parentSpanId === spanId);
|
||||||
|
children.forEach((child) => addSpanAndChildren(child.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
rootSpans.forEach((root) => addSpanAndChildren(root.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
traceStartTime,
|
||||||
|
totalDuration,
|
||||||
|
displaySpans,
|
||||||
|
};
|
||||||
|
}, [trace, spans]);
|
||||||
|
|
||||||
|
if (!timelineData) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-neutral-500">
|
||||||
|
No spans to visualize
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { traceStartTime, totalDuration, displaySpans } = timelineData;
|
||||||
|
|
||||||
|
// Generate time markers
|
||||||
|
const timeMarkers = useMemo(() => {
|
||||||
|
const markers: { label: string; percent: number }[] = [];
|
||||||
|
const step = totalDuration <= 1000 ? 100 : totalDuration <= 5000 ? 500 : 1000;
|
||||||
|
const count = Math.ceil(totalDuration / step);
|
||||||
|
|
||||||
|
for (let i = 0; i <= count; i++) {
|
||||||
|
const timeMs = i * step;
|
||||||
|
if (timeMs <= totalDuration) {
|
||||||
|
const percent = (timeMs / totalDuration) * 100;
|
||||||
|
let label: string;
|
||||||
|
if (timeMs < 1000) {
|
||||||
|
label = `${timeMs}ms`;
|
||||||
|
} else {
|
||||||
|
label = `${(timeMs / 1000).toFixed(timeMs % 1000 === 0 ? 0 : 1)}s`;
|
||||||
|
}
|
||||||
|
markers.push({ label, percent });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return markers;
|
||||||
|
}, [totalDuration]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Ruler */}
|
||||||
|
<div className="relative h-6 border-b border-neutral-700 ml-[200px]">
|
||||||
|
{timeMarkers.map((marker, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="absolute top-0 transform -translate-x-1/2"
|
||||||
|
style={{ left: `${marker.percent}%` }}
|
||||||
|
>
|
||||||
|
<div className="w-px h-2 bg-neutral-600" />
|
||||||
|
<span className="text-xs text-neutral-500 absolute top-3 -translate-x-1/2 whitespace-nowrap">
|
||||||
|
{marker.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Span rows */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{displaySpans.map((span) => {
|
||||||
|
const spanStartTime = new Date(span.startedAt).getTime();
|
||||||
|
const offsetPercent =
|
||||||
|
((spanStartTime - traceStartTime) / totalDuration) * 100;
|
||||||
|
const durationPercent =
|
||||||
|
((span.durationMs || 0) / totalDuration) * 100;
|
||||||
|
const isRunning = !span.endedAt;
|
||||||
|
const typeColors = spanTypeColors[span.type] || spanTypeColors.DEFAULT;
|
||||||
|
const statusColor = statusBarColors[span.status] || statusBarColors.OK;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={span.id}
|
||||||
|
className="flex items-center h-9"
|
||||||
|
style={{ marginLeft: `${span.depth * 16}px` }}
|
||||||
|
>
|
||||||
|
{/* Span info (left column) */}
|
||||||
|
<div className="w-[200px] flex items-center gap-2 pr-4 shrink-0">
|
||||||
|
<span className="text-sm text-neutral-300 truncate">
|
||||||
|
{span.name.length > 20 ? `${span.name.slice(0, 20)}...` : span.name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded text-[10px] font-medium uppercase tracking-wide shrink-0",
|
||||||
|
typeColors.bg,
|
||||||
|
typeColors.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{span.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline bar area */}
|
||||||
|
<div className="flex-1 relative h-full">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute top-2 h-5 rounded",
|
||||||
|
statusColor,
|
||||||
|
isRunning && "animate-pulse"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `${Math.max(0, offsetPercent)}%`,
|
||||||
|
width: `${Math.max(1, durationPercent)}%`,
|
||||||
|
minWidth: isRunning ? "4px" : "1px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Duration info */}
|
||||||
|
<div className="text-xs text-neutral-500 mt-2 pt-2 border-t border-neutral-800">
|
||||||
|
Total Duration: {formatDuration(totalDuration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section B: Cost Breakdown
|
||||||
|
function CostBreakdown({
|
||||||
|
trace,
|
||||||
|
spans,
|
||||||
|
decisionPoints,
|
||||||
|
}: {
|
||||||
|
trace: Trace;
|
||||||
|
spans: Span[];
|
||||||
|
decisionPoints: DecisionPoint[];
|
||||||
|
}) {
|
||||||
|
const { spanCosts, decisionCosts, totalCost, hasCostData } = useMemo(() => {
|
||||||
|
const spanCostsData = spans
|
||||||
|
.map((span) => ({
|
||||||
|
id: span.id,
|
||||||
|
name: span.name,
|
||||||
|
type: span.type,
|
||||||
|
cost: (span.metadata?.costUsd as number | null | undefined) ?? null,
|
||||||
|
}))
|
||||||
|
.filter((s) => s.cost !== null && s.cost > 0)
|
||||||
|
.sort((a, b) => (b.cost || 0) - (a.cost || 0));
|
||||||
|
|
||||||
|
// Decision costs - use null since costUsd may not be in the API response yet
|
||||||
|
const decisionCostsData = decisionPoints
|
||||||
|
.map((dp) => ({
|
||||||
|
id: dp.id,
|
||||||
|
name: dp.chosenAction,
|
||||||
|
type: dp.type,
|
||||||
|
cost: null as number | null,
|
||||||
|
}))
|
||||||
|
.filter((d) => d.cost !== null && d.cost > 0);
|
||||||
|
|
||||||
|
const totalSpanCost = spanCostsData.reduce((sum, s) => sum + (s.cost || 0), 0);
|
||||||
|
const totalDecisionCost = decisionCostsData.reduce(
|
||||||
|
(sum, d) => sum + (d.cost || 0),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
const totalCostValue = trace.costUsd ?? totalSpanCost + totalDecisionCost;
|
||||||
|
const hasData =
|
||||||
|
totalCostValue > 0 || spanCostsData.length > 0 || decisionCostsData.length > 0;
|
||||||
|
|
||||||
|
const maxSpanCost =
|
||||||
|
spanCostsData.length > 0 ? Math.max(...spanCostsData.map((s) => s.cost || 0)) : 0;
|
||||||
|
const maxDecisionCost =
|
||||||
|
decisionCostsData.length > 0
|
||||||
|
? Math.max(...decisionCostsData.map((d) => d.cost || 0))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
spanCosts: spanCostsData.map((s) => ({
|
||||||
|
...s,
|
||||||
|
percent: maxSpanCost > 0 ? ((s.cost || 0) / maxSpanCost) * 100 : 0,
|
||||||
|
})),
|
||||||
|
decisionCosts: decisionCostsData.map((d) => ({
|
||||||
|
...d,
|
||||||
|
percent: maxDecisionCost > 0 ? ((d.cost || 0) / maxDecisionCost) * 100 : 0,
|
||||||
|
})),
|
||||||
|
totalCost: totalCostValue,
|
||||||
|
hasCostData: hasData,
|
||||||
|
};
|
||||||
|
}, [trace, spans, decisionPoints]);
|
||||||
|
|
||||||
|
if (!hasCostData) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-8 text-neutral-500">
|
||||||
|
<DollarSign className="w-8 h-8 mx-auto mb-2 text-neutral-600" />
|
||||||
|
No cost data available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Total cost summary */}
|
||||||
|
<div className="flex items-center gap-2 pb-4 border-b border-neutral-800">
|
||||||
|
<DollarSign className="w-5 h-5 text-emerald-400" />
|
||||||
|
<span className="text-lg font-semibold text-neutral-100">
|
||||||
|
Total: ${totalCost.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cost breakdown columns */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Span Costs */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-3">
|
||||||
|
Span Costs
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{spanCosts.length > 0 ? (
|
||||||
|
spanCosts.map((span) => {
|
||||||
|
const typeColors =
|
||||||
|
spanTypeColors[span.type] || spanTypeColors.DEFAULT;
|
||||||
|
return (
|
||||||
|
<div key={span.id} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded text-[10px] font-medium uppercase",
|
||||||
|
typeColors.bg,
|
||||||
|
typeColors.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{span.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-300 truncate max-w-[150px]">
|
||||||
|
{span.name.length > 20
|
||||||
|
? `${span.name.slice(0, 20)}...`
|
||||||
|
: span.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-emerald-400 font-medium">
|
||||||
|
${(span.cost || 0).toFixed(4)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${span.percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-500">No span cost data</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decision Costs */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xs font-medium text-neutral-500 uppercase tracking-wide mb-3">
|
||||||
|
Decision Costs
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{decisionCosts.length > 0 ? (
|
||||||
|
decisionCosts.map((decision) => {
|
||||||
|
const typeColors =
|
||||||
|
spanTypeColors[decision.type] || spanTypeColors.DEFAULT;
|
||||||
|
return (
|
||||||
|
<div key={decision.id} className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-0.5 rounded text-[10px] font-medium uppercase",
|
||||||
|
typeColors.bg,
|
||||||
|
typeColors.text
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{decision.type}
|
||||||
|
</span>
|
||||||
|
<span className="text-neutral-300 truncate max-w-[150px]">
|
||||||
|
{decision.name.length > 20
|
||||||
|
? `${decision.name.slice(0, 20)}...`
|
||||||
|
: decision.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-emerald-400 font-medium">
|
||||||
|
{decision.cost !== null
|
||||||
|
? `$${decision.cost.toFixed(4)}`
|
||||||
|
: "N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-neutral-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-emerald-500 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${decision.percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-neutral-500">No decision cost data</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Section C: Token Usage Gauge
|
||||||
|
function TokenUsageGauge({ trace }: { trace: Trace }) {
|
||||||
|
const tokenData = useMemo(() => {
|
||||||
|
// Try to get total tokens from various sources
|
||||||
|
const totalTokens =
|
||||||
|
(trace.metadata?.totalTokens as number | null | undefined) ??
|
||||||
|
(trace.metadata?.tokenCount as number | null | undefined) ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const maxTokens = 128000; // Default context window
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTokens,
|
||||||
|
maxTokens,
|
||||||
|
percent: totalTokens !== null ? (totalTokens / maxTokens) * 100 : 0,
|
||||||
|
hasData: totalTokens !== null && totalTokens > 0,
|
||||||
|
};
|
||||||
|
}, [trace]);
|
||||||
|
|
||||||
|
const { totalTokens, maxTokens, percent, hasData } = tokenData;
|
||||||
|
|
||||||
|
// Determine color based on usage percentage
|
||||||
|
const getUsageColor = (pct: number): string => {
|
||||||
|
if (pct <= 50) return "#10b981"; // emerald-500
|
||||||
|
if (pct <= 80) return "#f59e0b"; // amber-500
|
||||||
|
return "#ef4444"; // red-500
|
||||||
|
};
|
||||||
|
|
||||||
|
const usageColor = getUsageColor(percent);
|
||||||
|
|
||||||
|
// Create conic gradient for the ring
|
||||||
|
const ringStyle = {
|
||||||
|
background: hasData
|
||||||
|
? `conic-gradient(${usageColor} ${percent * 3.6}deg, #262626 ${percent * 3.6}deg)`
|
||||||
|
: "conic-gradient(#404040 0deg, #404040 360deg)",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-6">
|
||||||
|
{/* Ring container */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Outer ring with conic gradient */}
|
||||||
|
<div
|
||||||
|
className="w-40 h-40 rounded-full p-3"
|
||||||
|
style={ringStyle}
|
||||||
|
>
|
||||||
|
{/* Inner circle (background) */}
|
||||||
|
<div className="w-full h-full rounded-full bg-neutral-900 flex items-center justify-center">
|
||||||
|
{/* Center content */}
|
||||||
|
<div className="text-center">
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
<div className="text-3xl font-bold text-neutral-100">
|
||||||
|
{totalTokens?.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-neutral-500 mt-1">tokens</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Activity className="w-8 h-8 text-neutral-600 mx-auto" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Percentage indicator */}
|
||||||
|
{hasData && (
|
||||||
|
<div
|
||||||
|
className="absolute -top-1 left-1/2 transform -translate-x-1/2 w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
||||||
|
style={{ backgroundColor: usageColor }}
|
||||||
|
>
|
||||||
|
<span className="text-neutral-950">{Math.round(percent)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Token info below */}
|
||||||
|
<div className="mt-6 text-center space-y-1">
|
||||||
|
{hasData ? (
|
||||||
|
<>
|
||||||
|
<p className="text-lg font-medium text-neutral-200">
|
||||||
|
{totalTokens?.toLocaleString()} / {maxTokens.toLocaleString()} tokens
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
{percent.toFixed(1)}% of context window used
|
||||||
|
</p>
|
||||||
|
{percent > 80 && (
|
||||||
|
<p className="text-xs text-red-400 mt-2">
|
||||||
|
High token usage - consider context optimization
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-lg font-medium text-neutral-400">No token data</p>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
Token usage information not available for this trace
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,9 +21,10 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
|
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
|
||||||
import { DecisionTree } from "./decision-tree";
|
import { DecisionTree } from "./decision-tree";
|
||||||
|
import { TraceAnalytics } from "./trace-analytics";
|
||||||
|
|
||||||
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
|
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
|
||||||
type TabType = "tree" | "decisions" | "spans" | "events";
|
type TabType = "tree" | "analytics" | "decisions" | "spans" | "events";
|
||||||
|
|
||||||
interface DecisionPoint {
|
interface DecisionPoint {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -253,6 +254,12 @@ export function TraceDetail({
|
|||||||
icon={GitBranch}
|
icon={GitBranch}
|
||||||
label="Tree"
|
label="Tree"
|
||||||
/>
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === "analytics"}
|
||||||
|
onClick={() => setActiveTab("analytics")}
|
||||||
|
icon={Activity}
|
||||||
|
label="Analytics"
|
||||||
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === "decisions"}
|
active={activeTab === "decisions"}
|
||||||
onClick={() => setActiveTab("decisions")}
|
onClick={() => setActiveTab("decisions")}
|
||||||
@@ -284,6 +291,14 @@ export function TraceDetail({
|
|||||||
events={events}
|
events={events}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{activeTab === "analytics" && (
|
||||||
|
<TraceAnalytics
|
||||||
|
trace={trace}
|
||||||
|
spans={spans}
|
||||||
|
decisionPoints={decisionPoints}
|
||||||
|
events={events}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{activeTab === "decisions" && (
|
{activeTab === "decisions" && (
|
||||||
<DecisionsTab decisionPoints={decisionPoints} />
|
<DecisionsTab decisionPoints={decisionPoints} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user