From e5a0d3df2202927ccc64c5a94f81b1defbf5eaa7 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Thu, 28 May 2026 07:25:04 -0300 Subject: [PATCH] feat(ui): traffic-inspector tabs (headers/request/response/timing/llm/stats) (F8) --- .../components/tabs/ConversationTab.tsx | 44 ++++++ .../components/tabs/HeadersTab.tsx | 27 ++++ .../components/tabs/LlmDetailsTab.tsx | 60 ++++++++ .../components/tabs/RequestBodyTab.tsx | 61 ++++++++ .../components/tabs/ResponseBodyTab.tsx | 71 +++++++++ .../components/tabs/StatsTab.tsx | 136 ++++++++++++++++++ .../components/tabs/TimingTab.tsx | 38 +++++ 7 files changed, 437 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ConversationTab.tsx create mode 100644 src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/HeadersTab.tsx create mode 100644 src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/LlmDetailsTab.tsx create mode 100644 src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/RequestBodyTab.tsx create mode 100644 src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx create mode 100644 src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/StatsTab.tsx create mode 100644 src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/TimingTab.tsx diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ConversationTab.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ConversationTab.tsx new file mode 100644 index 0000000000..ee30cf0e16 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ConversationTab.tsx @@ -0,0 +1,44 @@ +"use client"; + +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { normalizeConversation } from "@/mitm/inspector/conversationNormalizer"; +import { ChatBubble } from "../chat/ChatBubble"; + +interface ConversationTabProps { + request: InterceptedRequest; +} + +export function ConversationTab({ request }: ConversationTabProps) { + const conversation = normalizeConversation(request); + + if (!conversation) { + return ( +
+ Conversation data not available. This may not be an LLM request or the body + could not be parsed. +
+ ); + } + + const allTurns = [...conversation.request, ...conversation.response]; + + if (allTurns.length === 0) { + return ( +
No messages found in this request.
+ ); + } + + return ( +
+ {conversation.contextKey && ( +
+ Context fingerprint:{" "} + #{conversation.contextKey.slice(0, 12)} +
+ )} + {allTurns.map((turn, i) => ( + + ))} +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/HeadersTab.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/HeadersTab.tsx new file mode 100644 index 0000000000..46dbcc2f1b --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/HeadersTab.tsx @@ -0,0 +1,27 @@ +"use client"; + +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { HeaderTable } from "../shared/HeaderTable"; + +interface HeadersTabProps { + request: InterceptedRequest; +} + +export function HeadersTab({ request }: HeadersTabProps) { + return ( +
+
+

+ Request Headers +

+ +
+
+

+ Response Headers +

+ +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/LlmDetailsTab.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/LlmDetailsTab.tsx new file mode 100644 index 0000000000..3f26d123ba --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/LlmDetailsTab.tsx @@ -0,0 +1,60 @@ +"use client"; + +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { extractLlmMetadata } from "@/mitm/inspector/llmMetadataExtractor"; +import { TokenBadge } from "../shared/TokenBadge"; + +interface LlmDetailsTabProps { + request: InterceptedRequest; +} + +export function LlmDetailsTab({ request }: LlmDetailsTabProps) { + const meta = extractLlmMetadata(request); + + if (!meta) { + return ( +
+ LLM metadata not available for this request. +
+ ); + } + + const rows: Array<{ label: string; value: string | null | undefined }> = [ + { label: "Detected provider", value: meta.provider }, + { label: "API kind", value: meta.apiKind }, + { label: "Model", value: meta.model }, + { label: "Messages", value: meta.messages > 0 ? String(meta.messages) : null }, + { label: "Stream", value: meta.streamed ? "yes (SSE)" : "no" }, + { label: "Mapped to", value: meta.mappedTo }, + { + label: "Cost estimate", + value: meta.costEstimateUsd != null ? `$${meta.costEstimateUsd.toFixed(6)}` : null, + }, + ]; + + return ( +
+
+ + + {rows.map(({ label, value }) => ( + + + + + ))} + +
{label}{value ?? "—"}
+
+ +
+ + {(meta.tokensIn != null || meta.tokensOut != null) && ( + + Total: {(meta.tokensIn ?? 0) + (meta.tokensOut ?? 0)} tokens + + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/RequestBodyTab.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/RequestBodyTab.tsx new file mode 100644 index 0000000000..0e14f4e5fc --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/RequestBodyTab.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useState } from "react"; +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { JsonViewer } from "../shared/JsonViewer"; +import { SecretMaskToggle } from "../shared/SecretMaskToggle"; + +interface RequestBodyTabProps { + request: InterceptedRequest; +} + +const MASK_PATTERNS = [/sk-[A-Za-z0-9]+/g, /Bearer [A-Za-z0-9._-]+/g, /eyJ[A-Za-z0-9._-]+/g]; + +function maskSecrets(text: string): string { + let out = text; + for (const p of MASK_PATTERNS) { + out = out.replace(p, "••••"); + } + return out; +} + +export function RequestBodyTab({ request }: RequestBodyTabProps) { + const [masked, setMasked] = useState(true); + const [raw, setRaw] = useState(false); + + const body = request.requestBody; + if (!body) { + return

No request body.

; + } + + const display = masked ? maskSecrets(body) : body; + let parsed: unknown = null; + try { + parsed = JSON.parse(display); + } catch { + // not JSON + } + + return ( +
+
+ setMasked((m) => !m)} /> + + {request.requestSize} B +
+
+ {raw || !parsed ? ( +
{display}
+ ) : ( + + )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx new file mode 100644 index 0000000000..c5fe9349f4 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState } from "react"; +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { parseSseStream, mergeStream } from "@/mitm/inspector/sseMerger"; +import { JsonViewer } from "../shared/JsonViewer"; +import { SseEventList } from "../shared/SseEventList"; + +interface ResponseBodyTabProps { + request: InterceptedRequest; +} + +export function ResponseBodyTab({ request }: ResponseBodyTabProps) { + const [showRaw, setShowRaw] = useState(false); + + const body = request.responseBody; + if (!body) { + return

No response body.

; + } + + const isSSE = body.startsWith("data:") || body.includes("\ndata:"); + const events = isSSE ? parseSseStream(body) : []; + const merged = isSSE && !showRaw ? mergeStream(events) : null; + + let parsed: unknown = null; + if (!isSSE) { + try { + parsed = JSON.parse(body); + } catch { + // not JSON + } + } + + return ( +
+
+ {isSSE && ( + + )} + {request.responseSize} B + {request.status === "in-flight" && ( + streaming… + )} +
+
+ {isSSE && showRaw ? ( + + ) : isSSE && merged ? ( +
+ {merged.text && ( +
{merged.text}
+ )} + {merged.toolCalls && merged.toolCalls.length > 0 && ( + + )} +
+ ) : parsed ? ( + + ) : ( +
{body}
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/StatsTab.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/StatsTab.tsx new file mode 100644 index 0000000000..7e919d2360 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/StatsTab.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; +import type { InterceptedRequest } from "@/mitm/inspector/types"; + +interface StatsTabProps { + requests: InterceptedRequest[]; +} + +// Recharts is lazy-loaded via dynamic() with ssr: false — avoids including the +// full Recharts bundle in the initial page load. +const _rechartsPreload = dynamic(() => import("recharts"), { ssr: false }); +void _rechartsPreload; + +// Using ComponentType instead of any to satisfy strict lint rules. +type AnyComponent = React.ComponentType>; + +interface RechartsLib { + ResponsiveContainer: AnyComponent; + BarChart: AnyComponent; + Bar: AnyComponent; + XAxis: AnyComponent; + YAxis: AnyComponent; + Tooltip: AnyComponent; + LineChart: AnyComponent; + Line: AnyComponent; +} + +function StatsCharts({ requests }: StatsTabProps) { + const [lib, setLib] = useState(null); + + useEffect(() => { + import("recharts").then((mod) => { + setLib(mod as unknown as RechartsLib); + }); + }, []); + + if (!lib) { + return
Loading charts…
; + } + + const { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } = lib; + + const statusDist = requests.reduce>((acc, r) => { + const key = + typeof r.status === "number" ? `${Math.floor(r.status / 100)}xx` : String(r.status); + acc[key] = (acc[key] ?? 0) + 1; + return acc; + }, {}); + const statusData = Object.entries(statusDist).map(([name, count]) => ({ name, count })); + + const latencyData = requests + .filter((r) => r.totalLatencyMs != null) + .slice(-50) + .map((r, i) => ({ i, ms: r.totalLatencyMs })); + + return ( +
+
+

+ Status distribution +

+
+ + + + + + + + +
+
+ + {latencyData.length > 1 && ( +
+

+ Latency (last 50 requests) +

+
+ + + + + [`${String(v)}ms`, "latency"]} /> + + + +
+
+ )} + +
+
+
{requests.length}
+
Total requests
+
+
+
+ {requests.filter((r) => typeof r.status === "number" && r.status < 400).length} +
+
Successful
+
+
+
+ { + requests.filter( + (r) => + r.status === "error" || (typeof r.status === "number" && r.status >= 400), + ).length + } +
+
Errors
+
+
+
+ ); +} + +export function StatsTab({ requests }: StatsTabProps) { + if (requests.length === 0) { + return ( +
+ No requests yet. Start a session recording to capture data for stats. +
+ ); + } + return ; +} diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/TimingTab.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/TimingTab.tsx new file mode 100644 index 0000000000..e62bd88ea8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/TimingTab.tsx @@ -0,0 +1,38 @@ +"use client"; + +import type { InterceptedRequest } from "@/mitm/inspector/types"; +import { TimingWaterfall } from "../shared/TimingWaterfall"; + +interface TimingTabProps { + request: InterceptedRequest; +} + +export function TimingTab({ request }: TimingTabProps) { + return ( +
+ +
+
+ Timestamp + {request.timestamp} +
+
+ Method + {request.method} +
+
+ Status + {String(request.status)} +
+
+ Request size + {request.requestSize} B +
+
+ Response size + {request.responseSize} B +
+
+
+ ); +}