feat: 支持漏洞关联多条流量证据

This commit is contained in:
ji ruo committed 2026-09-10 13:03:20 +08:00
1 parent 8dae851b9b
commit ec4ef35fff
41 files changed
+4390 -496

No files matched your search

@@ -0,0 +1,285 @@
"use client";
import * as React from "react";
import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from "lucide-react";
import { toast } from "sonner";
import { TrafficEvidenceViewer } from "@/components/traffic-evidence-viewer";
import { TrafficPickerDialog } from "@/components/traffic-picker-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { FindingTraffic, FindingTrafficBinding, TrafficEvidenceRole } from "@/lib/types";
const ROLES: Record<TrafficEvidenceRole, string> = {
baseline: "正常对照",
proof: "漏洞证明",
verification: "补充验证",
supporting: "辅助证据",
};
export function FindingTrafficPanel({
findingId,
contextTask,
readOnly,
onChanged,
}: {
findingId: string;
contextTask?: string;
readOnly?: boolean;
onChanged: () => void;
}) {
const [data, setData] = React.useState<FindingTraffic | null>(null);
const [error, setError] = React.useState("");
const [adding, setAdding] = React.useState(false);
const [preview, setPreview] = React.useState<string | null>(null);
const [editing, setEditing] = React.useState<FindingTrafficBinding | null>(null);
const [role, setRole] = React.useState<TrafficEvidenceRole>("supporting");
const [note, setNote] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [reload, setReload] = React.useState(0);
// biome-ignore lint/correctness/useExhaustiveDependencies: retry token intentionally refreshes this request.
React.useEffect(() => {
let active = true;
setData(null);
setError("");
api
.findingTraffic(findingId, contextTask)
.then((value) => {
if (active) setData(value);
})
.catch((e: Error) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [findingId, contextTask, reload]);
async function mutate(action: () => Promise<FindingTraffic>) {
setBusy(true);
try {
setData(await action());
setEditing(null);
onChanged();
toast.success("流量证据已更新");
} catch (e) {
toast.error((e as Error).message);
setReload((n) => n + 1);
} finally {
setBusy(false);
}
}
function move(index: number, delta: number) {
if (!data) return;
const ids = data.bindings.map((b) => b.id);
[ids[index], ids[index + delta]] = [ids[index + delta], ids[index]];
void mutate(() => api.orderFindingTraffic(findingId, ids, data.version, contextTask));
}
return (
<>
<Card>
<CardHeader>
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle>关联流量 {data ? `(${data.bindings.length})` : ""}</CardTitle>
{!readOnly ? (
<Button variant="outline" size="sm" disabled={!data || busy} onClick={() => setAdding(true)}>
<PlusIcon data-icon="inline-start" />
绑定流量
</Button>
) : (
<Badge variant="outline">继承证据 · 只读</Badge>
)}
</div>
<CardDescription>按复现顺序组织请求与响应。清理原始流量后,已绑定证据仍然保留。</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{error ? (
<Alert variant="destructive">
<AlertDescription>
{error}
<Button variant="link" onClick={() => setReload((n) => n + 1)}>
重试
</Button>
</AlertDescription>
</Alert>
) : null}
{!data ? (
<Skeleton className="h-28 w-full" />
) : data.bindings.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>暂无关联流量</EmptyTitle>
<EmptyDescription>可绑定正常对照、漏洞证明和补充验证请求。</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
data.bindings.map((b, index) => (
<div key={b.id} className="flex min-w-0 flex-col gap-2 rounded-md border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
{index + 1}. {ROLES[b.role]}
</Badge>
<Badge variant="secondary">
{b.snapshot.method} · {b.snapshot.status}
</Badge>
<span className="text-xs text-muted-foreground">证据 #{b.id}</span>
</div>
<Button
variant="link"
className="h-auto justify-start p-0 text-left whitespace-normal"
onClick={() => setPreview(b.id)}
>
<span className="break-all font-mono text-xs">{b.snapshot.url}</span>
</Button>
{b.note ? <p className="text-sm whitespace-pre-wrap">{b.note}</p> : null}
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
{new Date(b.snapshot.captured_at * 1000).toLocaleString("zh-CN")}
</span>
<div className="flex flex-wrap gap-1">
<Button variant="outline" size="sm" onClick={() => setPreview(b.id)}>
查看报文
</Button>
{!readOnly ? (
<>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() => {
setEditing(b);
setRole(b.role);
setNote(b.note);
}}
>
编辑说明
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`上移证据 ${b.id}`}
disabled={busy || index === 0}
onClick={() => move(index, -1)}
>
<ArrowUpIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`下移证据 ${b.id}`}
disabled={busy || index === data.bindings.length - 1}
onClick={() => move(index, 1)}
>
<ArrowDownIcon />
</Button>
<Button
variant="ghost"
size="sm"
disabled={busy}
onClick={() =>
void mutate(() => api.removeFindingTraffic(findingId, b.id, data.version, contextTask))
}
>
解除绑定
</Button>
</>
) : null}
</div>
</div>
</div>
))
)}
</CardContent>
</Card>
{adding && data ? (
<TrafficPickerDialog
findingId={findingId}
contextTask={contextTask}
bound={data.bindings}
onClose={() => setAdding(false)}
onBound={() => {
setReload((n) => n + 1);
onChanged();
}}
/>
) : null}
<TrafficEvidenceViewer
findingId={findingId}
bindingId={preview}
contextTask={contextTask}
onClose={() => setPreview(null)}
/>
<Dialog
open={editing !== null}
onOpenChange={(open) => {
if (!open && !busy) setEditing(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>编辑流量证据</DialogTitle>
<DialogDescription>说明这组请求/响应如何支持漏洞结论。</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="evidence-role">用途</FieldLabel>
<Select value={role} onValueChange={(v) => setRole(v as TrafficEvidenceRole)}>
<SelectTrigger id="evidence-role">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{Object.entries(ROLES).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="evidence-note">证据说明</FieldLabel>
<Textarea id="evidence-note" value={note} onChange={(e) => setNote(e.target.value)} />
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={() => setEditing(null)}>
取消
</Button>
<Button
disabled={busy || !editing || !data}
onClick={() => {
if (editing && data)
void mutate(() =>
api.editFindingTraffic(findingId, editing.id, data.version, { role, note }, contextTask),
);
}}
>
保存说明
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
+216
View File
@@ -0,0 +1,216 @@
"use client";
import * as React from "react";
import { CheckIcon, CopyIcon, WrapTextIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn, copyText } from "@/lib/utils";
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 StartLine({ line }: { line: string }) {
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
if (response) {
return (
<>
<span className="text-muted-foreground">{response[1]}</span>
{response[2]}
<span className={statusTone(Number(response[3]))}>{response[3]}</span>
{response[4]}
</>
);
}
const request = /^([A-Z]+)(\s+)(\S+)(\s+)(HTTP\/\S+)$/.exec(line);
if (request) {
return (
<>
<span className="font-semibold text-primary">{request[1]}</span>
{request[2]}
<span className="text-chart-2">{request[3]}</span>
{request[4]}
<span className="text-muted-foreground">{request[5]}</span>
</>
);
}
return line;
}
function HeaderLine({ line }: { line: string }) {
const separator = line.indexOf(":");
if (separator <= 0) return line;
return (
<>
<span className="text-primary">{line.slice(0, separator)}</span>
<span className="text-muted-foreground">:</span>
<span className="text-chart-2">{line.slice(separator + 1)}</span>
</>
);
}
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(
<span key={`${index}-${match[0].length}`} className={className}>
{match[0]}
</span>,
);
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][^>]*>|<!--[\s\S]*?-->/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(
<span key={`${index}-${match[0].length}`} className="text-primary">
{match[0]}
</span>,
);
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 <JsonBody body={body} />;
if (format === "markup") return <MarkupBody body={body} />;
return body;
}
export 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 () => {
const ok = await copyText(value);
if (ok) {
setCopied(true);
return;
}
toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制");
};
const renderLine = (line: string, index: number) => {
if (index === 0) return <StartLine line={line} />;
if (separator < 0 || index < separator) return <HeaderLine line={line} />;
if (index === separator) return null;
return <HighlightedBody body={line} format={bodyFormat} />;
};
return (
<div className="relative mx-3 my-4 overflow-hidden rounded-md border bg-background shadow-xs">
<div className="absolute top-2 right-2 flex items-center gap-0.5 rounded-md border bg-background/95 p-0.5 shadow-xs">
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={wrapLines ? "关闭自动换行" : "开启自动换行"}
aria-pressed={wrapLines}
onClick={() => setWrapLines((current) => !current)}
>
<WrapTextIcon />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{wrapLines ? "关闭自动换行" : "开启自动换行"}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={copied ? "已复制报文" : "复制报文"}
onClick={() => void copyPacket()}
>
{copied ? <CheckIcon /> : <CopyIcon />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{copied ? "已复制" : "复制报文"}</TooltipContent>
</Tooltip>
</div>
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
<div
role="textbox"
aria-label="HTTP 报文代码"
aria-multiline="true"
aria-readonly="true"
tabIndex={0}
className="max-h-[calc(100vh-15rem)] min-w-0 overflow-auto bg-background py-3 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
onKeyDown={(event) => {
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) => (
<div
key={`${index}-${line}`}
data-line={index + 1}
className="grid min-w-0 grid-cols-[1.5rem_minmax(0,1fr)] leading-relaxed before:sticky before:left-0 before:self-stretch before:border-r before:bg-muted/20 before:px-1 before:text-right before:text-muted-foreground/60 before:content-[attr(data-line)]"
>
<code
className={cn(
"min-h-[1lh] min-w-0 pr-16 pl-1.5 [tab-size:4]",
wrapLines ? "break-words whitespace-pre-wrap" : "whitespace-pre",
)}
>
{renderLine(line, index)}
</code>
</div>
))}
</div>
</div>
);
}
+163
View File
@@ -0,0 +1,163 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { api } from "@/lib/api";
import type { Finding, FindingsPage } from "@/lib/types";
export function LinkTrafficDialog({
trafficIds,
onClose,
onBound,
}: {
trafficIds: string[];
onClose: () => void;
onBound: () => void;
}) {
const [query, setQuery] = React.useState("");
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<FindingsPage | null>(null);
const [selected, setSelected] = React.useState<Finding | null>(null);
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
let active = true;
setLoading(true);
setError("");
const timer = setTimeout(() => {
api
.findingsPage({ page, pageSize: 20, query })
.then((d) => {
if (active) setData(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
}, 250);
return () => {
active = false;
clearTimeout(timer);
};
}, [query, page]);
async function save() {
if (!selected?.finding_id) return;
setBusy(true);
setError("");
try {
await api.bindFindingTraffic(
selected.finding_id,
trafficIds.map((traffic_id) => ({ traffic_id })),
);
toast.success(`已关联 ${trafficIds.length} 条流量到漏洞 #${selected.finding_id}`);
onBound();
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Dialog
open
onOpenChange={(open) => {
if (!open && !busy) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>关联到漏洞</DialogTitle>
<DialogDescription>将所选 {trafficIds.length} 条流量保存为已有漏洞的证据。</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="link-finding-query">查找漏洞</FieldLabel>
<Input
id="link-finding-query"
placeholder="名称 / 摘要 / 漏洞类别"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(1);
}}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="flex max-h-[40vh] flex-col gap-2 overflow-auto" aria-busy={loading}>
{(data?.items ?? []).map((f) => (
<Button
key={f.finding_id ?? f.id}
variant={selected?.finding_id === f.finding_id ? "secondary" : "outline"}
className="h-auto justify-start p-3 text-left whitespace-normal"
aria-pressed={selected?.finding_id === f.finding_id}
disabled={busy || !f.finding_id || f.inherited}
onClick={() => setSelected(f)}
>
<span className="flex min-w-0 flex-col gap-1">
<span>
#{f.finding_id ?? f.id} · {f.name || f.vulnclass}
</span>
<span className="line-clamp-2 text-xs text-muted-foreground">{f.summary}</span>
</span>
</Button>
))}
{!data?.items.length ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{loading ? "加载中…" : "没有匹配的漏洞,请先登记漏洞"}
</p>
) : null}
</div>
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="sm" disabled={loading || page <= 1} onClick={() => setPage((p) => p - 1)}>
上一页
</Button>
<span className="text-xs">
第 {page} 页 · 共 {data?.total ?? 0} 条
</span>
<Button
variant="outline"
size="sm"
disabled={loading || page * 20 >= (data?.total ?? 0)}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
<p className="text-sm">
{selected ? `已选漏洞:#${selected.finding_id} ${selected.name || selected.vulnclass}` : "请选择一个漏洞"}
</p>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>
取消
</Button>
<Button disabled={busy || !selected} onClick={() => void save()}>
{busy ? "保存中…" : "确认关联"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,181 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { HttpCodeBlock } from "@/components/http-code-block";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/lib/api";
import type { FindingTrafficDetail, TrafficDetail } from "@/lib/types";
export function TrafficEvidenceViewer({
findingId,
bindingId,
contextTask,
onClose,
}: {
findingId: string;
bindingId: string | null;
contextTask?: string;
onClose: () => void;
}) {
const [detail, setDetail] = React.useState<FindingTrafficDetail | null>(null);
const [error, setError] = React.useState("");
const [busy, setBusy] = React.useState(false);
React.useEffect(() => {
if (!bindingId) return;
let active = true;
setDetail(null);
setError("");
api
.findingTrafficDetail(findingId, bindingId, contextTask)
.then((d) => {
if (active) setDetail(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [findingId, bindingId, contextTask]);
async function more(side: "request" | "response") {
if (!detail || !bindingId) return;
setBusy(true);
try {
const next = await api.findingTrafficBody(findingId, bindingId, side, detail[side].next_offset, contextTask);
setDetail((current) =>
current?.binding.id === bindingId
? { ...current, [side]: { ...next, content: current[side].content + next.content } }
: current,
);
} catch (e) {
toast.error((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<Dialog
open={bindingId !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>流量证据 #{bindingId}</DialogTitle>
<DialogDescription className="break-all">
{detail?.binding.snapshot.url ?? "查看绑定时保存的请求与响应"}
</DialogDescription>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">请求 Request</TabsTrigger>
<TabsTrigger value="response">响应 Response</TabsTrigger>
</TabsList>
{(["request", "response"] as const).map((side) => (
<TabsContent key={side} value={side}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-xs text-muted-foreground">
正文 {detail[side].total.toLocaleString()} 字节{detail[side].truncated ? " · 当前为预览" : ""}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
void api
.downloadFindingTrafficBody(findingId, detail.binding.id, side, contextTask)
.catch((e: Error) => toast.error(e.message))
}
>
下载完整{side === "request" ? "请求" : "响应"}正文
</Button>
</div>
<HttpCodeBlock
raw={`${(side === "request" ? detail.binding.snapshot.req_head : detail.binding.snapshot.resp_head)?.trimEnd() ?? ""}\n\n${detail[side].content}`}
/>
{detail[side].truncated && !detail[side].binary ? (
<Button variant="outline" size="sm" disabled={busy} onClick={() => void more(side)}>
加载更多正文
</Button>
) : null}
</TabsContent>
))}
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}
</DialogContent>
</Dialog>
);
}
export function CapturedTrafficViewer({ id, onClose }: { id: string | null; onClose: () => void }) {
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
const [error, setError] = React.useState("");
React.useEffect(() => {
if (!id) return;
let active = true;
setDetail(null);
setError("");
api
.trafficExchange(id)
.then((d) => {
if (active) setDetail(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
});
return () => {
active = false;
};
}, [id]);
return (
<Dialog
open={id !== null}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl">
<DialogHeader>
<DialogTitle>预览流量</DialogTitle>
<DialogDescription>流量 ID:{id}。绑定时会保存完整正文。</DialogDescription>
</DialogHeader>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : detail ? (
<Tabs defaultValue="request">
<TabsList>
<TabsTrigger value="request">请求 Request</TabsTrigger>
<TabsTrigger value="response">响应 Response</TabsTrigger>
</TabsList>
<TabsContent value="request">
<HttpCodeBlock raw={detail.req} />
</TabsContent>
<TabsContent value="response">
<HttpCodeBlock raw={detail.resp} />
</TabsContent>
</Tabs>
) : (
<Skeleton className="h-56 w-full" />
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,277 @@
"use client";
import * as React from "react";
import { toast } from "sonner";
import { CapturedTrafficViewer } from "@/components/traffic-evidence-viewer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import type { FindingTrafficBinding, TrafficResp } from "@/lib/types";
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
export function TrafficPickerDialog({
findingId,
contextTask,
bound,
onClose,
onBound,
}: {
findingId: string;
contextTask?: string;
bound: FindingTrafficBinding[];
onClose: () => void;
onBound: () => void;
}) {
const [host, setHost] = React.useState("");
const [method, setMethod] = React.useState("all");
const [query, setQuery] = React.useState("");
const [page, setPage] = React.useState(0);
const [data, setData] = React.useState<TrafficResp | null>(null);
const [selected, setSelected] = React.useState<Set<string>>(() => new Set());
const [preview, setPreview] = React.useState<string | null>(null);
const [loading, setLoading] = React.useState(true);
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState("");
const alreadyBound = new Set(bound.map((b) => b.snapshot.source_traffic_id));
React.useEffect(() => {
let active = true;
setLoading(true);
setError("");
const timer = setTimeout(() => {
api
.traffic(page, 25, host, method === "all" ? "" : method, query)
.then((d) => {
if (active) setData(d);
})
.catch((e: Error) => {
if (active) setError(e.message);
})
.finally(() => {
if (active) setLoading(false);
});
}, 250);
return () => {
active = false;
clearTimeout(timer);
};
}, [page, host, method, query]);
const rows = data?.exchanges ?? [];
const selectable = rows.filter((e) => !alreadyBound.has(e.id));
function toggle(id: string, checked: boolean) {
setSelected((previous) => {
const next = new Set(previous);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}
async function save() {
setBusy(true);
setError("");
try {
await api.bindFindingTraffic(
findingId,
[...selected].map((traffic_id) => ({ traffic_id })),
contextTask,
);
toast.success(`已绑定 ${selected.size} 条流量`);
onBound();
onClose();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<>
<Dialog
open
onOpenChange={(open) => {
if (!open && !busy) onClose();
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>绑定流量</DialogTitle>
<DialogDescription>
筛选并多选请求/响应,已选记录会跨页保留。绑定后可设置用途、说明和顺序。
</DialogDescription>
</DialogHeader>
<FieldGroup className="flex flex-col gap-3 sm:flex-row">
<Field>
<FieldLabel htmlFor="evidence-host">目标 host</FieldLabel>
<Input
id="evidence-host"
value={host}
placeholder="域名或 IP"
onChange={(e) => {
setHost(e.target.value);
setPage(0);
}}
/>
</Field>
<Field>
<FieldLabel htmlFor="evidence-method">请求方法</FieldLabel>
<Select
value={method}
onValueChange={(v) => {
setMethod(v);
setPage(0);
}}
>
<SelectTrigger id="evidence-method">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="all">全部方法</SelectItem>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="evidence-query">关键词</FieldLabel>
<Input
id="evidence-query"
value={query}
placeholder="URL / 正文关键词"
onChange={(e) => {
setQuery(e.target.value);
setPage(0);
}}
/>
</Field>
</FieldGroup>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="max-h-[45vh] overflow-auto rounded-md border" aria-busy={loading}>
<Table>
<TableHeader>
<TableRow>
<TableHead>
<Checkbox
aria-label="选择本页未绑定流量"
disabled={loading || busy || selectable.length === 0}
checked={selectable.length > 0 && selectable.every((e) => selected.has(e.id))}
onCheckedChange={(checked) =>
setSelected((previous) => {
const next = new Set(previous);
for (const e of selectable) {
if (checked === true) next.add(e.id);
else next.delete(e.id);
}
return next;
})
}
/>
</TableHead>
<TableHead>方法 / URL</TableHead>
<TableHead>时间</TableHead>
<TableHead>状态码</TableHead>
<TableHead>操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((e) => (
<TableRow key={e.id}>
<TableCell>
<Checkbox
aria-label={`选择流量 ${e.id}`}
checked={selected.has(e.id) || alreadyBound.has(e.id)}
disabled={busy || loading || alreadyBound.has(e.id)}
onCheckedChange={(checked) => toggle(e.id, checked === true)}
/>
</TableCell>
<TableCell>
<span className="font-mono text-xs">
{e.method} {e.url}
</span>
{alreadyBound.has(e.id) ? <Badge variant="secondary">已绑定</Badge> : null}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{new Date(e.ts).toLocaleString("zh-CN")}
</TableCell>
<TableCell>{e.status}</TableCell>
<TableCell>
<Button variant="ghost" size="sm" onClick={() => setPreview(e.id)}>
预览
</Button>
</TableCell>
</TableRow>
))}
{!rows.length ? (
<TableRow>
<TableCell colSpan={5} className="py-8 text-center">
{loading ? "加载中…" : "没有匹配的流量"}
</TableCell>
</TableRow>
) : null}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm">
已选 {selected.size} 条 · 共 {data?.total ?? 0} 条
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={loading || page === 0}
onClick={() => setPage((p) => p - 1)}
>
上一页
</Button>
<span className="text-xs">第 {page + 1} 页</span>
<Button
variant="outline"
size="sm"
disabled={loading || (page + 1) * 25 >= (data?.total ?? 0)}
onClick={() => setPage((p) => p + 1)}
>
下一页
</Button>
</div>
</div>
<DialogFooter>
<Button variant="outline" disabled={busy} onClick={onClose}>
取消
</Button>
<Button disabled={busy || selected.size === 0} onClick={() => void save()}>
{busy ? "保存中…" : `绑定 ${selected.size} 条流量`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CapturedTrafficViewer id={preview} onClose={() => setPreview(null)} />
</>
);
}