Merge origin/main into codex/btw-side-question

合入 main 的 norma v0.3.7、会话分组与流量证据更新,保留 /btw 快照和输入布局。同步自动放行、关闭流量绑定的测试断言,并等待归档测试夹具的旁路后台退出。
This commit is contained in:
ji ruo committed 2026-09-11 00:54:51 +08:00
commit 2930c4b915
94 files changed
+9737 -1241

No files matched your search

+17 -12
View File
@@ -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>
+312 -58
View File
@@ -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>
) : (
+69 -3
View File
@@ -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>
+55 -204
View File
@@ -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>
);
}
+55 -10
View File
@@ -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">