feat(ui/): cleanup

This commit is contained in:
Krrish Dholakia 2026-03-03 10:01:18 -08:00
parent 1dc2df14f2
commit 36ca05739c
8 changed files with 1440 additions and 11 deletions

View file

@ -0,0 +1,75 @@
"use client";
import { useState, useMemo } from "react";
import { Drawer } from "antd";
import type { AgentTraceSession, Span } from "../agentTraceTypes";
import { TraceView } from "./TraceView";
import { SpanDetail } from "./SpanDetail";
function findSpan(spans: Span[], id: string): Span | null {
for (const s of spans) {
if (s.id === id) return s;
if (s.children?.length) {
const found = findSpan(s.children, id);
if (found) return found;
}
}
return null;
}
export interface AgentTraceDrawerProps {
open: boolean;
onClose: () => void;
session: AgentTraceSession | null;
}
const DRAWER_WIDTH = 960;
export function AgentTraceDrawer({
open,
onClose,
session,
}: AgentTraceDrawerProps) {
const [selectedSpanId, setSelectedSpanId] = useState<string | null>(null);
const selectedSpan = useMemo(() => {
if (!session?.spans || selectedSpanId == null) return null;
return findSpan(session.spans, selectedSpanId);
}, [session?.spans, selectedSpanId]);
const handleClose = () => {
setSelectedSpanId(null);
onClose();
};
return (
<Drawer
title={session ? session.rootAgentName : "Agent Trace"}
placement="right"
width={Math.min(DRAWER_WIDTH, typeof window !== "undefined" ? window.innerWidth * 0.9 : 960)}
open={open}
onClose={handleClose}
destroyOnClose
styles={{ body: { padding: 0, display: "flex", flexDirection: "column", height: "100%" } }}
>
{session == null ? (
<div className="flex flex-col items-center justify-center flex-1 text-slate-500 p-6 text-center">
<p className="text-sm">No session selected</p>
</div>
) : (
<div className="flex flex-1 min-h-0">
<div className="flex-1 min-w-0 border-r border-slate-200 overflow-hidden flex flex-col p-4">
<TraceView
session={session}
selectedSpanId={selectedSpanId}
onSelectSpan={setSelectedSpanId}
/>
</div>
<div className="w-[380px] flex-shrink-0 overflow-hidden flex flex-col p-4 bg-slate-50/50">
<SpanDetail span={selectedSpan} />
</div>
</div>
)}
</Drawer>
);
}

View file

@ -0,0 +1,271 @@
"use client";
import { useState } from "react";
import { Button, Tabs, Tag, Typography } from "antd";
import { CopyOutlined, DownOutlined, RightOutlined } from "@ant-design/icons";
import { Bot, MessageSquare, Wrench, Zap } from "lucide-react";
import type { Span, SpanType } from "../agentTraceTypes";
const { Text } = Typography;
const SPAN_CONFIG: Record<
SpanType,
{ icon: React.ElementType; label: string; color: string; badgeClass: string }
> = {
orchestrator: {
icon: Bot,
label: "Orchestrator",
color: "text-blue-600",
badgeClass: "bg-blue-100 text-blue-800 border-blue-200",
},
agent: {
icon: Bot,
label: "Agent",
color: "text-indigo-600",
badgeClass: "bg-indigo-100 text-indigo-800 border-indigo-200",
},
llm: {
icon: MessageSquare,
label: "LLM",
color: "text-purple-600",
badgeClass: "bg-purple-100 text-purple-800 border-purple-200",
},
mcp: {
icon: Wrench,
label: "MCP",
color: "text-amber-600",
badgeClass: "bg-amber-100 text-amber-800 border-amber-200",
},
function: {
icon: Zap,
label: "Function",
color: "text-green-600",
badgeClass: "bg-green-100 text-green-800 border-green-200",
},
};
const STATUS_CONFIG: Record<
string,
{ color: string; dotClass: string }
> = {
success: { color: "text-green-600", dotClass: "bg-green-500" },
error: { color: "text-red-600", dotClass: "bg-red-500" },
running: { color: "text-blue-600", dotClass: "bg-blue-500" },
retry: { color: "text-amber-600", dotClass: "bg-amber-500" },
};
function copyToClipboard(text: string) {
void navigator.clipboard.writeText(text);
}
export interface SpanDetailProps {
span: Span | null;
}
export function SpanDetail({ span }: SpanDetailProps) {
const [metricsOpen, setMetricsOpen] = useState(true);
const [ioOpen, setIoOpen] = useState(true);
const [costOpen, setCostOpen] = useState(true);
if (span === null) {
return (
<div className="flex flex-col items-center justify-center h-full min-h-[200px] text-slate-500 p-6 text-center">
<p className="font-medium">Select a span</p>
<p className="text-sm mt-1">Click a span in the trace view to see details.</p>
</div>
);
}
const config = SPAN_CONFIG[span.type];
const statusConfig = STATUS_CONFIG[span.status] ?? STATUS_CONFIG.success;
const Icon = config.icon;
const tryFormatJson = (raw: string | undefined): string => {
if (raw == null || raw === "") return "";
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
};
return (
<div className="flex flex-col h-full overflow-auto">
{/* Header */}
<div className="flex-shrink-0 border-b border-slate-200 pb-3 mb-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<Icon className={`flex-shrink-0 w-4 h-4 ${config.color}`} />
<span className="font-medium text-slate-900 truncate">{span.name}</span>
<Tag className={`flex-shrink-0 ${config.badgeClass} border`}>
{config.label}
</Tag>
</div>
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() => copyToClipboard(span.id)}
title="Copy span ID"
/>
</div>
<div className="flex items-center gap-3 mt-2 flex-wrap">
<span className={`flex items-center gap-1.5 text-sm ${statusConfig.color}`}>
<span
className={`w-2 h-2 rounded-full flex-shrink-0 ${statusConfig.dotClass}`}
/>
{span.status}
</span>
<Text type="secondary" className="text-sm">
{span.durationMs} ms
</Text>
</div>
</div>
{/* Collapsible: Metrics */}
<div className="border border-slate-200 rounded-lg mb-3 overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-3 py-2 bg-slate-50 hover:bg-slate-100 text-left text-sm font-medium text-slate-700 transition-colors"
onClick={() => setMetricsOpen(!metricsOpen)}
>
<span>Metrics</span>
{metricsOpen ? (
<DownOutlined className="text-slate-500" />
) : (
<RightOutlined className="text-slate-500" />
)}
</button>
{metricsOpen && (
<div className="p-3 bg-white text-sm space-y-1.5">
<div className="flex justify-between">
<span className="text-slate-500">Duration</span>
<span className="font-mono">{span.durationMs} ms</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Start (ms)</span>
<span className="font-mono">{span.startMs}</span>
</div>
{span.tokens != null && (
<>
<div className="flex justify-between">
<span className="text-slate-500">Prompt tokens</span>
<span className="font-mono">{span.tokens.prompt}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Completion tokens</span>
<span className="font-mono">{span.tokens.completion}</span>
</div>
</>
)}
{span.cost != null && (
<div className="flex justify-between">
<span className="text-slate-500">Cost</span>
<span className="font-mono">${span.cost.toFixed(6)}</span>
</div>
)}
{span.mcpServer != null && (
<div className="flex justify-between">
<span className="text-slate-500">MCP Server</span>
<span className="font-mono truncate max-w-[180px]" title={span.mcpServer}>
{span.mcpServer}
</span>
</div>
)}
{span.mcpTool != null && (
<div className="flex justify-between">
<span className="text-slate-500">MCP Tool</span>
<span className="font-mono">{span.mcpTool}</span>
</div>
)}
</div>
)}
</div>
{/* Collapsible: Input / Output */}
<div className="border border-slate-200 rounded-lg mb-3 overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-3 py-2 bg-slate-50 hover:bg-slate-100 text-left text-sm font-medium text-slate-700 transition-colors"
onClick={() => setIoOpen(!ioOpen)}
>
<span>Input / Output</span>
{ioOpen ? (
<DownOutlined className="text-slate-500" />
) : (
<RightOutlined className="text-slate-500" />
)}
</button>
{ioOpen && (
<div className="p-2 bg-white">
<Tabs
size="small"
items={[
{
key: "input",
label: "Input",
children: (
<pre className="text-xs bg-slate-50 p-3 rounded border border-slate-200 overflow-auto max-h-64 font-mono whitespace-pre-wrap break-words">
{tryFormatJson(span.input) || "—"}
</pre>
),
},
{
key: "output",
label: "Output",
children: (
<pre className="text-xs bg-slate-50 p-3 rounded border border-slate-200 overflow-auto max-h-64 font-mono whitespace-pre-wrap break-words">
{tryFormatJson(span.output) || "—"}
</pre>
),
},
]}
/>
</div>
)}
</div>
{/* Cost breakdown (LLM spans only) */}
{span.type === "llm" && (span.cost != null || span.tokens != null) && (
<div className="border border-slate-200 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-3 py-2 bg-slate-50 hover:bg-slate-100 text-left text-sm font-medium text-slate-700 transition-colors"
onClick={() => setCostOpen(!costOpen)}
>
<span>Cost breakdown</span>
{costOpen ? (
<DownOutlined className="text-slate-500" />
) : (
<RightOutlined className="text-slate-500" />
)}
</button>
{costOpen && (
<div className="p-3 bg-white text-sm space-y-1.5">
{span.model != null && (
<div className="flex justify-between">
<span className="text-slate-500">Model</span>
<span className="font-mono">{span.model}</span>
</div>
)}
{span.tokens != null && (
<div className="flex justify-between">
<span className="text-slate-500">Total tokens</span>
<span className="font-mono">
{span.tokens.prompt + span.tokens.completion}
</span>
</div>
)}
{span.cost != null && (
<div className="flex justify-between">
<span className="text-slate-500">Cost</span>
<span className="font-mono">${span.cost.toFixed(6)}</span>
</div>
)}
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,451 @@
"use client";
import { useState, useMemo } from "react";
import { ChevronDown, ChevronRight, Clock, Cpu } from "lucide-react";
import { Bot, MessageSquare, Wrench, Zap } from "lucide-react";
import type { AgentTraceSession, Span, SpanType } from "../agentTraceTypes";
export interface TraceViewProps {
session: AgentTraceSession;
selectedSpanId: string | null;
onSelectSpan: (id: string) => void;
}
const SPAN_CONFIG: Record<
SpanType,
{ icon: React.ElementType; label: string; bg: string; text: string; border: string; bar: string }
> = {
orchestrator: {
icon: Bot,
label: "Orchestrator",
bg: "bg-blue-50",
text: "text-blue-700",
border: "border-blue-200",
bar: "bg-blue-500",
},
agent: {
icon: Bot,
label: "Agent",
bg: "bg-indigo-50",
text: "text-indigo-700",
border: "border-indigo-200",
bar: "bg-indigo-500",
},
llm: {
icon: MessageSquare,
label: "LLM",
bg: "bg-purple-50",
text: "text-purple-700",
border: "border-purple-200",
bar: "bg-purple-500",
},
mcp: {
icon: Wrench,
label: "MCP",
bg: "bg-amber-50",
text: "text-amber-700",
border: "border-amber-200",
bar: "bg-amber-500",
},
function: {
icon: Zap,
label: "Function",
bg: "bg-green-50",
text: "text-green-700",
border: "border-green-200",
bar: "bg-green-500",
},
};
function formatDuration(ms: number): string {
if (ms >= 1000) return `${(ms / 1000).toFixed(3)}s`;
return `${ms}ms`;
}
function flattenSpans(
spans: Span[],
depth = 0
): Array<{ span: Span; depth: number }> {
const result: Array<{ span: Span; depth: number }> = [];
for (const span of spans) {
result.push({ span, depth });
if (span.children?.length) {
result.push(...flattenSpans(span.children, depth + 1));
}
}
return result;
}
function TreeRow({
span,
depth,
isSelected,
isExpanded,
hasChildren,
onSelect,
onToggle,
}: {
span: Span;
depth: number;
isSelected: boolean;
isExpanded: boolean;
hasChildren: boolean;
onSelect: () => void;
onToggle: () => void;
}) {
const config = SPAN_CONFIG[span.type];
const Icon = config.icon;
return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
className={`flex items-center gap-1.5 px-2 py-1.5 cursor-pointer border-b border-slate-100 transition-colors ${isSelected ? "bg-blue-50" : "hover:bg-slate-50"}`}
style={{ paddingLeft: `${8 + depth * 20}px` }}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
className="w-4 h-4 flex items-center justify-center text-slate-400 hover:text-slate-600 flex-shrink-0"
aria-label={isExpanded ? "Collapse" : "Expand"}
>
{hasChildren ? (
isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)
) : (
<span className="w-3 h-3 block" />
)}
</button>
<div
className={`w-5 h-5 rounded flex items-center justify-center flex-shrink-0 ${config.bg} border ${config.border}`}
>
<Icon className={`w-2.5 h-2.5 ${config.text}`} />
</div>
<span
className={`text-xs font-mono flex-shrink-0 truncate max-w-[140px] ${isSelected ? "text-slate-900" : "text-slate-700"}`}
>
{span.name}
</span>
<span
className={`text-[10px] px-1 py-0.5 rounded border flex-shrink-0 ${config.bg} ${config.text} ${config.border}`}
>
{config.label}
</span>
<div className="flex-1 min-w-0" />
{span.tokens != null && (
<div className="flex items-center gap-1 text-[10px] text-slate-400 font-mono flex-shrink-0">
<Cpu className="w-2.5 h-2.5" />
<span>{span.tokens.prompt + span.tokens.completion} tok</span>
</div>
)}
{span.cost != null && (
<span className="text-[10px] text-slate-400 font-mono flex-shrink-0">
${span.cost.toFixed(5)}
</span>
)}
<span className="text-[10px] text-slate-500 font-mono flex-shrink-0 w-14 text-right">
{formatDuration(span.durationMs)}
</span>
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${span.status === "success" ? "bg-green-500" : span.status === "error" ? "bg-red-500" : "bg-amber-500"}`}
aria-hidden
/>
</div>
);
}
function TreeView({
session,
selectedSpanId,
onSelectSpan,
}: TraceViewProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(() => {
const ids = new Set<string>();
function collect(spans: Span[]) {
for (const s of spans) {
ids.add(s.id);
if (s.children?.length) collect(s.children);
}
}
collect(session.spans);
return ids;
});
const toggleExpand = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const renderSpans = (spans: Span[], depth: number): React.ReactNode[] => {
const rows: React.ReactNode[] = [];
for (const span of spans) {
const isExpanded = expandedIds.has(span.id);
const hasChildren = !!(span.children && span.children.length > 0);
rows.push(
<TreeRow
key={span.id}
span={span}
depth={depth}
isSelected={selectedSpanId === span.id}
isExpanded={isExpanded}
hasChildren={hasChildren}
onSelect={() => onSelectSpan(span.id)}
onToggle={() => toggleExpand(span.id)}
/>
);
if (hasChildren && isExpanded && span.children) {
rows.push(...renderSpans(span.children, depth + 1));
}
}
return rows;
};
if (!session.spans.length) {
return (
<div className="flex-1 flex items-center justify-center text-slate-500 text-sm">
No trace data available
</div>
);
}
return (
<div className="flex-1 overflow-y-auto flex flex-col min-h-0">
<div className="flex items-center px-2 py-1.5 border-b border-slate-200 bg-slate-50 sticky top-0 z-10 shrink-0">
<div className="w-4 shrink-0" />
<div className="w-5 shrink-0 ml-1.5" />
<span className="text-[10px] text-slate-500 uppercase tracking-wider ml-1.5">
Span
</span>
<div className="flex-1" />
<span className="text-[10px] text-slate-500 uppercase tracking-wider w-20 text-right">
Tokens
</span>
<span className="text-[10px] text-slate-500 uppercase tracking-wider w-16 text-right">
Cost
</span>
<span className="text-[10px] text-slate-500 uppercase tracking-wider w-14 text-right">
Duration
</span>
<div className="w-4 shrink-0 ml-1.5" />
</div>
<div className="shrink-0">{renderSpans(session.spans, 0)}</div>
</div>
);
}
function WaterfallView({
session,
selectedSpanId,
onSelectSpan,
}: TraceViewProps) {
const totalMs = session.totalDurationMs;
const flatSpans = useMemo(() => flattenSpans(session.spans), [session.spans]);
if (!flatSpans.length) {
return (
<div className="flex-1 flex items-center justify-center text-slate-500 text-sm">
No trace data available
</div>
);
}
const LABEL_WIDTH = 200;
const tickCount = 6;
const ticks = Array.from(
{ length: tickCount + 1 },
(_, i) => Math.round((totalMs / tickCount) * i)
);
return (
<div className="flex-1 overflow-auto flex flex-col min-h-0">
<div className="flex border-b border-slate-200 bg-slate-50 sticky top-0 z-10 shrink-0">
<div
style={{ width: LABEL_WIDTH }}
className="shrink-0 px-3 py-1.5"
>
<span className="text-[10px] text-slate-500 uppercase tracking-wider">
Span
</span>
</div>
<div className="flex-1 relative py-1.5 pr-4">
<div className="relative h-4">
{ticks.map((tick) => (
<div
key={tick}
className="absolute top-0 flex flex-col items-center"
style={{ left: `${(tick / totalMs) * 100}%` }}
>
<span className="text-[9px] text-slate-400 font-mono -translate-x-1/2">
{tick >= 1000 ? `${(tick / 1000).toFixed(1)}s` : `${tick}ms`}
</span>
</div>
))}
</div>
</div>
</div>
{flatSpans.map(({ span, depth }) => {
const config = SPAN_CONFIG[span.type];
const Icon = config.icon;
const leftPct = (span.startMs / totalMs) * 100;
const widthPct = Math.max((span.durationMs / totalMs) * 100, 0.5);
const isSelected = selectedSpanId === span.id;
return (
<div
key={span.id}
role="button"
tabIndex={0}
onClick={() => onSelectSpan(span.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelectSpan(span.id);
}
}}
className={`flex items-center border-b border-slate-100 cursor-pointer transition-colors ${isSelected ? "bg-blue-50" : "hover:bg-slate-50"}`}
>
<div
style={{
width: LABEL_WIDTH,
paddingLeft: `${12 + depth * 16}px`,
}}
className="shrink-0 flex items-center gap-1.5 py-1.5 pr-3"
>
<div
className={`w-4 h-4 rounded flex items-center justify-center shrink-0 ${config.bg} border ${config.border}`}
>
<Icon className={`w-2 h-2 ${config.text}`} />
</div>
<span
className={`text-[11px] font-mono truncate ${isSelected ? "text-slate-900" : "text-slate-600"}`}
>
{span.name}
</span>
</div>
<div className="flex-1 relative py-2 pr-4 min-h-[24px]">
{ticks.slice(1, -1).map((tick) => (
<div
key={tick}
className="absolute top-0 bottom-0 w-px bg-slate-200"
style={{ left: `${(tick / totalMs) * 100}%` }}
/>
))}
<div
className={`absolute top-1.5 bottom-1.5 rounded-sm ${config.bar} ${isSelected ? "opacity-90" : "opacity-70"} transition-opacity`}
style={{
left: `${leftPct}%`,
width: `${widthPct}%`,
minWidth: "3px",
}}
/>
</div>
</div>
);
})}
</div>
);
}
export function TraceView({
session,
selectedSpanId,
onSelectSpan,
}: TraceViewProps) {
const [activeTab, setActiveTab] = useState<"tree" | "waterfall">("tree");
const totalTokens = useMemo(() => {
let t = 0;
function sum(spans: Span[]) {
for (const s of spans) {
if (s.tokens) t += s.tokens.prompt + s.tokens.completion;
if (s.children?.length) sum(s.children);
}
}
sum(session.spans);
return t;
}, [session.spans]);
return (
<div className="flex flex-col h-full min-h-0">
<div className="shrink-0 px-2 py-2 border-b border-slate-200 space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-900 truncate">
{session.rootAgentName}
</h3>
<span
className={`text-[10px] px-2 py-0.5 rounded-full font-medium shrink-0 ${session.status === "success" ? "bg-green-50 text-green-700 border border-green-200" : "bg-red-50 text-red-700 border border-red-200"}`}
>
{session.status}
</span>
</div>
<div className="font-mono text-[11px] text-slate-500 truncate">
{session.shortId}
</div>
<div className="flex items-center gap-3 flex-wrap text-xs text-slate-600">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3 text-slate-400" />
{formatDuration(session.totalDurationMs)}
</span>
<span className="flex items-center gap-1">
<Cpu className="w-3 h-3 text-slate-400" />
{totalTokens > 0
? `${totalTokens} tokens`
: `${session.totalSpans} spans`}
</span>
<span className="font-mono">${session.totalCost.toFixed(4)}</span>
<span className="text-slate-400">{session.relativeTime}</span>
</div>
</div>
<div className="flex gap-0 border-b border-slate-200 shrink-0">
{(["tree", "waterfall"] as const).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`relative px-3 py-2 text-xs font-medium capitalize transition-colors ${activeTab === tab ? "text-blue-600" : "text-slate-500 hover:text-slate-700"}`}
>
{tab === "tree" ? "Tree View" : "Waterfall"}
{activeTab === tab && (
<span
className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500"
aria-hidden
/>
)}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === "tree" ? (
<TreeView
session={session}
selectedSpanId={selectedSpanId}
onSelectSpan={onSelectSpan}
/>
) : (
<WaterfallView
session={session}
selectedSpanId={selectedSpanId}
onSelectSpan={onSelectSpan}
/>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,8 @@
export { AgentTraceDrawer } from "./AgentTraceDrawer";
export type { AgentTraceDrawerProps } from "./AgentTraceDrawer";
export { TraceView } from "./TraceView";
export type { TraceViewProps } from "./TraceView";
export { SpanDetail } from "./SpanDetail";
export type { SpanDetailProps } from "./SpanDetail";
export type { Span, AgentTraceSession, SpanType, SpanStatus } from "../agentTraceTypes";
export { MOCK_AGENT_TRACE_SESSIONS } from "../agentTraceTypes";

View file

@ -0,0 +1,127 @@
import { act, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import { LogViewer } from "./LogViewer";
import type { LogEntry } from "./mockData";
vi.mock("@/components/view_logs/LogDetailsDrawer", () => ({
LogDetailsDrawer: ({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) =>
open ? (
<div data-testid="log-details-drawer" role="dialog">
Log details drawer
<button type="button" onClick={onClose}>
Close log drawer
</button>
</div>
) : null,
}));
vi.mock("./AgentTrace", () => ({
AgentTraceDrawer: ({
open,
onClose,
session,
}: {
open: boolean;
onClose: () => void;
session: { rootAgentName: string; shortId: string } | null;
}) =>
open && session ? (
<div data-testid="agent-trace-drawer" role="dialog">
Agent trace: {session.rootAgentName}
<p>Session: {session.shortId}</p>
<button type="button" onClick={onClose}>
Close trace drawer
</button>
</div>
) : null,
}));
const mockLogs: LogEntry[] = [
{
id: "log-1",
timestamp: "2026-03-02 21:00:00",
action: "passed",
model: "gpt-4o",
input_snippet: "First guardrail log request",
},
{
id: "log-2",
timestamp: "2026-03-02 21:01:00",
action: "blocked",
model: "claude-3",
input_snippet: "Second guardrail log request",
},
{
id: "log-3",
timestamp: "2026-03-02 21:02:00",
action: "flagged",
input_snippet: "Third guardrail log request",
},
];
function wrapper({ children }: { children: React.ReactNode }) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe("LogViewer", () => {
it("should render agent-trace demo rows first then guardrail log entries", () => {
render(
<LogViewer
guardrailName="Test Guardrail"
logs={mockLogs}
logsLoading={false}
/>,
{ wrapper }
);
// First 2 rows are the fixed agent-trace demo rows
expect(screen.getByText("Currency Research Agent")).toBeDefined();
expect(screen.getByText("Travel Booking Agent")).toBeDefined();
// Trace badge appears for demo rows
const traceBadges = screen.getAllByText("Trace");
expect(traceBadges.length).toBeGreaterThanOrEqual(2);
// Following rows are guardrail log entries (input snippets are unique to log rows)
expect(screen.getByText("First guardrail log request")).toBeDefined();
expect(screen.getByText("Second guardrail log request")).toBeDefined();
expect(screen.getByText("Third guardrail log request")).toBeDefined();
});
it("should open AgentTraceDrawer when first demo trace row is clicked", async () => {
render(
<LogViewer
guardrailName="Test Guardrail"
logs={mockLogs}
logsLoading={false}
/>,
{ wrapper }
);
const currencyRow = screen.getByText("Currency Research Agent");
expect(screen.queryByTestId("agent-trace-drawer")).toBeNull();
await act(async () => {
currencyRow.closest("button")?.click();
});
expect(screen.getByTestId("agent-trace-drawer")).toBeDefined();
expect(screen.getByText(/Agent trace: Currency Research Agent/)).toBeDefined();
expect(screen.getByText(/Session: 0c4b4759-83aa/)).toBeDefined();
});
});

View file

@ -5,12 +5,18 @@ import {
WarningOutlined,
} from "@ant-design/icons";
import { useQuery } from "@tanstack/react-query";
import { Activity } from "lucide-react";
import moment from "moment";
import { Button, Spin } from "antd";
import React, { useState } from "react";
import { uiSpendLogsCall } from "@/components/networking";
import { LogDetailsDrawer } from "@/components/view_logs/LogDetailsDrawer";
import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/columns";
import { AgentTraceDrawer } from "./AgentTrace";
import {
type AgentTraceSession,
MOCK_AGENT_TRACE_SESSIONS,
} from "./agentTraceTypes";
import type { LogEntry } from "./mockData";
const actionConfig: Record<
@ -65,7 +71,10 @@ export function LogViewer({
const [activeFilter, setActiveFilter] = useState<string>(filterAction);
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedTraceSession, setSelectedTraceSession] =
useState<AgentTraceSession | null>(null);
const demoTraces = MOCK_AGENT_TRACE_SESSIONS.slice(0, 2);
const filteredLogs = logs.filter(
(log) => activeFilter === "all" || log.action === activeFilter
);
@ -107,10 +116,15 @@ export function LogViewer({
fullLogResponse?.data?.[0] ?? null;
const handleLogClick = (log: LogEntry) => {
setSelectedTraceSession(null);
setSelectedRequestId(log.id);
setDrawerOpen(true);
};
const handleTraceSessionClick = (session: AgentTraceSession) => {
setSelectedTraceSession(session);
};
const handleCloseDrawer = () => {
setDrawerOpen(false);
setSelectedRequestId(null);
@ -127,9 +141,11 @@ export function LogViewer({
<p className="text-xs text-gray-500 mt-0.5">
{logsLoading
? "Loading…"
: logs.length > 0
? `Showing ${displayLogs.length} of ${total} entries`
: "No logs for this period. Select a guardrail and date range."}
: demoTraces.length > 0
? `2 demo traces, then showing ${displayLogs.length} of ${total} log entries`
: logs.length > 0
? `Showing ${displayLogs.length} of ${total} entries`
: "No logs for this period. Select a guardrail and date range."}
</p>
</div>
{logs.length > 0 && (
@ -170,13 +186,64 @@ export function LogViewer({
<Spin />
</div>
)}
{!logsLoading && displayLogs.length === 0 && (
{!logsLoading && demoTraces.length === 0 && displayLogs.length === 0 && (
<div className="py-12 text-center text-sm text-gray-500">
No logs to display. Adjust filters or date range.
</div>
)}
{!logsLoading && displayLogs.length > 0 && (
{!logsLoading && (demoTraces.length > 0 || displayLogs.length > 0) && (
<div className="divide-y divide-gray-100">
{demoTraces.map((session) => {
const statusDotColor =
session.status === "success"
? "bg-green-500"
: session.status === "error"
? "bg-red-500"
: session.status === "running"
? "bg-blue-500"
: "bg-amber-500";
return (
<button
key={session.id}
type="button"
onClick={() => handleTraceSessionClick(session)}
className="w-full text-left px-4 py-3 hover:bg-gray-50 transition-colors flex items-start gap-3"
>
<Activity className="w-4 h-4 mt-0.5 flex-shrink-0 text-indigo-500" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded border bg-indigo-50 text-indigo-600 border-indigo-200">
Trace
</span>
<span className="text-xs text-gray-400">
{session.relativeTime}
</span>
<span className="text-xs text-gray-400">·</span>
<span className="text-xs text-gray-500">
{session.totalSpans} spans
</span>
<span className="text-xs text-gray-400">·</span>
<span className="text-xs text-gray-500">
{session.totalDurationMs}ms
</span>
<span className="text-xs text-gray-400">·</span>
<span className="text-xs text-gray-500">
${session.totalCost.toFixed(4)}
</span>
<span
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${statusDotColor}`}
title={session.status}
aria-hidden
/>
</div>
<p className="text-sm text-gray-800 truncate font-medium">
{session.rootAgentName}
</p>
</div>
<DownOutlined className="w-4 h-4 text-gray-400 flex-shrink-0 mt-1" />
</button>
);
})}
{displayLogs.map((log) => {
const config = actionConfig[log.action];
const ActionIcon = config.icon;
@ -222,6 +289,11 @@ export function LogViewer({
allLogs={selectedLog ? [selectedLog] : []}
startTime={startTime}
/>
<AgentTraceDrawer
open={selectedTraceSession != null}
onClose={() => setSelectedTraceSession(null)}
session={selectedTraceSession}
/>
</div>
);
}

View file

@ -0,0 +1,425 @@
/**
* Types and mock data for agent trace demo in GuardrailsMonitor LogViewer.
* Supports orchestrator → sub-agents → LLM + MCP hierarchy.
*/
export type SpanType =
| "orchestrator"
| "agent"
| "llm"
| "mcp"
| "function";
export type SpanStatus = "success" | "error" | "running" | "retry";
export interface Span {
id: string;
name: string;
type: SpanType;
status: SpanStatus;
startMs: number;
durationMs: number;
model?: string;
tokens?: { prompt: number; completion: number };
cost?: number;
mcpServer?: string;
mcpTool?: string;
input?: string;
output?: string;
children?: Span[];
}
export interface AgentTraceSession {
id: string;
shortId: string;
rootAgentName: string;
timestamp: string;
relativeTime: string;
totalSpans: number;
totalCost: number;
totalDurationMs: number;
status: SpanStatus;
spans: Span[];
}
/** First 2 sessions for demo: always shown as first 1–2 rows in LogViewer. */
export const MOCK_AGENT_TRACE_SESSIONS: AgentTraceSession[] = [
{
id: "0c4b4759-83aa-4f2e-b91c-d3e7f8a12345",
shortId: "0c4b4759-83aa",
rootAgentName: "Currency Research Agent",
timestamp: "2026-03-02 09:36:12 PM",
relativeTime: "2 min ago",
totalSpans: 10,
totalCost: 0.00366,
totalDurationMs: 2677,
status: "success",
spans: [
{
id: "span-orch-001",
name: "OrchestratorAgent",
type: "orchestrator",
status: "success",
startMs: 0,
durationMs: 2677,
input: JSON.stringify(
{
task: "Research current currency exchange rates and recent news for USD/EUR, USD/GBP",
context: { user_id: "u_8821", session: "0c4b4759" },
},
null,
2
),
output: JSON.stringify(
{
summary:
"Completed currency research. EUR/USD at 1.0842 (+0.23%), GBP/USD at 1.2634 (+0.11%). Key news: ECB holds rates, UK inflation data due Thursday.",
agents_used: ["CurrencyAgent", "NewsAgent"],
total_tokens: 1026,
},
null,
2
),
children: [
{
id: "span-currency-001",
name: "CurrencyAgent",
type: "agent",
status: "success",
startMs: 50,
durationMs: 2100,
input: JSON.stringify(
{
task: "Fetch current exchange rates and 7-day history for USD/EUR and USD/GBP",
tools: ["forex-api"],
},
null,
2
),
output: JSON.stringify(
{
rates: { "USD/EUR": 1.0842, "USD/GBP": 1.2634 },
trend: "bullish",
confidence: 0.87,
},
null,
2
),
children: [
{
id: "span-llm-001",
name: "gpt-4o",
type: "llm",
status: "success",
startMs: 100,
durationMs: 1346,
model: "gpt-4o",
tokens: { prompt: 198, completion: 74 },
cost: 0.00096,
input: JSON.stringify(
{
messages: [
{
role: "system",
content:
"You are a currency research agent. Use the available tools to fetch exchange rates.",
},
{
role: "user",
content:
"Get current USD/EUR and USD/GBP rates and 7-day history.",
},
],
tools: [
{ name: "get_rates", description: "Get current exchange rates" },
{ name: "get_history", description: "Get historical rate data" },
],
},
null,
2
),
output: JSON.stringify(
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_abc123",
type: "function",
function: {
name: "get_rates",
arguments: '{"pairs": ["USD/EUR", "USD/GBP"]}',
},
},
],
},
null,
2
),
children: [
{
id: "span-mcp-001",
name: "forex-api/get_rates",
type: "mcp",
status: "success",
startMs: 150,
durationMs: 234,
mcpServer: "forex-api",
mcpTool: "get_rates",
input: JSON.stringify(
{ pairs: ["USD/EUR", "USD/GBP"], timestamp: "latest" },
null,
2
),
output: JSON.stringify(
{
USD_EUR: 1.0842,
USD_GBP: 1.2634,
timestamp: "2026-03-02T21:36:12Z",
source: "ECB",
},
null,
2
),
},
{
id: "span-mcp-002",
name: "forex-api/get_history",
type: "mcp",
status: "success",
startMs: 400,
durationMs: 189,
mcpServer: "forex-api",
mcpTool: "get_history",
input: JSON.stringify(
{ pairs: ["USD/EUR", "USD/GBP"], days: 7 },
null,
2
),
output: JSON.stringify(
{
data: [
{ date: "2026-02-24", USD_EUR: 1.0791 },
{ date: "2026-02-25", USD_EUR: 1.0803 },
{ date: "2026-02-26", USD_EUR: 1.0821 },
{ date: "2026-02-27", USD_EUR: 1.0834 },
{ date: "2026-02-28", USD_EUR: 1.0842 },
],
},
null,
2
),
},
],
},
{
id: "span-llm-002",
name: "gpt-4o",
type: "llm",
status: "success",
startMs: 1500,
durationMs: 826,
model: "gpt-4o",
tokens: { prompt: 171, completion: 44 },
cost: 0.00087,
input: JSON.stringify(
{
messages: [
{
role: "system",
content:
"Analyze the fetched currency data and provide a summary.",
},
{
role: "user",
content: "Summarize the exchange rate data and trends.",
},
{
role: "tool",
content: '{"USD_EUR": 1.0842, "USD_GBP": 1.2634}',
},
],
},
null,
2
),
output: JSON.stringify(
{
role: "assistant",
content:
"EUR/USD is trading at 1.0842, up 0.23% over the past week showing a steady bullish trend. GBP/USD at 1.2634 with modest gains of 0.11%. Both pairs showing positive momentum.",
},
null,
2
),
},
],
},
{
id: "span-news-001",
name: "NewsAgent",
type: "agent",
status: "success",
startMs: 50,
durationMs: 1800,
input: JSON.stringify(
{
task: "Search for recent news about USD/EUR and USD/GBP exchange rates",
tools: ["web-search"],
},
null,
2
),
output: JSON.stringify(
{
articles: 3,
sentiment: "neutral",
key_events: ["ECB rate hold", "UK CPI data pending"],
},
null,
2
),
children: [
{
id: "span-llm-003",
name: "gpt-4o",
type: "llm",
status: "success",
startMs: 100,
durationMs: 923,
model: "gpt-4o",
tokens: { prompt: 245, completion: 96 },
cost: 0.00112,
input: JSON.stringify(
{
messages: [
{
role: "system",
content: "You are a financial news research agent.",
},
{
role: "user",
content:
"Find recent news about EUR and GBP currency movements.",
},
],
tools: [
{ name: "query", description: "Search the web" },
{ name: "fetch", description: "Fetch a URL" },
],
},
null,
2
),
output: JSON.stringify(
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_def456",
type: "function",
function: {
name: "query",
arguments:
'{"q": "EUR USD GBP exchange rate news March 2026"}',
},
},
],
},
null,
2
),
children: [
{
id: "span-mcp-003",
name: "web-search/query",
type: "mcp",
status: "success",
startMs: 150,
durationMs: 445,
mcpServer: "web-search",
mcpTool: "query",
input: JSON.stringify(
{ q: "EUR USD GBP exchange rate news March 2026", num_results: 5 },
null,
2
),
output: JSON.stringify(
{
results: [
{
title: "ECB Holds Rates, Euro Steady Against Dollar",
url: "https://reuters.com/...",
snippet:
"The European Central Bank held rates steady...",
},
{
title: "UK Inflation Data Due Thursday, GBP Cautious",
url: "https://ft.com/...",
snippet: "Markets await UK CPI data...",
},
],
},
null,
2
),
},
],
},
{
id: "span-llm-004",
name: "gpt-4o",
type: "llm",
status: "success",
startMs: 1100,
durationMs: 654,
model: "gpt-4o",
tokens: { prompt: 156, completion: 42 },
cost: 0.00071,
input: JSON.stringify(
{
messages: [
{
role: "system",
content: "Summarize the news findings into key insights.",
},
{
role: "tool",
content:
"ECB holds rates steady. UK CPI data pending Thursday.",
},
],
},
null,
2
),
output: JSON.stringify(
{
role: "assistant",
content:
"Key findings: (1) ECB held rates steady — neutral for EUR short-term. (2) UK CPI data due Thursday — potential GBP volatility. (3) Overall sentiment: cautiously neutral with slight bullish bias.",
},
null,
2
),
},
],
},
],
},
],
},
{
id: "6419f0a4-82ef-4b3c-a921-e8f2d3c45678",
shortId: "6419f0a4-82ef",
rootAgentName: "Travel Booking Agent",
timestamp: "2026-03-02 09:34:27 PM",
relativeTime: "4 min ago",
totalSpans: 4,
totalCost: 0.0031,
totalDurationMs: 3200,
status: "success",
spans: [],
},
];

View file

@ -136,12 +136,6 @@ const menuGroups: MenuGroup[] = [
label: "Vector Stores",
icon: <DatabaseOutlined />,
},
{
key: "tool-policies",
page: "tool-policies",
label: "Tool Policies",
icon: <SafetyOutlined />,
},
],
},
],
@ -169,6 +163,12 @@ const menuGroups: MenuGroup[] = [
icon: <SafetyOutlined />,
roles: [...all_admin_roles, ...internalUserRoles],
},
{
key: "tool-policies",
page: "tool-policies",
label: "Tool Monitor",
icon: <SafetyOutlined />,
},
],
},
{