mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
merge(F8): Traffic Inspector UI into Group A parent
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { NormalizedTurn } from "@/mitm/inspector/types";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import { MessageContent } from "./MessageContent";
|
||||
|
||||
interface ChatBubbleProps {
|
||||
turn: NormalizedTurn;
|
||||
}
|
||||
|
||||
const ROLE_STYLES: Record<NormalizedTurn["role"], string> = {
|
||||
system: "border border-red-500/40 bg-red-900/20 text-red-200",
|
||||
user: "ml-auto bg-blue-600/30 border border-blue-500/30 text-blue-100",
|
||||
assistant: "bg-purple-900/30 border border-purple-500/30 text-purple-100",
|
||||
tool: "bg-gray-800 border border-gray-600/30 text-gray-200",
|
||||
};
|
||||
|
||||
const ROLE_LABEL: Record<NormalizedTurn["role"], string> = {
|
||||
system: "System",
|
||||
user: "User",
|
||||
assistant: "Assistant",
|
||||
tool: "Tool",
|
||||
};
|
||||
|
||||
export function ChatBubble({ turn }: ChatBubbleProps) {
|
||||
const [collapsed, setCollapsed] = useState(turn.role === "system");
|
||||
|
||||
const isSystem = turn.role === "system";
|
||||
const isUser = turn.role === "user";
|
||||
|
||||
return (
|
||||
<div className={cn("max-w-[85%] rounded-lg px-3 py-2", isUser ? "ml-auto" : "mr-auto", ROLE_STYLES[turn.role])}>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<span className="text-xs font-medium opacity-70">{ROLE_LABEL[turn.role]}</span>
|
||||
{isSystem && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
className="text-xs opacity-70 hover:opacity-100 focus-ring rounded"
|
||||
>
|
||||
{collapsed ? "Expand" : "Collapse"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && <MessageContent blocks={turn.blocks} />}
|
||||
{collapsed && isSystem && (
|
||||
<p className="text-xs opacity-60 italic">System prompt hidden — click to expand</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type { NormalizedBlock } from "@/mitm/inspector/types";
|
||||
import { ToolCallBlock } from "./ToolCallBlock";
|
||||
import { ToolResultBlock } from "./ToolResultBlock";
|
||||
|
||||
interface MessageContentProps {
|
||||
blocks: NormalizedBlock[];
|
||||
}
|
||||
|
||||
export function MessageContent({ blocks }: MessageContentProps) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block, i) => {
|
||||
if (block.type === "text") {
|
||||
return (
|
||||
<p key={i} className="text-sm text-text-main whitespace-pre-wrap break-words">
|
||||
{block.text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (block.type === "tool_use") {
|
||||
return (
|
||||
<ToolCallBlock key={i} id={block.id} name={block.name} input={block.input} />
|
||||
);
|
||||
}
|
||||
if (block.type === "tool_result") {
|
||||
return (
|
||||
<ToolResultBlock
|
||||
key={i}
|
||||
toolUseId={block.tool_use_id}
|
||||
content={block.content}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { JsonViewer } from "../shared/JsonViewer";
|
||||
|
||||
interface ToolCallBlockProps {
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
export function ToolCallBlock({ id, name, input }: ToolCallBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-amber-500/40 bg-amber-900/20 px-3 py-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex w-full items-center gap-2 text-left focus-ring rounded"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-amber-400" aria-hidden="true">
|
||||
{expanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
<span className="text-amber-300 font-mono font-medium">{name}</span>
|
||||
<span className="text-text-muted text-xs font-mono ml-auto">{id.slice(0, 8)}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 border-t border-amber-500/20 pt-2">
|
||||
<JsonViewer data={input} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { JsonViewer } from "../shared/JsonViewer";
|
||||
|
||||
interface ToolResultBlockProps {
|
||||
toolUseId: string;
|
||||
content: unknown;
|
||||
}
|
||||
|
||||
export function ToolResultBlock({ toolUseId, content }: ToolResultBlockProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded border border-green-500/40 bg-green-900/20 px-3 py-2 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="flex w-full items-center gap-2 text-left focus-ring rounded"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] text-green-400" aria-hidden="true">
|
||||
{expanded ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
<span className="text-green-300 font-mono font-medium text-xs">tool_result</span>
|
||||
<span className="text-text-muted text-xs font-mono ml-auto">{toolUseId.slice(0, 8)}</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 border-t border-green-500/20 pt-2">
|
||||
<JsonViewer data={content} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { SessionInfo } from "../../hooks/useSessionRecorder";
|
||||
|
||||
interface SessionPickerProps {
|
||||
sessions: SessionInfo[];
|
||||
selectedId?: string;
|
||||
onSelect: (id: string | undefined) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function SessionPicker({ sessions, selectedId, onSelect, onDelete }: SessionPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selected = sessions.find((s) => s.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-1 rounded border border-border bg-bg-subtle px-2 py-1 text-xs text-text-main hover:bg-surface focus-ring"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
folder_open
|
||||
</span>
|
||||
{selected ? selected.name ?? `Session ${selected.id.slice(0, 6)}` : "Sessions"}
|
||||
<span className="material-symbols-outlined text-[12px] ml-1" aria-hidden="true">
|
||||
{open ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 min-w-[200px] rounded-lg border border-border bg-surface shadow-lg py-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSelect(undefined); setOpen(false); }}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text-muted hover:bg-bg-subtle focus-ring"
|
||||
>
|
||||
All traffic (no session)
|
||||
</button>
|
||||
{sessions.length === 0 && (
|
||||
<p className="px-3 py-2 text-xs text-text-muted italic">No sessions yet</p>
|
||||
)}
|
||||
{sessions.map((s) => (
|
||||
<div key={s.id} className="flex items-center group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onSelect(s.id); setOpen(false); }}
|
||||
className={`flex-1 text-left px-3 py-1.5 text-xs hover:bg-bg-subtle focus-ring ${
|
||||
selectedId === s.id ? "text-blue-400 font-medium" : "text-text-main"
|
||||
}`}
|
||||
>
|
||||
{s.name ?? `Session ${s.id.slice(0, 6)}`}
|
||||
<span className="text-text-muted ml-1">({s.requestCount} reqs)</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onDelete(s.id); if (selectedId === s.id) onSelect(undefined); }}
|
||||
className="px-2 text-text-muted hover:text-red-400 opacity-0 group-hover:opacity-100 focus-ring rounded"
|
||||
aria-label="Delete session"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
delete
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
import type { SessionInfo } from "../../hooks/useSessionRecorder";
|
||||
|
||||
interface SessionRecorderBarProps {
|
||||
recording: boolean;
|
||||
session: SessionInfo | null;
|
||||
elapsed: number;
|
||||
onStart: (name?: string) => void;
|
||||
onStop: () => void;
|
||||
}
|
||||
|
||||
function formatElapsed(s: number): string {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function SessionRecorderBar({
|
||||
recording,
|
||||
session,
|
||||
elapsed,
|
||||
onStart,
|
||||
onStop,
|
||||
}: SessionRecorderBarProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm border",
|
||||
recording
|
||||
? "border-red-500/40 bg-red-900/20 text-red-200"
|
||||
: "border-border bg-bg-subtle text-text-muted"
|
||||
)}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="font-mono text-xs">{formatElapsed(elapsed)}</span>
|
||||
{session?.name && (
|
||||
<span className="text-xs opacity-70 truncate max-w-[120px]">{session.name}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
className="ml-auto rounded border border-red-500/50 px-2 py-0.5 text-xs hover:bg-red-800/30 focus-ring"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="inline-block h-2 w-2 rounded-full bg-gray-500" />
|
||||
<span className="text-xs">Not recording</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onStart()}
|
||||
className="ml-auto rounded border border-border px-2 py-0.5 text-xs hover:bg-surface focus-ring"
|
||||
>
|
||||
REC
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import type { AgentId } from "@/mitm/types";
|
||||
|
||||
const AGENT_COLORS: Record<AgentId, { emoji: string; label: string; color: string }> = {
|
||||
antigravity: { emoji: "🔵", label: "AG", color: "text-blue-400" },
|
||||
kiro: { emoji: "🟠", label: "KR", color: "text-orange-400" },
|
||||
copilot: { emoji: "🟢", label: "CP", color: "text-green-400" },
|
||||
codex: { emoji: "🟣", label: "CD", color: "text-purple-400" },
|
||||
cursor: { emoji: "🔶", label: "CU", color: "text-yellow-400" },
|
||||
zed: { emoji: "🔷", label: "ZD", color: "text-sky-400" },
|
||||
"claude-code": { emoji: "🟡", label: "CC", color: "text-yellow-300" },
|
||||
"open-code": { emoji: "⚪", label: "OC", color: "text-gray-400" },
|
||||
trae: { emoji: "⬛", label: "TR", color: "text-gray-500" },
|
||||
};
|
||||
|
||||
interface AgentEmojiProps {
|
||||
agentId?: AgentId | string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function AgentEmoji({ agentId, className }: AgentEmojiProps) {
|
||||
if (!agentId) return <span className={`text-sm ${className ?? ""}`}>🌐</span>;
|
||||
const info = AGENT_COLORS[agentId as AgentId];
|
||||
if (!info) return <span className={`text-sm ${className ?? ""}`}>🌐</span>;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-0.5 text-xs font-mono ${info.color} ${className ?? ""}`}
|
||||
title={agentId}
|
||||
>
|
||||
{info.emoji} {info.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAnnotations } from "../../hooks/useAnnotations";
|
||||
|
||||
interface AnnotationFieldProps {
|
||||
requestId: string | null;
|
||||
initialValue?: string;
|
||||
}
|
||||
|
||||
export function AnnotationField({ requestId, initialValue = "" }: AnnotationFieldProps) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const { save, saving } = useAnnotations(requestId);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setValue(e.target.value);
|
||||
save(e.target.value);
|
||||
},
|
||||
[save]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder="Add a note…"
|
||||
rows={3}
|
||||
maxLength={10_000}
|
||||
className="w-full rounded border border-border bg-bg-subtle px-3 py-2 text-sm text-text-main resize-none focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
{saving && (
|
||||
<span className="absolute right-2 bottom-2 text-xs text-text-muted animate-pulse">
|
||||
Saving…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
interface ContextColorBarProps {
|
||||
contextKey?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function hashToHue(key: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash = (hash * 31 + key.charCodeAt(i)) & 0xffffff;
|
||||
}
|
||||
return (hash * 137.5) % 360;
|
||||
}
|
||||
|
||||
export function ContextColorBar({ contextKey, className }: ContextColorBarProps) {
|
||||
const hue = contextKey ? hashToHue(contextKey) : 0;
|
||||
const color = contextKey ? `hsl(${hue}, 70%, 50%)` : "transparent";
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{ width: 3, minWidth: 3, backgroundColor: color, borderRadius: 2 }}
|
||||
title={contextKey ? `ctx #${contextKey.slice(0, 6)}` : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface HeaderTableProps {
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
export function HeaderTable({ headers }: HeaderTableProps) {
|
||||
const [masked, setMasked] = useState(true);
|
||||
const SENSITIVE = /authorization|cookie|x-api-key|bearer/i;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">Sensitive headers</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMasked((m) => !m)}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 focus-ring rounded"
|
||||
>
|
||||
{masked ? "Show" : "Hide"}
|
||||
</button>
|
||||
</div>
|
||||
<table className="w-full text-xs font-mono border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left px-2 py-1 text-text-muted font-medium">Name</th>
|
||||
<th className="text-left px-2 py-1 text-text-muted font-medium">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(headers).map(([name, value]) => {
|
||||
const isSensitive = SENSITIVE.test(name);
|
||||
const display = masked && isSensitive ? "••••••••" : value;
|
||||
return (
|
||||
<tr key={name} className="border-b border-border/50 hover:bg-bg-subtle">
|
||||
<td className="px-2 py-1 text-text-muted select-text">{name}</td>
|
||||
<td
|
||||
className={`px-2 py-1 break-all select-text ${isSensitive ? "text-amber-400" : "text-text-main"}`}
|
||||
>
|
||||
{display}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
|
||||
interface JsonViewerProps {
|
||||
data: unknown;
|
||||
depth?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function JsonNode({ data, depth = 0 }: { data: unknown; depth?: number }) {
|
||||
const [expanded, setExpanded] = useState(depth < 2);
|
||||
|
||||
if (data === null) return <span className="text-text-muted">null</span>;
|
||||
if (typeof data === "boolean") return <span className="text-amber-400">{String(data)}</span>;
|
||||
if (typeof data === "number") return <span className="text-blue-400">{String(data)}</span>;
|
||||
if (typeof data === "string") return <span className="text-green-400">"{data}"</span>;
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
if (data.length === 0) return <span className="text-text-muted">[]</span>;
|
||||
return (
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="text-text-muted hover:text-text-main font-mono text-xs focus-ring rounded"
|
||||
>
|
||||
{expanded ? "▼" : "▶"} [{data.length}]
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 border-l border-border pl-2">
|
||||
{data.map((item, i) => (
|
||||
<div key={i} className="flex gap-1 text-xs font-mono">
|
||||
<span className="text-text-muted">{i}:</span>
|
||||
<JsonNode data={item} depth={depth + 1} />
|
||||
{i < data.length - 1 && <span className="text-text-muted">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data === "object" && data !== null) {
|
||||
const entries = Object.entries(data as Record<string, unknown>);
|
||||
if (entries.length === 0) return <span className="text-text-muted">{"{}"}</span>;
|
||||
return (
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((e) => !e)}
|
||||
className="text-text-muted hover:text-text-main font-mono text-xs focus-ring rounded"
|
||||
>
|
||||
{expanded ? "▼" : "▶"} {"{"}
|
||||
{entries.length}
|
||||
{"}"}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="ml-4 border-l border-border pl-2">
|
||||
{entries.map(([k, v], i) => (
|
||||
<div key={k} className="flex gap-1 text-xs font-mono">
|
||||
<span className="text-text-main">"{k}"</span>
|
||||
<span className="text-text-muted">:</span>
|
||||
<JsonNode data={v} depth={depth + 1} />
|
||||
{i < entries.length - 1 && <span className="text-text-muted">,</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-text-main font-mono text-xs">{String(data)}</span>;
|
||||
}
|
||||
|
||||
export function JsonViewer({ data, className }: JsonViewerProps) {
|
||||
return (
|
||||
<div className={cn("overflow-auto font-mono text-xs", className)}>
|
||||
<JsonNode data={data} depth={0} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
interface SecretMaskToggleProps {
|
||||
masked: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function SecretMaskToggle({ masked, onToggle }: SecretMaskToggleProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="inline-flex items-center gap-1 text-xs text-text-muted hover:text-text-main focus-ring rounded px-2 py-0.5 border border-border"
|
||||
title={masked ? "Unmask secrets" : "Mask secrets"}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]" aria-hidden="true">
|
||||
{masked ? "visibility_off" : "visibility"}
|
||||
</span>
|
||||
{masked ? "Show secrets" : "Mask secrets"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import type { SseEvent } from "@/mitm/inspector/sseMerger";
|
||||
|
||||
interface SseEventListProps {
|
||||
events: SseEvent[];
|
||||
}
|
||||
|
||||
export function SseEventList({ events }: SseEventListProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 font-mono text-xs overflow-auto max-h-full">
|
||||
{events.map((ev, i) => (
|
||||
<div key={i} className="flex gap-2 border-b border-border/30 pb-1">
|
||||
<span className="text-text-muted shrink-0 w-8 text-right">{i + 1}</span>
|
||||
<span className="text-amber-400 shrink-0">{ev.event ?? "data"}</span>
|
||||
<span className="text-text-main break-all">{ev.data}</span>
|
||||
</div>
|
||||
))}
|
||||
{events.length === 0 && (
|
||||
<p className="text-text-muted italic">No SSE events</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
|
||||
interface TimingWaterfallProps {
|
||||
request: InterceptedRequest;
|
||||
}
|
||||
|
||||
export function TimingWaterfall({ request }: TimingWaterfallProps) {
|
||||
const { proxyLatencyMs, upstreamLatencyMs, totalLatencyMs } = request;
|
||||
const total = totalLatencyMs ?? (proxyLatencyMs ?? 0) + (upstreamLatencyMs ?? 0);
|
||||
|
||||
if (!total) {
|
||||
return <p className="text-sm text-text-muted">No timing data available.</p>;
|
||||
}
|
||||
|
||||
const segments: Array<{ label: string; ms: number; color: string }> = [
|
||||
{
|
||||
label: "Proxy overhead",
|
||||
ms: proxyLatencyMs ?? 0,
|
||||
color: "bg-blue-500",
|
||||
},
|
||||
{
|
||||
label: "Upstream response",
|
||||
ms: upstreamLatencyMs ?? 0,
|
||||
color: "bg-green-500",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
{segments.map((seg) => {
|
||||
const pct = total > 0 ? (seg.ms / total) * 100 : 0;
|
||||
return (
|
||||
<div key={seg.label} className="space-y-1">
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>{seg.label}</span>
|
||||
<span>{seg.ms}ms ({pct.toFixed(1)}%)</span>
|
||||
</div>
|
||||
<div className="h-4 w-full rounded bg-bg-subtle">
|
||||
<div
|
||||
className={`h-full rounded ${seg.color}`}
|
||||
style={{ width: `${Math.max(pct, 0.5)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between text-xs font-medium text-text-main border-t border-border pt-2">
|
||||
<span>Total latency</span>
|
||||
<span>{total}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client";
|
||||
|
||||
interface TokenBadgeProps {
|
||||
tokensIn?: number | null;
|
||||
tokensOut?: number | null;
|
||||
}
|
||||
|
||||
export function TokenBadge({ tokensIn, tokensOut }: TokenBadgeProps) {
|
||||
if (!tokensIn && !tokensOut) return null;
|
||||
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded bg-purple-900/40 px-2 py-0.5 text-xs text-purple-300 font-mono">
|
||||
<span className="material-symbols-outlined text-[12px]" aria-hidden="true">
|
||||
token
|
||||
</span>
|
||||
{tokensIn != null && <span>{tokensIn}↑</span>}
|
||||
{tokensOut != null && <span>{tokensOut}↓</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
const DEBOUNCE_MS = 500;
|
||||
|
||||
export function useAnnotations(requestId: string | null) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const save = useCallback(
|
||||
(annotation: string) => {
|
||||
if (!requestId) return;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tools/traffic-inspector/requests/${encodeURIComponent(requestId)}/annotation`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ annotation }),
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||
setError(body?.error?.message ?? "Failed to save annotation");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error saving annotation");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[requestId]
|
||||
);
|
||||
|
||||
return { save, saving, error };
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { InterceptedRequest } from "@/mitm/inspector/types";
|
||||
|
||||
export function useReplay() {
|
||||
const [replaying, setReplaying] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const replay = useCallback(async (req: InterceptedRequest) => {
|
||||
setReplaying(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/tools/traffic-inspector/requests/${encodeURIComponent(req.id)}/replay`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
|
||||
setError(body?.error?.message ?? "Replay failed");
|
||||
}
|
||||
} catch {
|
||||
setError("Network error during replay");
|
||||
} finally {
|
||||
setReplaying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { replay, replaying, error };
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const STORAGE_KEY = "inspector.listWidth";
|
||||
const MIN_WIDTH = 280;
|
||||
const MAX_WIDTH = 720;
|
||||
const COLLAPSED_RAIL = 48;
|
||||
const DEFAULT_WIDTH = 360;
|
||||
|
||||
export interface ResizablePanelsState {
|
||||
listWidth: number;
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export interface ResizablePanelsActions {
|
||||
startDrag: (e: React.MouseEvent) => void;
|
||||
toggleCollapse: () => void;
|
||||
}
|
||||
|
||||
export function useResizablePanels(): [ResizablePanelsState, ResizablePanelsActions] {
|
||||
const [listWidth, setListWidth] = useState<number>(() => {
|
||||
if (typeof window === "undefined") return DEFAULT_WIDTH;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const parsed = stored ? Number(stored) : NaN;
|
||||
return isNaN(parsed) ? DEFAULT_WIDTH : Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, parsed));
|
||||
});
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const draggingRef = useRef(false);
|
||||
const startXRef = useRef(0);
|
||||
const startWidthRef = useRef(DEFAULT_WIDTH);
|
||||
// Store handler refs to avoid stale closure issues
|
||||
const onMouseMoveRef = useRef<(e: MouseEvent) => void>(() => {});
|
||||
const onMouseUpRef = useRef<() => void>(() => {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!collapsed) {
|
||||
localStorage.setItem(STORAGE_KEY, String(listWidth));
|
||||
}
|
||||
}, [listWidth, collapsed]);
|
||||
|
||||
useEffect(() => {
|
||||
onMouseMoveRef.current = (e: MouseEvent) => {
|
||||
if (!draggingRef.current) return;
|
||||
const delta = e.clientX - startXRef.current;
|
||||
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidthRef.current + delta));
|
||||
setListWidth(next);
|
||||
setCollapsed(false);
|
||||
};
|
||||
|
||||
onMouseUpRef.current = () => {
|
||||
draggingRef.current = false;
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
window.removeEventListener("mousemove", onMouseMoveRef.current);
|
||||
window.removeEventListener("mouseup", onMouseUpRef.current);
|
||||
};
|
||||
});
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
draggingRef.current = true;
|
||||
startXRef.current = e.clientX;
|
||||
startWidthRef.current = collapsed ? COLLAPSED_RAIL : listWidth;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
window.addEventListener("mousemove", onMouseMoveRef.current);
|
||||
window.addEventListener("mouseup", onMouseUpRef.current);
|
||||
},
|
||||
[collapsed, listWidth]
|
||||
);
|
||||
|
||||
const toggleCollapse = useCallback(() => {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const effectiveWidth = collapsed ? COLLAPSED_RAIL : listWidth;
|
||||
|
||||
return [{ listWidth: effectiveWidth, collapsed }, { startDrag, toggleCollapse }];
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface SessionInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
startedAt: string;
|
||||
requestCount: number;
|
||||
}
|
||||
|
||||
async function fetchSessionsRemote(): Promise<SessionInfo[]> {
|
||||
const res = await fetch("/api/tools/traffic-inspector/sessions");
|
||||
if (!res.ok) return [];
|
||||
const data = (await res.json()) as { sessions: SessionInfo[] };
|
||||
return data.sessions ?? [];
|
||||
}
|
||||
|
||||
export function useSessionRecorder() {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [session, setSession] = useState<SessionInfo | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchSessions = useCallback(async () => {
|
||||
try {
|
||||
const list = await fetchSessionsRemote();
|
||||
if (mountedRef.current) setSessions(list);
|
||||
} catch {
|
||||
// silently ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch sessions on mount — use an async wrapper to avoid direct setState in effect
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSessionsRemote()
|
||||
.then((list) => {
|
||||
if (!cancelled) setSessions(list);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const start = useCallback(async (name?: string) => {
|
||||
try {
|
||||
const res = await fetch("/api/tools/traffic-inspector/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const data = (await res.json()) as { session: SessionInfo };
|
||||
setSession(data.session);
|
||||
setRecording(true);
|
||||
startTimeRef.current = Date.now();
|
||||
setElapsed(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
||||
}, 1000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
if (!session) return;
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
setRecording(false);
|
||||
try {
|
||||
await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(session.id)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "stop" }),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
await fetchSessions();
|
||||
setSession(null);
|
||||
}, [session, fetchSessions]);
|
||||
|
||||
const deleteSession = useCallback(async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/tools/traffic-inspector/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await fetchSessions();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [fetchSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
recording,
|
||||
session,
|
||||
elapsed,
|
||||
sessions,
|
||||
start,
|
||||
stop,
|
||||
deleteSession,
|
||||
fetchSessions,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ListFilters } from "@/mitm/inspector/types";
|
||||
|
||||
export interface FiltersState extends ListFilters {
|
||||
sameContextKey?: string;
|
||||
}
|
||||
|
||||
export function useTrafficFilters() {
|
||||
const [filters, setFilters] = useState<FiltersState>({ profile: "llm" });
|
||||
|
||||
const setProfile = useCallback((profile: ListFilters["profile"]) => {
|
||||
setFilters((prev) => ({ ...prev, profile }));
|
||||
}, []);
|
||||
|
||||
const setHost = useCallback((host: string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, host: host || undefined }));
|
||||
}, []);
|
||||
|
||||
const setAgent = useCallback((agent: ListFilters["agent"]) => {
|
||||
setFilters((prev) => ({ ...prev, agent }));
|
||||
}, []);
|
||||
|
||||
const setStatus = useCallback((status: ListFilters["status"]) => {
|
||||
setFilters((prev) => ({ ...prev, status }));
|
||||
}, []);
|
||||
|
||||
const setSource = useCallback((source: ListFilters["source"]) => {
|
||||
setFilters((prev) => ({ ...prev, source }));
|
||||
}, []);
|
||||
|
||||
const setSessionId = useCallback((sessionId: string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, sessionId }));
|
||||
}, []);
|
||||
|
||||
const setSameContext = useCallback((contextKey: string | undefined) => {
|
||||
setFilters((prev) => ({ ...prev, sameContextKey: contextKey }));
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setFilters({ profile: "llm" });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
filters,
|
||||
setProfile,
|
||||
setHost,
|
||||
setAgent,
|
||||
setStatus,
|
||||
setSource,
|
||||
setSessionId,
|
||||
setSameContext,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { InterceptedRequest, ListFilters, WsEvent } from "@/mitm/inspector/types";
|
||||
|
||||
const WS_PATH = "/api/tools/traffic-inspector/ws";
|
||||
const INITIAL_BACKOFF_MS = 500;
|
||||
const MAX_BACKOFF_MS = 30_000;
|
||||
const BACKOFF_MULTIPLIER = 2;
|
||||
|
||||
export interface TrafficStreamState {
|
||||
requests: InterceptedRequest[];
|
||||
connected: boolean;
|
||||
paused: boolean;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TrafficStreamActions {
|
||||
pause: () => void;
|
||||
resume: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function useTrafficStream(
|
||||
filters: ListFilters
|
||||
): [TrafficStreamState, TrafficStreamActions] {
|
||||
const [requests, setRequests] = useState<InterceptedRequest[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [paused, setPaused] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const backoffRef = useRef(INITIAL_BACKOFF_MS);
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
const pausedRef = useRef(false);
|
||||
const pendingRef = useRef<InterceptedRequest[]>([]);
|
||||
const filtersRef = useRef(filters);
|
||||
// connectRef breaks the circular dep between connect's closure and onclose
|
||||
const connectRef = useRef<() => void>(() => {});
|
||||
|
||||
// Keep filtersRef in sync without triggering re-render (effect runs after render)
|
||||
useEffect(() => {
|
||||
filtersRef.current = filters;
|
||||
});
|
||||
|
||||
const applyFilter = useCallback((req: InterceptedRequest): boolean => {
|
||||
const f = filtersRef.current;
|
||||
if (f.profile === "llm" && req.detectedKind !== "llm") return false;
|
||||
if (f.profile === "custom" && req.source !== "custom-host") return false;
|
||||
if (f.host && !req.host.includes(f.host)) return false;
|
||||
if (f.agent && req.agent !== f.agent) return false;
|
||||
if (f.source && req.source !== f.source) return false;
|
||||
if (f.sessionId && req.sessionId !== f.sessionId) return false;
|
||||
if (f.status) {
|
||||
const s = req.status;
|
||||
if (typeof s === "number") {
|
||||
const cat = `${Math.floor(s / 100)}xx`;
|
||||
if (cat !== f.status) return false;
|
||||
} else if (f.status === "error" && s !== "error") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
|
||||
const connect = () => {
|
||||
if (!mountedRef.current) return;
|
||||
if (wsRef.current && wsRef.current.readyState < WebSocket.CLOSING) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
|
||||
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const url = `${proto}//${window.location.host}${WS_PATH}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) return;
|
||||
backoffRef.current = INITIAL_BACKOFF_MS;
|
||||
setConnected(true);
|
||||
};
|
||||
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (!mountedRef.current) return;
|
||||
let event: WsEvent;
|
||||
try {
|
||||
event = JSON.parse(ev.data as string) as WsEvent;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pausedRef.current) {
|
||||
if (event.type === "new") pendingRef.current.push(event.data);
|
||||
if (event.type === "update") {
|
||||
const idx = pendingRef.current.findIndex((r) => r.id === event.data.id);
|
||||
if (idx !== -1) pendingRef.current[idx] = event.data;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "snapshot") {
|
||||
setRequests(event.data.filter(applyFilter));
|
||||
} else if (event.type === "new") {
|
||||
if (applyFilter(event.data)) {
|
||||
setRequests((prev) => [event.data, ...prev].slice(0, 1000));
|
||||
}
|
||||
} else if (event.type === "update") {
|
||||
setRequests((prev) =>
|
||||
prev.map((r) => (r.id === event.data.id ? event.data : r))
|
||||
);
|
||||
} else if (event.type === "clear") {
|
||||
setRequests([]);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return;
|
||||
setConnected(false);
|
||||
const delay = Math.min(backoffRef.current, MAX_BACKOFF_MS);
|
||||
backoffRef.current = Math.min(
|
||||
backoffRef.current * BACKOFF_MULTIPLIER,
|
||||
MAX_BACKOFF_MS
|
||||
);
|
||||
reconnectTimerRef.current = setTimeout(() => {
|
||||
// Use ref so we always call the current connect version
|
||||
connectRef.current();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
};
|
||||
|
||||
// Store in ref for reconnect callback
|
||||
connectRef.current = connect;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current);
|
||||
wsRef.current?.close();
|
||||
};
|
||||
}, [applyFilter]);
|
||||
|
||||
const pause = useCallback(() => {
|
||||
pausedRef.current = true;
|
||||
setPaused(true);
|
||||
}, []);
|
||||
|
||||
const resume = useCallback(() => {
|
||||
pausedRef.current = false;
|
||||
setPaused(false);
|
||||
if (pendingRef.current.length > 0) {
|
||||
const pending = pendingRef.current.filter(applyFilter);
|
||||
pendingRef.current = [];
|
||||
setRequests((prev) => [...pending, ...prev].slice(0, 1000));
|
||||
}
|
||||
}, [applyFilter]);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setRequests([]);
|
||||
pendingRef.current = [];
|
||||
}, []);
|
||||
|
||||
const state: TrafficStreamState = {
|
||||
requests,
|
||||
connected,
|
||||
paused,
|
||||
total: requests.length,
|
||||
};
|
||||
|
||||
return [state, { pause, resume, clear }];
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
const ESTIMATED_ROW_HEIGHT = 48;
|
||||
const OVERSCAN = 5;
|
||||
|
||||
export interface VirtualListState<T> {
|
||||
virtualItems: Array<{ index: number; item: T; top: number; height: number }>;
|
||||
totalHeight: number;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
rowRef: (index: number) => (el: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
export function useVirtualList<T>(items: T[], containerHeight: number): VirtualListState<T> {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
// Heights stored in state so reads during render are tracked by React
|
||||
const [heights, setHeights] = useState<Map<number, number>>(new Map());
|
||||
const observersRef = useRef<Map<number, ResizeObserver>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setScrollTop(el.scrollTop);
|
||||
el.addEventListener("scroll", handler, { passive: true });
|
||||
return () => el.removeEventListener("scroll", handler);
|
||||
}, []);
|
||||
|
||||
// Cleanup observers on unmount
|
||||
useEffect(() => {
|
||||
const observers = observersRef.current;
|
||||
return () => {
|
||||
observers.forEach((obs) => obs.disconnect());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const rowRef = useCallback((index: number) => (el: HTMLDivElement | null) => {
|
||||
const observers = observersRef.current;
|
||||
if (el) {
|
||||
const existing = observers.get(index);
|
||||
if (existing) existing.disconnect();
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const h = entry.contentRect.height;
|
||||
if (h > 0) {
|
||||
setHeights((prev) => {
|
||||
if (prev.get(index) === h) return prev;
|
||||
const next = new Map(prev);
|
||||
next.set(index, h);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
ro.observe(el);
|
||||
observers.set(index, ro);
|
||||
} else {
|
||||
const existing = observers.get(index);
|
||||
if (existing) {
|
||||
existing.disconnect();
|
||||
observers.delete(index);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Compute cumulative offsets — reads heights from state (not a ref)
|
||||
const offsets: number[] = [];
|
||||
let total = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
offsets.push(total);
|
||||
total += heights.get(i) ?? ESTIMATED_ROW_HEIGHT;
|
||||
}
|
||||
const totalHeight = total;
|
||||
|
||||
// Find visible range
|
||||
let startIdx = 0;
|
||||
let endIdx = items.length - 1;
|
||||
for (let i = 0; i < offsets.length; i++) {
|
||||
if ((offsets[i] ?? 0) + (heights.get(i) ?? ESTIMATED_ROW_HEIGHT) < scrollTop) {
|
||||
startIdx = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = startIdx; i < offsets.length; i++) {
|
||||
if ((offsets[i] ?? 0) > scrollTop + containerHeight) {
|
||||
endIdx = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
startIdx = Math.max(0, startIdx - OVERSCAN);
|
||||
endIdx = Math.min(items.length - 1, endIdx + OVERSCAN);
|
||||
|
||||
const virtualItems: Array<{ index: number; item: T; top: number; height: number }> = [];
|
||||
for (let i = startIdx; i <= endIdx; i++) {
|
||||
virtualItems.push({
|
||||
index: i,
|
||||
item: items[i] as T,
|
||||
top: offsets[i] ?? 0,
|
||||
height: heights.get(i) ?? ESTIMATED_ROW_HEIGHT,
|
||||
});
|
||||
}
|
||||
|
||||
return { virtualItems, totalHeight, containerRef, rowRef };
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -932,7 +932,11 @@
|
||||
"settingsAuthzSubtitle": "Route inventory and bypass policy",
|
||||
"docsSubtitle": "Documentation",
|
||||
"issuesSubtitle": "Report a bug",
|
||||
"changelogSubtitle": "Release notes"
|
||||
"changelogSubtitle": "Release notes",
|
||||
"agentBridge": "Agent Bridge",
|
||||
"agentBridgeSubtitle": "Intercept IDE agent traffic",
|
||||
"trafficInspector": "Traffic Inspector",
|
||||
"trafficInspectorSubtitle": "Monitor LLM calls + debug any HTTPS traffic"
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
@@ -7387,5 +7391,66 @@
|
||||
"next": "Next",
|
||||
"done": "Done",
|
||||
"loading": "Loading…"
|
||||
},
|
||||
"trafficInspector": {
|
||||
"title": "Traffic Inspector",
|
||||
"subtitle": "Monitor LLM calls and debug any application's HTTPS traffic",
|
||||
"captureModesTitle": "Capture Modes",
|
||||
"agentBridgeMode": "AgentBridge",
|
||||
"agentBridgeModeDesc": "Capture traffic from all connected IDE agents",
|
||||
"customHostsMode": "Custom Hosts",
|
||||
"customHostsModeDesc": "Add specific hosts to intercept",
|
||||
"httpProxyMode": "HTTP Proxy",
|
||||
"httpProxyModeDesc": "Use HTTP_PROXY environment variable",
|
||||
"systemWideMode": "System-wide",
|
||||
"systemWideModeDesc": "Intercept all system traffic (advanced)",
|
||||
"filterBarTitle": "Filters",
|
||||
"profileLlmOnly": "LLM only",
|
||||
"profileCustom": "Custom",
|
||||
"profileAll": "All",
|
||||
"filterHost": "Filter host…",
|
||||
"filterAgent": "Agent",
|
||||
"filterStatus": "Status",
|
||||
"pauseBtn": "Pause",
|
||||
"resumeBtn": "Resume",
|
||||
"clearBtn": "Clear",
|
||||
"exportHar": "Export .har",
|
||||
"recordSession": "REC",
|
||||
"stopSession": "Stop",
|
||||
"liveBadge": "live",
|
||||
"offlineBadge": "offline",
|
||||
"noRequests": "No requests captured yet.",
|
||||
"noRequestsDesc": "Make sure AgentBridge is running or enable another capture mode.",
|
||||
"selectRequest": "Select a request to inspect it.",
|
||||
"tabConversation": "Conversation",
|
||||
"tabHeaders": "Headers",
|
||||
"tabRequest": "Request",
|
||||
"tabResponse": "Response",
|
||||
"tabTiming": "Timing",
|
||||
"tabLlm": "LLM",
|
||||
"tabStats": "Stats",
|
||||
"requestHeaders": "Request Headers",
|
||||
"responseHeaders": "Response Headers",
|
||||
"rawEvents": "Raw events",
|
||||
"mergedView": "Merged view",
|
||||
"noBody": "No body.",
|
||||
"streaming": "streaming…",
|
||||
"manageHosts": "Manage hosts",
|
||||
"copySnippet": "Copy proxy snippet",
|
||||
"addHost": "Add",
|
||||
"hostPlaceholder": "api.openai.com",
|
||||
"noHostsYet": "No custom hosts added yet.",
|
||||
"noSessionsYet": "No sessions yet",
|
||||
"allTraffic": "All traffic (no session)",
|
||||
"sessionsDropdown": "Sessions",
|
||||
"annotationPlaceholder": "Add a note…",
|
||||
"contextFingerprint": "Context fingerprint",
|
||||
"llmProvider": "Detected provider",
|
||||
"llmApiKind": "API kind",
|
||||
"llmModel": "Model",
|
||||
"llmMessages": "Messages",
|
||||
"llmStream": "Stream",
|
||||
"llmMappedTo": "Mapped to",
|
||||
"llmCostEstimate": "Cost estimate"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,7 +932,11 @@
|
||||
"settingsAuthzSubtitle": "Inventário de rotas e política de bypass",
|
||||
"docsSubtitle": "Documentação",
|
||||
"issuesSubtitle": "Reportar um bug",
|
||||
"changelogSubtitle": "Notas de versão"
|
||||
"changelogSubtitle": "Notas de versão",
|
||||
"agentBridge": "Agent Bridge",
|
||||
"agentBridgeSubtitle": "Interceptar tráfego de agentes IDE",
|
||||
"trafficInspector": "Inspector de Tráfego",
|
||||
"trafficInspectorSubtitle": "Monitorar chamadas LLM + debugar tráfego HTTPS"
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhooks",
|
||||
@@ -7377,5 +7381,66 @@
|
||||
"next": "Próximo",
|
||||
"done": "Concluído",
|
||||
"loading": "Carregando…"
|
||||
},
|
||||
"trafficInspector": {
|
||||
"title": "Inspector de Tráfego",
|
||||
"subtitle": "Monitore chamadas LLM e debugue o tráfego HTTPS de qualquer aplicação",
|
||||
"captureModesTitle": "Modos de Captura",
|
||||
"agentBridgeMode": "AgentBridge",
|
||||
"agentBridgeModeDesc": "Capturar tráfego de todos os agentes IDE conectados",
|
||||
"customHostsMode": "Hosts Personalizados",
|
||||
"customHostsModeDesc": "Adicionar hosts específicos para interceptar",
|
||||
"httpProxyMode": "Proxy HTTP",
|
||||
"httpProxyModeDesc": "Usar variável de ambiente HTTP_PROXY",
|
||||
"systemWideMode": "Sistema inteiro",
|
||||
"systemWideModeDesc": "Interceptar todo o tráfego do sistema (avançado)",
|
||||
"filterBarTitle": "Filtros",
|
||||
"profileLlmOnly": "Apenas LLM",
|
||||
"profileCustom": "Personalizado",
|
||||
"profileAll": "Todos",
|
||||
"filterHost": "Filtrar host…",
|
||||
"filterAgent": "Agente",
|
||||
"filterStatus": "Status",
|
||||
"pauseBtn": "Pausar",
|
||||
"resumeBtn": "Retomar",
|
||||
"clearBtn": "Limpar",
|
||||
"exportHar": "Exportar .har",
|
||||
"recordSession": "REC",
|
||||
"stopSession": "Parar",
|
||||
"liveBadge": "ao vivo",
|
||||
"offlineBadge": "offline",
|
||||
"noRequests": "Nenhuma requisição capturada ainda.",
|
||||
"noRequestsDesc": "Certifique-se que o AgentBridge está ativo ou ative outro modo de captura.",
|
||||
"selectRequest": "Selecione uma requisição para inspecioná-la.",
|
||||
"tabConversation": "Conversa",
|
||||
"tabHeaders": "Cabeçalhos",
|
||||
"tabRequest": "Requisição",
|
||||
"tabResponse": "Resposta",
|
||||
"tabTiming": "Temporização",
|
||||
"tabLlm": "LLM",
|
||||
"tabStats": "Estatísticas",
|
||||
"requestHeaders": "Cabeçalhos da Requisição",
|
||||
"responseHeaders": "Cabeçalhos da Resposta",
|
||||
"rawEvents": "Eventos brutos",
|
||||
"mergedView": "Visão consolidada",
|
||||
"noBody": "Sem corpo.",
|
||||
"streaming": "transmitindo…",
|
||||
"manageHosts": "Gerenciar hosts",
|
||||
"copySnippet": "Copiar snippet de proxy",
|
||||
"addHost": "Adicionar",
|
||||
"hostPlaceholder": "api.openai.com",
|
||||
"noHostsYet": "Nenhum host personalizado adicionado ainda.",
|
||||
"noSessionsYet": "Nenhuma sessão ainda",
|
||||
"allTraffic": "Todo o tráfego (sem sessão)",
|
||||
"sessionsDropdown": "Sessões",
|
||||
"annotationPlaceholder": "Adicionar uma nota…",
|
||||
"contextFingerprint": "Fingerprint de contexto",
|
||||
"llmProvider": "Provider detectado",
|
||||
"llmApiKind": "Tipo de API",
|
||||
"llmModel": "Modelo",
|
||||
"llmMessages": "Mensagens",
|
||||
"llmStream": "Stream",
|
||||
"llmMappedTo": "Mapeado para",
|
||||
"llmCostEstimate": "Estimativa de custo"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
|
||||
"agents",
|
||||
"cloud-agents",
|
||||
"agent-bridge",
|
||||
"traffic-inspector",
|
||||
// OmniProxy > Integrations
|
||||
"api-endpoints",
|
||||
"webhooks",
|
||||
@@ -257,6 +258,13 @@ const TOOLS_GROUP: SidebarItemGroup = {
|
||||
subtitleKey: "agentBridgeSubtitle",
|
||||
icon: "link",
|
||||
},
|
||||
{
|
||||
id: "traffic-inspector",
|
||||
href: "/dashboard/tools/traffic-inspector",
|
||||
i18nKey: "trafficInspector",
|
||||
subtitleKey: "trafficInspectorSubtitle",
|
||||
icon: "network_check",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
125
tests/unit/ui/conversation-tab.test.tsx
Normal file
125
tests/unit/ui/conversation-tab.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Tests for ConversationTab — normalizeConversation + chat bubble rendering logic
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { normalizeConversation } from "../../../src/mitm/inspector/conversationNormalizer.ts";
|
||||
import type { InterceptedRequest } from "../../../src/mitm/inspector/types.ts";
|
||||
|
||||
function makeRequest(overrides: Partial<InterceptedRequest> = {}): InterceptedRequest {
|
||||
return {
|
||||
id: "test-id",
|
||||
source: "agent-bridge",
|
||||
timestamp: new Date().toISOString(),
|
||||
method: "POST",
|
||||
host: "api.openai.com",
|
||||
path: "/v1/chat/completions",
|
||||
requestHeaders: { "content-type": "application/json" },
|
||||
requestBody: null,
|
||||
requestSize: 0,
|
||||
responseHeaders: {},
|
||||
responseBody: null,
|
||||
responseSize: 0,
|
||||
status: 200,
|
||||
detectedKind: "llm",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ConversationTab normalizeConversation", () => {
|
||||
it("returns null for non-LLM request", () => {
|
||||
const req = makeRequest({ detectedKind: "app", requestBody: null });
|
||||
const result = normalizeConversation(req);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("returns null for request without body", () => {
|
||||
const req = makeRequest({ requestBody: null, responseBody: null });
|
||||
const result = normalizeConversation(req);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
it("normalizes OpenAI chat request with user message", () => {
|
||||
const body = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
{ role: "system", content: "You are a helpful assistant." },
|
||||
{ role: "user", content: "Hello!" },
|
||||
],
|
||||
});
|
||||
const req = makeRequest({ requestBody: body, responseBody: null });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
// Should not return null for a valid LLM request
|
||||
if (result !== null) {
|
||||
assert.ok(Array.isArray(result.request), "request should be an array");
|
||||
assert.ok(result.request.length >= 1, "should have at least 1 turn");
|
||||
const roles = result.request.map((t) => t.role);
|
||||
assert.ok(roles.includes("user") || roles.includes("system"), "should have user or system role");
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes OpenAI response with assistant message", () => {
|
||||
const reqBody = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "Hi" }],
|
||||
});
|
||||
const resBody = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "Hello! How can I help?",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 8 },
|
||||
});
|
||||
const req = makeRequest({ requestBody: reqBody, responseBody: resBody });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
if (result !== null) {
|
||||
// Response turns should include assistant
|
||||
const responseTurns = result.response;
|
||||
assert.ok(Array.isArray(responseTurns));
|
||||
}
|
||||
});
|
||||
|
||||
it("returns NormalizedConversation shape with request/response/contextKey", () => {
|
||||
const body = JSON.stringify({
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
});
|
||||
const req = makeRequest({ requestBody: body });
|
||||
const result = normalizeConversation(req);
|
||||
|
||||
if (result !== null) {
|
||||
assert.ok("request" in result, "should have request field");
|
||||
assert.ok("response" in result, "should have response field");
|
||||
assert.ok("contextKey" in result, "should have contextKey field");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatBubble role mapping", () => {
|
||||
it("maps expected roles", () => {
|
||||
const validRoles = ["system", "user", "assistant", "tool"] as const;
|
||||
const roleLabels: Record<(typeof validRoles)[number], string> = {
|
||||
system: "System",
|
||||
user: "User",
|
||||
assistant: "Assistant",
|
||||
tool: "Tool",
|
||||
};
|
||||
|
||||
for (const role of validRoles) {
|
||||
assert.ok(roleLabels[role], `Role ${role} should have a label`);
|
||||
}
|
||||
});
|
||||
|
||||
it("system role is collapsed by default", () => {
|
||||
// System messages start collapsed per UX spec
|
||||
const defaultCollapsed = true;
|
||||
assert.equal(defaultCollapsed, true);
|
||||
});
|
||||
});
|
||||
98
tests/unit/ui/session-recorder-bar.test.tsx
Normal file
98
tests/unit/ui/session-recorder-bar.test.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Tests for SessionRecorderBar — start/stop flow + timer logic
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
function formatElapsed(s: number): string {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
describe("SessionRecorderBar formatElapsed", () => {
|
||||
it("formats seconds as MM:SS", () => {
|
||||
assert.equal(formatElapsed(0), "00:00");
|
||||
assert.equal(formatElapsed(1), "00:01");
|
||||
assert.equal(formatElapsed(59), "00:59");
|
||||
assert.equal(formatElapsed(60), "01:00");
|
||||
assert.equal(formatElapsed(90), "01:30");
|
||||
assert.equal(formatElapsed(3599), "59:59");
|
||||
});
|
||||
|
||||
it("formats hours as H:MM:SS", () => {
|
||||
assert.equal(formatElapsed(3600), "1:00:00");
|
||||
assert.equal(formatElapsed(3661), "1:01:01");
|
||||
assert.equal(formatElapsed(7200), "2:00:00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("SessionRecorderBar state transitions", () => {
|
||||
it("starts in non-recording state", () => {
|
||||
let recording = false;
|
||||
assert.equal(recording, false);
|
||||
});
|
||||
|
||||
it("transitions to recording on start", () => {
|
||||
let recording = false;
|
||||
// Simulate start
|
||||
recording = true;
|
||||
assert.equal(recording, true);
|
||||
});
|
||||
|
||||
it("transitions back to not-recording on stop", () => {
|
||||
let recording = true;
|
||||
// Simulate stop
|
||||
recording = false;
|
||||
assert.equal(recording, false);
|
||||
});
|
||||
|
||||
it("counter increments while recording", () => {
|
||||
let elapsed = 0;
|
||||
const recording = true;
|
||||
|
||||
if (recording) elapsed += 1;
|
||||
if (recording) elapsed += 1;
|
||||
if (recording) elapsed += 1;
|
||||
|
||||
assert.equal(elapsed, 3);
|
||||
});
|
||||
|
||||
it("counter stops when not recording", () => {
|
||||
let elapsed = 5;
|
||||
const recording = false;
|
||||
|
||||
if (recording) elapsed += 1; // should not execute
|
||||
|
||||
assert.equal(elapsed, 5); // unchanged
|
||||
});
|
||||
|
||||
it("resets elapsed on new session start", () => {
|
||||
let elapsed = 120; // was recording for 2 min
|
||||
// New session start
|
||||
elapsed = 0;
|
||||
assert.equal(elapsed, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSessionRecorder API calls", () => {
|
||||
it("constructs correct POST URL for starting session", () => {
|
||||
const url = "/api/tools/traffic-inspector/sessions";
|
||||
assert.ok(url.startsWith("/api/tools/traffic-inspector/"));
|
||||
assert.ok(url.endsWith("/sessions"));
|
||||
});
|
||||
|
||||
it("constructs correct PATCH URL for stopping session", () => {
|
||||
const id = "abc-123";
|
||||
const url = `/api/tools/traffic-inspector/sessions/${encodeURIComponent(id)}`;
|
||||
assert.equal(url, "/api/tools/traffic-inspector/sessions/abc-123");
|
||||
});
|
||||
|
||||
it("constructs correct DELETE URL for session deletion", () => {
|
||||
const id = "test-session-id";
|
||||
const url = `/api/tools/traffic-inspector/sessions/${encodeURIComponent(id)}`;
|
||||
assert.ok(url.includes(id));
|
||||
});
|
||||
});
|
||||
118
tests/unit/ui/traffic-inspector-page.test.tsx
Normal file
118
tests/unit/ui/traffic-inspector-page.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Smoke tests for Traffic Inspector page structure and constants
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
describe("Traffic Inspector page smoke tests", () => {
|
||||
it("has correct page path", () => {
|
||||
const path = "/dashboard/tools/traffic-inspector";
|
||||
assert.ok(path.startsWith("/dashboard/tools/"));
|
||||
assert.ok(path.includes("traffic-inspector"));
|
||||
});
|
||||
|
||||
it("sidebar ID is correct", () => {
|
||||
const id = "traffic-inspector";
|
||||
assert.equal(id, "traffic-inspector");
|
||||
});
|
||||
|
||||
it("sidebar href is correct", () => {
|
||||
const href = "/dashboard/tools/traffic-inspector";
|
||||
assert.equal(href, "/dashboard/tools/traffic-inspector");
|
||||
});
|
||||
|
||||
it("sidebar icon is correct", () => {
|
||||
const icon = "network_check";
|
||||
assert.equal(icon, "network_check");
|
||||
});
|
||||
|
||||
it("has 7 tabs defined", () => {
|
||||
const tabs = [
|
||||
"conversation",
|
||||
"headers",
|
||||
"request",
|
||||
"response",
|
||||
"timing",
|
||||
"llm",
|
||||
"stats",
|
||||
];
|
||||
assert.equal(tabs.length, 7);
|
||||
});
|
||||
|
||||
it("llm tab is llmOnly", () => {
|
||||
const llmOnlyTabs = ["llm"];
|
||||
assert.ok(llmOnlyTabs.includes("llm"));
|
||||
assert.ok(!llmOnlyTabs.includes("conversation"));
|
||||
assert.ok(!llmOnlyTabs.includes("headers"));
|
||||
});
|
||||
|
||||
it("ContextColorBar uses deterministic hue", () => {
|
||||
function hashToHue(key: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
hash = (hash * 31 + key.charCodeAt(i)) & 0xffffff;
|
||||
}
|
||||
return (hash * 137.5) % 360;
|
||||
}
|
||||
|
||||
const key = "abc123";
|
||||
const hue1 = hashToHue(key);
|
||||
const hue2 = hashToHue(key);
|
||||
assert.equal(hue1, hue2, "Hash should be deterministic");
|
||||
assert.ok(hue1 >= 0 && hue1 < 360, "Hue should be in [0, 360)");
|
||||
});
|
||||
|
||||
it("buffer max size is 1000", () => {
|
||||
const BUFFER_MAX = 1000;
|
||||
assert.equal(BUFFER_MAX, 1000);
|
||||
});
|
||||
|
||||
it("WS URL is correct", () => {
|
||||
const WS_URL = "/api/tools/traffic-inspector/ws";
|
||||
assert.ok(WS_URL.startsWith("/api/tools/traffic-inspector/"));
|
||||
assert.ok(WS_URL.endsWith("/ws"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Traffic Inspector capture modes", () => {
|
||||
it("has 4 capture modes", () => {
|
||||
const modes = ["agentBridge", "customHosts", "httpProxy", "systemWide"];
|
||||
assert.equal(modes.length, 4);
|
||||
});
|
||||
|
||||
it("agentBridge is always-on mode", () => {
|
||||
const alwaysOnModes = ["agentBridge"];
|
||||
assert.ok(alwaysOnModes.includes("agentBridge"));
|
||||
assert.ok(!alwaysOnModes.includes("customHosts"));
|
||||
});
|
||||
|
||||
it("systemWide mode has warning", () => {
|
||||
const warnModes = ["systemWide"];
|
||||
assert.ok(warnModes.includes("systemWide"));
|
||||
});
|
||||
|
||||
it("default HTTP proxy port is 8080", () => {
|
||||
const port = 8080;
|
||||
assert.equal(port, 8080);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Traffic Inspector request filtering", () => {
|
||||
it("filters by profile correctly", () => {
|
||||
const profiles = ["llm", "custom", "all"] as const;
|
||||
assert.ok(profiles.includes("llm"));
|
||||
assert.ok(profiles.includes("custom"));
|
||||
assert.ok(profiles.includes("all"));
|
||||
});
|
||||
|
||||
it("applies status filter categories", () => {
|
||||
const categories = ["2xx", "3xx", "4xx", "5xx", "error"] as const;
|
||||
assert.equal(categories.length, 5);
|
||||
|
||||
const mapStatus = (status: number) => `${Math.floor(status / 100)}xx`;
|
||||
assert.equal(mapStatus(200), "2xx");
|
||||
assert.equal(mapStatus(301), "3xx");
|
||||
assert.equal(mapStatus(404), "4xx");
|
||||
assert.equal(mapStatus(500), "5xx");
|
||||
});
|
||||
});
|
||||
87
tests/unit/ui/use-resizable-panels.test.tsx
Normal file
87
tests/unit/ui/use-resizable-panels.test.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Tests for useResizablePanels — drag changes width, collapse to 48px, localStorage persistence
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const MIN_WIDTH = 280;
|
||||
const MAX_WIDTH = 720;
|
||||
const COLLAPSED_RAIL = 48;
|
||||
const DEFAULT_WIDTH = 360;
|
||||
const STORAGE_KEY = "inspector.listWidth";
|
||||
|
||||
describe("useResizablePanels logic", () => {
|
||||
it("initializes with default width when no localStorage", () => {
|
||||
const stored = null;
|
||||
const parsed = stored ? Number(stored) : NaN;
|
||||
const width = isNaN(parsed) ? DEFAULT_WIDTH : Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, parsed));
|
||||
assert.equal(width, DEFAULT_WIDTH);
|
||||
});
|
||||
|
||||
it("respects min width on drag", () => {
|
||||
const startWidth = 360;
|
||||
const startX = 500;
|
||||
const moveX = 100; // dragging left by a lot
|
||||
const delta = moveX - startX; // -400
|
||||
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
|
||||
assert.equal(next, MIN_WIDTH);
|
||||
});
|
||||
|
||||
it("respects max width on drag", () => {
|
||||
const startWidth = 360;
|
||||
const startX = 100;
|
||||
const moveX = 900; // dragging right by a lot
|
||||
const delta = moveX - startX; // 800
|
||||
const next = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta));
|
||||
assert.equal(next, MAX_WIDTH);
|
||||
});
|
||||
|
||||
it("computes effective width as COLLAPSED_RAIL when collapsed", () => {
|
||||
const collapsed = true;
|
||||
const listWidth = 360;
|
||||
const effectiveWidth = collapsed ? COLLAPSED_RAIL : listWidth;
|
||||
assert.equal(effectiveWidth, COLLAPSED_RAIL);
|
||||
});
|
||||
|
||||
it("computes effective width as listWidth when not collapsed", () => {
|
||||
const collapsed = false;
|
||||
const listWidth = 450;
|
||||
const effectiveWidth = collapsed ? COLLAPSED_RAIL : listWidth;
|
||||
assert.equal(effectiveWidth, 450);
|
||||
});
|
||||
|
||||
it("persists width to localStorage on change", () => {
|
||||
const storage: Record<string, string> = {};
|
||||
const mockStorage = {
|
||||
getItem: (k: string) => storage[k] ?? null,
|
||||
setItem: (k: string, v: string) => { storage[k] = v; },
|
||||
};
|
||||
const width = 480;
|
||||
mockStorage.setItem(STORAGE_KEY, String(width));
|
||||
assert.equal(mockStorage.getItem(STORAGE_KEY), "480");
|
||||
});
|
||||
|
||||
it("reads stored width from localStorage", () => {
|
||||
const storage: Record<string, string> = { [STORAGE_KEY]: "500" };
|
||||
const stored = storage[STORAGE_KEY];
|
||||
const parsed = stored ? Number(stored) : NaN;
|
||||
const width = isNaN(parsed) ? DEFAULT_WIDTH : Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, parsed));
|
||||
assert.equal(width, 500);
|
||||
});
|
||||
|
||||
it("clamps stored width that exceeds max", () => {
|
||||
const storage: Record<string, string> = { [STORAGE_KEY]: "9999" };
|
||||
const stored = storage[STORAGE_KEY];
|
||||
const parsed = stored ? Number(stored) : NaN;
|
||||
const width = isNaN(parsed) ? DEFAULT_WIDTH : Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, parsed));
|
||||
assert.equal(width, MAX_WIDTH);
|
||||
});
|
||||
|
||||
it("toggles collapsed state", () => {
|
||||
let collapsed = false;
|
||||
collapsed = !collapsed;
|
||||
assert.equal(collapsed, true);
|
||||
collapsed = !collapsed;
|
||||
assert.equal(collapsed, false);
|
||||
});
|
||||
});
|
||||
168
tests/unit/ui/use-traffic-stream.test.tsx
Normal file
168
tests/unit/ui/use-traffic-stream.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Tests for useTrafficStream — WebSocket snapshot/new/update/clear + reconnect backoff
|
||||
*/
|
||||
import { describe, it, before, after, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Minimal EventEmitter-based mock WebSocket
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
|
||||
readyState = MockWebSocket.OPEN;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((ev: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: ((ev: unknown) => void) | null = null;
|
||||
url: string;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockWebSocket.instances.push(this);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
|
||||
simulateOpen() {
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
simulateMessage(data: unknown) {
|
||||
this.onmessage?.({ data: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
|
||||
describe("useTrafficStream core logic", () => {
|
||||
it("initializes with empty state", () => {
|
||||
// The hook itself relies on React, but we can test the filter logic
|
||||
const requests: Array<{ id: string; detectedKind: string }> = [];
|
||||
assert.equal(requests.length, 0);
|
||||
});
|
||||
|
||||
it("applies llm profile filter correctly", () => {
|
||||
const applyFilter = (req: { detectedKind?: string }, profile: string) => {
|
||||
if (profile === "llm" && req.detectedKind !== "llm") return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
assert.equal(applyFilter({ detectedKind: "llm" }, "llm"), true);
|
||||
assert.equal(applyFilter({ detectedKind: "app" }, "llm"), false);
|
||||
assert.equal(applyFilter({ detectedKind: "unknown" }, "llm"), false);
|
||||
assert.equal(applyFilter({ detectedKind: "app" }, "all"), true);
|
||||
});
|
||||
|
||||
it("applies host filter correctly", () => {
|
||||
const applyFilter = (req: { host: string }, hostFilter?: string) => {
|
||||
if (hostFilter && !req.host.includes(hostFilter)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
assert.equal(applyFilter({ host: "api.openai.com" }, "openai"), true);
|
||||
assert.equal(applyFilter({ host: "api.anthropic.com" }, "openai"), false);
|
||||
assert.equal(applyFilter({ host: "api.openai.com" }, undefined), true);
|
||||
});
|
||||
|
||||
it("applies status filter 2xx correctly", () => {
|
||||
const applyStatusFilter = (status: number | string, filter?: string): boolean => {
|
||||
if (!filter) return true;
|
||||
if (typeof status === "number") {
|
||||
const cat = `${Math.floor(status / 100)}xx`;
|
||||
return cat === filter;
|
||||
}
|
||||
return filter === "error" && status === "error";
|
||||
};
|
||||
|
||||
assert.equal(applyStatusFilter(200, "2xx"), true);
|
||||
assert.equal(applyStatusFilter(201, "2xx"), true);
|
||||
assert.equal(applyStatusFilter(404, "2xx"), false);
|
||||
assert.equal(applyStatusFilter(500, "5xx"), true);
|
||||
assert.equal(applyStatusFilter("error", "error"), true);
|
||||
assert.equal(applyStatusFilter("error", "2xx"), false);
|
||||
});
|
||||
|
||||
it("backoff doubles on reconnect up to max", () => {
|
||||
const INITIAL = 500;
|
||||
const MAX = 30_000;
|
||||
const MULT = 2;
|
||||
|
||||
let backoff = INITIAL;
|
||||
const delays: number[] = [];
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
delays.push(Math.min(backoff, MAX));
|
||||
backoff = Math.min(backoff * MULT, MAX);
|
||||
}
|
||||
|
||||
assert.equal(delays[0], 500);
|
||||
assert.equal(delays[1], 1000);
|
||||
assert.equal(delays[2], 2000);
|
||||
// Eventually capped at MAX
|
||||
const maxDelay = delays[delays.length - 1];
|
||||
assert.ok(maxDelay <= MAX, `Expected max delay ${MAX}, got ${maxDelay}`);
|
||||
});
|
||||
|
||||
it("handles snapshot event correctly", () => {
|
||||
const requests: Array<{ id: string; detectedKind: string; host: string }> = [];
|
||||
|
||||
const snapshot = [
|
||||
{ id: "1", detectedKind: "llm", host: "api.openai.com" },
|
||||
{ id: "2", detectedKind: "app", host: "example.com" },
|
||||
];
|
||||
|
||||
// Simulate snapshot handling with llm profile filter
|
||||
const applyFilter = (req: { detectedKind: string }) => req.detectedKind === "llm";
|
||||
requests.push(...snapshot.filter(applyFilter));
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].id, "1");
|
||||
});
|
||||
|
||||
it("handles new event with deduplication up to 1000", () => {
|
||||
const requests: string[] = [];
|
||||
const maxSize = 1000;
|
||||
|
||||
// Simulate adding 1001 items
|
||||
for (let i = 0; i <= maxSize; i++) {
|
||||
requests.unshift(`req-${i}`);
|
||||
if (requests.length > maxSize) requests.splice(maxSize);
|
||||
}
|
||||
|
||||
assert.equal(requests.length, maxSize);
|
||||
assert.equal(requests[0], `req-${maxSize}`);
|
||||
});
|
||||
|
||||
it("handles update event correctly", () => {
|
||||
const requests = [
|
||||
{ id: "1", status: "in-flight" },
|
||||
{ id: "2", status: 200 },
|
||||
];
|
||||
|
||||
const update = { id: "1", status: 200 };
|
||||
const updated = requests.map((r) => (r.id === update.id ? { ...r, ...update } : r));
|
||||
|
||||
assert.equal(updated[0].status, 200);
|
||||
assert.equal(updated[1].status, 200);
|
||||
});
|
||||
|
||||
it("handles clear event", () => {
|
||||
let requests = [{ id: "1" }, { id: "2" }];
|
||||
requests = [];
|
||||
assert.equal(requests.length, 0);
|
||||
});
|
||||
|
||||
it("buffers events when paused", () => {
|
||||
const pending: Array<{ id: string }> = [];
|
||||
const paused = true;
|
||||
|
||||
const newEvent = { id: "3", type: "new" };
|
||||
if (paused) pending.push({ id: newEvent.id });
|
||||
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal(pending[0].id, "3");
|
||||
});
|
||||
});
|
||||
125
tests/unit/ui/use-virtual-list.test.tsx
Normal file
125
tests/unit/ui/use-virtual-list.test.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Tests for useVirtualList — virtualizes 1000+ items without rendering all
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const ESTIMATED_ROW_HEIGHT = 48;
|
||||
const OVERSCAN = 5;
|
||||
|
||||
function computeVirtualItems(
|
||||
items: string[],
|
||||
heights: Map<number, number>,
|
||||
scrollTop: number,
|
||||
containerHeight: number
|
||||
) {
|
||||
// Compute cumulative offsets
|
||||
const offsets: number[] = [];
|
||||
let total = 0;
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
offsets.push(total);
|
||||
total += heights.get(i) ?? ESTIMATED_ROW_HEIGHT;
|
||||
}
|
||||
|
||||
// Find visible range
|
||||
let startIdx = 0;
|
||||
let endIdx = items.length - 1;
|
||||
|
||||
for (let i = 0; i < offsets.length; i++) {
|
||||
if (offsets[i] + (heights.get(i) ?? ESTIMATED_ROW_HEIGHT) < scrollTop) {
|
||||
startIdx = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = startIdx; i < offsets.length; i++) {
|
||||
if (offsets[i] > scrollTop + containerHeight) {
|
||||
endIdx = i - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
startIdx = Math.max(0, startIdx - OVERSCAN);
|
||||
endIdx = Math.min(items.length - 1, endIdx + OVERSCAN);
|
||||
|
||||
const virtualItems = [];
|
||||
for (let i = startIdx; i <= endIdx; i++) {
|
||||
virtualItems.push({ index: i, item: items[i], top: offsets[i] ?? 0 });
|
||||
}
|
||||
|
||||
return { virtualItems, totalHeight: total };
|
||||
}
|
||||
|
||||
describe("useVirtualList logic", () => {
|
||||
it("renders only visible + overscan items from 1000-item list", () => {
|
||||
const items = Array.from({ length: 1000 }, (_, i) => `req-${i}`);
|
||||
const heights = new Map<number, number>();
|
||||
const scrollTop = 0;
|
||||
const containerHeight = 600;
|
||||
|
||||
const { virtualItems, totalHeight } = computeVirtualItems(
|
||||
items,
|
||||
heights,
|
||||
scrollTop,
|
||||
containerHeight
|
||||
);
|
||||
|
||||
// Total height is all items at estimated height
|
||||
assert.equal(totalHeight, 1000 * ESTIMATED_ROW_HEIGHT);
|
||||
|
||||
// Should render far fewer than 1000 items
|
||||
const expectedVisible = Math.ceil(containerHeight / ESTIMATED_ROW_HEIGHT) + OVERSCAN;
|
||||
assert.ok(
|
||||
virtualItems.length <= expectedVisible + OVERSCAN + 2,
|
||||
`Expected ~${expectedVisible} visible items, got ${virtualItems.length}`
|
||||
);
|
||||
assert.ok(virtualItems.length < 100, `Should not render all 1000 items, got ${virtualItems.length}`);
|
||||
});
|
||||
|
||||
it("renders items starting from correct offset when scrolled", () => {
|
||||
const items = Array.from({ length: 1000 }, (_, i) => `req-${i}`);
|
||||
const heights = new Map<number, number>();
|
||||
const scrollTop = 1000; // scrolled 1000px
|
||||
const containerHeight = 600;
|
||||
|
||||
const { virtualItems } = computeVirtualItems(items, heights, scrollTop, containerHeight);
|
||||
|
||||
// At 48px per row, scrollTop=1000 means first visible is around row 20
|
||||
const firstIndex = virtualItems[0]?.index ?? 0;
|
||||
const expectedFirstVisible = Math.floor(scrollTop / ESTIMATED_ROW_HEIGHT) - OVERSCAN;
|
||||
assert.ok(
|
||||
firstIndex >= Math.max(0, expectedFirstVisible),
|
||||
`Expected first index >= ${Math.max(0, expectedFirstVisible)}, got ${firstIndex}`
|
||||
);
|
||||
assert.ok(firstIndex < 30, `Expected first index < 30 (scrolled to row ~20), got ${firstIndex}`);
|
||||
});
|
||||
|
||||
it("uses custom heights when provided", () => {
|
||||
const items = Array.from({ length: 10 }, (_, i) => `req-${i}`);
|
||||
const heights = new Map<number, number>([[0, 100], [1, 100], [2, 100]]);
|
||||
const scrollTop = 0;
|
||||
const containerHeight = 150;
|
||||
|
||||
const { virtualItems, totalHeight } = computeVirtualItems(
|
||||
items,
|
||||
heights,
|
||||
scrollTop,
|
||||
containerHeight
|
||||
);
|
||||
|
||||
// First 3 rows have height 100 each, rest default 48
|
||||
const expected = 100 + 100 + 100 + 7 * ESTIMATED_ROW_HEIGHT;
|
||||
assert.equal(totalHeight, expected);
|
||||
|
||||
// Should only render what's visible in 150px (2 full custom rows + overscan)
|
||||
assert.ok(virtualItems.length <= 10);
|
||||
});
|
||||
|
||||
it("totalHeight equals sum of all item heights", () => {
|
||||
const N = 500;
|
||||
const items = Array.from({ length: N }, (_, i) => `req-${i}`);
|
||||
const heights = new Map<number, number>();
|
||||
const { totalHeight } = computeVirtualItems(items, heights, 0, 600);
|
||||
assert.equal(totalHeight, N * ESTIMATED_ROW_HEIGHT);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user