Merge origin/main into codex/btw-side-question
合入 main 的 norma v0.3.7、会话分组与流量证据更新,保留 /btw 快照和输入布局。同步自动放行、关闭流量绑定的测试断言,并等待归档测试夹具的旁路后台退出。
This commit is contained in:
commit
2930c4b915
94 files changed
+9737
-1241
No files matched your search
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ClipboardListIcon,
|
||||
CopyIcon,
|
||||
RefreshCwIcon,
|
||||
ShieldAlertIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow, InterceptAudit, InterceptDetail } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtTime(value?: string) {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function source(row: InterceptApprovalRow) {
|
||||
if (row.decision_source) return row.decision_source;
|
||||
if (row.rule_id) return "rule";
|
||||
return row.reason?.startsWith("[模型]") ? "model" : "unknown";
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const labels: Record<string, string> = { pending: "待审批", allowed: "已允许", denied: "已拒绝", timeout: "已超时" };
|
||||
let variant: "default" | "destructive" | "secondary" | "outline" = "outline";
|
||||
if (status === "allowed") variant = "default";
|
||||
if (status === "denied") variant = "destructive";
|
||||
if (status === "pending") variant = "secondary";
|
||||
return <Badge variant={variant}>{labels[status] ?? status}</Badge>;
|
||||
}
|
||||
|
||||
function MatchCell({ row, showReason = true }: { row: InterceptApprovalRow; showReason?: boolean }) {
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "");
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
{source(row) === "model" ? (
|
||||
<Badge variant="outline">
|
||||
<BotIcon />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="truncate">{row.rule_name || "规则未记录或已删除"}</span>
|
||||
)}
|
||||
{showReason ? (
|
||||
<p className="truncate text-muted-foreground text-xs" title={reason}>
|
||||
{reason || "未记录理由"}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ label, text, truncated = false }: { label: string; text: string; truncated?: boolean }) {
|
||||
async function copy() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success("已复制");
|
||||
} catch {
|
||||
toast.error("复制失败,请手动选择内容复制");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<section className="flex min-w-0 flex-col gap-2" aria-label={label}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="font-medium text-muted-foreground text-xs">{label}</h3>
|
||||
{text ? (
|
||||
<Button variant="ghost" size="icon-xs" aria-label={`复制${label}`} onClick={() => void copy()}>
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<pre className="max-h-80 min-w-0 overflow-auto whitespace-pre-wrap break-words rounded-lg bg-muted/60 p-3 font-mono text-xs leading-6 [overflow-wrap:anywhere]">
|
||||
{text || "未记录"}
|
||||
</pre>
|
||||
{truncated ? <p className="text-muted-foreground text-xs">内容已截断,以上为保存的片段。</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const contextLabels: Record<string, string> = {
|
||||
user: "用户消息",
|
||||
assistant: "Agent 消息",
|
||||
text: "Agent 消息",
|
||||
tool_use: "工具请求",
|
||||
tool_result: "工具输出",
|
||||
};
|
||||
|
||||
const actionLabels: Record<string, string> = { allow: "允许", ask: "转人工审批", deny: "拒绝" };
|
||||
const executionLabels: Record<InterceptAudit["execution_status"], string> = {
|
||||
not_started: "尚未执行",
|
||||
not_executed: "未执行",
|
||||
awaiting_result: "已允许,等待执行结果",
|
||||
succeeded: "执行成功",
|
||||
failed: "执行失败",
|
||||
unknown: "执行结果未知",
|
||||
};
|
||||
|
||||
type Decide = (id: number, decision: "allowed" | "denied") => Promise<void>;
|
||||
|
||||
function DecisionActions({ row, busy, decide }: { row: InterceptApprovalRow; busy: boolean; decide: Decide }) {
|
||||
if (row.status !== "pending") return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" disabled={busy} onClick={() => void decide(row.id, "allowed")}>
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
允许
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" disabled={busy} onClick={() => void decide(row.id, "denied")}>
|
||||
<XIcon data-icon="inline-start" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApprovalDetail({
|
||||
row,
|
||||
busy,
|
||||
decide,
|
||||
revision,
|
||||
}: {
|
||||
row: InterceptApprovalRow;
|
||||
busy: boolean;
|
||||
decide: Decide;
|
||||
revision: number;
|
||||
}) {
|
||||
const [detail, setDetail] = React.useState<InterceptDetail | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [retry, setRetry] = React.useState(0);
|
||||
const [more, setMore] = React.useState(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Status, refresh and retry invalidate details without closing the panel.
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
async function load() {
|
||||
try {
|
||||
const next = await api.interceptDetail(row.id);
|
||||
if (cancelled) return;
|
||||
setDetail(next);
|
||||
setError("");
|
||||
if (next.status === "pending" || next.audit?.execution_status === "awaiting_result")
|
||||
timer = setTimeout(() => void load(), 5000);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError((e as Error).message || "详情加载失败");
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [row.id, row.status, revision, retry]);
|
||||
|
||||
// Row updates are authoritative until the lazy detail has caught up.
|
||||
const current = detail?.status === row.status ? detail : row;
|
||||
const audit = detail?.audit;
|
||||
let execution = audit ? executionLabels[audit.execution_status] : "未记录";
|
||||
if (row.status === "pending") execution = "尚未执行";
|
||||
if (audit && audit.correlation !== "exact" && audit.effective_action === "allow") execution = "未关联执行结果";
|
||||
const command = typeof row.tool_input?.command === "string" ? row.tool_input.command : undefined;
|
||||
let initialLabel = source(row) === "model" ? "模型初判" : "规则初判";
|
||||
if (audit?.model_fallback) initialLabel = "模型异常回退";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-4 p-3 sm:p-5">
|
||||
<div className="grid min-w-0 gap-5 rounded-xl border bg-muted/20 p-4 lg:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<CodeBlock
|
||||
label={`${row.agent_name || row.conv_agent_key || "Agent"} · 工具请求`}
|
||||
text={JSON.stringify(row.tool_input ?? {}, null, 2)}
|
||||
/>
|
||||
{command ? (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDownIcon data-icon="inline-start" />
|
||||
查看命令内容
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<CodeBlock label="命令内容" text={command} />
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
执行结果:<span className="text-foreground">{detail ? execution : "加载中…"}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-4 lg:border-l lg:pl-5">
|
||||
<h3 className="font-medium text-muted-foreground text-xs">
|
||||
{current.status === "pending" ? "审查状态" : "审批裁决"}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge status={current.status} />
|
||||
<MatchCell row={current} showReason={false} />
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-7 [overflow-wrap:anywhere]">
|
||||
{current.reason?.replace(/^\[模型\]\s*/, "") || "未记录审批理由"}
|
||||
</p>
|
||||
{audit?.decision_reason ? <p className="text-sm">{audit.decision_reason}</p> : null}
|
||||
{audit?.effective_action ? <p className="text-sm">最终动作:{actionLabels[audit.effective_action]}</p> : null}
|
||||
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-2 text-xs">
|
||||
<dt className="text-muted-foreground">来源</dt>
|
||||
<dd className="break-words">
|
||||
{row.conv_title || (row.task_id ? `任务 ${row.task_id}` : row.agent_name || "未记录")}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">申请时间</dt>
|
||||
<dd>{fmtTime(row.created_at)}</dd>
|
||||
<dt className="text-muted-foreground">决定时间</dt>
|
||||
<dd>{fmtTime(current.decided_at)}</dd>
|
||||
{audit?.rule_name ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">规则快照</dt>
|
||||
<dd>{audit.rule_name}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{audit?.profile_id ? (
|
||||
<>
|
||||
<dt className="text-muted-foreground">审批模型配置</dt>
|
||||
<dd>#{audit.profile_id}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
<DecisionActions row={current} busy={busy} decide={decide} />
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>详情加载失败:{error}</span>
|
||||
<Button variant="outline" size="sm" onClick={() => setRetry((v) => v + 1)}>
|
||||
重试详情
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!detail && !error ? <Skeleton className="h-8 w-60" /> : null}
|
||||
{detail && !audit ? (
|
||||
<Alert>
|
||||
<AlertDescription>此记录未保存审批详情快照,无法还原当时的上下文、模型初判和执行输出。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{audit ? (
|
||||
<Collapsible open={more} onOpenChange={setMore}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ChevronDownIcon data-icon="inline-start" className={cn(more && "rotate-180")} />
|
||||
{more ? "收起上下文" : "查看上下文与执行结果"}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<CodeBlock label="用户消息 / 当前轮输入" text={audit.user_message} truncated={audit.user_truncated} />
|
||||
<section className="flex min-w-0 flex-col gap-3">
|
||||
<h3 className="font-medium text-muted-foreground text-xs">可见会话上下文</h3>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
保存于 {fmtTime(audit.captured_at)} 的会话记录片段。模型审批目前仅接收工具名称和参数。
|
||||
</p>
|
||||
{audit.context_truncated ? (
|
||||
<p className="text-muted-foreground text-xs">仅保存最近的上下文,部分内容已截断。</p>
|
||||
) : null}
|
||||
{audit.context?.length ? (
|
||||
audit.context.map((entry, index) => (
|
||||
<CodeBlock
|
||||
key={`${entry.kind}-${entry.tool_use_id || index}`}
|
||||
label={`${contextLabels[entry.kind] ?? entry.kind}${entry.tool ? ` · ${entry.tool}` : ""}${entry.is_error ? " · 异常" : ""}`}
|
||||
text={entry.text}
|
||||
truncated={entry.truncated}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">未记录可关联的上下文</p>
|
||||
)}
|
||||
</section>
|
||||
<CodeBlock
|
||||
label={`${initialLabel}:${actionLabels[audit.initial_action] ?? audit.initial_action}`}
|
||||
text={audit.initial_reason.replace(/^\[模型\]\s*/, "")}
|
||||
/>
|
||||
<CodeBlock
|
||||
label="执行输出"
|
||||
text={audit.output ?? (audit.execution_status === "not_executed" ? "工具未执行。" : "尚无执行输出")}
|
||||
truncated={audit.output_truncated}
|
||||
/>
|
||||
{audit.correlation !== "exact" ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{audit.correlation === "ambiguous"
|
||||
? "存在相同参数的并发调用,无法唯一关联工具调用;本记录不展示推测的执行结果。"
|
||||
: "未记录可唯一关联的工具调用 ID。"}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<dl className="grid gap-2 text-muted-foreground text-xs [overflow-wrap:anywhere]">
|
||||
<div>工具调用 ID:{audit.tool_use_id || "未记录"}</div>
|
||||
<div>参数摘要 SHA-256:{audit.input_digest}</div>
|
||||
<div>审查配置指纹 SHA-256:{audit.config_digest || "未记录"}</div>
|
||||
{audit.execution_ended_at ? <div>结果记录时间:{fmtTime(audit.execution_ended_at)}</div> : null}
|
||||
</dl>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Hidden cells do not occupy table columns. Keep the detail span in sync so
|
||||
// expansion cannot create empty columns and leave a gap in the selected row.
|
||||
const approvalBreakpoints = ["(min-width: 40rem)", "(min-width: 48rem)", "(min-width: 64rem)", "(min-width: 80rem)"];
|
||||
function subscribeColumns(onChange: () => void) {
|
||||
const queries = approvalBreakpoints.map((query) => window.matchMedia(query));
|
||||
for (const query of queries) query.addEventListener("change", onChange);
|
||||
return () => {
|
||||
for (const query of queries) query.removeEventListener("change", onChange);
|
||||
};
|
||||
}
|
||||
function visibleColumnCount() {
|
||||
const matches = approvalBreakpoints.map((query) => window.matchMedia(query).matches);
|
||||
return 3 + Number(matches[0]) + Number(matches[1]) + 2 * Number(matches[2]) + 2 * Number(matches[3]);
|
||||
}
|
||||
function serverColumnCount() {
|
||||
return 9;
|
||||
}
|
||||
|
||||
function ApprovalTable({
|
||||
rows,
|
||||
busy,
|
||||
decide,
|
||||
revision,
|
||||
label,
|
||||
}: {
|
||||
rows: InterceptApprovalRow[];
|
||||
busy: boolean;
|
||||
decide: Decide;
|
||||
revision: number;
|
||||
label: string;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState<Set<number>>(() => new Set());
|
||||
const columns = React.useSyncExternalStore(subscribeColumns, visibleColumnCount, serverColumnCount);
|
||||
const prefix = React.useId();
|
||||
function toggle(id: number) {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
return (
|
||||
<Table className="table-fixed" aria-label={label}>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<span className="sr-only">展开详情</span>
|
||||
</TableHead>
|
||||
<TableHead className="hidden w-14 sm:table-cell">#</TableHead>
|
||||
<TableHead className="w-28">工具</TableHead>
|
||||
<TableHead className="hidden md:table-cell">来源</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">匹配规则</TableHead>
|
||||
<TableHead className="hidden xl:table-cell">参数</TableHead>
|
||||
<TableHead className="w-24">状态</TableHead>
|
||||
<TableHead className="hidden w-36 lg:table-cell">申请时间</TableHead>
|
||||
<TableHead className="hidden w-36 xl:table-cell">决定时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => {
|
||||
const open = expanded.has(row.id);
|
||||
const panelID = `${prefix}-${row.id}`;
|
||||
return (
|
||||
<React.Fragment key={row.id}>
|
||||
<TableRow
|
||||
data-state={open ? "selected" : undefined}
|
||||
className="cursor-pointer"
|
||||
onClick={() => toggle(row.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`${open ? "收起" : "展开"}审批 #${row.id}`}
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? panelID : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle(row.id);
|
||||
}}
|
||||
>
|
||||
{open ? <ChevronDownIcon /> : <ChevronRightIcon />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground sm:table-cell">{row.id}</TableCell>
|
||||
<TableCell>
|
||||
<code className="block truncate text-xs">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="truncate" title={row.conv_title || row.task_id}>
|
||||
{row.conv_title || row.task_id || "—"}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{row.agent_name || row.conv_agent_key}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
<MatchCell row={row} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden xl:table-cell">
|
||||
<code className="block truncate text-muted-foreground text-xs">{JSON.stringify(row.tool_input)}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={row.status} />
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground text-xs lg:table-cell">
|
||||
{fmtTime(row.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground text-xs xl:table-cell">
|
||||
{fmtTime(row.decided_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{open ? (
|
||||
<TableRow className="hover:bg-transparent has-aria-expanded:bg-transparent">
|
||||
<TableCell colSpan={columns} className="whitespace-normal p-0">
|
||||
<section id={panelID} aria-label={`审批详情 #${row.id}`}>
|
||||
<ApprovalDetail row={row} busy={busy} decide={decide} revision={revision} />
|
||||
</section>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
||||
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
const [error, setError] = React.useState("");
|
||||
const [deciding, setDeciding] = React.useState(false);
|
||||
const [revision, setRevision] = React.useState(0);
|
||||
const request = React.useRef(0);
|
||||
const decisionLock = React.useRef(false);
|
||||
|
||||
const load = React.useCallback(
|
||||
async (manual = false) => {
|
||||
const id = ++request.current;
|
||||
if (manual) setRefreshing(true);
|
||||
try {
|
||||
const items = taskId ? await api.interceptTask(taskId) : await api.interceptHistory();
|
||||
if (id !== request.current) return;
|
||||
setRows(items);
|
||||
setError("");
|
||||
if (manual) setRevision((v) => v + 1);
|
||||
} catch (e) {
|
||||
if (id === request.current) setError((e as Error).message || "加载失败");
|
||||
} finally {
|
||||
if (id === request.current) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[taskId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
const timer = setInterval(() => void load(), 5000);
|
||||
return () => {
|
||||
request.current++;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const decide: Decide = async (id, decision) => {
|
||||
if (decisionLock.current) return;
|
||||
decisionLock.current = true;
|
||||
setDeciding(true);
|
||||
try {
|
||||
await api.interceptDecide(id, decision);
|
||||
// Invalidate a list request started before this decision.
|
||||
request.current++;
|
||||
setRows((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
|
||||
);
|
||||
setRevision((v) => v + 1);
|
||||
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
await load(true);
|
||||
} finally {
|
||||
decisionLock.current = false;
|
||||
setDeciding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pending = rows.filter((row) => row.status === "pending");
|
||||
const title = taskId ? "拦截审批" : "审批记录";
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardListIcon className="size-5 text-muted-foreground" />
|
||||
<h1 className="font-semibold text-xl">{title}</h1>
|
||||
{pending.length ? <Badge variant="secondary">{pending.length} 待审批</Badge> : null}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void load(true)} disabled={loading || refreshing}>
|
||||
<RefreshCwIcon data-icon="inline-start" className={cn(refreshing && "animate-spin")} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。</p>
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>记录加载失败:{error}。请点击刷新重试。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{pending.length ? (
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="flex items-center gap-2 border-b bg-muted/40 px-4 py-3 font-medium text-sm">
|
||||
<ShieldAlertIcon className="size-4" />
|
||||
待处理({pending.length})<span className="text-muted-foreground text-xs">展开后允许或拒绝</span>
|
||||
</div>
|
||||
<ApprovalTable rows={pending} busy={deciding} decide={decide} revision={revision} label="待处理审批" />
|
||||
</section>
|
||||
) : null}
|
||||
<section className="overflow-hidden rounded-xl border">
|
||||
<div className="border-b px-4 py-3 font-medium text-sm">
|
||||
{taskId ? "全部记录" : "最近记录"}({rows.length})
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
) : null}
|
||||
{!loading && !rows.length && !error ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ClipboardListIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>暂无审批记录</EmptyTitle>
|
||||
<EmptyDescription>规则或模型作出审批决定后,记录将显示在这里。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
{rows.length ? (
|
||||
<ApprovalTable rows={rows} busy={deciding} decide={decide} revision={revision} label="审批记录列表" />
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { RotateCcwIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingRetest } from "@/lib/types";
|
||||
|
||||
interface FindingRetestDialogProps {
|
||||
findingId: string;
|
||||
findingName?: string;
|
||||
onClose: () => void;
|
||||
onStarted?: (retest: FindingRetest) => void;
|
||||
}
|
||||
|
||||
// 仅在打开时挂载,关闭后清空说明;列表与详情共用提交锁及错误处理,启动后留在当前页。
|
||||
export function FindingRetestDialog({ findingId, findingName, onClose, onStarted }: FindingRetestDialogProps) {
|
||||
const notesId = React.useId();
|
||||
const [notes, setNotes] = React.useState("");
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const submitLock = React.useRef(false);
|
||||
|
||||
async function start() {
|
||||
if (submitLock.current) return;
|
||||
submitLock.current = true;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await api.startFindingRetest(findingId, notes.trim());
|
||||
onStarted?.(result.retest);
|
||||
onClose();
|
||||
toast.success(result.created ? "复测已启动,可点击「复测中」查看会话" : "该漏洞正在复测,可查看已有会话");
|
||||
} catch (e) {
|
||||
toast.error(`发起复测失败:${(e as Error).message}`);
|
||||
} finally {
|
||||
submitLock.current = false;
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && !submitLock.current && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>复测漏洞 #{findingId}</DialogTitle>
|
||||
<DialogDescription className="break-words">
|
||||
{findingName ? <span className="mb-2 block">{findingName}</span> : null}
|
||||
复测 Agent
|
||||
将读取原证据和测试约束,在独立会话中执行针对性验证。复测成功完成且确认修复后,漏洞状态自动改为「已修复」,其他结论保留原状态。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FieldGroup>
|
||||
<Field data-disabled={submitting}>
|
||||
<FieldLabel htmlFor={notesId}>补充说明(可选)</FieldLabel>
|
||||
<Textarea
|
||||
id={notesId}
|
||||
value={notes}
|
||||
maxLength={4000}
|
||||
rows={4}
|
||||
disabled={submitting}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="例如:使用原测试账号验证原接口;修复版本为 v2。"
|
||||
/>
|
||||
<FieldDescription>可补充修复版本、测试条件或本次限制。</FieldDescription>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" disabled={submitting} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={submitting} onClick={() => void start()}>
|
||||
{submitting ? <Spinner data-icon="inline-start" /> : <RotateCcwIcon data-icon="inline-start" />}
|
||||
{submitting ? "正在创建…" : "开始复测"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { RotateCcwIcon } from "lucide-react";
|
||||
|
||||
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
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 { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { FindingRetest } from "@/lib/types";
|
||||
|
||||
const statusLabels = {
|
||||
pending: "等待启动",
|
||||
running: "复测中",
|
||||
completed: "已完成",
|
||||
failed: "复测失败",
|
||||
stopped: "已停止",
|
||||
};
|
||||
const verdictLabels = { reproduced: "仍可复现", fixed: "已修复", inconclusive: "无法确认" };
|
||||
|
||||
function active(r: FindingRetest) {
|
||||
return r.status === "pending" || r.status === "running";
|
||||
}
|
||||
|
||||
export function FindingRetestPanel({
|
||||
findingId,
|
||||
findingName,
|
||||
readOnly = false,
|
||||
onCompleted,
|
||||
}: {
|
||||
findingId: string;
|
||||
findingName?: string;
|
||||
readOnly?: boolean;
|
||||
onCompleted?: () => void;
|
||||
}) {
|
||||
const [items, setItems] = React.useState<FindingRetest[] | null>(null);
|
||||
const [error, setError] = React.useState("");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const requestSeq = React.useRef(0);
|
||||
const previousItems = React.useRef<FindingRetest[]>([]);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
const seq = ++requestSeq.current;
|
||||
try {
|
||||
const rows = await api.findingRetests(findingId);
|
||||
if (seq !== requestSeq.current) return;
|
||||
const completed = previousItems.current.some(
|
||||
(item) => active(item) && rows.some((row) => row.id === item.id && !active(row)),
|
||||
);
|
||||
previousItems.current = rows;
|
||||
setItems(rows);
|
||||
setError("");
|
||||
if (completed) onCompleted?.();
|
||||
} catch (e) {
|
||||
if (seq === requestSeq.current) setError((e as Error).message);
|
||||
}
|
||||
}, [findingId, onCompleted]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
return () => {
|
||||
requestSeq.current++;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error || !items?.some(active)) return;
|
||||
const timer = setTimeout(() => void load(), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [error, items, load]);
|
||||
|
||||
const running = items?.find(active);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<CardTitle>漏洞复测</CardTitle>
|
||||
<CardDescription>在独立会话中验证当前状态,保留每次复测的结论与证据。</CardDescription>
|
||||
</div>
|
||||
{running?.conversation_id != null ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/chat?c=${running.conversation_id}`} title="查看正在进行的复测会话">
|
||||
<Spinner data-icon="inline-start" aria-hidden="true" />
|
||||
复测中
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{!running && !readOnly ? (
|
||||
<Button size="sm" onClick={() => setOpen(true)} disabled={items === null || !!error}>
|
||||
<RotateCcwIcon data-icon="inline-start" />
|
||||
发起复测
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
加载复测记录失败:{error}
|
||||
<Button variant="outline" size="sm" onClick={() => void load()}>
|
||||
重试
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{!error && items === null ? <Skeleton className="h-16 w-full" /> : null}
|
||||
{!error && items?.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无复测记录</EmptyTitle>
|
||||
<EmptyDescription>修复部署完成后,可发起复测并比较新旧证据。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
{!error && items
|
||||
? items.map((item) => (
|
||||
<div key={item.id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant={item.status === "completed" && item.verdict === "reproduced" ? "destructive" : "secondary"}
|
||||
>
|
||||
{item.status === "completed" && item.verdict
|
||||
? verdictLabels[item.verdict]
|
||||
: statusLabels[item.status]}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
#{item.id} · {new Date(item.created_at).toLocaleString("zh-CN")}
|
||||
</span>
|
||||
{item.conversation_id != null ? (
|
||||
<Button asChild variant="ghost" size="sm" className="ml-auto">
|
||||
<Link href={`/chat?c=${item.conversation_id}`}>查看会话</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">会话已删除</span>
|
||||
)}
|
||||
</div>
|
||||
{item.status === "completed" && item.summary ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm">{item.summary}</p>
|
||||
) : null}
|
||||
{item.error ? (
|
||||
<p className="whitespace-pre-wrap break-words text-destructive text-sm">{item.error}</p>
|
||||
) : null}
|
||||
{item.notes ? (
|
||||
<p className="whitespace-pre-wrap break-words text-muted-foreground text-xs">
|
||||
补充说明:{item.notes}
|
||||
</p>
|
||||
) : null}
|
||||
{item.status === "completed" && item.evidence ? (
|
||||
<details className="min-w-0">
|
||||
<summary className="cursor-pointer text-sm">复测证据</summary>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<Markdown text={item.evidence} />
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</CardContent>
|
||||
{open ? (
|
||||
<FindingRetestDialog
|
||||
findingId={findingId}
|
||||
findingName={findingName}
|
||||
onClose={() => setOpen(false)}
|
||||
onStarted={(retest) => {
|
||||
requestSeq.current++;
|
||||
const rows = [retest, ...previousItems.current.filter((item) => item.id !== retest.id)];
|
||||
previousItems.current = rows;
|
||||
setItems(rows);
|
||||
setError("");
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user