feat(ui): traffic-inspector page + capture toolbar + streaming list (F8)

This commit is contained in:
diegosouzapw
2026-05-28 07:25:00 -03:00
parent cb6a8c1641
commit 2502993581
9 changed files with 956 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
"use client";
import { useCallback, useRef, useState } from "react";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { useTrafficStream } from "./hooks/useTrafficStream";
import { useTrafficFilters } from "./hooks/useTrafficFilters";
import { useResizablePanels } from "./hooks/useResizablePanels";
import { useSessionRecorder } from "./hooks/useSessionRecorder";
import { CaptureModesToolbar } from "./components/CaptureModesToolbar";
import { TopBarControls } from "./components/TopBarControls";
import { RequestStreamingList } from "./components/RequestStreamingList";
import { DetailsPanel } from "./components/DetailsPanel";
const BUFFER_MAX = 1000;
export function TrafficInspectorPageClient() {
const [containerHeight, setContainerHeight] = useState(600);
const listContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRequest, setSelectedRequest] = useState<InterceptedRequest | null>(null);
const { filters, setProfile, setHost, setAgent, setStatus, setSessionId } =
useTrafficFilters();
const [{ listWidth, collapsed }, { startDrag, toggleCollapse }] = useResizablePanels();
const [streamState, streamActions] = useTrafficStream(filters);
const recorder = useSessionRecorder();
const listContainerCallback = useCallback((el: HTMLDivElement | null) => {
listContainerRef.current = el;
if (el) setContainerHeight(el.clientHeight);
}, []);
const exportHar = useCallback(async () => {
try {
const res = await fetch("/api/tools/traffic-inspector/export/har");
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `traffic-${Date.now()}.har`;
a.click();
URL.revokeObjectURL(url);
} catch {
// ignore
}
}, []);
const handleSessionSelect = useCallback(
(id: string | undefined) => {
setSessionId(id);
},
[setSessionId]
);
const handleRecordStart = useCallback(() => {
void recorder.start();
}, [recorder]);
const handleRecordStop = useCallback(() => {
void recorder.stop();
}, [recorder]);
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Capture modes toolbar */}
<div className="shrink-0 px-4 pt-4 pb-2">
<CaptureModesToolbar customHostCount={0} />
</div>
{/* Top bar filter/controls */}
<div className="shrink-0">
<TopBarControls
filters={filters}
onProfileChange={setProfile}
onHostChange={setHost}
onAgentChange={setAgent}
onStatusChange={setStatus}
paused={streamState.paused}
onPause={streamActions.pause}
onResume={streamActions.resume}
onClear={streamActions.clear}
onExport={exportHar}
connected={streamState.connected}
total={streamState.total}
maxSize={BUFFER_MAX}
recording={recorder.recording}
session={recorder.session}
elapsed={recorder.elapsed}
sessions={recorder.sessions}
onRecordStart={handleRecordStart}
onRecordStop={handleRecordStop}
onSessionSelect={handleSessionSelect}
onSessionDelete={recorder.deleteSession}
/>
</div>
{/* Split pane */}
<div className="flex flex-1 overflow-hidden">
{/* List pane */}
<div
ref={listContainerCallback}
className="shrink-0 overflow-hidden border-r border-border flex flex-col"
style={{ width: listWidth }}
>
<div className="flex items-center justify-between px-2 py-1 border-b border-border bg-bg-subtle shrink-0">
<span className="text-xs text-text-muted font-medium">
{streamState.total} requests
</span>
<button
type="button"
onClick={toggleCollapse}
className="text-text-muted hover:text-text-main focus-ring rounded"
aria-label={collapsed ? "Expand list" : "Collapse list"}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
{collapsed ? "chevron_right" : "chevron_left"}
</span>
</button>
</div>
{!collapsed && (
<div className="flex-1 overflow-hidden">
<RequestStreamingList
requests={streamState.requests}
selectedId={selectedRequest?.id ?? null}
onSelect={setSelectedRequest}
containerHeight={containerHeight}
/>
</div>
)}
{collapsed && (
<div className="flex-1 flex items-start justify-center pt-4">
<span className="text-xs text-text-muted font-mono" style={{ writingMode: "vertical-rl" }}>
{streamState.total} reqs
</span>
</div>
)}
</div>
{/* Drag handle */}
<div
onMouseDown={startDrag}
className="w-1 bg-border hover:bg-blue-500 cursor-col-resize shrink-0 transition-colors"
aria-hidden="true"
/>
{/* Detail pane */}
<div className="flex-1 overflow-hidden">
<DetailsPanel request={selectedRequest} allRequests={streamState.requests} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,113 @@
"use client";
import { useState } from "react";
import { cn } from "@/shared/utils/cn";
import { CustomHostsManager } from "./CustomHostsManager";
import { HttpProxySnippetCard } from "./HttpProxySnippetCard";
interface CaptureModeState {
agentBridge: boolean; // always on, cannot disable
customHosts: boolean;
httpProxy: boolean;
systemWide: boolean;
}
interface CaptureModesToolbarProps {
customHostCount: number;
}
export function CaptureModesToolbar({ customHostCount }: CaptureModesToolbarProps) {
const [modes, setModes] = useState<CaptureModeState>({
agentBridge: true,
customHosts: false,
httpProxy: false,
systemWide: false,
});
const [showHosts, setShowHosts] = useState(false);
const [showProxy, setShowProxy] = useState(false);
const [proxyPort] = useState(8080);
const toggleMode = (key: keyof CaptureModeState) => {
if (key === "agentBridge") return; // always on
setModes((prev) => ({ ...prev, [key]: !prev[key] }));
};
const buttons: Array<{
key: keyof CaptureModeState;
label: string;
alwaysOn?: boolean;
warn?: boolean;
extra?: React.ReactNode;
}> = [
{ key: "agentBridge", label: "AgentBridge", alwaysOn: true },
{
key: "customHosts",
label: `Custom hosts (${customHostCount})`,
},
{
key: "httpProxy",
label: `HTTP_PROXY :${proxyPort}`,
},
{
key: "systemWide",
label: "System-wide",
warn: true,
},
];
return (
<>
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-bg-subtle px-3 py-2">
{buttons.map(({ key, label, alwaysOn, warn }) => {
const active = modes[key];
return (
<button
key={key}
type="button"
onClick={() => toggleMode(key)}
disabled={alwaysOn}
className={cn(
"inline-flex items-center gap-1.5 rounded border px-2.5 py-1 text-xs font-medium transition-colors",
"focus-ring disabled:cursor-default",
active
? warn
? "border-amber-500/50 bg-amber-900/30 text-amber-300"
: "border-green-500/50 bg-green-900/30 text-green-300"
: "border-border text-text-muted hover:text-text-main hover:bg-surface"
)}
>
<span
className={cn(
"inline-block h-1.5 w-1.5 rounded-full",
active ? (warn ? "bg-amber-400" : "bg-green-400") : "bg-gray-600"
)}
/>
{label}
{warn && <span className="text-amber-400"></span>}
</button>
);
})}
<div className="ml-auto flex items-center gap-2">
<button
type="button"
onClick={() => setShowHosts(true)}
className="text-xs text-text-muted hover:text-text-main focus-ring rounded"
>
Manage hosts
</button>
<button
type="button"
onClick={() => setShowProxy(true)}
className="text-xs text-text-muted hover:text-text-main focus-ring rounded"
>
Copy proxy snippet
</button>
</div>
</div>
{showHosts && <CustomHostsManager onClose={() => setShowHosts(false)} />}
{showProxy && <HttpProxySnippetCard port={proxyPort} onClose={() => setShowProxy(false)} />}
</>
);
}

View File

@@ -0,0 +1,140 @@
"use client";
import { useEffect, useState } from "react";
import { z } from "zod";
interface CustomHost {
host: string;
enabled: boolean;
label?: string | null;
kind: "llm" | "app" | "custom";
}
interface CustomHostsManagerProps {
onClose: () => void;
}
export function CustomHostsManager({ onClose }: CustomHostsManagerProps) {
const [hosts, setHosts] = useState<CustomHost[]>([]);
const [input, setInput] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const HostInputSchema = z.string().min(1).max(253).regex(/^[a-z0-9.-]+$/i, "Invalid hostname");
const fetchHosts = async () => {
setLoading(true);
try {
const res = await fetch("/api/tools/traffic-inspector/custom-hosts");
if (res.ok) {
const data = (await res.json()) as { hosts: CustomHost[] };
setHosts(data.hosts ?? []);
}
} finally {
setLoading(false);
}
};
useEffect(() => {
void fetchHosts();
}, []);
const addHost = async () => {
setError(null);
const parsed = HostInputSchema.safeParse(input.trim());
if (!parsed.success) {
setError(parsed.error.errors[0]?.message ?? "Invalid host");
return;
}
const host = parsed.data;
try {
const res = await fetch("/api/tools/traffic-inspector/custom-hosts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ host, enabled: true }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
setError(body?.error?.message ?? "Failed to add host");
return;
}
setInput("");
await fetchHosts();
} catch {
setError("Network error");
}
};
const deleteHost = async (host: string) => {
try {
await fetch(`/api/tools/traffic-inspector/custom-hosts/${encodeURIComponent(host)}`, {
method: "DELETE",
});
await fetchHosts();
} catch {
// ignore
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-xl border border-border bg-surface shadow-xl p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-text-main">Custom Hosts</h2>
<button
type="button"
onClick={onClose}
className="text-text-muted hover:text-text-main focus-ring rounded"
aria-label="Close"
>
<span className="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</div>
<div className="flex gap-2 mb-4">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && addHost()}
placeholder="api.openai.com"
className="flex-1 rounded border border-border bg-bg-subtle px-3 py-1.5 text-sm text-text-main focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<button
type="button"
onClick={addHost}
className="rounded border border-border bg-blue-600 px-3 py-1.5 text-sm text-white hover:bg-blue-700 focus-ring"
>
Add
</button>
</div>
{error && <p className="text-xs text-red-400 mb-2">{error}</p>}
<div className="space-y-1 max-h-60 overflow-y-auto">
{loading && <p className="text-sm text-text-muted">Loading</p>}
{!loading && hosts.length === 0 && (
<p className="text-sm text-text-muted italic">No custom hosts added yet.</p>
)}
{hosts.map((h) => (
<div
key={h.host}
className="flex items-center justify-between rounded border border-border/50 bg-bg-subtle px-3 py-1.5"
>
<span className="text-sm font-mono text-text-main">{h.host}</span>
<button
type="button"
onClick={() => deleteHost(h.host)}
className="text-text-muted hover:text-red-400 focus-ring rounded"
aria-label={`Remove ${h.host}`}
>
<span className="material-symbols-outlined text-[16px]" aria-hidden="true">
delete
</span>
</button>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
"use client";
import { useState } from "react";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { cn } from "@/shared/utils/cn";
import { HeadersTab } from "./tabs/HeadersTab";
import { RequestBodyTab } from "./tabs/RequestBodyTab";
import { ResponseBodyTab } from "./tabs/ResponseBodyTab";
import { TimingTab } from "./tabs/TimingTab";
import { LlmDetailsTab } from "./tabs/LlmDetailsTab";
import { ConversationTab } from "./tabs/ConversationTab";
import { StatsTab } from "./tabs/StatsTab";
import { AnnotationField } from "./shared/AnnotationField";
type TabId = "conversation" | "headers" | "request" | "response" | "timing" | "llm" | "stats";
interface Tab {
id: TabId;
label: string;
icon: string;
llmOnly?: boolean;
}
const TABS: Tab[] = [
{ id: "conversation", label: "Conversation", icon: "chat_bubble" },
{ id: "headers", label: "Headers", icon: "list" },
{ id: "request", label: "Request", icon: "upload" },
{ id: "response", label: "Response", icon: "download" },
{ id: "timing", label: "Timing", icon: "timer" },
{ id: "llm", label: "LLM", icon: "psychology", llmOnly: true },
{ id: "stats", label: "Stats", icon: "bar_chart" },
];
interface DetailsPanelProps {
request: InterceptedRequest | null;
allRequests: InterceptedRequest[];
}
export function DetailsPanel({ request, allRequests }: DetailsPanelProps) {
const [activeTab, setActiveTab] = useState<TabId>("conversation");
if (!request) {
return (
<div className="h-full flex items-center justify-center text-text-muted">
<div className="text-center space-y-2">
<span
className="material-symbols-outlined text-[36px] block"
aria-hidden="true"
>
info
</span>
<p className="text-sm">Select a request to inspect it.</p>
</div>
</div>
);
}
const isLlm = request.detectedKind === "llm";
const visibleTabs = TABS.filter((t) => !t.llmOnly || isLlm);
// Ensure active tab is valid
const currentTab = visibleTabs.find((t) => t.id === activeTab) ? activeTab : "conversation";
return (
<div className="h-full flex flex-col overflow-hidden">
{/* Tab bar */}
<div
role="tablist"
aria-label="Request details"
className="flex flex-wrap items-center gap-0.5 border-b border-border px-2 pt-1 bg-bg-subtle shrink-0"
>
{visibleTabs.map((tab) => {
const selected = currentTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={selected}
onClick={() => setActiveTab(tab.id)}
className={cn(
"inline-flex items-center gap-1 h-8 px-2 text-xs rounded-t border-b-2 transition-colors focus-ring",
selected
? "border-blue-500 text-blue-400 bg-surface"
: "border-transparent text-text-muted hover:text-text-main hover:bg-surface/50"
)}
>
<span className="material-symbols-outlined text-[13px]" aria-hidden="true">
{tab.icon}
</span>
{tab.label}
</button>
);
})}
</div>
{/* Tab content */}
<div className="flex-1 overflow-hidden">
{currentTab === "conversation" && <ConversationTab request={request} />}
{currentTab === "headers" && <HeadersTab request={request} />}
{currentTab === "request" && <RequestBodyTab request={request} />}
{currentTab === "response" && <ResponseBodyTab request={request} />}
{currentTab === "timing" && <TimingTab request={request} />}
{currentTab === "llm" && isLlm && <LlmDetailsTab request={request} />}
{currentTab === "stats" && <StatsTab requests={allRequests} />}
</div>
{/* Annotation footer */}
<div className="shrink-0 border-t border-border px-3 py-2 bg-bg-subtle">
<AnnotationField requestId={request.id} initialValue={request.annotation ?? ""} />
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { cn } from "@/shared/utils/cn";
interface HttpProxySnippetCardProps {
port: number;
onClose: () => void;
}
type Lang = "bash" | "python" | "node";
export function HttpProxySnippetCard({ port, onClose }: HttpProxySnippetCardProps) {
const [lang, setLang] = useState<Lang>("bash");
const [copied, setCopied] = useState(false);
const snippets: Record<Lang, string> = {
bash: `export HTTP_PROXY=http://127.0.0.1:${port}\nexport HTTPS_PROXY=http://127.0.0.1:${port}\nexport NODE_TLS_REJECT_UNAUTHORIZED=0\n# then run your command:\ncurl https://api.openai.com/v1/models`,
python: `import os\nos.environ["HTTP_PROXY"] = "http://127.0.0.1:${port}"\nos.environ["HTTPS_PROXY"] = "http://127.0.0.1:${port}"\nos.environ["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"\n# then use requests or httpx as usual`,
node: `process.env.HTTP_PROXY = "http://127.0.0.1:${port}";\nprocess.env.HTTPS_PROXY = "http://127.0.0.1:${port}";\nprocess.env.NODE_TLS_REJECT_UNAUTHORIZED = "0";\n// then use fetch / axios / undici as usual`,
};
const copy = async () => {
await navigator.clipboard.writeText(snippets[lang]);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-lg rounded-xl border border-border bg-surface shadow-xl p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-text-main">
HTTP Proxy Snippet port {port}
</h2>
<button
type="button"
onClick={onClose}
className="text-text-muted hover:text-text-main focus-ring rounded"
aria-label="Close"
>
<span className="material-symbols-outlined" aria-hidden="true">close</span>
</button>
</div>
<div className="flex gap-1 mb-3">
{(["bash", "python", "node"] as Lang[]).map((l) => (
<button
key={l}
type="button"
onClick={() => setLang(l)}
className={cn(
"px-3 py-1 text-xs rounded border focus-ring",
lang === l
? "border-blue-500 bg-blue-900/30 text-blue-300"
: "border-border text-text-muted hover:text-text-main"
)}
>
{l}
</button>
))}
</div>
<pre className="rounded bg-bg-subtle border border-border p-3 text-xs font-mono text-text-main overflow-x-auto whitespace-pre">
{snippets[lang]}
</pre>
<div className="mt-3 flex justify-end">
<button
type="button"
onClick={copy}
className="inline-flex items-center gap-1.5 rounded border border-border px-3 py-1.5 text-xs text-text-main hover:bg-bg-subtle focus-ring"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{copied ? "check" : "content_copy"}
</span>
{copied ? "Copied!" : "Copy"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { cn } from "@/shared/utils/cn";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { ContextColorBar } from "./shared/ContextColorBar";
import { AgentEmoji } from "./shared/AgentEmoji";
interface RequestRowProps {
request: InterceptedRequest;
selected: boolean;
onClick: () => void;
style?: React.CSSProperties;
}
function statusColor(status: InterceptedRequest["status"]): string {
if (status === "in-flight") return "text-gray-400";
if (status === "error") return "text-red-400";
if (typeof status === "number") {
if (status < 300) return "text-green-400";
if (status < 400) return "text-yellow-400";
if (status < 500) return "text-orange-400";
return "text-red-400";
}
return "text-text-muted";
}
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${bytes}B`;
}
function formatTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString("en", { hour12: false });
} catch {
return "";
}
}
export function RequestRow({ request, selected, onClick, style }: RequestRowProps) {
const pathShort = request.path.length > 32 ? `${request.path.slice(-30)}` : request.path;
const sc = statusColor(request.status);
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => (e.key === "Enter" || e.key === " ") && onClick()}
style={style}
className={cn(
"flex items-stretch gap-1 border-b border-border/40 cursor-pointer hover:bg-bg-subtle",
"focus:outline-none focus-visible:ring-1 focus-visible:ring-blue-500",
selected && "bg-surface"
)}
>
<ContextColorBar contextKey={request.contextKey} />
<div className="flex-1 min-w-0 px-2 py-1.5">
<div className="flex items-center gap-2 text-xs">
<span className="text-text-muted shrink-0 font-mono">{formatTime(request.timestamp)}</span>
<span className="font-mono font-medium text-text-main shrink-0">{request.method}</span>
<span className={cn("font-mono font-bold shrink-0", sc)}>
{String(request.status)}
</span>
<span className="text-text-muted shrink-0">{formatSize(request.responseSize)}</span>
<span className="shrink-0">
<AgentEmoji agentId={request.agent} />
</span>
</div>
<div className="text-xs text-text-muted truncate font-mono mt-0.5">
{request.host}
<span className="text-text-main">{pathShort}</span>
</div>
{request.contextKey && (
<div className="text-[10px] text-text-muted font-mono opacity-60">
ctx #{request.contextKey.slice(0, 6)}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useRef } from "react";
import type { InterceptedRequest } from "@/mitm/inspector/types";
import { useVirtualList } from "../hooks/useVirtualList";
import { RequestRow } from "./RequestRow";
interface RequestStreamingListProps {
requests: InterceptedRequest[];
selectedId: string | null;
onSelect: (req: InterceptedRequest) => void;
containerHeight: number;
}
export function RequestStreamingList({
requests,
selectedId,
onSelect,
containerHeight,
}: RequestStreamingListProps) {
const { virtualItems, totalHeight, containerRef, rowRef } = useVirtualList(
requests,
containerHeight
);
if (requests.length === 0) {
return (
<div
ref={containerRef}
className="h-full flex items-center justify-center text-sm text-text-muted"
>
<div className="text-center space-y-2">
<span
className="material-symbols-outlined text-[36px] text-text-muted block"
aria-hidden="true"
>
network_check
</span>
<p>No requests captured yet.</p>
<p className="text-xs">Make sure AgentBridge is running or enable another capture mode.</p>
</div>
</div>
);
}
return (
<div
ref={containerRef as React.RefObject<HTMLDivElement>}
className="h-full overflow-y-auto relative"
style={{ contain: "strict" }}
>
<div style={{ height: totalHeight, position: "relative" }}>
{virtualItems.map(({ index, item, top }) => (
<div
key={item.id}
ref={rowRef(index)}
style={{ position: "absolute", top, left: 0, right: 0 }}
>
<RequestRow
request={item}
selected={item.id === selectedId}
onClick={() => onSelect(item)}
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,189 @@
"use client";
import type { ListFilters } from "@/mitm/inspector/types";
import type { AgentId } from "@/mitm/types";
import { cn } from "@/shared/utils/cn";
import { SessionRecorderBar } from "./session/SessionRecorderBar";
import { SessionPicker } from "./session/SessionPicker";
import type { SessionInfo } from "../hooks/useSessionRecorder";
type Profile = "llm" | "custom" | "all";
interface TopBarControlsProps {
filters: ListFilters;
onProfileChange: (p: Profile) => void;
onHostChange: (h: string | undefined) => void;
onAgentChange: (a: AgentId | undefined) => void;
onStatusChange: (s: ListFilters["status"]) => void;
paused: boolean;
onPause: () => void;
onResume: () => void;
onClear: () => void;
onExport: () => void;
connected: boolean;
total: number;
maxSize?: number;
// session recorder
recording: boolean;
session: SessionInfo | null;
elapsed: number;
sessions: SessionInfo[];
onRecordStart: () => void;
onRecordStop: () => void;
onSessionSelect: (id: string | undefined) => void;
onSessionDelete: (id: string) => void;
}
const PROFILES: Array<{ id: Profile; label: string }> = [
{ id: "llm", label: "LLM only" },
{ id: "custom", label: "Custom" },
{ id: "all", label: "All" },
];
export function TopBarControls({
filters,
onProfileChange,
onHostChange,
onAgentChange,
onStatusChange,
paused,
onPause,
onResume,
onClear,
onExport,
connected,
total,
maxSize = 1000,
recording,
session,
elapsed,
sessions,
onRecordStart,
onRecordStop,
onSessionSelect,
onSessionDelete,
}: TopBarControlsProps) {
const profile: Profile = (filters.profile as Profile) ?? "llm";
return (
<div className="flex flex-wrap items-center gap-2 border-b border-border bg-bg-subtle px-3 py-2">
{/* Profile selector */}
<div
role="radiogroup"
aria-label="Traffic profile"
className="flex items-center gap-1 rounded border border-border bg-surface p-0.5"
>
{PROFILES.map((p) => (
<button
key={p.id}
type="button"
role="radio"
aria-checked={profile === p.id}
onClick={() => onProfileChange(p.id)}
className={cn(
"px-2 py-0.5 text-xs rounded focus-ring",
profile === p.id
? "bg-blue-600 text-white"
: "text-text-muted hover:text-text-main"
)}
>
{p.label}
</button>
))}
</div>
{/* Host filter */}
<input
type="text"
placeholder="Filter host…"
defaultValue={filters.host ?? ""}
onChange={(e) => onHostChange(e.target.value || undefined)}
className="rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main w-32 focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
{/* Status filter */}
<select
value={filters.status ?? ""}
onChange={(e) =>
onStatusChange((e.target.value as ListFilters["status"]) || undefined)
}
className="rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">Any status</option>
<option value="2xx">2xx</option>
<option value="3xx">3xx</option>
<option value="4xx">4xx</option>
<option value="5xx">5xx</option>
<option value="error">error</option>
</select>
{/* Action buttons */}
<button
type="button"
onClick={paused ? onResume : onPause}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:text-text-main focus-ring"
title={paused ? "Resume streaming" : "Pause streaming"}
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
{paused ? "play_arrow" : "pause"}
</span>
{paused ? "Resume" : "Pause"}
</button>
<button
type="button"
onClick={onClear}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:text-red-400 focus-ring"
title="Clear all requests"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
delete_sweep
</span>
Clear
</button>
<button
type="button"
onClick={onExport}
className="inline-flex items-center gap-1 rounded border border-border px-2 py-1 text-xs text-text-muted hover:text-text-main focus-ring"
title="Export as .har"
>
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
download
</span>
.har
</button>
{/* Session controls */}
<div className="flex items-center gap-2 ml-auto">
<SessionPicker
sessions={sessions}
selectedId={filters.sessionId}
onSelect={onSessionSelect}
onDelete={onSessionDelete}
/>
<SessionRecorderBar
recording={recording}
session={session}
elapsed={elapsed}
onStart={onRecordStart}
onStop={onRecordStop}
/>
{/* Live indicator */}
<div className="flex items-center gap-1.5 text-xs text-text-muted">
<span
className={cn(
"inline-block h-2 w-2 rounded-full",
connected ? "bg-green-400 animate-pulse" : "bg-gray-500"
)}
/>
{connected ? "live" : "offline"}
<span className="text-text-muted font-mono">
{total}/{maxSize}
</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { TrafficInspectorPageClient } from "./TrafficInspectorPageClient";
export const metadata = {
title: "Traffic Inspector — OmniRoute",
description: "Monitor LLM calls + debug any application's HTTPS traffic",
};
export default function TrafficInspectorPage() {
return <TrafficInspectorPageClient />;
}