feat: 支持会话级漏洞复测与运行状态展示
This commit is contained in:
1 parent
8dae851b9b
commit
82c1ed9cbb
30 files changed
+2080
-80
No files matched your search
@@ -46,8 +46,10 @@ 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 { mergeActivities } from "@/lib/activity-merge";
|
||||
import { api } from "@/lib/api";
|
||||
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
|
||||
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
|
||||
@@ -166,7 +168,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 && (
|
||||
<>
|
||||
@@ -243,7 +245,7 @@ function LLMProfileRow({
|
||||
const label = current ? current.name : `默认${activeDefault ? `(${activeDefault.name})` : ""}`;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-1 pb-1 pt-0.5">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1 px-3 pb-3">
|
||||
<ZapIcon className="text-muted-foreground/50 size-3 shrink-0" />
|
||||
<span className="text-muted-foreground/70 text-xs">{label}</span>
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
@@ -364,23 +366,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>
|
||||
);
|
||||
@@ -490,12 +494,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. */
|
||||
@@ -505,28 +509,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]);
|
||||
|
||||
@@ -549,10 +565,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;
|
||||
@@ -647,11 +660,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
|
||||
@@ -840,6 +851,12 @@ 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" />
|
||||
@@ -926,11 +943,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
|
||||
@@ -945,7 +966,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(() => {
|
||||
@@ -1023,7 +1060,7 @@ export default function ChatPage() {
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
reloadConvs();
|
||||
void reloadConvs();
|
||||
} catch (e) {
|
||||
toast.error("删除失败:" + (e as Error).message);
|
||||
}
|
||||
@@ -1063,10 +1100,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);
|
||||
}
|
||||
@@ -1077,7 +1114,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}`);
|
||||
}
|
||||
@@ -1096,7 +1133,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);
|
||||
}
|
||||
@@ -1226,7 +1263,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";
|
||||
@@ -32,9 +33,10 @@ 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 +46,7 @@ export const FINDING_STATUSES: FindingStatus[] = [
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
@@ -98,6 +101,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 +110,7 @@ interface FindingsTableProps {
|
||||
}
|
||||
|
||||
// FindingsTable 是发现列表的表格主体,平铺视图与按任务分组视图共用同一份行渲染
|
||||
// (勾选 / 行内展开 / 行内改名与改状态 / 深入 / 删除),差异只在外层容器与分页。
|
||||
// (勾选 / 行内展开 / 行内改名与改状态 / 复测 / 深入 / 删除),差异只在外层容器与分页。
|
||||
export function FindingsTable({
|
||||
items,
|
||||
selectedIds,
|
||||
@@ -119,6 +124,8 @@ export function FindingsTable({
|
||||
saving,
|
||||
onSave,
|
||||
onStatusChange,
|
||||
onRetest,
|
||||
activeRetests,
|
||||
onDeepen,
|
||||
onDelete,
|
||||
selectAllLabel = "选择当前页全部",
|
||||
@@ -133,9 +140,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 +154,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
|
||||
@@ -207,13 +214,13 @@ export function FindingsTable({
|
||||
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
|
||||
</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 +252,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 +268,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" />
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { FindingRetestPanel } from "@/components/finding-retest-panel";
|
||||
import { FindingLineageView } from "./lineage";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import { api } from "@/lib/api";
|
||||
@@ -33,6 +34,7 @@ const FINDING_STATUSES: FindingStatus[] = [
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
@@ -193,6 +195,7 @@ function FindingDetailInner() {
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FindingRetestPanel key={id} findingId={id} readOnly={finding.inherited} onCompleted={load} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">证据 / PoC</CardTitle>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -39,6 +39,7 @@ const FINDING_STATUSES: FindingStatus[] = [
|
||||
"in_progress",
|
||||
"confirmed",
|
||||
"resolved",
|
||||
"fixed",
|
||||
"false_positive",
|
||||
"ignored",
|
||||
"duplicate",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user