feat(ui): traffic-inspector tabs (headers/request/response/timing/llm/stats) (F8)

This commit is contained in:
diegosouzapw
2026-05-28 07:25:04 -03:00
parent 2502993581
commit e5a0d3df22
7 changed files with 437 additions and 0 deletions

View File

@@ -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 (
<div className="p-4 text-sm text-text-muted">
Conversation data not available. This may not be an LLM request or the body
could not be parsed.
</div>
);
}
const allTurns = [...conversation.request, ...conversation.response];
if (allTurns.length === 0) {
return (
<div className="p-4 text-sm text-text-muted">No messages found in this request.</div>
);
}
return (
<div className="h-full overflow-auto p-3 space-y-2">
{conversation.contextKey && (
<div className="text-xs text-text-muted mb-2">
Context fingerprint:{" "}
<span className="font-mono text-blue-400">#{conversation.contextKey.slice(0, 12)}</span>
</div>
)}
{allTurns.map((turn, i) => (
<ChatBubble key={i} turn={turn} />
))}
</div>
);
}

View File

@@ -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 (
<div className="space-y-4 overflow-auto h-full p-2">
<section>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
Request Headers
</h3>
<HeaderTable headers={request.requestHeaders} />
</section>
<section>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-2">
Response Headers
</h3>
<HeaderTable headers={request.responseHeaders} />
</section>
</div>
);
}

View File

@@ -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 (
<div className="p-4 text-sm text-text-muted">
LLM metadata not available for this request.
</div>
);
}
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 (
<div className="p-4 h-full overflow-auto space-y-4">
<div className="rounded border border-border bg-bg-subtle">
<table className="w-full text-sm">
<tbody>
{rows.map(({ label, value }) => (
<tr key={label} className="border-b border-border/50 last:border-b-0">
<td className="px-3 py-2 text-text-muted font-medium w-[40%]">{label}</td>
<td className="px-3 py-2 text-text-main font-mono">{value ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex items-center gap-2">
<TokenBadge tokensIn={meta.tokensIn} tokensOut={meta.tokensOut} />
{(meta.tokensIn != null || meta.tokensOut != null) && (
<span className="text-xs text-text-muted">
Total: {(meta.tokensIn ?? 0) + (meta.tokensOut ?? 0)} tokens
</span>
)}
</div>
</div>
);
}

View File

@@ -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 <p className="p-4 text-sm text-text-muted">No request body.</p>;
}
const display = masked ? maskSecrets(body) : body;
let parsed: unknown = null;
try {
parsed = JSON.parse(display);
} catch {
// not JSON
}
return (
<div className="h-full flex flex-col gap-2 p-2">
<div className="flex items-center gap-2">
<SecretMaskToggle masked={masked} onToggle={() => setMasked((m) => !m)} />
<button
type="button"
onClick={() => setRaw((r) => !r)}
className="text-xs text-text-muted hover:text-text-main border border-border rounded px-2 py-0.5 focus-ring"
>
{raw ? "Formatted" : "Raw"}
</button>
<span className="ml-auto text-xs text-text-muted">{request.requestSize} B</span>
</div>
<div className="flex-1 overflow-auto bg-bg-subtle rounded border border-border p-2">
{raw || !parsed ? (
<pre className="text-xs font-mono text-text-main whitespace-pre-wrap break-all">{display}</pre>
) : (
<JsonViewer data={parsed} />
)}
</div>
</div>
);
}

View File

@@ -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 <p className="p-4 text-sm text-text-muted">No response body.</p>;
}
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 (
<div className="h-full flex flex-col gap-2 p-2">
<div className="flex items-center gap-2">
{isSSE && (
<button
type="button"
onClick={() => setShowRaw((r) => !r)}
className="text-xs text-text-muted hover:text-text-main border border-border rounded px-2 py-0.5 focus-ring"
>
{showRaw ? "Merged view" : "Raw events"}
</button>
)}
<span className="ml-auto text-xs text-text-muted">{request.responseSize} B</span>
{request.status === "in-flight" && (
<span className="text-xs text-amber-400 animate-pulse">streaming</span>
)}
</div>
<div className="flex-1 overflow-auto bg-bg-subtle rounded border border-border p-2">
{isSSE && showRaw ? (
<SseEventList events={events} />
) : isSSE && merged ? (
<div className="space-y-2">
{merged.text && (
<pre className="text-xs font-mono text-text-main whitespace-pre-wrap break-words">{merged.text}</pre>
)}
{merged.toolCalls && merged.toolCalls.length > 0 && (
<JsonViewer data={merged.toolCalls} />
)}
</div>
) : parsed ? (
<JsonViewer data={parsed} />
) : (
<pre className="text-xs font-mono text-text-main whitespace-pre-wrap break-all">{body}</pre>
)}
</div>
</div>
);
}

View File

@@ -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<unknown> instead of any to satisfy strict lint rules.
type AnyComponent = React.ComponentType<Record<string, unknown>>;
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<RechartsLib | null>(null);
useEffect(() => {
import("recharts").then((mod) => {
setLib(mod as unknown as RechartsLib);
});
}, []);
if (!lib) {
return <div className="p-4 text-sm text-text-muted animate-pulse">Loading charts</div>;
}
const { ResponsiveContainer, BarChart, Bar, XAxis, YAxis, Tooltip, LineChart, Line } = lib;
const statusDist = requests.reduce<Record<string, number>>((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 (
<div className="h-full overflow-auto p-4 space-y-6">
<div>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3">
Status distribution
</h3>
<div style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={statusData}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="count" fill="#6366f1" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{latencyData.length > 1 && (
<div>
<h3 className="text-xs font-medium text-text-muted uppercase tracking-wider mb-3">
Latency (last 50 requests)
</h3>
<div style={{ height: 160 }}>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={latencyData}>
<XAxis dataKey="i" hide />
<YAxis tick={{ fontSize: 11 }} unit="ms" />
<Tooltip formatter={(v: unknown) => [`${String(v)}ms`, "latency"]} />
<Line
type="monotone"
dataKey="ms"
stroke="#10b981"
dot={false}
strokeWidth={2}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)}
<div className="grid grid-cols-3 gap-3 text-sm">
<div className="rounded border border-border bg-bg-subtle p-3">
<div className="text-2xl font-bold text-text-main">{requests.length}</div>
<div className="text-xs text-text-muted mt-1">Total requests</div>
</div>
<div className="rounded border border-border bg-bg-subtle p-3">
<div className="text-2xl font-bold text-green-400">
{requests.filter((r) => typeof r.status === "number" && r.status < 400).length}
</div>
<div className="text-xs text-text-muted mt-1">Successful</div>
</div>
<div className="rounded border border-border bg-bg-subtle p-3">
<div className="text-2xl font-bold text-red-400">
{
requests.filter(
(r) =>
r.status === "error" || (typeof r.status === "number" && r.status >= 400),
).length
}
</div>
<div className="text-xs text-text-muted mt-1">Errors</div>
</div>
</div>
</div>
);
}
export function StatsTab({ requests }: StatsTabProps) {
if (requests.length === 0) {
return (
<div className="p-4 text-sm text-text-muted">
No requests yet. Start a session recording to capture data for stats.
</div>
);
}
return <StatsCharts requests={requests} />;
}

View File

@@ -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 (
<div className="p-4 h-full overflow-auto space-y-4">
<TimingWaterfall request={request} />
<div className="border-t border-border pt-3 space-y-1 text-xs text-text-muted">
<div className="flex justify-between">
<span>Timestamp</span>
<span className="font-mono">{request.timestamp}</span>
</div>
<div className="flex justify-between">
<span>Method</span>
<span className="font-mono">{request.method}</span>
</div>
<div className="flex justify-between">
<span>Status</span>
<span className="font-mono">{String(request.status)}</span>
</div>
<div className="flex justify-between">
<span>Request size</span>
<span className="font-mono">{request.requestSize} B</span>
</div>
<div className="flex justify-between">
<span>Response size</span>
<span className="font-mono">{request.responseSize} B</span>
</div>
</div>
</div>
);
}