mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(ui): traffic-inspector page + capture toolbar + streaming list (F8)
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,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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user