"use client"; import * as React from "react"; import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, ListChecksIcon, Loader2Icon, RadioTowerIcon, SearchIcon, Trash2Icon, WrapTextIcon, } from "lucide-react"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { api } from "@/lib/api"; import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types"; import { cn } from "@/lib/utils"; function fmtTime(ts: string) { return new Date(ts).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", }); } function fmtBytes(n: number) { if (n <= 0) return "0 B"; const units = ["B", "KB", "MB"]; const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); const v = n / 1024 ** i; return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`; } function statusTone(status: number) { if (status >= 500) return "text-red-500"; if (status >= 400) return "text-amber-500"; if (status >= 300) return "text-blue-500"; if (status >= 200) return "text-emerald-500"; return "text-muted-foreground"; } function MethodBadge({ method }: { method: string }) { return {method}; } // Older captures may predate Host persistence because net/http keeps Host // outside Request.Header. Fill it for display while newly recorded traffic is // fixed at the recorder layer as well. function requestWithHost(raw: string, exchange: TrafficExchange): string { if (!raw.trim() || /^host\s*:/im.test(raw)) return raw; let host = exchange.host; try { host = new URL(exchange.url).host || host; } catch { // Relative or legacy URLs fall back to the indexed host. } const newline = raw.includes("\r\n") ? "\r\n" : "\n"; const firstLineEnd = raw.indexOf(newline); if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`; return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`; } function StartLine({ line }: { line: string }) { const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line); if (response) { return ( <> {response[1]} {response[2]} {response[3]} {response[4]} ); } const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line); if (request) { return ( <> {request[1]} {request[2]} {request[3]} {request[4]} {request[5]} ); } return line; } function HeaderLine({ line }: { line: string }) { const separator = line.indexOf(":"); if (separator <= 0) return line; return ( <> {line.slice(0, separator)} : {line.slice(separator + 1)} ); } function JsonBody({ body }: { body: string }) { const parts: React.ReactNode[] = []; const tokens = /("(?:\\.|[^"\\])*")(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g; let cursor = 0; for (const match of body.matchAll(tokens)) { const index = match.index ?? 0; if (index > cursor) parts.push(body.slice(cursor, index)); let className = "text-chart-4"; if (match[1]) className = match[2] ? "text-primary" : "text-chart-2"; else if (match[3]) className = "text-chart-3"; parts.push( {match[0]} , ); cursor = index + match[0].length; } if (cursor < body.length) parts.push(body.slice(cursor)); return parts; } function MarkupBody({ body }: { body: string }) { const parts: React.ReactNode[] = []; const tags = /<\/?[A-Za-z][^>]*>|/g; let cursor = 0; for (const match of body.matchAll(tags)) { const index = match.index ?? 0; if (index > cursor) parts.push(body.slice(cursor, index)); parts.push( {match[0]} , ); cursor = index + match[0].length; } if (cursor < body.length) parts.push(body.slice(cursor)); return parts; } type BodyFormat = "json" | "markup" | "plain"; function detectBodyFormat(body: string): BodyFormat { const trimmed = body.trimStart(); if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json"; if (trimmed.startsWith("<")) return "markup"; return "plain"; } function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) { if (format === "json") return ; if (format === "markup") return ; return body; } function HttpCodeBlock({ raw }: { raw: string }) { const [wrapLines, setWrapLines] = React.useState(true); const [copied, setCopied] = React.useState(false); const value = raw || "(空)"; const lines = value.replaceAll("\r\n", "\n").split("\n"); const separator = lines.indexOf(""); const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : ""; const bodyFormat = detectBodyFormat(body); React.useEffect(() => { if (!copied) return; const timer = window.setTimeout(() => setCopied(false), 1500); return () => window.clearTimeout(timer); }, [copied]); const copyPacket = async () => { try { await navigator.clipboard.writeText(value); setCopied(true); } catch { toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制"); } }; const renderLine = (line: string, index: number) => { if (index === 0) return ; if (separator < 0 || index < separator) return ; if (index === separator) return null; return ; }; return (
{wrapLines ? "关闭自动换行" : "开启自动换行"} {copied ? "已复制" : "复制报文"}
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
{ if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return; event.preventDefault(); const selection = window.getSelection(); if (!selection) return; const range = document.createRange(); range.selectNodeContents(event.currentTarget); selection.removeAllRanges(); selection.addRange(range); }} > {lines.map((line, index) => (
{renderLine(line, index)}
))}
); } // Fixed method set (server filters exact-match); avoids deriving options from a // single page, which would only ever list the methods on that page. const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; const PAGE_SIZES = [25, 50, 100, 200]; export default function TrafficPage() { const [page, setPage] = React.useState(0); const [size, setSize] = React.useState(50); const [host, setHost] = React.useState(""); // raw host input const [hostQ, setHostQ] = React.useState(""); // debounced → server const [query, setQuery] = React.useState(""); // raw free-text input const [queryQ, setQueryQ] = React.useState(""); // debounced → server const [method, setMethod] = React.useState("all"); const [traffic, setTraffic] = React.useState(null); const [selected, setSelected] = React.useState(null); const [detail, setDetail] = React.useState(null); const [detailLoading, setDetailLoading] = React.useState(false); const [hosts, setHosts] = React.useState([]); // target picker const [selectedHosts, setSelectedHosts] = React.useState([]); // checked in picker const [pickerOpen, setPickerOpen] = React.useState(false); const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | null>(null); // null = dialog closed const [deleting, setDeleting] = React.useState(false); const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger // Debounce both filters so we don't refetch on every keystroke. React.useEffect(() => { const t = setTimeout(() => setHostQ(host.trim()), 300); return () => clearTimeout(t); }, [host]); React.useEffect(() => { const t = setTimeout(() => setQueryQ(query.trim()), 300); return () => clearTimeout(t); }, [query]); // Any filter/size change resets to the first page. // biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset. React.useEffect(() => { setPage(0); }, [hostQ, queryQ, method, size]); // Load the current page. Auto-refresh only on page 0 (newest) so paging back // through history isn't yanked out from under the user. // biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger. React.useEffect(() => { let alive = true; const load = () => { api .traffic(page, size, hostQ, method, queryQ) .then((r) => { if (alive) setTraffic(r); }) .catch(() => { // Keep the last successful snapshot during transient refresh failures. }); api .trafficHosts() .then((r) => { if (alive) setHosts(r.hosts ?? []); }) .catch(() => { // Keep the last successful host list during transient refresh failures. }); }; load(); const t = setInterval(() => { if (page !== 0) return; // only auto-refresh the newest page load(); }, 5000); return () => { alive = false; clearInterval(t); }; }, [page, size, hostQ, method, queryQ, reloadTick]); // Delete traffic for the current host filter (substring) or the checked // hosts (exact batch), then refetch. const allSelected = hosts.length > 0 && hosts.every((h) => selectedHosts.includes(h.host)); const confirmDelete = () => { setDeleting(true); const p = deleteMode === "selected" ? api.trafficDeleteHosts(selectedHosts) : api.trafficDeleteHost(hostQ); p.then(() => { setDeleteMode(null); setSelected(null); setDetail(null); if (deleteMode === "selected") { setSelectedHosts([]); setPickerOpen(false); } setPage(0); setReloadTick((t) => t + 1); }) .catch(() => { // Keep the confirmation open so the user can retry a failed deletion. }) .finally(() => setDeleting(false)); }; // Lazy-load the raw request/response for the selected exchange. React.useEffect(() => { if (!selected) { setDetail(null); return; } let alive = true; setDetailLoading(true); setDetail(null); api .trafficExchange(selected.id) .then((d) => { if (alive) setDetail(d); }) .catch(() => { if (alive) setDetail({ req: "(无法加载报文)", resp: "" }); }) .finally(() => { if (alive) setDetailLoading(false); }); return () => { alive = false; }; }, [selected]); const exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]); const total = traffic?.total ?? exchanges.length; const pageCount = Math.max(1, Math.ceil(total / size)); const rangeStart = total === 0 ? 0 : page * size + 1; const rangeEnd = page * size + exchanges.length; return (

流量

全局录制代理 · 所有 HTTP 往来

{traffic?.enabled ? "录制中" : "已停用"} {traffic?.proxy && {traffic.proxy}} 共 {traffic?.count ?? 0} 条
{/* Toolbar */}
按目标批量删除 {hosts.length > 0 && ( )}
{hosts.length === 0 ? (
暂无流量记录
) : ( hosts.map((h, index) => ( )) )}
setHost(e.target.value)} className="h-8" />
setQuery(e.target.value)} className="h-8 pl-8" />
{rangeStart}–{rangeEnd} / {total} {page + 1} / {pageCount}
{/* History table */}
时间 host 方法 URL 状态码 content-type 响应长度 {exchanges.map((e) => ( setSelected(e)} > {fmtTime(e.ts)} {e.host} {e.url} {e.status} {e.content_type} {fmtBytes(e.resp_len)} ))} {exchanges.length === 0 && ( {traffic === null ? "加载中…" : "没有匹配的流量。"} )}
!open && setSelected(null)}> {selected && ( <>
{selected.status} {fmtTime(selected.ts)}
{selected.host} {selected.url}
请求 Request 响应 Response {detailLoading ? (
加载报文…
) : ( )}
{detailLoading ? (
加载报文…
) : ( )}
)}
{ if (!o) setDeleteMode(null); }} > {deleteMode === "selected" ? `删除选中的 ${selectedHosts.length} 个目标的全部流量?` : "删除该目标的全部流量?"} {deleteMode === "selected" ? ( <> 将永久删除 {selectedHosts.length} 个目标( {selectedHosts.slice(0, 3).join("、")} {selectedHosts.length > 3 ? "…" : ""} )的所有流量记录(含请求/响应原文),此操作不可撤销。 ) : ( <> 将永久删除 host 包含 {hostQ}{" "} 的所有流量记录(含请求/响应原文),此操作不可撤销。 )} 取消 { e.preventDefault(); confirmDelete(); }} disabled={deleting} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > {deleting ? "删除中…" : "确认删除"}
); }