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
@@ -19,11 +19,16 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
// 已登录直接进主界面(静态导出下无 middleware 代劳这层跳转)。
|
||||
if (auth.getToken()) {
|
||||
router.replace("/function/tasks");
|
||||
const token = auth.getToken();
|
||||
if (token) {
|
||||
// localStorage 可能仍有凭据但 cookie 已丢失。先同步,再发起全新请求,
|
||||
// 避免服务端守卫或路由缓存把跳转送回仍处于 checking 状态的登录页。
|
||||
auth.setToken(token);
|
||||
window.location.replace("/function/tasks");
|
||||
return;
|
||||
}
|
||||
api.authStatus()
|
||||
api
|
||||
.authStatus()
|
||||
.then(({ initialized }) => {
|
||||
if (!initialized) router.replace("/setup");
|
||||
})
|
||||
@@ -38,7 +43,7 @@ export default function LoginPage() {
|
||||
try {
|
||||
const { token } = await api.login("ARTEX", password);
|
||||
auth.setToken(token);
|
||||
router.replace("/function/tasks");
|
||||
window.location.replace("/function/tasks");
|
||||
} catch {
|
||||
setError("用户名或密码错误");
|
||||
} finally {
|
||||
@@ -46,7 +51,13 @@ export default function LoginPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) return null;
|
||||
if (checking) {
|
||||
return (
|
||||
<div role="status" className="flex min-h-dvh items-center justify-center text-muted-foreground">
|
||||
正在检查登录状态…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh">
|
||||
@@ -57,13 +68,7 @@ export default function LoginPage() {
|
||||
<div className="absolute size-60 rounded-full border border-primary-foreground/15" />
|
||||
<div className="absolute size-40 rounded-full border border-primary-foreground/20" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="ARTEX"
|
||||
width={160}
|
||||
height={160}
|
||||
className="relative brightness-0 invert"
|
||||
/>
|
||||
<img src="/logo.png" alt="ARTEX" width={160} height={160} className="relative brightness-0 invert" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ArrowUpIcon,
|
||||
Bot,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ListChecksIcon,
|
||||
Loader2Icon,
|
||||
MoreHorizontalIcon,
|
||||
@@ -47,11 +48,14 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useSideQuestions } from "@/hooks/use-side-questions";
|
||||
import { mergeActivities } from "@/lib/activity-merge";
|
||||
import { api } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||
import { isBtwCommand } from "@/lib/side-questions";
|
||||
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -86,10 +90,46 @@ function fmtDuration(ms: number): string {
|
||||
const HISTORY_PAGE = 200;
|
||||
const CONVERSATION_LIST_PAGE = 100;
|
||||
|
||||
// Which agent groups the user has collapsed in the left rail. Persisted so the
|
||||
// rail looks the same after a reload; unknown keys are harmless (a deleted agent
|
||||
// simply never renders a group again).
|
||||
const COLLAPSED_AGENTS_KEY = "artex.chat.collapsed-agents";
|
||||
|
||||
function conversationIsPinned(conversation: Conversation): boolean {
|
||||
return conversation.pinned ?? Boolean(conversation.pinned_at);
|
||||
}
|
||||
|
||||
// AgentGroup is one collapsible section of the left rail: all unpinned
|
||||
// conversations of a single agent, newest activity first.
|
||||
interface AgentGroup {
|
||||
key: string;
|
||||
name: string;
|
||||
conversations: Conversation[];
|
||||
runningCount: number;
|
||||
}
|
||||
|
||||
// groupByAgent buckets conversations by agent, preserving the incoming order
|
||||
// both inside a group and across groups. The server already sorts by updated_at
|
||||
// DESC, so first-appearance order == most-recently-active group first.
|
||||
function groupByAgent(conversations: Conversation[], agentByKey: Map<string, Agent>): AgentGroup[] {
|
||||
const groups = new Map<string, AgentGroup>();
|
||||
for (const conversation of conversations) {
|
||||
let group = groups.get(conversation.agent_key);
|
||||
if (!group) {
|
||||
group = {
|
||||
key: conversation.agent_key,
|
||||
name: agentByKey.get(conversation.agent_key)?.name || conversation.agent_key,
|
||||
conversations: [],
|
||||
runningCount: 0,
|
||||
};
|
||||
groups.set(conversation.agent_key, group);
|
||||
}
|
||||
group.conversations.push(conversation);
|
||||
if (conversation.running) group.runningCount++;
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
// LiveBadge is the small pulsing "实时" chip reused from the task's main-agent
|
||||
// console — shown while a turn is streaming.
|
||||
function LiveBadge() {
|
||||
@@ -171,7 +211,7 @@ function Composer({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{leftSlot ? <div className="w-full sm:w-auto">{leftSlot}</div> : null}
|
||||
{onPickFiles && (
|
||||
<>
|
||||
@@ -376,23 +416,25 @@ function DraftChat({
|
||||
|
||||
const agentPicker = (
|
||||
<Select value={agentKey} onValueChange={setAgentKey}>
|
||||
<SelectTrigger size="sm" className="w-full sm:w-40">
|
||||
<SelectTrigger className="w-full sm:w-40">
|
||||
<SelectValue placeholder="选择 Agent…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.key} value={a.key}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Bot className="size-3.5" />
|
||||
{a.name}
|
||||
{!a.builtin && (
|
||||
<Badge variant="outline" className="px-1 py-0 text-[9px]">
|
||||
自定义
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectGroup>
|
||||
{agents.map((a) => (
|
||||
<SelectItem key={a.key} value={a.key}>
|
||||
<span className="flex items-center gap-2">
|
||||
<Bot className="size-3.5" />
|
||||
{a.name}
|
||||
{!a.builtin && (
|
||||
<Badge variant="outline" className="px-1 py-0 text-[9px]">
|
||||
自定义
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
@@ -503,12 +545,12 @@ function ChatView({
|
||||
.conversationHistory(conv.id, 0, HISTORY_PAGE)
|
||||
.then((r) => {
|
||||
if (!live) return;
|
||||
setMessages(r.items);
|
||||
cursorRef.current = r.cursor; // newest id → incremental-tail anchor
|
||||
setMessages((current) => mergeActivities(r.items, current));
|
||||
cursorRef.current = Math.max(cursorRef.current, r.cursor);
|
||||
earliestRef.current = r.items.length ? r.items[0].seq : 0;
|
||||
hasMoreRef.current = r.hasMore;
|
||||
setHasMore(r.hasMore);
|
||||
setRunning(r.running);
|
||||
setRunning((current) => current || r.running);
|
||||
})
|
||||
.catch(() => {
|
||||
/* The empty state remains usable when history loading fails. */
|
||||
@@ -518,28 +560,40 @@ function ChatView({
|
||||
};
|
||||
}, [conv.id]);
|
||||
|
||||
// A trigger or another tab may start a turn while this conversation is open.
|
||||
// A false list snapshot must not stop the tail before its final messages load.
|
||||
React.useEffect(() => {
|
||||
if (conv.running) setRunning(true);
|
||||
}, [conv.running]);
|
||||
|
||||
// poll while a turn is running: pull new steps after the cursor.
|
||||
React.useEffect(() => {
|
||||
if (!running) return;
|
||||
let live = true;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const tick = async () => {
|
||||
let keepPolling = true;
|
||||
try {
|
||||
const r = await api.conversationMessages(conv.id, cursorRef.current);
|
||||
if (!live) return;
|
||||
if (r.items.length) {
|
||||
setMessages((prev) => [...prev, ...r.items]);
|
||||
cursorRef.current = r.cursor;
|
||||
setMessages((prev) => mergeActivities(prev, r.items));
|
||||
}
|
||||
cursorRef.current = Math.max(cursorRef.current, r.cursor);
|
||||
keepPolling = r.running;
|
||||
setRunning(r.running);
|
||||
if (!r.running) onTitleMaybeChanged(); // first-turn auto-title landed
|
||||
} catch {
|
||||
/* transient — keep polling */
|
||||
} finally {
|
||||
// Slow responses must not overlap another poll with the same cursor.
|
||||
if (live && keepPolling) timer = setTimeout(() => void tick(), 1000);
|
||||
}
|
||||
};
|
||||
const h = setInterval(tick, 1000);
|
||||
void tick();
|
||||
return () => {
|
||||
live = false;
|
||||
clearInterval(h);
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [running, conv.id, onTitleMaybeChanged]);
|
||||
|
||||
@@ -562,10 +616,7 @@ function ChatView({
|
||||
try {
|
||||
const r = await api.conversationHistory(conv.id, earliestRef.current, HISTORY_PAGE);
|
||||
if (r.items.length) {
|
||||
setMessages((prev) => {
|
||||
const seen = new Set(prev.map((a) => a.seq));
|
||||
return [...r.items.filter((a) => !seen.has(a.seq)), ...prev];
|
||||
});
|
||||
setMessages((prev) => mergeActivities(r.items, prev));
|
||||
earliestRef.current = r.items[0].seq;
|
||||
}
|
||||
hasMoreRef.current = r.hasMore;
|
||||
@@ -661,11 +712,9 @@ function ChatView({
|
||||
setAttachments([]);
|
||||
try {
|
||||
await api.sendConversationMessage(conv.id, msg, atts.length ? atts : undefined);
|
||||
// The live loop pulls the persisted human turn immediately. Sharing that
|
||||
// fetch avoids racing a separate post-send request against the poller.
|
||||
setRunning(true);
|
||||
// pull the just-persisted human turn immediately.
|
||||
const r = await api.conversationMessages(conv.id, cursorRef.current);
|
||||
setMessages((prev) => [...prev, ...r.items]);
|
||||
cursorRef.current = r.cursor;
|
||||
} catch (e) {
|
||||
toast.error("发送失败:" + (e as Error).message);
|
||||
setInput(msg); // restore so the user doesn't lose their text
|
||||
@@ -766,10 +815,12 @@ function ChatView({
|
||||
}
|
||||
|
||||
// ConversationItem is one row in the left rail: title, agent subtitle, inline
|
||||
// rename, pin marker, and a compact action menu.
|
||||
// rename, pin marker, and a compact action menu. Rows nested under an agent
|
||||
// group drop the agent subtitle (showAgent=false) — the header already says it.
|
||||
const ConversationItem = React.memo(function ConversationItem({
|
||||
conv,
|
||||
agent,
|
||||
showAgent = true,
|
||||
active,
|
||||
renaming,
|
||||
renameText,
|
||||
@@ -786,6 +837,7 @@ const ConversationItem = React.memo(function ConversationItem({
|
||||
}: {
|
||||
conv: Conversation;
|
||||
agent?: Agent;
|
||||
showAgent?: boolean;
|
||||
active: boolean;
|
||||
renaming: boolean;
|
||||
renameText: string;
|
||||
@@ -856,11 +908,21 @@ const ConversationItem = React.memo(function ConversationItem({
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
{pinned && <PinIcon className="text-primary size-3 shrink-0" aria-label="已置顶" />}
|
||||
<div className="truncate text-sm">{conv.title || "新对话"}</div>
|
||||
{conv.running ? (
|
||||
<Badge variant="secondary" className="shrink-0 gap-1" title="Agent 正在运行">
|
||||
<Spinner className="size-3" aria-hidden="true" />
|
||||
运行中
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-1 text-[11px]">
|
||||
<Bot className="size-3 shrink-0" />
|
||||
<span className="min-w-0 truncate">{agent?.name ?? conv.agent_key}</span>
|
||||
<span className="shrink-0">·</span>
|
||||
{showAgent && (
|
||||
<>
|
||||
<Bot className="size-3 shrink-0" />
|
||||
<span className="min-w-0 truncate">{agent?.name ?? conv.agent_key}</span>
|
||||
<span className="shrink-0">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="shrink-0">
|
||||
{new Date(conv.created_at).toLocaleDateString("zh-CN", {
|
||||
month: "numeric",
|
||||
@@ -920,10 +982,49 @@ const ConversationItem = React.memo(function ConversationItem({
|
||||
);
|
||||
});
|
||||
|
||||
// AgentGroupHeader is the sticky, clickable divider above one agent's rows:
|
||||
// collapse chevron, agent name, a running marker, and the row count.
|
||||
function AgentGroupHeader({
|
||||
group,
|
||||
collapsed,
|
||||
hasActive,
|
||||
onToggle,
|
||||
}: {
|
||||
group: AgentGroup;
|
||||
collapsed: boolean;
|
||||
hasActive: boolean;
|
||||
onToggle: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(group.key)}
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? `展开「${group.name}」` : `收起「${group.name}」`}
|
||||
className={cn(
|
||||
"sticky top-0 z-10 flex min-w-0 items-center gap-1.5 rounded-md bg-card px-1.5 py-1 text-left font-medium text-[11px] transition-colors hover:bg-accent/50",
|
||||
collapsed && hasActive ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<ChevronRightIcon className={cn("size-3 shrink-0 transition-transform", !collapsed && "rotate-90")} />
|
||||
<Bot className="size-3 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate">{group.name}</span>
|
||||
{collapsed && hasActive && (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-primary" title="当前对话在此分组内" />
|
||||
)}
|
||||
{group.runningCount > 0 && (
|
||||
<Spinner className="size-3 shrink-0" aria-label={`${group.runningCount} 个对话运行中`} />
|
||||
)}
|
||||
<span className="shrink-0 tabular-nums opacity-60">{group.conversations.length}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPage() {
|
||||
const [agents, setAgents] = React.useState<Agent[]>([]);
|
||||
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [convs, setConvs] = React.useState<Conversation[]>([]);
|
||||
const [agentFilter, setAgentFilter] = React.useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = React.useState<number | null>(null);
|
||||
const [renamingId, setRenamingId] = React.useState<number | null>(null);
|
||||
const [renameText, setRenameText] = React.useState("");
|
||||
@@ -935,6 +1036,9 @@ export default function ChatPage() {
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
|
||||
const [bulkDeleting, setBulkDeleting] = React.useState(false);
|
||||
const [visibleConversationCount, setVisibleConversationCount] = React.useState(CONVERSATION_LIST_PAGE);
|
||||
// Collapsed agent groups. Hydrated from localStorage after mount (not a lazy
|
||||
// useState init) so the server and client render the same first pass.
|
||||
const [collapsedAgents, setCollapsedAgents] = React.useState<Set<string>>(() => new Set());
|
||||
// Pending text + uploaded attachments handed off from a draft that created a
|
||||
// conversation via the paperclip, keyed by the new conversation id (consumed once
|
||||
// by ChatView on mount; ids never repeat, so leftover entries are harmless).
|
||||
@@ -942,11 +1046,15 @@ export default function ChatPage() {
|
||||
Record<number, { input?: string; attachments?: ChatAttachment[] }>
|
||||
>({});
|
||||
|
||||
const reloadConvs = React.useCallback(() => {
|
||||
api
|
||||
.conversations()
|
||||
.then(setConvs)
|
||||
.catch(() => setConvs([]));
|
||||
const conversationListSeq = React.useRef(0);
|
||||
const reloadConvs = React.useCallback(async () => {
|
||||
const seq = ++conversationListSeq.current;
|
||||
try {
|
||||
const items = await api.conversations();
|
||||
if (seq === conversationListSeq.current) setConvs(items);
|
||||
} catch {
|
||||
// Preserve the selected transcript and list on a transient poll failure.
|
||||
}
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
api
|
||||
@@ -961,7 +1069,23 @@ export default function ChatPage() {
|
||||
.catch(() => {
|
||||
/* The conversation remains usable without profile labels. */
|
||||
});
|
||||
reloadConvs();
|
||||
}, []);
|
||||
|
||||
// One list poll supplies runtime state for all sidebar rows, including the
|
||||
// unselected ones. Await completion so slow requests do not overlap.
|
||||
React.useEffect(() => {
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function poll() {
|
||||
await reloadConvs();
|
||||
if (!disposed) timer = setTimeout(() => void poll(), 2000);
|
||||
}
|
||||
void poll();
|
||||
return () => {
|
||||
disposed = true;
|
||||
conversationListSeq.current++;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [reloadConvs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -973,6 +1097,27 @@ export default function ChatPage() {
|
||||
});
|
||||
}, [convs]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const raw = getLocalStorageValue(COLLAPSED_AGENTS_KEY);
|
||||
if (!raw) return;
|
||||
try {
|
||||
const keys = JSON.parse(raw);
|
||||
if (Array.isArray(keys))
|
||||
setCollapsedAgents(new Set(keys.filter((key): key is string => typeof key === "string")));
|
||||
} catch {
|
||||
// Corrupted entry → start with everything expanded.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleAgentCollapsed = React.useCallback((key: string) => {
|
||||
setCollapsedAgents((current) => {
|
||||
const next = new Set(current);
|
||||
if (!next.delete(key)) next.add(key);
|
||||
setLocalStorageValue(COLLAPSED_AGENTS_KEY, JSON.stringify([...next]));
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Restore the open conversation from the URL (?c=<id>) on mount, so a refresh
|
||||
// returns to the same thread instead of the empty draft view. Runs after
|
||||
// hydration (not a lazy useState init) to avoid a server/client mismatch.
|
||||
@@ -994,17 +1139,58 @@ export default function ChatPage() {
|
||||
|
||||
const selected = React.useMemo(() => convs.find((c) => c.id === selectedId) ?? null, [convs, selectedId]);
|
||||
const agentByKey = React.useMemo(() => new Map(agents.map((agent) => [agent.key, agent])), [agents]);
|
||||
const filteredConversations = React.useMemo(
|
||||
() => (agentFilter === null ? convs : convs.filter((conversation) => conversation.agent_key === agentFilter)),
|
||||
[convs, agentFilter],
|
||||
);
|
||||
const visibleConversations = React.useMemo(
|
||||
() => convs.slice(0, visibleConversationCount),
|
||||
[convs, visibleConversationCount],
|
||||
() => filteredConversations.slice(0, visibleConversationCount),
|
||||
[filteredConversations, visibleConversationCount],
|
||||
);
|
||||
// Pinned rows stay a flat block above the groups (server order = pinned_at DESC);
|
||||
// everything else is bucketed per agent, most-recently-active agent first.
|
||||
const pinnedConversations = React.useMemo(
|
||||
() => visibleConversations.filter(conversationIsPinned),
|
||||
[visibleConversations],
|
||||
);
|
||||
const agentGroups = React.useMemo(
|
||||
() =>
|
||||
groupByAgent(
|
||||
visibleConversations.filter((c) => !conversationIsPinned(c)),
|
||||
agentByKey,
|
||||
),
|
||||
[visibleConversations, agentByKey],
|
||||
);
|
||||
// conversation agents: custom agents + conversational built-ins (role=assistant,
|
||||
// e.g. Auto / 渗透测试). The orchestration built-ins (goals/planner/mainagent/worker)
|
||||
// are task-specific and stay hidden from the chat page.
|
||||
const chatAgents = React.useMemo(() => agents.filter((a) => !a.builtin || a.role === "assistant"), [agents]);
|
||||
const agentFilterOptions = React.useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const conversation of convs) {
|
||||
counts.set(conversation.agent_key, (counts.get(conversation.agent_key) ?? 0) + 1);
|
||||
}
|
||||
// Include historical sources even if their Agent has since been disabled or deleted.
|
||||
const keys = new Set([...chatAgents.map((agent) => agent.key), ...counts.keys()]);
|
||||
if (agentFilter !== null) keys.add(agentFilter);
|
||||
return [...keys]
|
||||
.map((key) => ({ key, name: agentByKey.get(key)?.name || key, count: counts.get(key) ?? 0 }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
||||
}, [convs, chatAgents, agentByKey, agentFilter]);
|
||||
const conversationCountLabel =
|
||||
agentFilter === null ? `共 ${convs.length} 个` : `${filteredConversations.length} / ${convs.length} 个`;
|
||||
|
||||
function changeAgentFilter(key: string | null) {
|
||||
setAgentFilter(key);
|
||||
setVisibleConversationCount(CONVERSATION_LIST_PAGE);
|
||||
setSelectedConversationIds(new Set());
|
||||
setBulkDeleteOpen(false);
|
||||
setRenamingId(null);
|
||||
}
|
||||
|
||||
const selectedConversationCount = selectedConversationIds.size;
|
||||
const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length;
|
||||
const allConversationsSelected =
|
||||
filteredConversations.length > 0 && selectedConversationCount === filteredConversations.length;
|
||||
const someConversationsSelected = selectedConversationCount > 0 && !allConversationsSelected;
|
||||
let conversationHeaderChecked: boolean | "indeterminate" = false;
|
||||
if (allConversationsSelected) conversationHeaderChecked = true;
|
||||
@@ -1020,7 +1206,9 @@ export default function ChatPage() {
|
||||
}, []);
|
||||
|
||||
function toggleAllConversations(checked: boolean) {
|
||||
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
|
||||
setSelectedConversationIds(
|
||||
checked ? new Set(filteredConversations.map((conversation) => conversation.id)) : new Set(),
|
||||
);
|
||||
}
|
||||
|
||||
function exitSelectionMode() {
|
||||
@@ -1039,7 +1227,7 @@ export default function ChatPage() {
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
reloadConvs();
|
||||
void reloadConvs();
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e as Error).message);
|
||||
}
|
||||
@@ -1079,10 +1267,10 @@ export default function ChatPage() {
|
||||
// Fully successful → return to the clean list; keep selection mode on if
|
||||
// some failed so the user can retry the remaining ones.
|
||||
if (failed.length === 0) setSelectionMode(false);
|
||||
reloadConvs();
|
||||
void reloadConvs();
|
||||
} catch (error) {
|
||||
toast.error(`批量删除失败:${(error as Error).message}`);
|
||||
reloadConvs();
|
||||
void reloadConvs();
|
||||
} finally {
|
||||
setBulkDeleting(false);
|
||||
}
|
||||
@@ -1093,7 +1281,7 @@ export default function ChatPage() {
|
||||
const pinned = conversationIsPinned(conversation);
|
||||
try {
|
||||
await api.pinConversation(conversation.id, !pinned);
|
||||
reloadConvs();
|
||||
void reloadConvs();
|
||||
} catch (e) {
|
||||
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(e as Error).message}`);
|
||||
}
|
||||
@@ -1112,7 +1300,7 @@ export default function ChatPage() {
|
||||
if (!title) return;
|
||||
try {
|
||||
await api.renameConversation(id, title);
|
||||
reloadConvs();
|
||||
void reloadConvs();
|
||||
} catch (e) {
|
||||
toast.error("重命名失败:" + (e as Error).message);
|
||||
}
|
||||
@@ -1133,16 +1321,37 @@ export default function ChatPage() {
|
||||
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
|
||||
<PlusIcon /> 新建对话
|
||||
</Button>
|
||||
<Select
|
||||
value={agentFilter === null ? "all" : `agent:${agentFilter}`}
|
||||
onValueChange={(value) => changeAgentFilter(value === "all" ? null : value.slice(6))}
|
||||
disabled={bulkDeleting}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-full min-w-0" aria-label="按 Agent 筛选对话">
|
||||
<Bot />
|
||||
<SelectValue placeholder="全部 Agent" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="all">全部 Agent</SelectItem>
|
||||
{agentFilterOptions.map((agent) => (
|
||||
<SelectItem key={agent.key} value={`agent:${agent.key}`}>
|
||||
{agent.name}({agent.count})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{convs.length > 0 &&
|
||||
(selectionMode ? (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Checkbox
|
||||
checked={conversationHeaderChecked}
|
||||
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
|
||||
aria-label="选择全部对话"
|
||||
aria-label="选择当前筛选的全部对话"
|
||||
disabled={filteredConversations.length === 0 || bulkDeleting}
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
|
||||
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
|
||||
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : conversationCountLabel}
|
||||
</span>
|
||||
{selectedConversationCount > 0 && (
|
||||
<Button
|
||||
@@ -1162,13 +1371,14 @@ export default function ChatPage() {
|
||||
) : (
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
|
||||
共 {convs.length} 个
|
||||
{conversationCountLabel}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setSelectionMode(true)}
|
||||
disabled={filteredConversations.length === 0}
|
||||
>
|
||||
<ListChecksIcon data-icon="inline-start" />
|
||||
多选
|
||||
@@ -1176,10 +1386,18 @@ export default function ChatPage() {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
|
||||
<ScrollArea
|
||||
key={agentFilter === null ? "all" : `agent:${agentFilter}`}
|
||||
type="auto"
|
||||
className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5 p-2">
|
||||
{convs.length === 0 && <p className="text-muted-foreground px-2 py-6 text-center text-xs">暂无对话</p>}
|
||||
{visibleConversations.map((c) => (
|
||||
{filteredConversations.length === 0 && (
|
||||
<p className="text-muted-foreground px-2 py-6 text-center text-xs">
|
||||
{agentFilter === null ? "暂无对话" : "该 Agent 暂无对话"}
|
||||
</p>
|
||||
)}
|
||||
{pinnedConversations.map((c) => (
|
||||
<ConversationItem
|
||||
key={c.id}
|
||||
conv={c}
|
||||
@@ -1199,7 +1417,42 @@ export default function ChatPage() {
|
||||
onSelectedForDeleteChange={toggleConversationSelected}
|
||||
/>
|
||||
))}
|
||||
{visibleConversationCount < convs.length && (
|
||||
{agentGroups.map((group) => {
|
||||
const collapsed = collapsedAgents.has(group.key);
|
||||
return (
|
||||
<React.Fragment key={group.key}>
|
||||
<AgentGroupHeader
|
||||
group={group}
|
||||
collapsed={collapsed}
|
||||
hasActive={group.conversations.some((c) => c.id === selectedId)}
|
||||
onToggle={toggleAgentCollapsed}
|
||||
/>
|
||||
{!collapsed &&
|
||||
group.conversations.map((c) => (
|
||||
<ConversationItem
|
||||
key={c.id}
|
||||
conv={c}
|
||||
agent={agentByKey.get(c.agent_key)}
|
||||
showAgent={false}
|
||||
active={selectedId === c.id}
|
||||
renaming={renamingId === c.id}
|
||||
renameText={renamingId === c.id ? renameText : ""}
|
||||
onSelect={setSelectedId}
|
||||
onStartRename={startRename}
|
||||
onRenameText={setRenameText}
|
||||
onCommitRename={commitRename}
|
||||
onCancelRename={cancelRename}
|
||||
onTogglePinned={togglePinned}
|
||||
onDelete={del}
|
||||
selectionMode={selectionMode}
|
||||
selectedForDelete={selectedConversationIds.has(c.id)}
|
||||
onSelectedForDeleteChange={toggleConversationSelected}
|
||||
/>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{visibleConversationCount < filteredConversations.length && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -1231,6 +1484,7 @@ export default function ChatPage() {
|
||||
agents={chatAgents}
|
||||
profiles={profiles}
|
||||
onStarted={(c, pending) => {
|
||||
if (agentFilter !== null && agentFilter !== c.agent_key) changeAgentFilter(null);
|
||||
// Insert the new conversation immediately so `selected` resolves to
|
||||
// it on this render (switching to ChatView right away, before the
|
||||
// async reloadConvs lands); reloadConvs then reconciles titles etc.
|
||||
@@ -1242,7 +1496,7 @@ export default function ChatPage() {
|
||||
return [...prev.slice(0, insertAt), c, ...prev.slice(insertAt)];
|
||||
});
|
||||
setSelectedId(c.id);
|
||||
reloadConvs();
|
||||
void reloadConvs();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
ChevronRightIcon,
|
||||
FileTextIcon,
|
||||
FlaskConicalIcon,
|
||||
RotateCcwIcon,
|
||||
ShieldAlertIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
@@ -27,14 +28,16 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
|
||||
@@ -44,6 +47,7 @@ export const FINDING_STATUSES: FindingStatus[] = [
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
@@ -98,6 +102,8 @@ interface FindingsTableProps {
|
||||
saving: boolean;
|
||||
onSave: (finding: Finding) => void;
|
||||
onStatusChange: (finding: Finding, next: FindingStatus) => void;
|
||||
onRetest: (finding: Finding) => void;
|
||||
activeRetests: Record<string, ActiveFindingRetest>;
|
||||
onDeepen: (finding: Finding) => void;
|
||||
onDelete: (finding: Finding) => void;
|
||||
/** 全选框的无障碍标签,平铺视图与分组视图措辞不同。 */
|
||||
@@ -105,7 +111,7 @@ interface FindingsTableProps {
|
||||
}
|
||||
|
||||
// FindingsTable 是发现列表的表格主体,平铺视图与按任务分组视图共用同一份行渲染
|
||||
// (勾选 / 行内展开 / 行内改名与改状态 / 深入 / 删除),差异只在外层容器与分页。
|
||||
// (勾选 / 行内展开 / 行内改名与改状态 / 复测 / 深入 / 删除),差异只在外层容器与分页。
|
||||
export function FindingsTable({
|
||||
items,
|
||||
selectedIds,
|
||||
@@ -119,6 +125,8 @@ export function FindingsTable({
|
||||
saving,
|
||||
onSave,
|
||||
onStatusChange,
|
||||
onRetest,
|
||||
activeRetests,
|
||||
onDeepen,
|
||||
onDelete,
|
||||
selectAllLabel = "选择当前页全部",
|
||||
@@ -133,9 +141,8 @@ export function FindingsTable({
|
||||
}
|
||||
|
||||
return (
|
||||
/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内
|
||||
换行/内部滚动,不会把整张表撑出横向滚动条。 */
|
||||
<Table className="table-fixed">
|
||||
/* 固定列宽保证展开内容不撑开表格;窄屏只在表格内部横向滚动。 */
|
||||
<Table className="min-w-[60rem] table-fixed">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
@@ -148,17 +155,18 @@ export function FindingsTable({
|
||||
<TableHead className="w-8" />
|
||||
<TableHead className="w-20">严重度</TableHead>
|
||||
<TableHead>漏洞名称</TableHead>
|
||||
<TableHead className="w-52">资产</TableHead>
|
||||
<TableHead className="w-44">资产</TableHead>
|
||||
<TableHead className="w-28">状态</TableHead>
|
||||
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
|
||||
<TableHead className="w-32">时间</TableHead>
|
||||
<TableHead className="w-32 text-right">操作</TableHead>
|
||||
<TableHead className="w-32">所属任务</TableHead>
|
||||
<TableHead className="w-24">时间</TableHead>
|
||||
<TableHead className="w-48">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map((f) => {
|
||||
const rowKey = findingRowKey(f);
|
||||
const open = expandedKey === rowKey;
|
||||
const retest = f.finding_id ? activeRetests[f.finding_id] : undefined;
|
||||
return (
|
||||
<React.Fragment key={rowKey}>
|
||||
<TableRow
|
||||
@@ -205,15 +213,16 @@ export function FindingsTable({
|
||||
<span className="truncate font-medium">{f.name || f.vulnclass || "未分类"}</span>
|
||||
)}
|
||||
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
|
||||
<Badge variant="outline">流量证据 {f.traffic_count ?? 0} 条</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-52">
|
||||
<TableCell>
|
||||
{f.assets && f.assets.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{f.assets.slice(0, 3).map((a) => (
|
||||
<code
|
||||
key={a.id}
|
||||
className="max-w-[12rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
className="max-w-full truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
|
||||
title={`${a.type} · ${a.label}`}
|
||||
>
|
||||
{a.label}
|
||||
@@ -245,7 +254,7 @@ export function FindingsTable({
|
||||
<StatusBadge domain="finding" value={f.status} dot />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-36 max-w-[9rem]">
|
||||
<TableCell>
|
||||
{f.task_id ? (
|
||||
<Link
|
||||
href={`/function/tasks/detail?id=${f.task_id}`}
|
||||
@@ -261,8 +270,22 @@ export function FindingsTable({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(f.ts)}</TableCell>
|
||||
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center gap-1">
|
||||
{retest ? (
|
||||
<Button asChild size="sm" variant="ghost">
|
||||
<Link href={`/chat?c=${retest.conversation_id}`} title="查看正在进行的复测会话">
|
||||
<Spinner data-icon="inline-start" />
|
||||
复测中
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
{!retest && f.finding_id && !f.inherited ? (
|
||||
<Button size="sm" variant="ghost" onClick={() => onRetest(f)} title="在独立会话中复测该漏洞">
|
||||
<RotateCcwIcon data-icon="inline-start" />
|
||||
复测
|
||||
</Button>
|
||||
) : null}
|
||||
{f.finding_id && f.task_id && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onDeepen(f)}>
|
||||
<FlaskConicalIcon data-icon="inline-start" />
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
|
||||
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { FindingLineageView } from "./lineage";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import { api } from "@/lib/api";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
|
||||
import { FindingLineageView } from "./lineage";
|
||||
|
||||
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
|
||||
const FINDING_STATUSES: FindingStatus[] = [
|
||||
"pending",
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
@@ -44,21 +45,11 @@ function fmtTime(ts: string) {
|
||||
}
|
||||
|
||||
// FieldRow is one label/value line in the right-hand status panel.
|
||||
function FieldRow({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
function FieldRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-2.5">
|
||||
<span className="shrink-0 pt-0.5 text-xs text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-col items-end gap-1 text-right text-sm">
|
||||
{children}
|
||||
</div>
|
||||
<span className="shrink-0 pt-0.5 text-xs text-muted-foreground">{label}</span>
|
||||
<div className="flex min-w-0 flex-col items-end gap-1 text-right text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -123,9 +114,7 @@ function FindingDetailInner() {
|
||||
if (!finding) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
{loaded ? `未找到发现 ${id}` : "加载中…"}
|
||||
</p>
|
||||
<p className="text-muted-foreground">{loaded ? `未找到发现 ${id}` : "加载中…"}</p>
|
||||
{loaded && (
|
||||
<Button asChild variant="outline">
|
||||
<Link href="/function/findings">
|
||||
@@ -140,11 +129,7 @@ function FindingDetailInner() {
|
||||
const title = finding.name || finding.vulnclass || "未分类";
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
className="flex flex-1 flex-col gap-0"
|
||||
>
|
||||
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
|
||||
{/* Sticky header */}
|
||||
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
@@ -158,16 +143,12 @@ function FindingDetailInner() {
|
||||
<h1 className="max-w-md truncate text-sm font-semibold" title={title}>
|
||||
{title}
|
||||
</h1>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
#{finding.id}
|
||||
</code>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">#{finding.id}</code>
|
||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">
|
||||
来源任务 #{finding.source_task_id} · 只读
|
||||
</Badge>
|
||||
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
|
||||
)}
|
||||
</div>
|
||||
<TabsList>
|
||||
@@ -188,11 +169,10 @@ function FindingDetailInner() {
|
||||
<CardTitle className="text-sm">摘要</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{finding.summary || "(无摘要)"}
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{finding.summary || "(无摘要)"}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingRetestPanel key={id} findingId={id} readOnly={finding.inherited} onCompleted={load} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">证据 / PoC</CardTitle>
|
||||
@@ -207,21 +187,29 @@ function FindingDetailInner() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingTrafficPanel
|
||||
key={id}
|
||||
findingId={id}
|
||||
contextTask={contextTaskId || undefined}
|
||||
readOnly={finding.inherited}
|
||||
onChanged={load}
|
||||
/>
|
||||
{/* 证据下方:详细报告(Markdown 渲染) */}
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="text-sm">详细报告</CardTitle>
|
||||
{finding.report && (
|
||||
<CopyButton text={finding.report} successMessage="已复制详细报告" />
|
||||
)}
|
||||
{finding.report && <CopyButton text={finding.report} successMessage="已复制详细报告" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{finding.report_stale ? (
|
||||
<Alert>
|
||||
<AlertDescription>流量证据已变更,详细报告待更新。</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
{finding.report ? (
|
||||
<Markdown text={finding.report} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
暂无详细报告。
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">暂无详细报告。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -245,14 +233,8 @@ function FindingDetailInner() {
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
) : (
|
||||
<Select
|
||||
value={finding.severity}
|
||||
onValueChange={(v) => changeSeverity(v as Severity)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
|
||||
>
|
||||
<Select value={finding.severity} onValueChange={(v) => changeSeverity(v as Severity)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
@@ -273,14 +255,8 @@ function FindingDetailInner() {
|
||||
{finding.inherited ? (
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
) : (
|
||||
<Select
|
||||
value={finding.status}
|
||||
onValueChange={(v) => changeStatus(v as FindingStatus)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
|
||||
>
|
||||
<Select value={finding.status} onValueChange={(v) => changeStatus(v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
@@ -299,9 +275,7 @@ function FindingDetailInner() {
|
||||
{/* 漏洞类型 */}
|
||||
<FieldRow label="漏洞类型">
|
||||
{finding.vulnclass ? (
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
|
||||
{finding.vulnclass}
|
||||
</code>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">{finding.vulnclass}</code>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
@@ -334,9 +308,7 @@ function FindingDetailInner() {
|
||||
className="inline-flex max-w-[16rem] items-center gap-1 text-primary hover:underline"
|
||||
title={finding.task_description}
|
||||
>
|
||||
<span className="truncate">
|
||||
{finding.task_description || `#${finding.task_id}`}
|
||||
</span>
|
||||
<span className="truncate">{finding.task_description || `#${finding.task_id}`}</span>
|
||||
<ArrowUpRightIcon className="size-3 shrink-0" />
|
||||
</Link>
|
||||
) : (
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -40,7 +41,15 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { api } from "@/lib/api";
|
||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingAssetNode, FindingGroup, FindingStats, FindingStatus, Severity } from "@/lib/types";
|
||||
import type {
|
||||
ActiveFindingRetest,
|
||||
Finding,
|
||||
FindingAssetNode,
|
||||
FindingGroup,
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
Severity,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { AssetTree, assetPathOf } from "./_components/asset-tree";
|
||||
@@ -145,12 +154,44 @@ export default function FindingsPage() {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(10);
|
||||
const [deepenFinding, setDeepenFinding] = React.useState<Finding | null>(null);
|
||||
const [retestFinding, setRetestFinding] = React.useState<Finding | null>(null);
|
||||
const [activeRetests, setActiveRetests] = React.useState<Record<string, ActiveFindingRetest>>({});
|
||||
const activeRetestFingerprint = Object.values(activeRetests)
|
||||
.map((item) => item.id)
|
||||
.join(",");
|
||||
const retestRefreshVersion = React.useRef(0);
|
||||
const [deepenDescription, setDeepenDescription] = React.useState("");
|
||||
const [deepening, setDeepening] = React.useState(false);
|
||||
const filterFingerprint = JSON.stringify([severity, status, vulnclass, task, sort, query]);
|
||||
const activeFilterFingerprint = React.useRef(filterFingerprint);
|
||||
activeFilterFingerprint.current = filterFingerprint;
|
||||
|
||||
// 一个轻量请求覆盖所有行/视图,避免逐行拉取完整复测历史;等待上一轮完成再轮询。
|
||||
React.useEffect(() => {
|
||||
let disposed = false;
|
||||
let failed = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function refreshRetests() {
|
||||
const version = retestRefreshVersion.current;
|
||||
try {
|
||||
const rows = await api.activeFindingRetests();
|
||||
if (disposed || version !== retestRefreshVersion.current) return;
|
||||
setActiveRetests(Object.fromEntries(rows.map((item) => [item.finding_id, item])));
|
||||
failed = false;
|
||||
} catch (error) {
|
||||
if (!disposed && !failed) toast.error(`加载复测状态失败:${(error as Error).message}`);
|
||||
failed = true;
|
||||
} finally {
|
||||
if (!disposed) timer = setTimeout(() => void refreshRetests(), 3000);
|
||||
}
|
||||
}
|
||||
void refreshRetests();
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const raw = getLocalStorageValue(FINDING_LIST_PREFERENCE_KEY);
|
||||
if (raw) {
|
||||
@@ -482,14 +523,16 @@ export default function FindingsPage() {
|
||||
// refreshAfterMutation 主动重拉),不做轮询。
|
||||
React.useEffect(() => {
|
||||
if (!preferencesHydrated || view !== "asset") return;
|
||||
void activeRetestFingerprint; // 复测结束可能改变状态筛选下的资产计数。
|
||||
void loadAssetTree();
|
||||
}, [loadAssetTree, preferencesHydrated, view]);
|
||||
}, [activeRetestFingerprint, loadAssetTree, preferencesHydrated, view]);
|
||||
|
||||
// 只轮询当前视图:平铺视图刷当前页,分组视图刷组头与每个已展开的组(其分页彼此独立)。
|
||||
// 资产视图只查一次(见下面的 return),它的左树是导航结构,没必要每 5 秒重算。
|
||||
// 等偏好水合后再发首个请求,否则会先按默认视图/筛选白拉一次。
|
||||
React.useEffect(() => {
|
||||
if (!preferencesHydrated) return;
|
||||
void activeRetestFingerprint; // 包括不定时轮询的资产视图,也在复测结束后刷新处置状态。
|
||||
const refresh = () => {
|
||||
if (view === "flat" || view === "asset") {
|
||||
if (!flatStateRef.current.loading) void loadFlat();
|
||||
@@ -506,7 +549,7 @@ export default function FindingsPage() {
|
||||
if (view === "asset") return;
|
||||
const timer = setInterval(refresh, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]);
|
||||
}, [activeRetestFingerprint, loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const lastPage = Math.max(1, Math.ceil(groupTotal / pageSize));
|
||||
@@ -737,6 +780,8 @@ export default function FindingsPage() {
|
||||
saving,
|
||||
onSave: saveEdit,
|
||||
onStatusChange: updateStatus,
|
||||
onRetest: setRetestFinding,
|
||||
activeRetests,
|
||||
onDeepen: openDeepen,
|
||||
onDelete: deleteFinding,
|
||||
};
|
||||
@@ -1083,6 +1128,27 @@ export default function FindingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{retestFinding?.finding_id ? (
|
||||
<FindingRetestDialog
|
||||
key={retestFinding.finding_id}
|
||||
findingId={retestFinding.finding_id}
|
||||
findingName={retestFinding.name || retestFinding.vulnclass || retestFinding.summary}
|
||||
onStarted={(retest) => {
|
||||
const findingId = retestFinding.finding_id;
|
||||
if (!findingId || retest.conversation_id == null || !["pending", "running"].includes(retest.status)) return;
|
||||
retestRefreshVersion.current++;
|
||||
const active: ActiveFindingRetest = {
|
||||
id: retest.id,
|
||||
finding_id: findingId,
|
||||
conversation_id: retest.conversation_id,
|
||||
status: retest.status === "pending" ? "pending" : "running",
|
||||
};
|
||||
setActiveRetests((current) => ({ ...current, [findingId]: active }));
|
||||
}}
|
||||
onClose={() => setRetestFinding(null)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Dialog
|
||||
open={deepenFinding !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -10,13 +10,7 @@ import { toast } from "sonner";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { api } from "@/lib/api";
|
||||
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
@@ -39,6 +33,7 @@ const FINDING_STATUSES: FindingStatus[] = [
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
@@ -64,10 +59,7 @@ function Row({
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
open && "rotate-90",
|
||||
)}
|
||||
className={cn("size-4 shrink-0 text-muted-foreground transition-transform", open && "rotate-90")}
|
||||
/>
|
||||
<StatusBadge domain="severity" value={f.severity} dot />
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
@@ -79,11 +71,10 @@ function Row({
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{f.summary}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
|
||||
</div>
|
||||
</button>
|
||||
<Badge variant="outline">流量证据 {f.traffic_count ?? 0} 条</Badge>
|
||||
{f.assets && f.assets.length > 0 && (
|
||||
<div className="hidden shrink-0 flex-wrap justify-end gap-1 sm:flex">
|
||||
{f.assets.slice(0, 2).map((a) => (
|
||||
@@ -95,22 +86,12 @@ function Row({
|
||||
{a.label}
|
||||
</code>
|
||||
))}
|
||||
{f.assets.length > 2 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
+{f.assets.length - 2}
|
||||
</span>
|
||||
)}
|
||||
{f.assets.length > 2 && <span className="text-xs text-muted-foreground">+{f.assets.length - 2}</span>}
|
||||
</div>
|
||||
)}
|
||||
{f.finding_id && !f.inherited ? (
|
||||
<Select
|
||||
value={f.status}
|
||||
onValueChange={(v) => onStatus(f, v as FindingStatus)}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-7 w-28 shrink-0 border-none px-1 shadow-none focus-visible:ring-0"
|
||||
>
|
||||
<Select value={f.status} onValueChange={(v) => onStatus(f, v as FindingStatus)}>
|
||||
<SelectTrigger size="sm" className="h-7 w-28 shrink-0 border-none px-1 shadow-none focus-visible:ring-0">
|
||||
<StatusBadge domain="finding" value={f.status} dot />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper" align="end">
|
||||
@@ -146,9 +127,7 @@ function Row({
|
||||
</div>
|
||||
{open && (
|
||||
<div className="bg-muted/30 px-4 pb-4 pl-11">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
证据 / PoC
|
||||
</div>
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">证据 / PoC</div>
|
||||
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{f.evidence}
|
||||
</pre>
|
||||
@@ -187,25 +166,18 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
};
|
||||
}, [taskId]);
|
||||
|
||||
const onStatus = React.useCallback(
|
||||
async (f: Finding, next: FindingStatus) => {
|
||||
if (f.inherited || !f.finding_id || next === f.status) return;
|
||||
const prev = f.status;
|
||||
setFindings((cur) =>
|
||||
cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)),
|
||||
);
|
||||
try {
|
||||
await api.setFindingStatus(f.finding_id, next);
|
||||
toast.success(`已标记为「${statusMeta("finding", next).label}」`);
|
||||
} catch (e) {
|
||||
setFindings((cur) =>
|
||||
cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)),
|
||||
);
|
||||
toast.error("更新失败:" + (e as Error).message);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
const onStatus = React.useCallback(async (f: Finding, next: FindingStatus) => {
|
||||
if (f.inherited || !f.finding_id || next === f.status) return;
|
||||
const prev = f.status;
|
||||
setFindings((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)));
|
||||
try {
|
||||
await api.setFindingStatus(f.finding_id, next);
|
||||
toast.success(`已标记为「${statusMeta("finding", next).label}」`);
|
||||
} catch (e) {
|
||||
setFindings((cur) => cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)));
|
||||
toast.error("更新失败:" + (e as Error).message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const items = findings
|
||||
.filter((f) => f.task_id === taskId || f.inherited)
|
||||
@@ -243,9 +215,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
|
||||
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
本任务及直接关联任务暂无确认发现。
|
||||
</p>
|
||||
<p className="px-4 py-8 text-center text-sm text-muted-foreground">本任务及直接关联任务暂无确认发现。</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,241 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { BotIcon, CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { ApprovalRecords } from "@/components/approval-records";
|
||||
|
||||
function fmtTime(s: string) {
|
||||
if (!s) return "—";
|
||||
return new Date(s).toLocaleString("zh-CN", {
|
||||
month: "2-digit", day: "2-digit",
|
||||
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function AgentChip({ name }: { name: string }) {
|
||||
if (!name) return <span className="text-muted-foreground text-xs">—</span>;
|
||||
const color =
|
||||
name === "planner" ? "bg-amber-500" :
|
||||
name === "mainagent" ? "bg-primary" :
|
||||
"bg-sky-600";
|
||||
return (
|
||||
<span className={`inline-block rounded px-1.5 py-0.5 text-[10px] font-medium text-white ${color}`}>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
|
||||
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
|
||||
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
|
||||
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
|
||||
}
|
||||
|
||||
function MatchCell({ row }: { row: InterceptApprovalRow }) {
|
||||
const isModel = !row.rule_id;
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{row.rule_name ? (
|
||||
<span className="text-xs text-muted-foreground">{row.rule_name}</span>
|
||||
) : isModel ? (
|
||||
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
|
||||
<BotIcon className="h-3 w-3" />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">—</span>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InputPreview({ input }: { input: Record<string, unknown> }) {
|
||||
const full = JSON.stringify(input, null, 2);
|
||||
const s = JSON.stringify(input);
|
||||
const short = s.length > 100 ? s.slice(0, 100) + "…" : s;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">{short}</code>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-sm">
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export function InterceptTab({ taskId }: Props) {
|
||||
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [deciding, setDeciding] = React.useState<number | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setRows(await api.interceptTask(taskId));
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [load]);
|
||||
|
||||
async function decide(id: number, decision: "allowed" | "denied") {
|
||||
if (deciding !== null) return;
|
||||
setDeciding(id);
|
||||
try {
|
||||
await api.interceptDecide(id, decision);
|
||||
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
||||
setRows((prev) =>
|
||||
prev.map((r) => r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setDeciding(null);
|
||||
}
|
||||
}
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
|
||||
if (!loading && rows.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-20 text-center">
|
||||
<ShieldAlertIcon className="h-8 w-8 text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">此任务暂无拦截审批记录</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<ShieldAlertIcon className="h-4 w-4 text-muted-foreground" />
|
||||
拦截审批
|
||||
{pending.length > 0 && (
|
||||
<Badge className="rounded-full bg-amber-500 text-white text-[10px] px-1.5">
|
||||
{pending.length} 待审批
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCwIcon className="mr-1 h-3.5 w-3.5" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* pending block */}
|
||||
{pending.length > 0 && (
|
||||
<div className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
|
||||
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-2.5 text-xs font-medium text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlertIcon className="h-3.5 w-3.5" />
|
||||
待处理({pending.length})— 超时 5 分钟后自动拒绝
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead className="w-28">Agent</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="w-32 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pending.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
|
||||
<TableCell><AgentChip name={row.agent_name} /></TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="sm" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "allowed")}>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />允许
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" className="h-7 px-2 text-xs" disabled={deciding === row.id} onClick={() => decide(row.id, "denied")}>
|
||||
<XIcon className="mr-1 h-3 w-3" />拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* history table */}
|
||||
<div className="rounded-lg border">
|
||||
<div className="border-b px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
全部记录({rows.length})
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead className="w-28">Agent</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>申请时间</TableHead>
|
||||
<TableHead>决定时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
|
||||
<TableCell className="text-muted-foreground text-xs">{row.id}</TableCell>
|
||||
<TableCell><AgentChip name={row.agent_name} /></TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell><StatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.decided_at ? fmtTime(row.decided_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
export function InterceptTab({ taskId }: { taskId: string }) {
|
||||
return <ApprovalRecords key={taskId} taskId={taskId} />;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { ArrowUpRightIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Finding, FindingsPage } from "@/lib/types";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
function findingLabel(finding: Finding) {
|
||||
return finding.name?.trim() || finding.vulnclass.trim() || "未分类";
|
||||
}
|
||||
|
||||
export function RetestsTab({ taskId }: { taskId: string }) {
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [data, setData] = React.useState<FindingsPage | null>(null);
|
||||
const [selectedId, setSelectedId] = React.useState("");
|
||||
const [error, setError] = React.useState("");
|
||||
const [refreshVersion, setRefreshVersion] = React.useState(0);
|
||||
const refresh = React.useCallback(() => setRefreshVersion((version) => version + 1), []);
|
||||
|
||||
// Only this task's own findings, including those without graph nodes.
|
||||
// Fetch history only for the selection, and wait for each poll to finish.
|
||||
React.useEffect(() => {
|
||||
let disposed = false;
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
async function load() {
|
||||
try {
|
||||
const result = await api.findingsPage({ task: taskId, page, pageSize: PAGE_SIZE, sort: "time" });
|
||||
if (disposed) return;
|
||||
const lastPage = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
|
||||
if (page > lastPage) {
|
||||
setPage(lastPage);
|
||||
return;
|
||||
}
|
||||
setData(result);
|
||||
setSelectedId((current) =>
|
||||
result.items.some((finding) => finding.id === current) ? current : (result.items[0]?.id ?? ""),
|
||||
);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
if (!disposed) setError((e as Error).message);
|
||||
} finally {
|
||||
if (!disposed) timer = setTimeout(() => void load(), 3000);
|
||||
}
|
||||
}
|
||||
void refreshVersion; // Refresh triage immediately after a retest completes.
|
||||
void load();
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [taskId, page, refreshVersion]);
|
||||
|
||||
const loaded = data?.page === page;
|
||||
const findings = loaded ? data.items : [];
|
||||
const selected = findings.find((finding) => finding.id === selectedId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
加载任务漏洞失败:{error}
|
||||
<Button variant="outline" size="sm" onClick={refresh}>
|
||||
重试
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="grid items-start gap-4 lg:grid-cols-[minmax(16rem,22rem)_minmax(0,1fr)]">
|
||||
<Card className="min-w-0">
|
||||
<CardHeader>
|
||||
<CardTitle>选择漏洞{data ? ` · ${data.total}` : ""}</CardTitle>
|
||||
<CardDescription>查看本任务漏洞的复测记录,或发起新的复测。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto">
|
||||
{!loaded && !error ? <Skeleton className="h-24 w-full" /> : null}
|
||||
{findings.map((finding) => (
|
||||
<Button
|
||||
key={finding.id}
|
||||
variant={finding.id === selectedId ? "secondary" : "ghost"}
|
||||
className="h-auto w-full shrink-0 flex-col items-start gap-2 whitespace-normal py-3 text-left"
|
||||
aria-label={`选择漏洞:${findingLabel(finding)}`}
|
||||
aria-pressed={finding.id === selectedId}
|
||||
onClick={() => setSelectedId(finding.id)}
|
||||
>
|
||||
<span className="line-clamp-2 break-words">{findingLabel(finding)}</span>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge domain="severity" value={finding.severity} dot />
|
||||
<StatusBadge domain="finding" value={finding.status} dot />
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{loaded && findings.length === 0 ? (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>暂无可复测漏洞</EmptyTitle>
|
||||
<EmptyDescription>本任务发现漏洞后,可在这里手动发起复测。</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
) : null}
|
||||
</CardContent>
|
||||
{data && data.total > PAGE_SIZE ? (
|
||||
<CardFooter className="justify-between gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="上一页漏洞"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
第 {page} / {Math.ceil(data.total / PAGE_SIZE)} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="下一页漏洞"
|
||||
disabled={page * PAGE_SIZE >= data.total}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</Card>
|
||||
{selected ? (
|
||||
<div className="flex min-w-0 flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<h2 className="min-w-0 flex-1 break-words font-medium">{findingLabel(selected)}</h2>
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link href={`/function/findings/detail?id=${selected.finding_id || selected.id}`}>
|
||||
漏洞详情 <ArrowUpRightIcon data-icon="inline-end" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="line-clamp-3 break-words text-muted-foreground text-sm">{selected.summary}</p>
|
||||
</div>
|
||||
<FindingRetestPanel
|
||||
key={selected.id}
|
||||
findingId={selected.finding_id || selected.id}
|
||||
findingName={findingLabel(selected)}
|
||||
onCompleted={refresh}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import { GraphTab } from "./_tabs/graph-tab";
|
||||
import { InterceptTab } from "./_tabs/intercept-tab";
|
||||
import { OverviewTab } from "./_tabs/overview-tab";
|
||||
import { ReportTab } from "./_tabs/report-tab";
|
||||
import { RetestsTab } from "./_tabs/retests-tab";
|
||||
import { SessionsTab } from "./_tabs/sessions-tab";
|
||||
|
||||
const TABS = [
|
||||
@@ -53,6 +54,7 @@ const TABS = [
|
||||
{ value: "overview", label: "总览" },
|
||||
{ value: "graph", label: "探索链路" },
|
||||
{ value: "findings", label: "发现" },
|
||||
{ value: "retests", label: "复测" },
|
||||
{ value: "assets", label: "测试资产" },
|
||||
{ value: "coverage", label: "资产覆盖图" },
|
||||
{ value: "intercept", label: "拦截审批" },
|
||||
@@ -425,6 +427,9 @@ function TaskDetailInner() {
|
||||
<TabsContent value="findings" className="mt-0">
|
||||
<FindingsTab taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="retests" className="mt-0">
|
||||
<RetestsTab key={id} taskId={id} />
|
||||
</TabsContent>
|
||||
<TabsContent value="assets" className="mt-0">
|
||||
<AssetsTab taskId={id} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -5,19 +5,18 @@ import * as React from "react";
|
||||
import {
|
||||
ArrowDownWideNarrowIcon,
|
||||
ArrowUpNarrowWideIcon,
|
||||
CheckIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
ListChecksIcon,
|
||||
Loader2Icon,
|
||||
RadioTowerIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
WrapTextIcon,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||
import { LinkTrafficDialog } from "@/components/link-traffic-dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -41,7 +40,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api } from "@/lib/api";
|
||||
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
|
||||
import { cn, copyText } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
@@ -90,205 +89,6 @@ function requestWithHost(raw: string, exchange: TrafficExchange): string {
|
||||
return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`;
|
||||
}
|
||||
|
||||
function StartLine({ line }: { line: string }) {
|
||||
const response = /^(HTTP\/\S+)(\s+)(\d{3})(.*)$/.exec(line);
|
||||
if (response) {
|
||||
return (
|
||||
<>
|
||||
<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;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Fixed method set (server filters exact-match); avoids deriving options from a
|
||||
// single page, which would only ever list the methods on that page.
|
||||
const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
|
||||
@@ -296,6 +96,8 @@ const PAGE_SIZES = [25, 50, 100, 200];
|
||||
type HostCountSortDirection = "asc" | "desc";
|
||||
|
||||
export default function TrafficPage() {
|
||||
const [selectedFlows, setSelectedFlows] = React.useState<Set<string>>(() => new Set());
|
||||
const [linking, setLinking] = React.useState(false);
|
||||
const [page, setPage] = React.useState(0);
|
||||
const [size, setSize] = React.useState(50);
|
||||
const [host, setHost] = React.useState(""); // raw host input
|
||||
@@ -623,6 +425,17 @@ export default function TrafficPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">已选 {selectedFlows.size} 条流量</span>
|
||||
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
|
||||
关联到漏洞
|
||||
</Button>
|
||||
{selectedFlows.size > 0 ? (
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Set())}>
|
||||
清空选择
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{/* History table */}
|
||||
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col">
|
||||
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
|
||||
@@ -630,6 +443,22 @@ export default function TrafficPage() {
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-card">
|
||||
<TableRow>
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
aria-label="选择本页流量"
|
||||
checked={exchanges.length > 0 && exchanges.every((e) => selectedFlows.has(e.id))}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedFlows((previous) => {
|
||||
const next = new Set(previous);
|
||||
for (const e of exchanges) {
|
||||
if (checked === true) next.add(e.id);
|
||||
else next.delete(e.id);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-36">时间</TableHead>
|
||||
<TableHead className="w-44">host</TableHead>
|
||||
<TableHead className="w-20">方法</TableHead>
|
||||
@@ -646,6 +475,21 @@ export default function TrafficPage() {
|
||||
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
|
||||
onClick={() => setSelected(e)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
aria-label={`选择流量 ${e.id}`}
|
||||
checked={selectedFlows.has(e.id)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onCheckedChange={(checked) =>
|
||||
setSelectedFlows((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (checked === true) next.add(e.id);
|
||||
else next.delete(e.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
|
||||
<TableCell className="font-mono text-xs">{e.host}</TableCell>
|
||||
<TableCell>
|
||||
@@ -665,7 +509,7 @@ export default function TrafficPage() {
|
||||
))}
|
||||
{exchanges.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-12 text-center text-sm text-muted-foreground">
|
||||
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
|
||||
{traffic === null ? "加载中…" : "没有匹配的流量。"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -676,6 +520,13 @@ export default function TrafficPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{linking ? (
|
||||
<LinkTrafficDialog
|
||||
trafficIds={[...selectedFlows]}
|
||||
onClose={() => setLinking(false)}
|
||||
onBound={() => setSelectedFlows(new Set())}
|
||||
/>
|
||||
) : null}
|
||||
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
|
||||
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
|
||||
{selected && (
|
||||
|
||||
@@ -1,263 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { BotIcon, CheckIcon, ClipboardListIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import type { InterceptApprovalRow } from "@/lib/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
// ---- helpers ----------------------------------------------------------------
|
||||
|
||||
function fmtTime(s: string) {
|
||||
if (!s) return "—";
|
||||
const d = new Date(s);
|
||||
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||
}
|
||||
|
||||
function SourceCell({ row }: { row: InterceptApprovalRow }) {
|
||||
if (row.task_id) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-mono text-xs font-medium">{row.task_id}</div>
|
||||
<div className="text-[11px] text-muted-foreground">{row.agent_name || "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (row.conversation_id) {
|
||||
const agent = row.conv_agent_key || "—";
|
||||
const title = row.conv_title || `对话 #${row.conversation_id}`;
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{title}</div>
|
||||
<div className="text-[11px] text-muted-foreground">Agent: {agent}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
// MatchCell shows the matched rule name, or a "模型判定" badge when the decision
|
||||
// came from the LLM fallback judge (rule_id null). The reason (rule message or
|
||||
// model rationale, minus the [模型] prefix) is shown beneath.
|
||||
function MatchCell({ row }: { row: InterceptApprovalRow }) {
|
||||
const isModel = !row.rule_id;
|
||||
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{row.rule_name ? (
|
||||
<span className="text-sm">{row.rule_name}</span>
|
||||
) : isModel ? (
|
||||
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
|
||||
<BotIcon className="h-3 w-3" />
|
||||
模型判定
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
{reason && (
|
||||
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
|
||||
{reason}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
if (status === "pending") return <Badge variant="outline" className="border-amber-400 text-amber-600">待审批</Badge>;
|
||||
if (status === "allowed") return <Badge variant="outline" className="border-emerald-500 text-emerald-600">已允许</Badge>;
|
||||
if (status === "denied") return <Badge variant="outline" className="border-red-500 text-red-600">已拒绝</Badge>;
|
||||
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
|
||||
}
|
||||
|
||||
function InputPreview({ input }: { input: Record<string, unknown> }) {
|
||||
const full = JSON.stringify(input, null, 2);
|
||||
const s = JSON.stringify(input);
|
||||
const short = s.length > 120 ? s.slice(0, 120) + "…" : s;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<code className="block w-full cursor-default truncate font-mono text-[11px] text-muted-foreground">
|
||||
{short}
|
||||
</code>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="start" className="max-w-sm">
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-xs">{full}</pre>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- main component ---------------------------------------------------------
|
||||
import { ApprovalRecords } from "@/components/approval-records";
|
||||
|
||||
export default function ApprovalsPage() {
|
||||
const [rows, setRows] = React.useState<InterceptApprovalRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [deciding, setDeciding] = React.useState<number | null>(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const items = await api.interceptHistory();
|
||||
setRows(items);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
|
||||
async function decide(id: number, decision: "allowed" | "denied") {
|
||||
if (deciding !== null) return;
|
||||
setDeciding(id);
|
||||
try {
|
||||
await api.interceptDecide(id, decision);
|
||||
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
||||
setRows((prev) =>
|
||||
prev.map((r) =>
|
||||
r.id === id ? { ...r, status: decision, decided_at: new Date().toISOString() } : r,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
toast.error((e as Error).message);
|
||||
} finally {
|
||||
setDeciding(null);
|
||||
}
|
||||
}
|
||||
|
||||
const pending = rows.filter((r) => r.status === "pending");
|
||||
const decided = rows.filter((r) => r.status !== "pending");
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex flex-1 flex-col gap-6 p-6">
|
||||
{/* header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ClipboardListIcon className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-xl font-semibold">审批记录</h1>
|
||||
{pending.length > 0 && (
|
||||
<Badge className="rounded-full bg-destructive text-destructive-foreground">{pending.length} 待审批</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCwIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* pending section */}
|
||||
{pending.length > 0 && (
|
||||
<section className="rounded-lg border border-amber-400/40 bg-amber-50/30 dark:bg-amber-950/10">
|
||||
<div className="flex items-center gap-2 border-b border-amber-400/30 px-4 py-3 text-sm font-medium text-amber-700 dark:text-amber-400">
|
||||
<ShieldAlertIcon className="h-4 w-4" />
|
||||
待处理 ({pending.length})
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>匹配规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead className="w-28 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pending.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell className="text-muted-foreground">{row.id}</TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><SourceCell row={row} /></TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={deciding === row.id}
|
||||
onClick={() => decide(row.id, "allowed")}
|
||||
>
|
||||
<CheckIcon className="mr-1 h-3 w-3" />
|
||||
允许
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={deciding === row.id}
|
||||
onClick={() => decide(row.id, "denied")}
|
||||
>
|
||||
<XIcon className="mr-1 h-3 w-3" />
|
||||
拒绝
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* history section */}
|
||||
<section className="rounded-lg border">
|
||||
<div className="border-b px-4 py-3 text-sm font-medium text-muted-foreground">
|
||||
全部记录 ({rows.length})
|
||||
</div>
|
||||
{rows.length === 0 && !loading ? (
|
||||
<div className="py-16 text-center text-sm text-muted-foreground">暂无审批记录</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>工具</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>匹配规则</TableHead>
|
||||
<TableHead className="w-48">参数</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>申请时间</TableHead>
|
||||
<TableHead>决定时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id} className={row.status === "pending" ? "opacity-40" : ""}>
|
||||
<TableCell className="text-muted-foreground">{row.id}</TableCell>
|
||||
<TableCell>
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
|
||||
</TableCell>
|
||||
<TableCell><SourceCell row={row} /></TableCell>
|
||||
<TableCell><MatchCell row={row} /></TableCell>
|
||||
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
|
||||
<TableCell><StatusBadge status={row.status} /></TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
{row.decided_at ? fmtTime(row.decided_at) : "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</section>
|
||||
<div className="flex min-w-0 flex-1 flex-col p-4 sm:p-6">
|
||||
<ApprovalRecords />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type { Settings } from "@/lib/types";
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const [trafficCapture, setTrafficCapture] = React.useState(false);
|
||||
const [agentTrafficBinding, setAgentTrafficBinding] = React.useState(false);
|
||||
const [webSearch, setWebSearch] = React.useState(false);
|
||||
const [backend, setBackend] = React.useState("ddgs");
|
||||
const [braveKeySet, setBraveKeySet] = React.useState(false);
|
||||
@@ -43,13 +44,14 @@ export default function SystemSettingsPage() {
|
||||
|
||||
const apply = React.useCallback((s: Settings) => {
|
||||
setTrafficCapture(!!s.traffic_capture);
|
||||
setAgentTrafficBinding(!!s.agent_traffic_binding);
|
||||
setWebSearch(!!s.web_search_enabled);
|
||||
setBackend(s.web_search_backend || "ddgs");
|
||||
setBraveKeySet(!!s.brave_key_set);
|
||||
setTavilyKeySet(!!s.tavily_key_set);
|
||||
setProxyInput(s.web_search_proxy || "");
|
||||
setGlobalProxyInput(s.global_proxy || "");
|
||||
setPyInterp(s.python_interpreter || "");
|
||||
setProxyInput(s.web_search_proxy ?? "");
|
||||
setGlobalProxyInput(s.global_proxy ?? "");
|
||||
setPyInterp(s.python_interpreter ?? "");
|
||||
setWorkers(String(s.workers ?? 3));
|
||||
setInjectPlanner(s.constraints_inject_planner !== false);
|
||||
setInjectWorker(s.constraints_inject_worker !== false);
|
||||
@@ -118,6 +120,22 @@ export default function SystemSettingsPage() {
|
||||
.catch(() => setInjectPlanner(!v)); // revert on failure
|
||||
};
|
||||
|
||||
const toggleAgentTrafficBinding = (v: boolean) => {
|
||||
setAgentTrafficBinding(v);
|
||||
setSaving(true);
|
||||
api
|
||||
.setSettings({ agent_traffic_binding: v })
|
||||
.then((s) => {
|
||||
apply(s);
|
||||
toast.success(v ? "已开启 Agent 自动绑定流量" : "已关闭 Agent 自动绑定流量");
|
||||
})
|
||||
.catch((e) => {
|
||||
setAgentTrafficBinding(!v);
|
||||
toast.error(`保存失败:${(e as Error).message}`);
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
};
|
||||
|
||||
const toggleInjectWorker = (v: boolean) => {
|
||||
setInjectWorker(v); // optimistic
|
||||
api
|
||||
@@ -256,6 +274,34 @@ export default function SystemSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<RadioTowerIcon className="size-4" />
|
||||
Agent 自动绑定流量
|
||||
</CardTitle>
|
||||
<CardDescription id="agent-traffic-binding-description">
|
||||
默认关闭。开启后,漏洞入库时触发的报告 Agent 会核对已有 HTTP 请求/响应,关联对应流量后再编写报告。
|
||||
<b>查阅数据包及额外的工具调用会增加 Token 消耗。</b>
|
||||
<br />
|
||||
TCP、未抓包或没有匹配流量时仍可正常上报。此开关不影响流量捕获、人工绑定及已保存证据的查看。 对下一轮 Agent
|
||||
生效;关闭后会立即拒绝新的自动绑定。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between gap-4">
|
||||
<Label htmlFor="agent-traffic-binding" className="text-sm font-normal text-muted-foreground">
|
||||
{agentTrafficBinding ? "已开启 · 会增加 Token 消耗" : "已关闭 · 可继续人工绑定"}
|
||||
</Label>
|
||||
<Switch
|
||||
id="agent-traffic-binding"
|
||||
aria-describedby="agent-traffic-binding-description"
|
||||
checked={agentTrafficBinding}
|
||||
disabled={!loaded || saving}
|
||||
onCheckedChange={toggleAgentTrafficBinding}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="mb-4 break-inside-avoid md:mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@@ -269,8 +315,9 @@ export default function SystemSettingsPage() {
|
||||
开启<b>流量捕获</b>时,它作为记录代理的<b>上游</b>(流量仍全量落库,再经此代理出网);关闭捕获时,直接注入
|
||||
Agent 的 bash / WebFetch 出网。与网络搜索代理、LLM 代理相互独立。
|
||||
<br />
|
||||
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持(curl 可用,部分工具可能忽略);
|
||||
若主要用 socks5,建议开启流量捕获——此路径由 MITM 亲自拨号,工具无感知、稳定生效。
|
||||
<b>提示</b>:socks5 在<b>关闭捕获</b>时依赖各命令行工具对 <code>ALL_PROXY</code> 的支持(curl
|
||||
可用,部分工具可能忽略); 若主要用 socks5,建议开启流量捕获——此路径由 MITM
|
||||
亲自拨号,工具无感知、稳定生效。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
@@ -291,9 +338,7 @@ export default function SystemSettingsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{globalProxyInput.trim()
|
||||
? "已配置 · 所有目标流量经此代理出网"
|
||||
: "未配置 · 目标流量直连出网"}
|
||||
{globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -308,8 +353,8 @@ export default function SystemSettingsPage() {
|
||||
开启后,把每个任务的<b>操作约束</b>(在任务总览「操作约束」里维护的 allow/deny 条目)拼进对应 Agent
|
||||
的系统提示,用来框定探索边界(如「仅测当前端口」「禁止爆破」)。
|
||||
<br />
|
||||
可分别控制注入到 <b>规划者(planner)</b>与 <b>执行者(worker)</b>;默认都开。切换即时生效(下一轮读取),无需重建
|
||||
Agent。关闭后该 Agent 不再看到约束。
|
||||
可分别控制注入到 <b>规划者(planner)</b>与 <b>执行者(worker)</b>
|
||||
;默认都开。切换即时生效(下一轮读取),无需重建 Agent。关闭后该 Agent 不再看到约束。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { mergeActivities } from "./activity-merge.ts";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const activity = (seq, kind = "tool_use", extra = {}) => ({
|
||||
seq,
|
||||
kind,
|
||||
worker: "auto",
|
||||
ts: "2026-09-10T07:00:00Z",
|
||||
summary: String(seq),
|
||||
...extra,
|
||||
});
|
||||
|
||||
test("overlapping poll responses render tool 198 only once", () => {
|
||||
const first = [activity(197), activity(198)];
|
||||
const second = [activity(198), activity(199, "tool_result")];
|
||||
const merged = mergeActivities(mergeActivities([], first), second);
|
||||
assert.deepEqual(
|
||||
merged.map((item) => item.seq),
|
||||
[197, 198, 199],
|
||||
);
|
||||
assert.deepEqual(mergeActivities(merged, second), merged);
|
||||
});
|
||||
|
||||
test("a late response and overlapping older page preserve newer messages", () => {
|
||||
let rows = mergeActivities([activity(198)], [activity(199), activity(200)]);
|
||||
rows = mergeActivities(rows, [activity(198), activity(199)]);
|
||||
rows = mergeActivities([activity(196), activity(197), activity(198)], rows);
|
||||
assert.deepEqual(
|
||||
rows.map((item) => item.seq),
|
||||
[196, 197, 198, 199, 200],
|
||||
);
|
||||
});
|
||||
|
||||
test("replayed results and duplicate entries do not double-count usage", () => {
|
||||
const result = activity(201, "result", { input_tokens: 120, output_tokens: 30 });
|
||||
const rows = mergeActivities([result, result], [result, result]);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(
|
||||
rows.reduce((sum, item) => sum + item.input_tokens, 0),
|
||||
120,
|
||||
);
|
||||
});
|
||||
|
||||
test("merging does not mutate either response and keeps distinct IDs", () => {
|
||||
const first = Object.freeze([Object.freeze(activity(199))]);
|
||||
const second = Object.freeze([Object.freeze(activity(198)), Object.freeze(activity(200))]);
|
||||
assert.deepEqual(
|
||||
mergeActivities(first, second).map((item) => item.seq),
|
||||
[198, 199, 200],
|
||||
);
|
||||
assert.equal(first.length, 1);
|
||||
assert.equal(second.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Activity } from "./types";
|
||||
|
||||
// History and live responses can overlap or arrive out of order. A persisted
|
||||
// activity's seq is its identity; replay must not duplicate rows or token totals.
|
||||
export function mergeActivities(current: Activity[], incoming: Activity[]): Activity[] {
|
||||
const bySeq = new Map<number, Activity>();
|
||||
for (const activity of current) bySeq.set(activity.seq, activity);
|
||||
for (const activity of incoming) bySeq.set(activity.seq, activity);
|
||||
return [...bySeq.values()].sort((left, right) => left.seq - right.seq);
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { mockHandle } from "@/lib/mock/handler";
|
||||
import type {
|
||||
ActiveFindingRetest,
|
||||
Activity,
|
||||
Agent,
|
||||
AgentDetail,
|
||||
@@ -28,16 +29,21 @@ import type {
|
||||
DeleteTaskOptions,
|
||||
DeleteTaskResult,
|
||||
Edge,
|
||||
EvidenceBodyPreview,
|
||||
Finding,
|
||||
FindingAssetTree,
|
||||
FindingDeepenResponse,
|
||||
FindingGroupsPage,
|
||||
FindingQuery,
|
||||
FindingRetest,
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
FindingsPage,
|
||||
FindingTraffic,
|
||||
FindingTrafficDetail,
|
||||
IntentAsset,
|
||||
InterceptApprovalRow,
|
||||
InterceptDetail,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
JudgeConfig,
|
||||
@@ -79,6 +85,8 @@ import type {
|
||||
Tool,
|
||||
ToolStat,
|
||||
TrafficDetail,
|
||||
TrafficEvidenceRef,
|
||||
TrafficEvidenceRole,
|
||||
TrafficHost,
|
||||
TrafficResp,
|
||||
UsageStats,
|
||||
@@ -526,6 +534,73 @@ export const api = {
|
||||
get<Finding>(
|
||||
`/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
|
||||
),
|
||||
findingTraffic: (id: string, contextTask?: string) =>
|
||||
get<FindingTraffic>(
|
||||
`/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
),
|
||||
bindFindingTraffic: (id: string, traffic_refs: TrafficEvidenceRef[], contextTask?: string) =>
|
||||
post<FindingTraffic>(
|
||||
`/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
{ traffic_refs },
|
||||
),
|
||||
editFindingTraffic: (
|
||||
id: string,
|
||||
bindingId: string,
|
||||
version: number,
|
||||
fields: { role: TrafficEvidenceRole; note: string },
|
||||
contextTask?: string,
|
||||
) =>
|
||||
patch<FindingTraffic>(
|
||||
`/exploration/findings/${id}/traffic/${bindingId}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
{ version, ...fields },
|
||||
),
|
||||
removeFindingTraffic: (id: string, bindingId: string, version: number, contextTask?: string) =>
|
||||
del<FindingTraffic>(
|
||||
`/exploration/findings/${id}/traffic/${bindingId}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
{ version },
|
||||
),
|
||||
orderFindingTraffic: (id: string, binding_ids: string[], version: number, contextTask?: string) =>
|
||||
http<FindingTraffic>(
|
||||
`/exploration/findings/${id}/traffic/order${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
{ method: "PUT", body: JSON.stringify({ binding_ids, version }) },
|
||||
),
|
||||
findingTrafficDetail: (id: string, bindingId: string, contextTask?: string) =>
|
||||
get<FindingTrafficDetail>(
|
||||
`/exploration/findings/${id}/traffic/${bindingId}${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
),
|
||||
findingTrafficBody: (
|
||||
id: string,
|
||||
bindingId: string,
|
||||
side: "request" | "response",
|
||||
offset: number,
|
||||
contextTask?: string,
|
||||
) =>
|
||||
get<EvidenceBodyPreview>(
|
||||
`/exploration/findings/${id}/traffic/${bindingId}/body?side=${side}&offset=${offset}${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
),
|
||||
downloadFindingTrafficBody: async (
|
||||
id: string,
|
||||
bindingId: string,
|
||||
side: "request" | "response",
|
||||
contextTask?: string,
|
||||
) => {
|
||||
const token = getToken();
|
||||
const response = await fetch(
|
||||
`/api/exploration/findings/${id}/traffic/${bindingId}/body?side=${side}&download=1${contextTask ? `&context_task=${encodeURIComponent(contextTask)}` : ""}`,
|
||||
{ headers: token ? { Authorization: `Bearer ${token}` } : {} },
|
||||
);
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: "下载失败" }));
|
||||
throw new Error(error.error ?? "下载失败");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `evidence-${bindingId}-${side}.bin`;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
},
|
||||
// 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。
|
||||
findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
|
||||
setFindingStatus: (id: string, status: FindingStatus) => patch<Finding>(`/exploration/findings/${id}`, { status }),
|
||||
@@ -537,6 +612,12 @@ export const api = {
|
||||
) => patch<Finding>(`/exploration/findings/${id}`, fields),
|
||||
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
|
||||
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
|
||||
findingRetests: (id: string) =>
|
||||
get<{ retests: FindingRetest[] }>(`/exploration/findings/${encodeURIComponent(id)}/retests`).then((r) => arr(r.retests)),
|
||||
activeFindingRetests: () =>
|
||||
get<{ retests: ActiveFindingRetest[] }>("/exploration/findings/retests/active").then((r) => arr(r.retests)),
|
||||
startFindingRetest: (id: string, notes: string) =>
|
||||
post<{ retest: FindingRetest; created: boolean }>(`/exploration/findings/${encodeURIComponent(id)}/retests`, { notes }),
|
||||
deepenFinding: (id: string, description: string) =>
|
||||
post<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }),
|
||||
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
|
||||
@@ -957,6 +1038,7 @@ export const api = {
|
||||
interceptGetOne: (id: number) => get<InterceptPending>(`/intercept/pending/${id}`),
|
||||
interceptDecide: (id: number, decision: "allowed" | "denied") =>
|
||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||
interceptDetail: (id: number) => get<InterceptDetail>(`/intercept/history/${id}`),
|
||||
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
||||
interceptTask: (taskId: string) =>
|
||||
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
Finding,
|
||||
FindingAsset,
|
||||
InterceptApprovalRow,
|
||||
InterceptAudit,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
LLMPoolStatus,
|
||||
@@ -3058,6 +3059,10 @@ export const llmConfig = {
|
||||
|
||||
// ── Agents ───────────────────────────────────────────────────────────────────
|
||||
export const agents: Agent[] = [
|
||||
{
|
||||
id: "1001", key: "retester", name: "漏洞复测", role: "assistant", builtin: false, enabled: true,
|
||||
description: "从漏洞详情手动启动,保存独立复测结论", max_turns: 0, mcp_count: 0, skill_count: 0, tool_count: 2,
|
||||
},
|
||||
{
|
||||
id: "1",
|
||||
key: "goals",
|
||||
@@ -3309,6 +3314,7 @@ export const tools: Tool[] = [
|
||||
// ── Settings ─────────────────────────────────────────────────────────────────
|
||||
export const settings: Settings = {
|
||||
traffic_capture: true,
|
||||
agent_traffic_binding: false,
|
||||
llm_record: false,
|
||||
web_search_enabled: true,
|
||||
web_search_backend: "ddgs",
|
||||
@@ -3469,6 +3475,40 @@ export const interceptHistory: InterceptApprovalRow[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// Approval detail fixtures use harmless report-writing examples.
|
||||
interceptHistory.unshift({
|
||||
id: 94, task_id: "t-acme-web", agent_name: "work#1", tool_name: "Write",
|
||||
tool_input: { path: "reports/summary.md", content: "# 检查摘要\n\n本轮验证已完成,整理已有证据与后续建议。" },
|
||||
status: "allowed", decision_source: "model", reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。",
|
||||
created_at: T("2026-07-26T08:00:00Z"), decided_at: T("2026-07-26T08:00:02Z"),
|
||||
conv_title: "", conv_agent_key: "", rule_name: "",
|
||||
});
|
||||
|
||||
export const interceptDetails: Record<number, InterceptAudit> = {
|
||||
94: {
|
||||
run_id: "demo-run-report", tool_use_id: "call-write-report", correlation: "exact",
|
||||
input_digest: "a3b458eca3b458eca3b458eca3b458eca3b458eca3b458eca3b458eca3b458ec1234",
|
||||
user_message: "请整理已经完成的检查,将结论和证据索引写入 reports/summary.md。\n保留待验证项,不修改业务数据。",
|
||||
context: [
|
||||
{ kind: "user", text: "汇总本轮已有证据,生成检查摘要。" },
|
||||
{ kind: "tool_use", tool: "Read", tool_use_id: "call-read-evidence", text: '{"path":"reports/evidence-index.json"}' },
|
||||
{ kind: "tool_result", tool: "Read", tool_use_id: "call-read-evidence", text: "已读取 3 条证据索引。\n记录包含请求时间、结果摘要和本地文件位置。" },
|
||||
],
|
||||
captured_at: T("2026-07-26T08:00:00Z"), initial_action: "allow",
|
||||
initial_reason: "[模型] 将已有检查结论写入本地报告,不修改业务数据。", effective_action: "allow",
|
||||
config_digest: "b4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fdb4c569fd1234",
|
||||
profile_id: 1, execution_status: "succeeded", output: "Successfully wrote reports/summary.md\n共写入 68 个字符。",
|
||||
execution_ended_at: T("2026-07-26T08:00:03Z"),
|
||||
},
|
||||
93: {
|
||||
run_id: "demo-run-review", tool_use_id: "call-pending-review", correlation: "exact",
|
||||
input_digest: "c5d670aec5d670aec5d670aec5d670aec5d670aec5d670aec5d670aec5d670ae1234",
|
||||
user_message: "检查备份表的用途,涉及删除时需要先确认。", context: [],
|
||||
captured_at: T("2026-07-25T15:14:00Z"), initial_action: "ask",
|
||||
initial_reason: "[模型] 疑似备份表,无法确定是否生产数据。", profile_id: 1, execution_status: "not_started",
|
||||
},
|
||||
};
|
||||
|
||||
// ── Conversations (chat) ─────────────────────────────────────────────────────
|
||||
export const conversations: Conversation[] = [
|
||||
{
|
||||
|
||||
+101
-10
@@ -16,6 +16,7 @@ import type {
|
||||
Company,
|
||||
CompanyScopeRule,
|
||||
Conversation,
|
||||
FindingRetest,
|
||||
IntentAsset,
|
||||
ScopeRow,
|
||||
Task,
|
||||
@@ -33,12 +34,41 @@ const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Requests mutate a runtime copy, never the exported fixtures. This keeps module
|
||||
// initialization deterministic for tests/HMR while preserving state across mock calls.
|
||||
const mockInterceptHistory = structuredClone(D.interceptHistory);
|
||||
const mockInterceptPending = structuredClone(D.interceptPending);
|
||||
const mockInterceptDetails = structuredClone(D.interceptDetails);
|
||||
const mockTasks = structuredClone(D.tasks);
|
||||
const mockFindings = structuredClone(D.findings);
|
||||
const mockLLMRecords = structuredClone(D.llmRecords);
|
||||
const mockTaskTemplates = structuredClone(D.taskTemplates);
|
||||
const mockTaskCategories = structuredClone(D.taskCategories);
|
||||
const mockConversations = structuredClone(D.conversations);
|
||||
const mockRetests: FindingRetest[] = [];
|
||||
const mockRetestMessages: Record<number, Activity[]> = {};
|
||||
|
||||
function advanceMockRetests() {
|
||||
for (const retest of mockRetests) {
|
||||
if (retest.status !== "running" || retest.conversation_id == null) continue;
|
||||
if (Date.now() - Date.parse(retest.created_at) < 15000) continue;
|
||||
retest.status = "completed";
|
||||
retest.verdict = "inconclusive";
|
||||
retest.summary = "演示环境未执行真实验证,无法确认漏洞当前状态。";
|
||||
retest.evidence = "### 演示记录\n\n已关联原漏洞。此环境未连接真实 Agent,也未向目标发送请求;请在实际部署中执行复测。";
|
||||
retest.finished_at = new Date().toISOString();
|
||||
mockRetestMessages[retest.conversation_id].push({
|
||||
seq: 3, worker: "retester", ts: retest.finished_at, kind: "text", summary: retest.summary, detail: retest.evidence,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopMockRetest(conversationID: number) {
|
||||
for (const retest of mockRetests) {
|
||||
if (retest.conversation_id !== conversationID || !["pending", "running"].includes(retest.status)) continue;
|
||||
retest.status = "stopped";
|
||||
retest.finished_at = new Date().toISOString();
|
||||
retest.error = "演示复测已停止";
|
||||
}
|
||||
}
|
||||
const mockIntents = structuredClone(D.intents);
|
||||
const mockCompanies = structuredClone(D.companies);
|
||||
const mockAssets = structuredClone(D.assets);
|
||||
@@ -837,6 +867,7 @@ function parseBody(body?: BodyInit | null): Record<string, unknown> {
|
||||
}
|
||||
|
||||
export async function mockHandle<T>(method: string, rawPath: string, body?: BodyInit | null): Promise<T> {
|
||||
advanceMockRetests();
|
||||
await delay();
|
||||
const [path, qs] = rawPath.split("?");
|
||||
const q = new URLSearchParams(qs ?? "");
|
||||
@@ -1700,6 +1731,35 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
page_size: pageSize,
|
||||
};
|
||||
}
|
||||
if (path === "/exploration/findings/retests/active" && m === "GET") {
|
||||
return { retests: mockRetests
|
||||
.filter((item) => ["pending", "running"].includes(item.status) && item.conversation_id != null)
|
||||
.map((item) => ({ id: item.id, finding_id: D.findings[item.finding_id - 1].id, conversation_id: item.conversation_id, status: item.status })) };
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "retests") {
|
||||
const finding = mockFindings.find((item) => item.id === seg[2]);
|
||||
if (!finding) throw new Error("漏洞不存在");
|
||||
const findingID = D.findings.findIndex((item) => item.id === finding.id) + 1;
|
||||
if (m === "GET") return { retests: structuredClone(mockRetests.filter((item) => item.finding_id === findingID)) };
|
||||
if (m === "POST") {
|
||||
const existing = mockRetests.find((item) => item.finding_id === findingID && ["pending", "running"].includes(item.status));
|
||||
if (existing) return { retest: structuredClone(existing), created: false };
|
||||
const now = new Date().toISOString();
|
||||
const conversationID = mockConversations.reduce((max, item) => Math.max(max, item.id), 0) + 1;
|
||||
mockConversations.unshift({ id: conversationID, agent_key: "retester", title: `复测 #${finding.id} · ${finding.name || finding.vulnclass}`, pinned: false, created_at: now, updated_at: now });
|
||||
const retest: FindingRetest = {
|
||||
id: mockRetests.length + 1, finding_id: findingID, conversation_id: conversationID,
|
||||
status: "running", verdict: "", notes: String(b.notes ?? ""), summary: "", evidence: "",
|
||||
error: "", created_at: now, started_at: now, finished_at: null,
|
||||
};
|
||||
mockRetests.unshift(retest);
|
||||
mockRetestMessages[conversationID] = [
|
||||
{ seq: 1, worker: "retester", ts: now, kind: "user", summary: `请复测漏洞 #${finding.id}`, detail: retest.notes },
|
||||
{ seq: 2, worker: "retester", ts: now, kind: "text", summary: "演示复测进行中(未向目标发送请求)", detail: "演示复测进行中(未向目标发送请求)" },
|
||||
];
|
||||
return { retest: structuredClone(retest), created: true };
|
||||
}
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "deepen" && m === "POST") {
|
||||
const finding = mockFindings.find((candidate) => candidate.id === seg[2]);
|
||||
const description = String(b.description ?? "").trim();
|
||||
@@ -1887,7 +1947,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
// ── conversations ──
|
||||
if (path === "/conversations" && m === "GET") {
|
||||
sortMockConversations();
|
||||
return { conversations: structuredClone(mockConversations) };
|
||||
return { conversations: structuredClone(mockConversations.map((conversation) => ({
|
||||
...conversation,
|
||||
running: mockRetests.some((item) => item.conversation_id === conversation.id && ["pending", "running"].includes(item.status)),
|
||||
}))) };
|
||||
}
|
||||
if (path === "/conversations" && m === "POST") {
|
||||
const now = new Date().toISOString();
|
||||
@@ -1918,8 +1981,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
if (seg[0] === "conversations" && seg.length === 2 && m === "DELETE") {
|
||||
const id = Number(seg[1]);
|
||||
stopMockRetest(id);
|
||||
const index = mockConversations.findIndex((item) => item.id === id);
|
||||
if (index >= 0) mockConversations.splice(index, 1);
|
||||
for (const retest of mockRetests) if (retest.conversation_id === id) retest.conversation_id = null;
|
||||
return { deleted: id };
|
||||
}
|
||||
if (path === "/conversations/delete/batch" && m === "POST") {
|
||||
@@ -1930,21 +1995,27 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
const index = mockConversations.findIndex((item) => item.id === id);
|
||||
if (index < 0) return { id, ok: false, error: "conversation not found" };
|
||||
mockConversations.splice(index, 1);
|
||||
stopMockRetest(id);
|
||||
for (const retest of mockRetests) if (retest.conversation_id === id) retest.conversation_id = null;
|
||||
return { id, ok: true };
|
||||
});
|
||||
return { items };
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
||||
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
|
||||
const items = mockRetestMessages[Number(seg[1])] ?? D.conversationMessages[Number(seg[1])] ?? [];
|
||||
const running = mockRetests.some((item) => item.conversation_id === Number(seg[1]) && ["pending", "running"].includes(item.status));
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running };
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 4) {
|
||||
const msgs = D.conversationMessages[Number(seg[1])] ?? [];
|
||||
const msgs = mockRetestMessages[Number(seg[1])] ?? D.conversationMessages[Number(seg[1])] ?? [];
|
||||
const a = msgs.find((x) => x.seq === Number(seg[3]));
|
||||
return { detail: a?.detail ?? a?.summary ?? "" };
|
||||
}
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && m === "POST") return { status: "ok" };
|
||||
if (seg[0] === "conversations" && seg[2] === "stop") return { status: "stopped" };
|
||||
if (seg[0] === "conversations" && seg[2] === "stop") {
|
||||
stopMockRetest(Number(seg[1]));
|
||||
return { status: "stopped" };
|
||||
}
|
||||
|
||||
// ── tools ──
|
||||
if (path === "/tools" && m === "GET") return { tools: D.tools };
|
||||
@@ -1981,13 +2052,33 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
|
||||
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle")
|
||||
return { ok: true, enabled: b.enabled ?? true };
|
||||
if (path === "/intercept/pending" && m === "GET") return { pending: D.interceptPending };
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") return { ok: true };
|
||||
if (path === "/intercept/pending" && m === "GET") return { pending: mockInterceptHistory.filter((r) => r.status === "pending") };
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") {
|
||||
const id = Number(seg[2]);
|
||||
const row = mockInterceptHistory.find((r) => r.id === id) ?? mockInterceptPending.find((r) => r.id === id);
|
||||
if (!row || row.status !== "pending") throw new Error("审批已处理或不存在,请刷新记录");
|
||||
if (b.decision !== "allowed" && b.decision !== "denied") throw new Error("无效审批动作");
|
||||
row.status = b.decision;
|
||||
row.decided_at = new Date().toISOString();
|
||||
const detail = mockInterceptDetails[id];
|
||||
if (detail) {
|
||||
detail.effective_action = b.decision === "allowed" ? "allow" : "deny";
|
||||
detail.decision_reason = b.decision === "allowed" ? "人工允许执行" : "人工拒绝执行";
|
||||
detail.execution_status = b.decision === "allowed" ? "unknown" : "not_executed";
|
||||
detail.output = b.decision === "allowed" ? "演示模式未执行工具。" : "";
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
if (seg[0] === "intercept" && seg[1] === "history" && seg.length === 3) {
|
||||
const row = mockInterceptHistory.find((r) => r.id === Number(seg[2]));
|
||||
if (!row) throw new Error("审批记录不存在");
|
||||
return { ...row, audit: mockInterceptDetails[row.id] ?? null };
|
||||
}
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
|
||||
return D.interceptPending.find((p) => p.id === Number(seg[2])) ?? null;
|
||||
if (path === "/intercept/history") return { items: D.interceptHistory };
|
||||
return mockInterceptPending.find((p) => p.id === Number(seg[2])) ?? null;
|
||||
if (path === "/intercept/history") return { items: mockInterceptHistory };
|
||||
if (seg[0] === "intercept" && seg[1] === "task")
|
||||
return { items: D.interceptHistory.filter((r) => r.task_id === seg[2]) };
|
||||
return { items: mockInterceptHistory.filter((r) => r.task_id === seg[2]) };
|
||||
if (path === "/intercept/tool-config") return { enabled_tools: ["bash"] };
|
||||
if (path === "/intercept/judge" && m === "GET")
|
||||
return {
|
||||
|
||||
@@ -79,6 +79,7 @@ const finding: Record<string, StatusMeta> = {
|
||||
in_progress: { label: "处理中", tone: "blue" },
|
||||
confirmed: { label: "已确认", tone: "red" },
|
||||
resolved: { label: "已处理", tone: "green" },
|
||||
fixed: { label: "已修复", tone: "green" },
|
||||
false_positive: { label: "误报", tone: "slate" },
|
||||
ignored: { label: "忽略", tone: "neutral" },
|
||||
duplicate: { label: "重复", tone: "neutral" },
|
||||
|
||||
+112
-3
@@ -401,12 +401,12 @@ export interface Company {
|
||||
}
|
||||
|
||||
// ---- Exploration graph (per task) ----
|
||||
export type ExploreKind = "task" | "begin" | "goal" | "intent" | "fact" | "finding" | "hint";
|
||||
export type ExploreKind = "task" | "begin" | "goal" | "intent" | "fact" | "finding" | "hint" | "digest";
|
||||
export type GoalState = "open" | "met" | "abandoned";
|
||||
export type IntentState = "open" | "running" | "paused" | "done" | "blocked" | "exhausted" | "stopped";
|
||||
export type FindingState = "confirmed" | "dismissed";
|
||||
export type HintState = "active" | "consumed";
|
||||
export type ExploreRel = "spawns" | "derived_from" | "yields" | "proves";
|
||||
export type ExploreRel = "spawns" | "derived_from" | "yields" | "proves" | "covers";
|
||||
|
||||
export interface TaskNode {
|
||||
id: string;
|
||||
@@ -443,12 +443,13 @@ export interface TaskConstraint {
|
||||
// ---- Findings ----
|
||||
export type Severity = "critical" | "high" | "medium" | "low";
|
||||
|
||||
// 漏洞处置状态:待处理 / 处理中 / 已确认 / 已处理 / 误报 / 忽略 / 重复 / 风险接受。
|
||||
// 漏洞处置状态:待处理 / 处理中 / 已确认 / 已处理 / 已修复 / 误报 / 忽略 / 重复 / 风险接受。
|
||||
export type FindingStatus =
|
||||
| "pending"
|
||||
| "in_progress"
|
||||
| "confirmed"
|
||||
| "resolved"
|
||||
| "fixed"
|
||||
| "false_positive"
|
||||
| "ignored"
|
||||
| "duplicate"
|
||||
@@ -462,6 +463,10 @@ export interface FindingAsset {
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
traffic_count?: number;
|
||||
evidence_version?: number;
|
||||
report_evidence_version?: number;
|
||||
report_stale?: boolean;
|
||||
id: string;
|
||||
finding_id?: string; // 独立 findings 表的行 id,状态更新的句柄(任务内旧节点可能缺失)
|
||||
vulnclass: string;
|
||||
@@ -681,8 +686,31 @@ export interface AgentTrigger {
|
||||
}
|
||||
|
||||
// ---- Conversations (chat page) ----
|
||||
export interface ActiveFindingRetest {
|
||||
id: number;
|
||||
finding_id: string;
|
||||
conversation_id: number;
|
||||
status: "pending" | "running";
|
||||
}
|
||||
|
||||
export interface FindingRetest {
|
||||
id: number;
|
||||
finding_id: number;
|
||||
conversation_id: number | null;
|
||||
status: "pending" | "running" | "completed" | "failed" | "stopped";
|
||||
verdict: "" | "reproduced" | "fixed" | "inconclusive";
|
||||
notes: string;
|
||||
summary: string;
|
||||
evidence: string;
|
||||
error: string;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: number;
|
||||
running?: boolean; // live server state, returned with the conversation list
|
||||
agent_key: string;
|
||||
title: string;
|
||||
llm_profile_id?: number;
|
||||
@@ -854,6 +882,7 @@ export interface TrafficHost {
|
||||
// ---- App settings (runtime toggles) ----
|
||||
export interface Settings {
|
||||
traffic_capture: boolean;
|
||||
agent_traffic_binding: boolean; // Agent 自动绑定流量证据,默认关闭;不影响人工绑定
|
||||
llm_record: boolean; // LLM 录制开关(默认关);关闭时不记录任何 LLM 调用
|
||||
// Web search. brave_key_set / tavily_key_set reflect whether a key is stored
|
||||
// (the values are never returned). On PUT, send the corresponding field to set/clear.
|
||||
@@ -1133,6 +1162,7 @@ export interface InterceptRule {
|
||||
}
|
||||
|
||||
export interface InterceptPending {
|
||||
decision_source?: "rule" | "model" | "unknown" | "";
|
||||
id: number;
|
||||
rule_id?: number;
|
||||
conversation_id?: number;
|
||||
@@ -1246,3 +1276,82 @@ export interface LLMTask {
|
||||
task_id: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// Immutable review snapshot plus separately recorded execution outcome.
|
||||
export interface InterceptAudit {
|
||||
run_id?: string;
|
||||
tool_use_id?: string;
|
||||
correlation: "exact" | "ambiguous" | "unavailable";
|
||||
input_digest: string;
|
||||
user_message: string;
|
||||
user_truncated?: boolean;
|
||||
context: { kind: string; tool?: string; tool_use_id?: string; text: string; is_error?: boolean; truncated?: boolean }[] | null;
|
||||
context_truncated?: boolean;
|
||||
captured_at: string;
|
||||
model_fallback?: boolean;
|
||||
initial_action: "allow" | "ask" | "deny";
|
||||
initial_reason: string;
|
||||
effective_action?: "allow" | "deny";
|
||||
decision_reason?: string;
|
||||
rule_name?: string;
|
||||
config_digest?: string;
|
||||
profile_id?: number;
|
||||
execution_status: "not_started" | "not_executed" | "awaiting_result" | "succeeded" | "failed" | "unknown";
|
||||
output?: string;
|
||||
output_truncated?: boolean;
|
||||
execution_ended_at?: string;
|
||||
}
|
||||
export interface InterceptDetail extends InterceptApprovalRow {
|
||||
audit: InterceptAudit | null;
|
||||
}
|
||||
|
||||
export type TrafficEvidenceRole = "baseline" | "proof" | "verification" | "supporting";
|
||||
export interface TrafficEvidenceRef {
|
||||
traffic_id: string;
|
||||
role?: TrafficEvidenceRole;
|
||||
note?: string;
|
||||
}
|
||||
export interface TrafficEvidenceSnapshot {
|
||||
id: string;
|
||||
source_traffic_id: string;
|
||||
captured_at: number;
|
||||
url: string;
|
||||
method: string;
|
||||
status: number;
|
||||
content_type: string;
|
||||
req_head?: string;
|
||||
resp_head?: string;
|
||||
req_hash: string;
|
||||
resp_hash: string;
|
||||
req_len: number;
|
||||
resp_len: number;
|
||||
}
|
||||
export interface FindingTrafficBinding {
|
||||
id: string;
|
||||
finding_id: string;
|
||||
snapshot_id: string;
|
||||
role: TrafficEvidenceRole;
|
||||
note: string;
|
||||
position: number;
|
||||
created_at: string;
|
||||
snapshot: TrafficEvidenceSnapshot;
|
||||
}
|
||||
export interface FindingTraffic {
|
||||
finding_id: string;
|
||||
version: number;
|
||||
report_version: number;
|
||||
bindings: FindingTrafficBinding[];
|
||||
}
|
||||
export interface EvidenceBodyPreview {
|
||||
content: string;
|
||||
offset: number;
|
||||
total: number;
|
||||
next_offset: number;
|
||||
truncated: boolean;
|
||||
binary: boolean;
|
||||
}
|
||||
export interface FindingTrafficDetail {
|
||||
binding: FindingTrafficBinding;
|
||||
request: EvidenceBodyPreview;
|
||||
response: EvidenceBodyPreview;
|
||||
}
|
||||
Reference in new issue
Block a user