feat(web): 任务详情页新增探索链路播报板

按创建时间分页平铺展示探索节点,作为探索链路图的时间轴视图,
复用同一份 exploration_nodes 数据,不影响链路图。

- 后端 NodesPage/NodesByIDs/EdgesTouching:分页下推 SQL,
  支持 kind/state 过滤、payload/origin 搜索、正倒序,
  并补出当页节点的相邻边与对端节点
- 新增 GET /api/exploration/nodes(仅本任务节点,不含继承)
- 播报板:时间轴分行、按天分组、类型快筛、搜索防抖、
  自动刷新、直播位未读提示、展开看上下游与 payload
- 补 mock,NEXT_PUBLIC_MOCK=1 下可无后端预览
This commit is contained in:
Autumn-27 committed 2026-09-17 08:42:49 -04:00
1 parent 13ce2ea6e3
commit c03e008d7b
7 files changed
+1053 -37

No files matched your search

+34 -8
View File
@@ -31,6 +31,8 @@ import type {
DeleteTaskResult,
Edge,
EvidenceBodyPreview,
ExplorationNodePage,
ExplorationNodeQuery,
Finding,
FindingAssetTree,
FindingDeepenResponse,
@@ -655,6 +657,25 @@ export const api = {
.then(arr)
.catch(() => [] as ConvTokenSummary[]),
explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`),
// 播报板:服务端按创建顺序分页的探索节点(默认最新在前)。
explorationNodes: (task: string, query: ExplorationNodeQuery = {}) => {
const q = new URLSearchParams();
if (task) q.set("task", task);
q.set("page", String(query.page ?? 1));
q.set("size", String(query.size ?? 20));
if (query.kinds?.length) q.set("kind", query.kinds.join(","));
if (query.states?.length) q.set("state", query.states.join(","));
if (query.q?.trim()) q.set("q", query.q.trim());
if (query.order === "asc") q.set("order", "asc");
return get<ExplorationNodePage>(`/exploration/nodes?${q.toString()}`).then((r) => ({
items: arr(r.items),
total: r.total ?? 0,
page: r.page ?? 1,
size: r.size ?? query.size ?? 20,
edges: arr(r.edges),
refs: r.refs ?? {},
}));
},
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
const q = new URLSearchParams();
if (task) q.set("task", task);
@@ -742,7 +763,10 @@ export const api = {
return r.text();
},
chatMentions: (kind: string, query: string, signal?: AbortSignal, cursor = "") =>
http<{ items: ChatMention[]; next_cursor?: string }>(`/chat/mentions?${new URLSearchParams({ kind, q: query, cursor })}`, { signal }),
http<{ items: ChatMention[]; next_cursor?: string }>(
`/chat/mentions?${new URLSearchParams({ kind, q: query, cursor })}`,
{ signal },
),
chat: (message: string, task?: string, attachments?: ChatAttachment[], seg?: number) =>
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments, seg }),
chatStatus: (taskId: string) => get<{ running: boolean }>(`/tasks/${taskId}/chat/status`),
@@ -1073,16 +1097,18 @@ export const api = {
interceptDecide: (id: number, decision: "allowed" | "denied") =>
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
interceptExecution: (id: number, conversationId?: number) =>
get<import("@/lib/types").InterceptExecution>(`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`),
get<import("@/lib/types").InterceptExecution>(
`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`,
),
interceptDetail: (id: number) => get<InterceptDetail>(`/intercept/history/${id}`),
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
interceptHistoryPage: (page = 1, size = 20, filter: InterceptApprovalFilter = {}) =>
get<{ items: InterceptApprovalRow[]; total?: number }>(`/intercept/history?${interceptPageQuery(page, size, filter)}`).then(
(r) => ({
items: arr(r.items),
total: r.total ?? r.items?.length ?? 0,
}),
),
get<{ items: InterceptApprovalRow[]; total?: number }>(
`/intercept/history?${interceptPageQuery(page, size, filter)}`,
).then((r) => ({
items: arr(r.items),
total: r.total ?? r.items?.length ?? 0,
})),
interceptTask: (taskId: string) =>
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
interceptTaskPage: (taskId: string, page = 1, size = 20, filter: InterceptApprovalFilter = {}) =>
+147 -29
View File
@@ -21,8 +21,6 @@ import type {
FindingTraffic,
FindingTrafficBinding,
IntentAsset,
TrafficEvidenceRole,
TrafficEvidenceSnapshot,
ScopeRow,
Task,
TaskArchive,
@@ -32,6 +30,8 @@ import type {
TaskLLMResolution,
TaskScopeRow,
TaskTemplate,
TrafficEvidenceRole,
TrafficEvidenceSnapshot,
} from "../types";
import * as D from "./data";
@@ -179,10 +179,16 @@ function advanceMockRetests() {
retest.status = "completed";
retest.verdict = "inconclusive";
retest.summary = "演示环境未执行真实验证,无法确认漏洞当前状态。";
retest.evidence = "### 演示记录\n\n已关联原漏洞。此环境未连接真实 Agent,也未向目标发送请求;请在实际部署中执行复测。";
retest.evidence =
"### 演示记录\n\n已关联原漏洞。此环境未连接真实 Agent,也未向目标发送请求;请在实际部署中执行复测。";
retest.finished_at = new Date().toISOString();
mockRetestMessages[retest.conversation_id].push({
seq: 3, worker: "retester", ts: retest.finished_at, kind: "text", summary: retest.summary, detail: retest.evidence,
seq: 3,
worker: "retester",
ts: retest.finished_at,
kind: "text",
summary: retest.summary,
detail: retest.evidence,
});
}
}
@@ -1010,14 +1016,42 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const kind = q.get("kind") ?? "";
const query = (q.get("q") ?? "").trim().toLowerCase();
const candidates = [
...D.findings.map((finding, index) => ({ kind: "finding", id: index + 1, label: finding.name || finding.vulnclass, description: `${finding.severity} · ${finding.summary}` })),
...D.findings.map((finding, index) => ({
kind: "finding",
id: index + 1,
label: finding.name || finding.vulnclass,
description: `${finding.severity} · ${finding.summary}`,
})),
...D.companies.map((company) => ({ kind: "company", id: company.id, label: company.name, description: "企业" })),
...D.assets.map((asset) => ({ kind: asset.type, id: asset.id, label: asset.type === "endpoint" ? `${asset.method || "GET"} ${asset.url}` : asset.app_name || asset.url || asset.domain || asset.ip || asset.bundle_id || `资产 #${asset.id}`, description: [asset.type, asset.page_title, asset.service_name, asset.bundle_id, asset.ip].filter(Boolean).join(" · ") })),
...D.assets.map((asset) => ({
kind: asset.type,
id: asset.id,
label:
asset.type === "endpoint"
? `${asset.method || "GET"} ${asset.url}`
: asset.app_name || asset.url || asset.domain || asset.ip || asset.bundle_id || `资产 #${asset.id}`,
description: [asset.type, asset.page_title, asset.service_name, asset.bundle_id, asset.ip]
.filter(Boolean)
.join(" · "),
})),
];
const filtered = candidates.filter((item) => (!kind || kind === item.kind || (kind === "asset" && item.kind !== "finding" && item.kind !== "company")) && (!query || String(item.id) === query || `${item.label} ${item.description}`.toLowerCase().includes(query)))
.sort((a, b) => Number(String(b.id) === query) - Number(String(a.id) === query) || b.id - a.id || a.kind.localeCompare(b.kind));
const filtered = candidates
.filter(
(item) =>
(!kind || kind === item.kind || (kind === "asset" && item.kind !== "finding" && item.kind !== "company")) &&
(!query || String(item.id) === query || `${item.label} ${item.description}`.toLowerCase().includes(query)),
)
.sort(
(a, b) =>
Number(String(b.id) === query) - Number(String(a.id) === query) ||
b.id - a.id ||
a.kind.localeCompare(b.kind),
);
const offset = Math.max(0, Number(q.get("cursor")) || 0);
return { items: filtered.slice(offset, offset + 20), next_cursor: offset + 20 < filtered.length ? String(offset + 20) : undefined };
return {
items: filtered.slice(offset, offset + 20),
next_cursor: offset + 20 < filtered.length ? String(offset + 20) : undefined,
};
}
// ── auth:让 demo 直接进主界面 ──
@@ -1872,9 +1906,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
};
}
if (path === "/exploration/findings/retests/active" && m === "GET") {
return { retests: mockRetests
.filter((item) => ["pending", "running"].includes(item.status) && item.conversation_id != null)
.map((item) => ({ id: item.id, finding_id: D.findings[item.finding_id - 1].id, conversation_id: item.conversation_id, status: item.status })) };
return {
retests: mockRetests
.filter((item) => ["pending", "running"].includes(item.status) && item.conversation_id != null)
.map((item) => ({
id: item.id,
finding_id: D.findings[item.finding_id - 1].id,
conversation_id: item.conversation_id,
status: item.status,
})),
};
}
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "retests") {
const finding = mockFindings.find((item) => item.id === seg[2]);
@@ -1882,20 +1923,52 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const findingID = D.findings.findIndex((item) => item.id === finding.id) + 1;
if (m === "GET") return { retests: structuredClone(mockRetests.filter((item) => item.finding_id === findingID)) };
if (m === "POST") {
const existing = mockRetests.find((item) => item.finding_id === findingID && ["pending", "running"].includes(item.status));
const existing = mockRetests.find(
(item) => item.finding_id === findingID && ["pending", "running"].includes(item.status),
);
if (existing) return { retest: structuredClone(existing), created: false };
const now = new Date().toISOString();
const conversationID = mockConversations.reduce((max, item) => Math.max(max, item.id), 0) + 1;
mockConversations.unshift({ id: conversationID, agent_key: "retester", title: `复测 #${finding.id} · ${finding.name || finding.vulnclass}`, pinned: false, created_at: now, updated_at: now });
mockConversations.unshift({
id: conversationID,
agent_key: "retester",
title: `复测 #${finding.id} · ${finding.name || finding.vulnclass}`,
pinned: false,
created_at: now,
updated_at: now,
});
const retest: FindingRetest = {
id: mockRetests.length + 1, finding_id: findingID, conversation_id: conversationID,
status: "running", verdict: "", notes: String(b.notes ?? ""), summary: "", evidence: "",
error: "", created_at: now, started_at: now, finished_at: null,
id: mockRetests.length + 1,
finding_id: findingID,
conversation_id: conversationID,
status: "running",
verdict: "",
notes: String(b.notes ?? ""),
summary: "",
evidence: "",
error: "",
created_at: now,
started_at: now,
finished_at: null,
};
mockRetests.unshift(retest);
mockRetestMessages[conversationID] = [
{ seq: 1, worker: "retester", ts: now, kind: "user", summary: `请复测漏洞 #${finding.id}`, detail: retest.notes },
{ seq: 2, worker: "retester", ts: now, kind: "text", summary: "演示复测进行中(未向目标发送请求)", detail: "演示复测进行中(未向目标发送请求)" },
{
seq: 1,
worker: "retester",
ts: now,
kind: "user",
summary: `请复测漏洞 #${finding.id}`,
detail: retest.notes,
},
{
seq: 2,
worker: "retester",
ts: now,
kind: "text",
summary: "演示复测进行中(未向目标发送请求)",
detail: "演示复测进行中(未向目标发送请求)",
},
];
return { retest: structuredClone(retest), created: true };
}
@@ -2070,6 +2143,37 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
};
}
if (path === "/exploration/graph") return D.explorationGraph;
// 播报板:和后端 /exploration/nodes 同语义 —— 按创建顺序(mock 里用 ts + id)分页,
// 并带上这一页涉及的边与边另一端的节点。
if (path === "/exploration/nodes") {
const all = D.explorationGraph.nodes;
const kinds = new Set((q.get("kind") ?? "").split(",").filter(Boolean));
const states = new Set((q.get("state") ?? "").split(",").filter(Boolean));
const needle = (q.get("q") ?? "").trim().toLowerCase();
const asc = q.get("order") === "asc";
const page = Math.max(1, Number(q.get("page") ?? 1));
const size = Math.min(200, Math.max(1, Number(q.get("size") ?? 20)));
const rank = (id: string) => all.findIndex((n) => n.id === id);
const matched = all
.filter((n) => (kinds.size === 0 || kinds.has(n.type)) && (states.size === 0 || states.has(n.state)))
.filter((n) => !needle || `${n.payload ?? ""} ${n.origin}`.toLowerCase().includes(needle))
.sort((a, b) => {
const d = Date.parse(a.ts) - Date.parse(b.ts) || rank(a.id) - rank(b.id);
return asc ? d : -d;
});
const items = matched.slice((page - 1) * size, page * size);
const onPage = new Set(items.map((n) => n.id));
const edges = D.explorationGraph.edges.filter((e) => onPage.has(e.src) || onPage.has(e.dst));
const refs: Record<string, (typeof all)[number]> = {};
for (const e of edges) {
for (const id of [e.src, e.dst]) {
if (onPage.has(id) || refs[id]) continue;
const node = all.find((n) => n.id === id);
if (node) refs[id] = node;
}
}
return { items, total: matched.length, page, size, edges, refs };
}
if (path === "/exploration/activity" && seg.length === 2) {
const since = Number(q.get("since") ?? 0);
const limit = Math.max(1, Number(q.get("limit") ?? 300));
@@ -2160,10 +2264,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── conversations ──
if (path === "/conversations" && m === "GET") {
sortMockConversations();
return { conversations: structuredClone(mockConversations.map((conversation) => ({
...conversation,
running: mockRetests.some((item) => item.conversation_id === conversation.id && ["pending", "running"].includes(item.status)),
}))) };
return {
conversations: structuredClone(
mockConversations.map((conversation) => ({
...conversation,
running: mockRetests.some(
(item) => item.conversation_id === conversation.id && ["pending", "running"].includes(item.status),
),
})),
),
};
}
if (path === "/conversations" && m === "POST") {
const now = new Date().toISOString();
@@ -2216,7 +2326,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
}
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
const items = mockRetestMessages[Number(seg[1])] ?? D.conversationMessages[Number(seg[1])] ?? [];
const running = mockRetests.some((item) => item.conversation_id === Number(seg[1]) && ["pending", "running"].includes(item.status));
const running = mockRetests.some(
(item) => item.conversation_id === Number(seg[1]) && ["pending", "running"].includes(item.status),
);
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running };
}
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 4) {
@@ -2265,7 +2377,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle")
return { ok: true, enabled: b.enabled ?? true };
if (path === "/intercept/pending" && m === "GET") return { pending: mockInterceptHistory.filter((r) => r.status === "pending") };
if (path === "/intercept/pending" && m === "GET")
return { pending: mockInterceptHistory.filter((r) => r.status === "pending") };
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") {
const id = Number(seg[2]);
const row = mockInterceptHistory.find((r) => r.id === id) ?? mockInterceptPending.find((r) => r.id === id);
@@ -2295,11 +2408,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态");
if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("无效判定来源");
const filtered = mockInterceptHistory.filter((row) => {
const source = row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown");
return (seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
(!status || row.status === status) && (!decisionSource || source === decisionSource);
const source =
row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown");
return (
(seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
(!status || row.status === status) &&
(!decisionSource || source === decisionSource)
);
});
if (!q.has("page") && !q.has("size") && !status && !decisionSource) return { items: filtered, total: filtered.length };
if (!q.has("page") && !q.has("size") && !status && !decisionSource)
return { items: filtered, total: filtered.length };
const page = Math.max(1, Number(q.get("page")) || 1);
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
const offset = (page - 1) * size;
+20
View File
@@ -420,6 +420,26 @@ export interface TaskNode {
inherited?: boolean;
}
// 播报板一页:按创建顺序分页的节点 + 这一页涉及的边 + 边另一端的节点(refs,按 id 索引),
// 这样每条播报都能说清「从哪来、产出了什么」,而不用把整张图拉下来。
export interface ExplorationNodePage {
items: TaskNode[];
total: number;
page: number;
size: number;
edges: Edge[];
refs: Record<string, TaskNode>;
}
export interface ExplorationNodeQuery {
page?: number;
size?: number;
kinds?: ExploreKind[];
states?: string[];
q?: string;
order?: "asc" | "desc";
}
// 目标管理卡片用的目标(后端已把 payload 拆成 text/vulnclass)。
export interface TaskGoal {
id: string;