Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
3ba3b5f
feat(console): selected_at anchor recovers item window on stale share…
May 15, 2026
8716662
feat(console): TPS instead of TPOT, agent-turns column reorder, sideb…
May 15, 2026
2352934
feat(console/charts): x-axis tick adapts to time-range duration
May 15, 2026
498ab0d
feat(ts-turn): proxy_pair module — passive llmproxy pair classification
May 18, 2026
a510d14
feat(ts-storage): pair sweeper + DuckDB pair-detection queries
May 18, 2026
0a8ed8a
feat(api): expose proxy_role / proxy_peer_turn_id + include_proxy_hops
May 18, 2026
77c3092
feat(console): ProxyBadge + Show-proxy-hops toggle on Agent Turns
May 18, 2026
7b80464
tune: pair sweeper lookback 5min → 30min
May 18, 2026
06d1421
feat: fold N-leg proxy duplicates into a single group (haproxy 3-leg)
May 18, 2026
f09b350
feat: proxy-view tab — surface what the proxy mutated across legs
May 19, 2026
55a7fd4
fix(api): wire /api/agent-turns/{id}/proxy-view route
May 19, 2026
8d6d506
Merge remote-tracking branch 'origin/feat/selected-id-in-url' into fe…
May 19, 2026
a0dbbe2
Merge remote-tracking branch 'origin/feat/ui-tps-and-column-reorder' …
May 19, 2026
33115d8
Merge remote-tracking branch 'origin/feat/selected-at-anchor' into fe…
May 19, 2026
17c0da9
Merge remote-tracking branch 'origin/feat/axis-time-multi-day' into f…
May 19, 2026
98d4c64
feat(console/gantt): badge multi-leg turns in the Timeline sidebar
May 19, 2026
132b1d6
feat(console): fold call-level proxy duplicates within a turn
May 19, 2026
1cc5aa7
fix(console): drop unused ContentKey interface (vite tsc strict)
May 19, 2026
7dc5fa5
fix(call-pair): drop request_path from content key — proxies rewrite …
May 19, 2026
306ab00
feat(console): timeline hop indicator + in-turn Proxy view fallback
May 19, 2026
90abdfc
Merge remote-tracking branch 'origin/feat/settings-in-app' into feat/…
May 19, 2026
1f8d169
fix(url-sync): apply selected_at anchor only on the first hydration
May 19, 2026
5c9936a
fix(call-pair): drop model from content key — LiteLLM rewrites it
May 19, 2026
397ce95
fix(pair): drop wire_api / finish_reason / model from content fingerp…
May 19, 2026
1a05052
Merge remote-tracking branch 'origin/main' into feat/llmproxy-pair-de…
May 21, 2026
6b1a429
fix(ts-storage): add query_distinct_agent_kinds to pair_sweeper StubS…
May 21, 2026
678225a
Merge remote-tracking branch 'origin/main' into feat/llmproxy-pair-de…
May 21, 2026
b40a760
fix(ts-storage): add include_bodies to pair_sweeper stub mocks
May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion console/src/components/turn-detail/call-card.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react"
import { ChevronRight, ChevronDown, Loader2 } from "lucide-react"
import { ChevronRight, ChevronDown, Layers, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatMs, formatNumber } from "@/lib/format"
import { Markdown } from "@/components/ui/markdown"
Expand Down Expand Up @@ -29,6 +29,11 @@ interface Props {
active?: boolean
defaultExpanded?: boolean
onOpenDetail?: (id: string) => void
/** When this call is the canonical leg of a folded proxy duplicate
* pair (e.g. one captured copy of a LiteLLM→upstream hop is hidden
* under it), >0 number of hops folded into this row. Renders a
* small "+N hop" chip in the header so the fold is discoverable. */
hopCount?: number
}

export function CallCard({
Expand All @@ -39,6 +44,7 @@ export function CallCard({
active,
defaultExpanded,
onOpenDetail,
hopCount = 0,
}: Props) {
const [expanded, setExpanded] = useState(Boolean(defaultExpanded))
const speed = classify(call)
Expand Down Expand Up @@ -87,6 +93,15 @@ export function CallCard({
finalCallId={turn.final_call_id}
/>
<span className="flex-1 truncate text-xs text-muted-foreground">{call.model}</span>
{hopCount > 0 && (
<span
className="inline-flex shrink-0 items-center gap-0.5 rounded bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-300"
title={`${hopCount} proxy hop call(s) folded under this leg — toggle "Show proxy hops" to reveal`}
>
<Layers className="size-3" />
+{hopCount}
</span>
)}
<span className={cn(
"shrink-0 text-xs tabular-nums",
(speed === "slow" || speed === "warn") && "text-amber-600",
Expand Down
80 changes: 79 additions & 1 deletion console/src/components/turn-detail/gantt-nav.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
import { useMemo } from "react"
import { ArrowLeftRight, Copy, Layers } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatDuration, formatMs } from "@/lib/format"
import { classifyType } from "@/lib/wire-apis/dispatch"
import { GanttCallTypeIcon } from "@/components/call-renderers/chips/dispatch"
import { finishTone } from "@/lib/finish-tone"
import { readProxyMeta, proxyGroupSize } from "@/lib/proxy-meta"
import type { AgentTurnCallItem, AgentTurnDetail } from "@/types/api"

interface Props {
turn: AgentTurnDetail
calls: AgentTurnCallItem[]
activeSequence: number | null
onSelect: (sequence: number) => void
/** When the parent panel folds call-level proxy duplicates, this map
* tells GanttNav which canonical call ids carry hidden hops so it
* can stack a small "+N" indicator on those bars. Empty map (default)
* keeps the timeline a flat per-call view. */
hopsByCanonical?: Map<string, AgentTurnCallItem[]>
}

const SLOW_THRESHOLD_MS = 10_000
Expand All @@ -25,7 +32,53 @@ function classifySpeed(call: AgentTurnCallItem): "normal" | "slow" | "warn" | "e
}


export function GanttNav({ turn, calls, activeSequence, onSelect }: Props) {
/**
* Compact badge surfaced under the Timeline header when the turn is one
* leg of a proxy group. Tells the user "this turn is folded together
* with N other captured legs — see the Proxy view tab for the merged
* view". Color follows the role-tone palette used by `ProxyBadge` in
* the agent-turns list so the two callsites are visually consistent.
*/
function MultiLegBadge({
proxy,
groupSize,
}: {
proxy: ReturnType<typeof readProxyMeta>
groupSize: number
}) {
if (!proxy) return null
const role = proxy.role
const isPrimary = role === "proxy_in" || role === "mirror_primary"
const Icon = isPrimary ? ArrowLeftRight : Copy
const label =
role === "proxy_in"
? "via proxy"
: role === "proxy_out"
? "proxy hop"
: role === "mirror_primary"
? "mirrored"
: "mirror copy"
const peers = proxy.peer_turn_ids ?? (proxy.peer_turn_id ? [proxy.peer_turn_id] : [])
const title = peers.length > 0
? `${label} — group of ${groupSize} captured legs:\n${peers.join("\n")}`
: label
return (
<div
title={title}
className={cn(
"mt-1 inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium",
isPrimary
? "bg-blue-500/10 text-blue-600 dark:text-blue-300"
: "bg-muted text-muted-foreground",
)}
>
<Icon className="size-3" />
<span>{groupSize}-leg {label}</span>
</div>
)
}

export function GanttNav({ turn, calls, activeSequence, onSelect, hopsByCanonical }: Props) {
const { minStart, total } = useMemo(() => {
if (calls.length === 0) return { minStart: turn.start_time, total: turn.duration_ms || 1 }
const min = Math.min(...calls.map((c) => c.request_time))
Expand All @@ -38,11 +91,15 @@ export function GanttNav({ turn, calls, activeSequence, onSelect }: Props) {
[calls, turn.final_call_id],
)

const proxy = readProxyMeta(turn.metadata)
const groupSize = proxyGroupSize(proxy)

return (
<aside className="flex w-[140px] shrink-0 flex-col border-r border-border">
<div className="shrink-0 border-b border-border px-3 py-2">
<div className="text-xs font-medium">Timeline</div>
<div className="text-[11px] tabular-nums text-muted-foreground">{formatDuration(turn.duration_ms)}</div>
{proxy && groupSize >= 2 && <MultiLegBadge proxy={proxy} groupSize={groupSize} />}
</div>
<div className="flex-1 overflow-y-auto p-1">
{calls.length === 0 ? (
Expand All @@ -53,15 +110,20 @@ export function GanttNav({ turn, calls, activeSequence, onSelect }: Props) {
const offset = ((c.request_time - minStart) / total) * 100
const width = Math.max(((end - c.request_time) / total) * 100, 0.5)
const speed = classifySpeed(c)
const hops = hopsByCanonical?.get(c.id) ?? []
return (
<button
key={c.id}
onClick={() => onSelect(c.sequence)}
title={hops.length > 0
? `Folded ${hops.length} proxy-duplicate leg(s) under this call`
: undefined}
className={cn(
"grid w-full grid-cols-[16px_16px_1fr_36px] items-center gap-1 rounded px-1 py-1 text-left text-[10px]",
activeSequence === c.sequence ? "bg-blue-50 dark:bg-blue-950/40" : "hover:bg-muted/60",
(speed === "slow" || speed === "warn") && "border-l-2 border-amber-500/70",
speed === "error" && "border-l-2 border-red-500/70",
hops.length > 0 && speed === "normal" && "border-l-2 border-blue-500/70",
)}
>
<span className="tabular-nums text-muted-foreground">{c.sequence}</span>
Expand All @@ -76,13 +138,29 @@ export function GanttNav({ turn, calls, activeSequence, onSelect }: Props) {
)}
style={{ left: `${offset}%`, width: `${width}%`, minWidth: "2px" }}
/>
{/* Folded-hop overlay: a thin underline-style bar
directly below the main bar, indicating one or
more captured peers were folded into this leg.
Width matches the main bar so the eye reads it
as a "shadow" of the same call. */}
{hops.length > 0 && (
<div
className="absolute -bottom-1 h-0.5 rounded bg-blue-500/60"
style={{ left: `${offset}%`, width: `${width}%`, minWidth: "2px" }}
/>
)}
</div>
<span className={cn(
"text-right tabular-nums",
(speed === "slow" || speed === "warn") && "text-amber-600",
speed === "error" && "text-red-600",
speed === "normal" && "text-muted-foreground",
)}>
{hops.length > 0 && (
<span className="mr-1 inline-flex items-center text-blue-500" title={`+${hops.length} folded hop(s)`}>
<Layers className="size-2.5" />
</span>
)}
{formatMs(c.e2e_latency_ms)}
</span>
</button>
Expand Down
147 changes: 147 additions & 0 deletions console/src/components/turn-detail/in-turn-proxy-view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* In-turn Proxy View — used when call-level duplicates are folded
* client-side but the *turn* itself is not part of a backend proxy
* group. Common scenario: LiteLLM is captured on the same host and
* every LLM call is recorded twice (once on the client-facing port,
* once on the upstream-facing port) under one logical agent turn.
*
* Renders one card per folded pair group:
* - the canonical leg's 5-tuple + status + latency
* - each hidden hop's 5-tuple + status + latency
* - proxy_overhead_ms = canonical.e2e - hop.e2e (when both known)
* - model rewrite line when the canonical and hop models differ
*
* The header-diff view (response x-litellm-* etc.) would require
* parsing the headers blob client-side and is intentionally left as a
* follow-up — the v1 surfaces topology + timing + model, which is what
* most users want first.
*/
import { ArrowRightLeft, Layers } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatMs } from "@/lib/format"
import type { AgentTurnCallItem } from "@/types/api"

interface Props {
hopsByCanonical: Map<string, AgentTurnCallItem[]>
/** All visible (canonical) calls in their original order — we look
* up each canonical's hops in the map. */
canonicals: AgentTurnCallItem[]
}

export function InTurnProxyView({ hopsByCanonical, canonicals }: Props) {
// Only canonical calls that actually have folded hops show up here;
// direct calls without proxy duplicates are not part of any pair
// group and don't need a card.
const groups = canonicals.filter((c) => (hopsByCanonical.get(c.id) ?? []).length > 0)
if (groups.length === 0) {
return (
<div className="px-3 py-4 text-xs text-muted-foreground">
No call-level proxy duplicates detected in this turn.
</div>
)
}
return (
<div className="flex flex-col gap-3 p-3 text-sm">
<div className="rounded border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
This turn isn't part of a backend proxy group, but{" "}
<span className="font-semibold">{groups.length} of its LLM calls</span>{" "}
were captured at two vantage points (client → proxy and proxy →
upstream). The cards below pair them up with the matching peer.
</div>
{groups.map((c) => (
<CallPairCard key={c.id} canonical={c} hops={hopsByCanonical.get(c.id) ?? []} />
))}
</div>
)
}

function CallPairCard({
canonical,
hops,
}: {
canonical: AgentTurnCallItem
hops: AgentTurnCallItem[]
}) {
return (
<section className="rounded border border-border">
<header className="flex items-center gap-2 border-b border-border bg-muted/40 px-3 py-2 text-xs">
<Layers className="size-3 text-blue-500" />
<span className="font-medium">Call #{canonical.sequence}</span>
<span className="text-muted-foreground">
+ {hops.length} folded hop{hops.length > 1 ? "s" : ""}
</span>
</header>
<div className="flex flex-col gap-1.5 px-3 py-2">
<CallRow call={canonical} role="canonical" />
{hops.map((h) => (
<CallRow
key={h.id}
call={h}
role="hop"
overheadVs={canonical}
modelOf={canonical}
/>
))}
</div>
</section>
)
}

function CallRow({
call,
role,
overheadVs,
modelOf,
}: {
call: AgentTurnCallItem
role: "canonical" | "hop"
overheadVs?: AgentTurnCallItem
modelOf?: AgentTurnCallItem
}) {
const overhead =
overheadVs?.e2e_latency_ms != null && call.e2e_latency_ms != null
? overheadVs.e2e_latency_ms - call.e2e_latency_ms
: null
const modelRewrite = modelOf && modelOf.model !== call.model
return (
<div className="flex items-center gap-3 text-xs">
<span
className={cn(
"shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium",
role === "canonical"
? "bg-blue-500/15 text-blue-700 dark:text-blue-300"
: "bg-muted text-muted-foreground",
)}
>
{role === "canonical" ? "Client-facing" : "Proxy hop"}
</span>
<span className="font-mono">
{call.client_ip}:{call.client_port} → {call.server_ip}:{call.server_port}
</span>
{modelRewrite && (
<span
className="inline-flex items-center gap-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-700 dark:text-amber-300"
title={`Model rewrite: ${modelOf?.model} → ${call.model}`}
>
<ArrowRightLeft className="size-2.5" />
{call.model}
</span>
)}
<span className="ml-auto tabular-nums text-muted-foreground">
{formatMs(call.e2e_latency_ms)}
</span>
{overhead != null && (
<span
className={cn(
"tabular-nums font-mono text-[10px]",
overhead > 5 ? "text-amber-600 dark:text-amber-300" : "text-muted-foreground",
)}
title="Proxy overhead — canonical e2e − hop e2e"
>
Δ{overhead >= 0 ? "+" : ""}
{overhead.toFixed(1)}ms
</span>
)}
</div>
)
}
Loading
Loading