mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-12 21:53:50 +08:00
feat: add task traffic history and report recovery
This commit is contained in:
1 parent
b0a29236f8
commit
7946869762
10 files changed
+1236
-899
No files matched your search
@@ -0,0 +1,894 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import {
|
||||
ArrowDownWideNarrowIcon,
|
||||
ArrowUpNarrowWideIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
EraserIcon,
|
||||
FilterXIcon,
|
||||
ListChecksIcon,
|
||||
Loader2Icon,
|
||||
RadioTowerIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { LinkTrafficDialog } from "@/components/link-traffic-dialog";
|
||||
import { requestFromTraffic } from "@/components/repeater/repeater-http";
|
||||
import { seedFromEvidence } from "@/components/repeater/repeater-store";
|
||||
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 { SortableHead } from "@/components/ui/sortable-head";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api } from "@/lib/api";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
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";
|
||||
// GB matters for the reclaimed-space figure a full purge reports; a capture-heavy
|
||||
// instance can hand back several.
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
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 <Badge className="shrink-0 font-mono">{method}</Badge>;
|
||||
}
|
||||
|
||||
// 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)}`;
|
||||
}
|
||||
|
||||
// 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];
|
||||
type HostCountSortDirection = "asc" | "desc";
|
||||
|
||||
// Server-sortable columns. The list is sent to the backend verbatim as `sort`,
|
||||
// which whitelists these same names, so keep them in sync with traffic.Page.
|
||||
const SORT_FIELDS = ["ts", "status", "resp_len"] as const;
|
||||
type SortField = (typeof SORT_FIELDS)[number];
|
||||
const SORT_STORAGE_KEY = "traffic-sort";
|
||||
|
||||
// Status-class buckets for the filter dropdown; the value is sent as `status`,
|
||||
// which the backend reads as either an exact code or an "Nxx" class band.
|
||||
const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"];
|
||||
|
||||
export function TrafficHistory({ taskId }: { taskId?: string }) {
|
||||
const taskScoped = Boolean(taskId);
|
||||
const [selectedFlows, setSelectedFlows] = React.useState<Map<string, TrafficExchange>>(() => new Map());
|
||||
const router = useRouter();
|
||||
const [importing, setImporting] = React.useState(false);
|
||||
const importLock = React.useRef(false);
|
||||
const [linking, setLinking] = React.useState(false);
|
||||
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");
|
||||
|
||||
// Advanced filters (issue #177): response-body content, path, status class and
|
||||
// response-size range. Text inputs are debounced like host/query; the status
|
||||
// select applies immediately.
|
||||
const [body, setBody] = React.useState("");
|
||||
const [bodyQ, setBodyQ] = React.useState("");
|
||||
const [path, setPath] = React.useState("");
|
||||
const [pathQ, setPathQ] = React.useState("");
|
||||
const [statusFilter, setStatusFilter] = React.useState("all");
|
||||
const [respMin, setRespMin] = React.useState("");
|
||||
const [respMinQ, setRespMinQ] = React.useState("");
|
||||
const [respMax, setRespMax] = React.useState("");
|
||||
const [respMaxQ, setRespMaxQ] = React.useState("");
|
||||
const [sort, setSort] = useStoredSortPreference<SortField>(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc");
|
||||
|
||||
const [traffic, setTraffic] = React.useState<TrafficResp | null>(null);
|
||||
const [selected, setSelected] = React.useState<TrafficExchange | null>(null);
|
||||
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
|
||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||
|
||||
const [hosts, setHosts] = React.useState<TrafficHost[]>([]); // target picker
|
||||
const [selectedHosts, setSelectedHosts] = React.useState<string[]>([]); // checked in picker
|
||||
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||
const [hostCountSortDirection, setHostCountSortDirection] = React.useState<HostCountSortDirection>("desc");
|
||||
|
||||
const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | "all" | 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]);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setBodyQ(body.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [body]);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setPathQ(path.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [path]);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setRespMinQ(respMin.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [respMin]);
|
||||
React.useEffect(() => {
|
||||
const t = setTimeout(() => setRespMaxQ(respMax.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [respMax]);
|
||||
|
||||
const hasAdvancedFilter = Boolean(bodyQ || pathQ || respMinQ || respMaxQ) || statusFilter !== "all";
|
||||
const resetAdvancedFilters = () => {
|
||||
setBody("");
|
||||
setBodyQ("");
|
||||
setPath("");
|
||||
setPathQ("");
|
||||
setStatusFilter("all");
|
||||
setRespMin("");
|
||||
setRespMinQ("");
|
||||
setRespMax("");
|
||||
setRespMaxQ("");
|
||||
};
|
||||
|
||||
// Any filter/size/sort 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, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, taskId]);
|
||||
|
||||
// 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, {
|
||||
taskId,
|
||||
body: bodyQ,
|
||||
path: pathQ,
|
||||
status: statusFilter,
|
||||
respMin: respMinQ,
|
||||
respMax: respMaxQ,
|
||||
sort: sort.field,
|
||||
order: sort.direction,
|
||||
})
|
||||
.then((r) => {
|
||||
if (alive) setTraffic(r);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the last successful snapshot during transient refresh failures.
|
||||
});
|
||||
if (!taskScoped) {
|
||||
api
|
||||
.trafficHosts()
|
||||
.then((r) => {
|
||||
if (alive) setHosts(r.hosts ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
// Keep the last successful host list during transient refresh failures.
|
||||
});
|
||||
} else if (alive) {
|
||||
setHosts([]);
|
||||
}
|
||||
};
|
||||
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, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick, taskId, taskScoped]);
|
||||
|
||||
// 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 sortedHosts = React.useMemo(
|
||||
() =>
|
||||
[...hosts].sort((a, b) => {
|
||||
const countOrder = hostCountSortDirection === "asc" ? a.count - b.count : b.count - a.count;
|
||||
return countOrder || a.host.localeCompare(b.host);
|
||||
}),
|
||||
[hosts, hostCountSortDirection],
|
||||
);
|
||||
|
||||
// "清空" for the unfiltered purge, "删除" for the host-scoped ones — the dialog's
|
||||
// title and its confirm button both follow from which is in play.
|
||||
const deleteVerb = deleteMode === "all" ? "清空" : "删除";
|
||||
const deleteTitle = deleteMode
|
||||
? {
|
||||
all: "清空全部流量记录?",
|
||||
selected: `删除选中的 ${selectedHosts.length} 个目标的全部流量?`,
|
||||
filter: "删除该目标的全部流量?",
|
||||
}[deleteMode]
|
||||
: "";
|
||||
|
||||
// `reclaimed` only comes back from the full purge; the host-scoped deletions
|
||||
// report the row count alone.
|
||||
const requestDelete = (mode: "filter" | "selected" | "all"): Promise<{ deleted: number; reclaimed?: number }> => {
|
||||
if (mode === "all") return api.trafficDeleteAll();
|
||||
if (mode === "selected") return api.trafficDeleteHosts(selectedHosts);
|
||||
return api.trafficDeleteHost(hostQ);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteMode) return;
|
||||
setDeleting(true);
|
||||
const mode = deleteMode;
|
||||
requestDelete(mode)
|
||||
.then((r) => {
|
||||
setDeleteMode(null);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
if (mode !== "filter") {
|
||||
setSelectedHosts([]);
|
||||
setPickerOpen(false);
|
||||
}
|
||||
if (mode === "all") {
|
||||
// Reclaimed space is the whole point of compacting an emptied index, so say so.
|
||||
const reclaimed = r.reclaimed ?? 0;
|
||||
const freed = reclaimed > 0 ? `,释放 ${fmtBytes(reclaimed)} 存储` : "";
|
||||
toast.success(`已清空 ${r.deleted} 条流量${freed}`);
|
||||
}
|
||||
setPage(0);
|
||||
setReloadTick((t) => t + 1);
|
||||
})
|
||||
.catch((e) => {
|
||||
// Keep the confirmation open so the user can retry a failed deletion.
|
||||
if (mode === "all") toast.error(`清空失败:${(e as Error).message}`);
|
||||
})
|
||||
.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]);
|
||||
|
||||
async function openRepeater(flows: TrafficExchange[]) {
|
||||
if (!flows.length || importLock.current) return;
|
||||
importLock.current = true;
|
||||
setImporting(true);
|
||||
let imported = 0;
|
||||
try {
|
||||
// Sequential loading limits pressure when a whole page is selected. A bad
|
||||
// capture does not discard drafts imported successfully before it.
|
||||
for (const flow of flows) {
|
||||
try {
|
||||
const raw = await api.trafficExchange(flow.id);
|
||||
seedFromEvidence(
|
||||
requestFromTraffic(raw.req, flow),
|
||||
{ label: `流量 ${flow.id}` },
|
||||
{ title: `${flow.method} ${flow.host}` },
|
||||
);
|
||||
imported += 1;
|
||||
} catch (error) {
|
||||
toast.error(`${flow.host}:${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
if (imported) {
|
||||
toast.success(`已送入 ${imported} 条请求,可编辑后发送`);
|
||||
setSelected(null);
|
||||
router.push("/function/repeater");
|
||||
}
|
||||
} finally {
|
||||
importLock.current = false;
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle direction when re-clicking the active column, else sort the new column
|
||||
// newest/largest-first.
|
||||
const toggleSort = (field: SortField) =>
|
||||
setSort((prev) =>
|
||||
prev.field === field
|
||||
? { field, direction: prev.direction === "asc" ? "desc" : "asc" }
|
||||
: { field, direction: "desc" },
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">流量</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{taskScoped ? "当前任务资产范围内的 HTTP 往来" : "全局录制代理 · 所有 HTTP 往来"}
|
||||
</p>
|
||||
{taskScoped && traffic && !traffic.enabled ? (
|
||||
<p className="text-amber-600 text-xs dark:text-amber-400">流量录制已关闭,历史记录仍可查看。</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
traffic?.enabled
|
||||
? "border-emerald-500/20 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
|
||||
: "border-transparent bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<RadioTowerIcon className="size-3.5" />
|
||||
{traffic?.enabled ? "录制中" : "已停用"}
|
||||
</span>
|
||||
{traffic?.proxy && <span className="font-mono text-xs text-muted-foreground">{traffic.proxy}</span>}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
共 <span className="tabular-nums">{traffic?.count ?? 0}</span> 条
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!taskScoped && (
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8">
|
||||
<ListChecksIcon className="size-3.5" />
|
||||
{selectedHosts.length > 0 ? `选择目标(${selectedHosts.length})` : "选择目标…"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[calc(100vw-2rem)] p-0 data-open:animate-none data-closed:animate-none sm:w-80"
|
||||
align="start"
|
||||
collisionPadding={16}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-xs font-medium text-muted-foreground">按目标批量删除</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{hosts.length > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setHostCountSortDirection((current) => (current === "desc" ? "asc" : "desc"))}
|
||||
aria-label={
|
||||
hostCountSortDirection === "desc"
|
||||
? "数据包数量当前倒序,点击切换为正序"
|
||||
: "数据包数量当前正序,点击切换为倒序"
|
||||
}
|
||||
>
|
||||
{hostCountSortDirection === "desc" ? <ArrowDownWideNarrowIcon /> : <ArrowUpNarrowWideIcon />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>按数据包数量{hostCountSortDirection === "desc" ? "倒序" : "正序"}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{hosts.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setSelectedHosts(allSelected ? [] : hosts.map((h) => h.host))}
|
||||
>
|
||||
{allSelected ? "取消全选" : "全选"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{hosts.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-xs text-muted-foreground">暂无流量记录</div>
|
||||
) : (
|
||||
sortedHosts.map((h, index) => (
|
||||
<label
|
||||
key={h.host}
|
||||
htmlFor={`traffic-host-${index}`}
|
||||
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
|
||||
>
|
||||
<Checkbox
|
||||
id={`traffic-host-${index}`}
|
||||
checked={selectedHosts.includes(h.host)}
|
||||
onCheckedChange={() =>
|
||||
setSelectedHosts((prev) =>
|
||||
prev.includes(h.host) ? prev.filter((x) => x !== h.host) : [...prev, h.host],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="truncate font-mono">{h.host}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">{h.count}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t p-2">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled={selectedHosts.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteMode("selected");
|
||||
setPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
删除选中({selectedHosts.length})
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
<div className="relative w-48">
|
||||
<Input placeholder="host…" value={host} onChange={(e) => setHost(e.target.value)} className="h-8" />
|
||||
</div>
|
||||
{!taskScoped && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
disabled={!hostQ || deleting}
|
||||
title={hostQ ? undefined : "先在左侧选择目标或输入 host"}
|
||||
onClick={() => setDeleteMode("filter")}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
删除该目标
|
||||
</Button>
|
||||
)}
|
||||
{/* Outline rather than a second destructive button: this one ignores every
|
||||
filter, so it must not look one mis-click away from "删除该目标". */}
|
||||
{!taskScoped && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={!traffic?.count || deleting}
|
||||
title={traffic?.count ? "删除全部流量并压实存储" : "当前没有流量记录"}
|
||||
onClick={() => setDeleteMode("all")}
|
||||
>
|
||||
<EraserIcon className="size-3.5" />
|
||||
清空全部
|
||||
</Button>
|
||||
)}
|
||||
<div className="relative max-w-sm flex-1">
|
||||
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索全部(URL / 方法 / 类型 / 状态码…)"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="h-8 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue placeholder="方法" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部方法</SelectItem>
|
||||
{METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
|
||||
<SelectTrigger size="sm" className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAGE_SIZES.map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>
|
||||
{n} / 页
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{rangeStart}–{rangeEnd} / {total}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
disabled={page <= 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="tabular-nums">
|
||||
{page + 1} / {pageCount}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
disabled={page + 1 >= pageCount}
|
||||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */}
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 px-2 py-1.5">
|
||||
<span className="pl-1 text-xs font-medium text-muted-foreground">高级筛选</span>
|
||||
<div className="relative w-56">
|
||||
<Input
|
||||
placeholder="响应内容(正文关键词,≥3字)"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="relative w-52">
|
||||
<Input
|
||||
placeholder="路径(如 /api/user/…)"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger size="sm" className="w-28">
|
||||
<SelectValue placeholder="状态码" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态码</SelectItem>
|
||||
{STATUS_BUCKETS.map((s) => (
|
||||
<SelectItem key={s} value={s}>
|
||||
{s}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>响应长度</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="最小(B)"
|
||||
value={respMin}
|
||||
onChange={(e) => setRespMin(e.target.value)}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
<span>–</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
placeholder="最大(B)"
|
||||
value={respMax}
|
||||
onChange={(e) => setRespMax(e.target.value)}
|
||||
className="h-8 w-24"
|
||||
/>
|
||||
</div>
|
||||
{hasAdvancedFilter ? (
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={resetAdvancedFilters}>
|
||||
<FilterXIcon className="size-3.5" />
|
||||
清除筛选
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">已选 {selectedFlows.size} 条流量</span>
|
||||
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
|
||||
关联到漏洞
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={selectedFlows.size === 0 || importing}
|
||||
onClick={() => void openRepeater([...selectedFlows.values()])}
|
||||
>
|
||||
{importing ? "转入中…" : "送入请求重发"}
|
||||
</Button>
|
||||
{selectedFlows.size > 0 ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Map())}>
|
||||
清空选择
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{/* History table */}
|
||||
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col">
|
||||
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
aria-label="选择本页流量"
|
||||
checked={exchanges.length > 0 && exchanges.every((e) => selectedFlows.has(e.id))}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedFlows((previous) => {
|
||||
const next = new Map(previous);
|
||||
for (const e of exchanges) {
|
||||
if (checked === true) next.set(e.id, e);
|
||||
else next.delete(e.id);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<SortableHead
|
||||
field="ts"
|
||||
label="时间"
|
||||
activeField={sort.field}
|
||||
direction={sort.direction}
|
||||
onSort={toggleSort}
|
||||
className="w-36"
|
||||
/>
|
||||
<TableHead className="w-44">host</TableHead>
|
||||
<TableHead className="w-20">方法</TableHead>
|
||||
<TableHead>URL</TableHead>
|
||||
<SortableHead
|
||||
field="status"
|
||||
label="状态码"
|
||||
activeField={sort.field}
|
||||
direction={sort.direction}
|
||||
onSort={toggleSort}
|
||||
className="w-20"
|
||||
/>
|
||||
<TableHead className="w-36">content-type</TableHead>
|
||||
<SortableHead
|
||||
field="resp_len"
|
||||
label="响应长度"
|
||||
activeField={sort.field}
|
||||
direction={sort.direction}
|
||||
onSort={toggleSort}
|
||||
align="right"
|
||||
className="w-24 text-right"
|
||||
/>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{exchanges.map((e) => (
|
||||
<TableRow
|
||||
key={e.id}
|
||||
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(e)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
aria-label={`选择流量 ${e.id}`}
|
||||
checked={selectedFlows.has(e.id)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedFlows((previous) => {
|
||||
const next = new Map(previous);
|
||||
if (checked === true) next.set(e.id, e);
|
||||
else next.delete(e.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.host}</TableCell>
|
||||
<TableCell>
|
||||
<MethodBadge method={e.method} />
|
||||
</TableCell>
|
||||
<TableCell className="max-w-0">
|
||||
<span className="block truncate font-mono text-xs">{e.url}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(e.status))}>
|
||||
{e.status}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{e.content_type}</TableCell>
|
||||
<TableCell className="text-right text-xs tabular-nums">{fmtBytes(e.resp_len)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{exchanges.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
|
||||
{traffic === null ? "加载中…" : "没有匹配的流量。"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{linking ? (
|
||||
<LinkTrafficDialog
|
||||
trafficIds={[...selectedFlows.keys()]}
|
||||
onClose={() => setLinking(false)}
|
||||
onBound={() => setSelectedFlows(new Map())}
|
||||
/>
|
||||
) : null}
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[72rem]! sm:max-w-[72rem]!">
|
||||
{selected && (
|
||||
<>
|
||||
<SheetHeader className="border-b px-5 py-4">
|
||||
<div className="flex items-center gap-2 pr-8">
|
||||
<MethodBadge method={selected.method} />
|
||||
<Badge variant="secondary" className={cn("font-mono tabular-nums", statusTone(selected.status))}>
|
||||
{selected.status}
|
||||
</Badge>
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{fmtTime(selected.ts)}</span>
|
||||
</div>
|
||||
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
|
||||
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2 w-fit"
|
||||
disabled={importing}
|
||||
onClick={() => void openRepeater([selected])}
|
||||
>
|
||||
{importing ? "转入中…" : "送入请求重发"}
|
||||
</Button>
|
||||
</SheetHeader>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 gap-2 overflow-hidden p-2 sm:grid-cols-2">
|
||||
{[
|
||||
["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)],
|
||||
["response", "响应 Response", detail?.resp ?? ""],
|
||||
].map(([key, label, raw]) => (
|
||||
<div key={key} className="flex min-h-0 min-w-0 flex-col gap-1 overflow-hidden">
|
||||
<span className="px-1 text-[11px] font-medium text-muted-foreground">{label}</span>
|
||||
{detailLoading ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center gap-2 rounded-md border bg-muted/10 text-xs text-muted-foreground">
|
||||
<Loader2Icon className="size-3.5 animate-spin" />
|
||||
加载报文…
|
||||
</div>
|
||||
) : (
|
||||
<HttpCodeBlock raw={raw} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteMode !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteMode(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{deleteTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteMode === "all" && (
|
||||
<>
|
||||
将永久删除全部 <span className="font-semibold tabular-nums">{traffic?.count ?? 0}</span>{" "}
|
||||
条流量记录(含请求/响应原文),忽略当前的筛选条件,此操作不可撤销。已绑定到漏洞的流量证据保存在独立的证据库中,不受影响。
|
||||
<br />
|
||||
<span className="text-muted-foreground">
|
||||
清空后会顺带压实存储,把索引占用的磁盘空间还给系统;这期间流量录制会短暂暂停。
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{deleteMode === "selected" && (
|
||||
<>
|
||||
将永久删除 <span className="font-semibold tabular-nums">{selectedHosts.length}</span> 个目标(
|
||||
<span className="font-mono">
|
||||
{selectedHosts.slice(0, 3).join("、")}
|
||||
{selectedHosts.length > 3 ? "…" : ""}
|
||||
</span>
|
||||
)的所有流量记录(含请求/响应原文),此操作不可撤销。
|
||||
</>
|
||||
)}
|
||||
{deleteMode === "filter" && (
|
||||
<>
|
||||
将永久删除 host 包含 <span className="font-mono font-semibold">{hostQ}</span>{" "}
|
||||
的所有流量记录(含请求/响应原文),此操作不可撤销。
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
confirmDelete();
|
||||
}}
|
||||
disabled={deleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{deleting ? `${deleteVerb}中…` : `确认${deleteVerb}`}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user