diff --git a/console/src/app.tsx b/console/src/app.tsx index 99c171d5..5593f622 100644 --- a/console/src/app.tsx +++ b/console/src/app.tsx @@ -6,6 +6,7 @@ import { PerformancePage } from "@/pages/performance" import { TrafficPage } from "@/pages/traffic" import { ErrorsPage } from "@/pages/errors" import { ModelsPage } from "@/pages/models" +import { ServicesPage } from "@/pages/services" import { LlmCallsPage } from "@/pages/llm-calls" import { AgentSessionsPage } from "@/pages/agent-sessions" import { AgentSessionDetailPage } from "@/pages/agent-session-detail" @@ -36,6 +37,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/console/src/components/charts/agent-activity-chart.tsx b/console/src/components/charts/agent-activity-chart.tsx new file mode 100644 index 00000000..904d0611 --- /dev/null +++ b/console/src/components/charts/agent-activity-chart.tsx @@ -0,0 +1,115 @@ +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from "recharts" +import { formatNumber, formatAxisTime } from "@/lib/format" +import type { AgentActivityPoint } from "@/types/api" + +// Same palette as RequestVolumeChart — keeps the Overview row +// internally consistent. +const SERIES_COLORS = [ + "#3b82f6", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#ec4899", + "#06b6d4", + "#84cc16", +] + +interface Props { + points: AgentActivityPoint[] +} + +export function AgentActivityChart({ points }: Props) { + if (points.length === 0) { + return ( +
+ No agent activity in the selected window +
+ ) + } + + // Pivot the long-form rows `(ts, kind, count)` into wide-form + // recharts data: `{time: , kind1: n, kind2: n, ...}`. + const tsSet = new Set() + const kindSet = new Set() + const byTsKind = new Map>() + for (const p of points) { + tsSet.add(p.timestamp_ms) + kindSet.add(p.agent_kind) + if (!byTsKind.has(p.timestamp_ms)) byTsKind.set(p.timestamp_ms, new Map()) + byTsKind.get(p.timestamp_ms)!.set(p.agent_kind, p.turn_count) + } + const tsAsc = [...tsSet].sort((a, b) => a - b) + // Sort kinds by total turns desc so the dominant agent renders + // closest to the X axis — same convention as RequestVolumeChart. + const totals = new Map() + for (const p of points) { + totals.set(p.agent_kind, (totals.get(p.agent_kind) ?? 0) + p.turn_count) + } + const kindsByVolume = [...kindSet].sort( + (a, b) => (totals.get(b) ?? 0) - (totals.get(a) ?? 0), + ) + const chartData = tsAsc.map((ms) => { + const row: Record = { time: Math.floor(ms / 1000) } + const m = byTsKind.get(ms)! + for (const k of kindsByVolume) { + row[k] = m.get(k) ?? 0 + } + return row + }) + const spanSec = + tsAsc.length > 1 ? (tsAsc[tsAsc.length - 1] - tsAsc[0]) / 1000 : 0 + + return ( + + + + formatAxisTime(v, spanSec)} + className="text-[11px] fill-muted-foreground" + tickLine={false} + axisLine={false} + /> + formatNumber(v)} + className="text-[11px] fill-muted-foreground" + tickLine={false} + axisLine={false} + /> + new Date(Number(v) * 1000).toLocaleString()} + formatter={(value, name) => [formatNumber(Number(value)), String(name)]} + contentStyle={{ + backgroundColor: "hsl(var(--card))", + borderColor: "hsl(var(--border))", + borderRadius: "8px", + fontSize: "12px", + }} + /> + + {kindsByVolume.map((kind, i) => ( + + ))} + + + ) +} diff --git a/console/src/components/charts/agent-distribution-chart.tsx b/console/src/components/charts/agent-distribution-chart.tsx new file mode 100644 index 00000000..951d7b98 --- /dev/null +++ b/console/src/components/charts/agent-distribution-chart.tsx @@ -0,0 +1,88 @@ +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts" +import { formatNumber } from "@/lib/format" +import type { AgentKindSummary } from "@/types/api" + +interface Props { + rows: AgentKindSummary[] +} + +export function AgentDistributionChart({ rows }: Props) { + if (rows.length === 0) { + return ( +
+ No agents observed in the selected window +
+ ) + } + + // Trim to top 10 — agent_kind cardinality is usually <10 anyway, + // but a misconfigured client can dump dozens of arbitrary strings. + const sorted = [...rows].sort((a, b) => b.turn_count - a.turn_count).slice(0, 10) + const chartData = sorted.map((r) => ({ + label: r.agent_kind.length > 24 ? r.agent_kind.slice(0, 22) + "…" : r.agent_kind, + full: r.agent_kind, + turns: r.turn_count, + in: r.total_input_tokens, + out: r.total_output_tokens, + })) + + return ( + + + + formatNumber(v)} + className="text-[11px] fill-muted-foreground" + tickLine={false} + axisLine={false} + /> + + { + if (name === "turns") return [formatNumber(Number(value)), "Turns"] + return [String(value), String(name)] + }} + labelFormatter={(_label, payload) => + (payload[0]?.payload as Record)?.full ?? String(_label) + } + contentStyle={{ + backgroundColor: "hsl(var(--card))", + borderColor: "hsl(var(--border))", + borderRadius: "8px", + fontSize: "12px", + }} + /> + + + + ) +} diff --git a/console/src/components/layout/sidebar.tsx b/console/src/components/layout/sidebar.tsx index ea1123c7..b56712ad 100644 --- a/console/src/components/layout/sidebar.tsx +++ b/console/src/components/layout/sidebar.tsx @@ -4,7 +4,7 @@ import { Gauge, BarChart3, AlertTriangle, - Cpu, + Server, Sparkles, MessageSquare, MessagesSquare, @@ -22,9 +22,11 @@ const TOOLBAR_KEYS = ["preset", "start", "end", "wire_api", "model", "server_ip" const navItems = [ { to: "/", icon: LayoutDashboard, label: "Overview" }, { to: "/performance", icon: Gauge, label: "Performance" }, - { to: "/traffic", icon: BarChart3, label: "Traffic" }, + // Models view is now a tab inside Services; route /models still + // resolves for shared links but the sidebar entry was redundant. + { to: "/traffic", icon: BarChart3, label: "Usage" }, { to: "/errors", icon: AlertTriangle, label: "Errors" }, - { to: "/models", icon: Cpu, label: "Models" }, + { to: "/services", icon: Server, label: "Services" }, { to: "/agent-sessions", icon: MessageSquare, label: "Agent Sessions" }, { to: "/agent-turns", icon: MessagesSquare, label: "Agent Turns" }, { to: "/llm-calls", icon: Sparkles, label: "LLM Calls" }, diff --git a/console/src/components/services/path-view.tsx b/console/src/components/services/path-view.tsx new file mode 100644 index 00000000..c4c20898 --- /dev/null +++ b/console/src/components/services/path-view.tsx @@ -0,0 +1,367 @@ +import { useMemo } from "react" +import { Users } from "lucide-react" +import { cn } from "@/lib/utils" +import { formatNumber } from "@/lib/format" +import type { ServicesTopology, TopologyNode } from "@/types/api" + +const APP_NODE_STYLE: Record = { + vllm: "border-purple-400 bg-purple-50 dark:bg-purple-950/40", + sglang: "border-cyan-400 bg-cyan-50 dark:bg-cyan-950/40", + ollama: "border-amber-400 bg-amber-50 dark:bg-amber-950/40", + llamacpp: "border-emerald-400 bg-emerald-50 dark:bg-emerald-950/40", + litellm: "border-pink-400 bg-pink-50 dark:bg-pink-950/40", + openai: "border-green-400 bg-green-50 dark:bg-green-950/40", + anthropic: "border-orange-400 bg-orange-50 dark:bg-orange-950/40", + gemini: "border-blue-400 bg-blue-50 dark:bg-blue-950/40", + clients: "border-slate-400 bg-slate-100 dark:bg-slate-800/60", +} + +const APP_BADGE_STYLE: Record = { + vllm: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300", + sglang: "bg-cyan-100 text-cyan-800 dark:bg-cyan-900/40 dark:text-cyan-300", + ollama: "bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300", + llamacpp: "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300", + litellm: "bg-pink-100 text-pink-800 dark:bg-pink-900/40 dark:text-pink-300", + openai: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300", + anthropic: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", + gemini: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300", +} + +const CLIENTS_ID = "__clients__:0" +const NODE_W = 200 +const NODE_H = 80 +const COL_GAP = 120 +const ROW_GAP = 24 +const SVG_PAD = 24 + +function nodeId(ip: string, port: number): string { + return `${ip}:${port}` +} + +interface LayoutNode extends TopologyNode { + id: string + col: number + row: number + x: number + y: number +} + +interface LayoutEdge { + from: string + to: string + kind: "proxy" | "inferred" | "client" + turn_count: number +} + +/// BFS from the clients node assigns each service to a depth column. +/// Services not reachable from clients (e.g. an isolated proxy_out +/// without observed proxy_in this window) get appended to the last +/// column so they still render somewhere. Sibling order within a +/// column is stable by `call_count` desc for visual predictability. +function layoutGraph(topology: ServicesTopology) { + const nodesById = new Map() + for (const n of topology.nodes) { + nodesById.set(nodeId(n.server_ip, n.server_port), n) + } + const adj = new Map() + for (const e of topology.edges) { + const from = nodeId(e.from_ip, e.from_port) + const to = nodeId(e.to_ip, e.to_port) + if (!adj.has(from)) adj.set(from, []) + adj.get(from)!.push(to) + } + + const colOf = new Map() + const queue: Array<{ id: string; col: number }> = [] + if (nodesById.has(CLIENTS_ID)) { + queue.push({ id: CLIENTS_ID, col: 0 }) + colOf.set(CLIENTS_ID, 0) + } + while (queue.length > 0) { + const { id, col } = queue.shift()! + const neighbors = adj.get(id) ?? [] + for (const nb of neighbors) { + if (!nodesById.has(nb)) continue + const existing = colOf.get(nb) + // Keep the LARGEST depth so a node always sits to the right of + // any predecessor we've actually seen — matters for the + // 3-leg haproxy case where clients → A → B AND clients → B + // both exist and B should still render to the right of A. + if (existing === undefined || col + 1 > existing) { + colOf.set(nb, col + 1) + queue.push({ id: nb, col: col + 1 }) + } + } + } + + // Place stragglers (no incoming edge from clients) into the + // rightmost discovered column + 1 so they render but don't overlap. + let maxCol = 0 + for (const c of colOf.values()) maxCol = Math.max(maxCol, c) + for (const id of nodesById.keys()) { + if (!colOf.has(id)) colOf.set(id, maxCol + 1) + } + + // Group by column, sort within by call_count desc. + const cols: LayoutNode[][] = [] + for (const [id, n] of nodesById) { + const c = colOf.get(id) ?? 0 + if (!cols[c]) cols[c] = [] + cols[c].push({ + ...n, + id, + col: c, + row: 0, // assigned below + x: 0, + y: 0, + }) + } + for (const colNodes of cols) { + if (!colNodes) continue + colNodes.sort((a, b) => b.call_count - a.call_count) + colNodes.forEach((n, i) => { + n.row = i + }) + } + + const colCount = cols.length + // Center each column vertically so columns of unequal length don't + // visually drift to the top — easier to scan upstream/downstream. + const maxRows = Math.max(1, ...cols.filter(Boolean).map((c) => c.length)) + const totalH = maxRows * NODE_H + (maxRows - 1) * ROW_GAP + 2 * SVG_PAD + const totalW = colCount * NODE_W + (colCount - 1) * COL_GAP + 2 * SVG_PAD + + const placed: LayoutNode[] = [] + for (let c = 0; c < cols.length; c++) { + const colNodes = cols[c] + if (!colNodes) continue + const colH = colNodes.length * NODE_H + (colNodes.length - 1) * ROW_GAP + const startY = SVG_PAD + (totalH - 2 * SVG_PAD - colH) / 2 + for (const n of colNodes) { + n.x = SVG_PAD + c * (NODE_W + COL_GAP) + n.y = startY + n.row * (NODE_H + ROW_GAP) + placed.push(n) + } + } + + const edges: LayoutEdge[] = topology.edges.map((e) => ({ + from: nodeId(e.from_ip, e.from_port), + to: nodeId(e.to_ip, e.to_port), + kind: e.kind, + turn_count: e.turn_count, + })) + + return { placed, edges, totalW, totalH } +} + +function NodeCard({ n }: { n: LayoutNode }) { + const app = n.app ?? "unknown" + const isClients = n.server_ip === "__clients__" + const wrapCls = + APP_NODE_STYLE[app] ?? "border-slate-300 bg-card" + const badgeCls = + APP_BADGE_STYLE[app] ?? + "bg-muted text-muted-foreground" + const topModels = n.models.slice(0, 2) + return ( +
0 + ? `Models: ${n.models.join(", ")}` + : undefined + } + > +
+ {isClients ? : null} + + {app} + + + {formatNumber(n.call_count)} + +
+ {!isClients && ( +
+ {n.server_ip}:{n.server_port} +
+ )} + {!isClients && topModels.length > 0 && ( +
+ {topModels.join(", ")} + {n.models.length > topModels.length ? ` +${n.models.length - topModels.length}` : ""} +
+ )} + {isClients && ( +
all upstream callers
+ )} +
+ ) +} + +export function ServicePathView({ topology }: { topology: ServicesTopology }) { + const layout = useMemo(() => layoutGraph(topology), [topology]) + const { placed, edges, totalW, totalH } = layout + + if (placed.length === 0) { + return ( +
+ No services observed in selected time range +
+ ) + } + + const posById = new Map() + for (const n of placed) posById.set(n.id, n) + + // Scale edge stroke width by turn_count relative to the max so the + // hottest path stands out without making low-volume edges invisible. + const maxCount = Math.max(1, ...edges.map((e) => e.turn_count)) + const strokeWidth = (count: number) => { + const norm = count / maxCount + return Math.max(1.2, Math.min(6, 1.2 + norm * 4.8)) + } + + return ( +
+ + + + + + + + + + + + + {edges.map((e, idx) => { + const from = posById.get(e.from) + const to = posById.get(e.to) + if (!from || !to) return null + const x1 = from.x + NODE_W + const y1 = from.y + NODE_H / 2 + const x2 = to.x + const y2 = to.y + NODE_H / 2 + // Cubic bezier so the curve looks intentional rather than + // a straight diagonal that crosses every other node. + const cx1 = x1 + COL_GAP / 2 + const cx2 = x2 - COL_GAP / 2 + const d = `M ${x1} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${x2} ${y2}` + const colorCls = + e.kind === "proxy" + ? "stroke-blue-500" + : e.kind === "inferred" + ? "stroke-blue-400" + : "stroke-slate-400" + // Solid for pair-confirmed; short-dash for heuristic + // inferred edges (signals lower confidence); long-dash for + // anonymous clients. + const dash = + e.kind === "client" ? "4 4" : e.kind === "inferred" ? "6 3" : undefined + const marker = + e.kind === "proxy" + ? "arrow-proxy" + : e.kind === "inferred" + ? "arrow-inferred" + : "arrow-client" + return ( + + + {/* Mid-edge label — show counts on real service→service + hops (proxy + inferred). Skip on client edges since + the aggregate already shows on the clients node. */} + {e.kind !== "client" && ( + + {formatNumber(e.turn_count)} + + )} + + ) + })} + {placed.map((n) => ( + + + + ))} + +
+ + + proxy hop (pair-confirmed) + + + + + + inferred (caller_ip = known service) + + + + + + anonymous client + + Edge width ∝ turn count +
+ + ) +} diff --git a/console/src/hooks/use-agent-overview.ts b/console/src/hooks/use-agent-overview.ts new file mode 100644 index 00000000..0dcffe89 --- /dev/null +++ b/console/src/hooks/use-agent-overview.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query" +import { apiFetch } from "@/lib/api" +import { useToolbarStore } from "@/stores/toolbar" +import type { AgentActivityData, AgentSummaryData } from "@/types/api" + +export function useAgentSummary() { + const start = useToolbarStore((s) => s.start) + const end = useToolbarStore((s) => s.end) + return useQuery({ + queryKey: ["agent-summary", { start, end }], + queryFn: () => + apiFetch("/api/agent-turns/summary", { start, end }), + placeholderData: (prev) => prev, + }) +} + +export function useAgentActivity() { + const start = useToolbarStore((s) => s.start) + const end = useToolbarStore((s) => s.end) + return useQuery({ + queryKey: ["agent-activity", { start, end }], + queryFn: () => + apiFetch("/api/agent-turns/activity", { start, end }), + placeholderData: (prev) => prev, + }) +} diff --git a/console/src/hooks/use-agent-turns.ts b/console/src/hooks/use-agent-turns.ts index db4c9256..d6fef3da 100644 --- a/console/src/hooks/use-agent-turns.ts +++ b/console/src/hooks/use-agent-turns.ts @@ -15,12 +15,14 @@ interface UseAgentTurnsParams { agentKind?: string /** CSV of client IPs e.g. "10.0.0.1,10.0.0.2" */ clientIp?: string + /** CSV of u16 server ports e.g. "4210,9000" */ + serverPort?: string /** When true, return turns the pair sweeper marked hidden * (`proxy_out` / `mirror_secondary`). Default false. */ includeProxyHops?: boolean } -export function useAgentTurns({ page, pageSize, sortBy, sortOrder, status, agentKind, clientIp, includeProxyHops }: UseAgentTurnsParams) { +export function useAgentTurns({ page, pageSize, sortBy, sortOrder, status, agentKind, clientIp, serverPort, includeProxyHops }: UseAgentTurnsParams) { const start = useToolbarStore((s) => s.start) const end = useToolbarStore((s) => s.end) const { params: fp } = useSupportedFilterParams() @@ -29,7 +31,7 @@ export function useAgentTurns({ page, pageSize, sortBy, sortOrder, status, agent queryKey: ["agent-turns", { start, end, page, pageSize, sortBy, sortOrder, ...fp, - status, agentKind, clientIp, includeProxyHops, + status, agentKind, clientIp, serverPort, includeProxyHops, }], queryFn: () => apiFetch("/api/agent-turns", { @@ -43,6 +45,7 @@ export function useAgentTurns({ page, pageSize, sortBy, sortOrder, status, agent status: status || undefined, agent_kind: agentKind || undefined, client_ip: clientIp || undefined, + server_port: serverPort || undefined, include_proxy_hops: includeProxyHops ? "true" : undefined, }), placeholderData: (prev) => prev, diff --git a/console/src/hooks/use-llm-calls.ts b/console/src/hooks/use-llm-calls.ts index bcd5d955..e2c904e8 100644 --- a/console/src/hooks/use-llm-calls.ts +++ b/console/src/hooks/use-llm-calls.ts @@ -13,6 +13,8 @@ interface UseLlmCallsParams { statusCode?: string finishReason?: string clientIp?: string + /** CSV of u16 server ports e.g. "4210,9000" */ + serverPort?: string requestPath?: string /** Stream-mode filter: "stream", "non-stream", or undefined for all. */ isStream?: string @@ -26,6 +28,7 @@ export function useLlmCalls({ statusCode, finishReason, clientIp, + serverPort, requestPath, isStream, }: UseLlmCallsParams) { @@ -37,7 +40,7 @@ export function useLlmCalls({ queryKey: ["llm-calls", { start, end, page, pageSize, sortBy, sortOrder, ...fp, - statusCode, finishReason, clientIp, requestPath, isStream, + statusCode, finishReason, clientIp, serverPort, requestPath, isStream, }], queryFn: () => apiFetch("/api/llm-calls", { @@ -51,6 +54,7 @@ export function useLlmCalls({ status_code: statusCode || undefined, finish_reason: finishReason || undefined, client_ip: clientIp || undefined, + server_port: serverPort || undefined, request_path: requestPath || undefined, is_stream: isStream || undefined, }), diff --git a/console/src/hooks/use-services-topology.ts b/console/src/hooks/use-services-topology.ts new file mode 100644 index 00000000..f864da62 --- /dev/null +++ b/console/src/hooks/use-services-topology.ts @@ -0,0 +1,16 @@ +import { useQuery } from "@tanstack/react-query" +import { apiFetch } from "@/lib/api" +import { useToolbarStore } from "@/stores/toolbar" +import type { ServicesTopology } from "@/types/api" + +export function useServicesTopology() { + const start = useToolbarStore((s) => s.start) + const end = useToolbarStore((s) => s.end) + + return useQuery({ + queryKey: ["services-topology", { start, end }], + queryFn: () => + apiFetch("/api/services/topology", { start, end }), + placeholderData: (prev) => prev, + }) +} diff --git a/console/src/hooks/use-services.ts b/console/src/hooks/use-services.ts new file mode 100644 index 00000000..bed31c3c --- /dev/null +++ b/console/src/hooks/use-services.ts @@ -0,0 +1,28 @@ +import { useQuery } from "@tanstack/react-query" +import { apiFetch } from "@/lib/api" +import { useToolbarStore } from "@/stores/toolbar" +import type { ServicesData } from "@/types/api" + +interface UseServicesParams { + sortBy?: string + sortOrder?: "asc" | "desc" + limit?: number +} + +export function useServices({ sortBy = "call_count", sortOrder = "desc", limit = 200 }: UseServicesParams = {}) { + const start = useToolbarStore((s) => s.start) + const end = useToolbarStore((s) => s.end) + + return useQuery({ + queryKey: ["services", { start, end, sortBy, sortOrder, limit }], + queryFn: () => + apiFetch("/api/services", { + start, + end, + sort_by: sortBy, + sort_order: sortOrder, + limit, + }), + placeholderData: (prev) => prev, + }) +} diff --git a/console/src/pages/agent-turns.tsx b/console/src/pages/agent-turns.tsx index 24e4692d..7c07175e 100644 --- a/console/src/pages/agent-turns.tsx +++ b/console/src/pages/agent-turns.tsx @@ -98,6 +98,7 @@ export function AgentTurnsPage() { const [statusStr, setStatusStr] = useSearchParamState("status", "") const [agentKindStr, setAgentKindStr] = useSearchParamState("agent_kind", "") const [clientIpStr, setClientIpStr] = useSearchParamState("client_ip", "") + const [serverPortStr, setServerPortStr] = useSearchParamState("server_port", "") // Default off — the user wanted the folded view as the primary // experience. URL serialization keeps "show hops" sticky on a // shared link. @@ -139,6 +140,7 @@ export function AgentTurnsPage() { status: statusStr || undefined, agentKind: agentKindStr || undefined, clientIp: clientIpStr || undefined, + serverPort: serverPortStr || undefined, includeProxyHops, }) @@ -192,6 +194,13 @@ export function AgentTurnsPage() { placeholder="Client IP (CSV)" className="w-[180px] rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:border-foreground/20 focus:outline-none" /> + { setServerPortStr(e.target.value); setPageStr("1") }} + placeholder="Server Port (CSV)" + inputMode="numeric" + className="w-[140px] rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:border-foreground/20 focus:outline-none" + />