mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-21 22:52:19 +03:00
feat(ui): traffic-inspector tabs (headers/request/response/timing/llm/stats) (F8)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user