feat: 新增发现详情页(多 Tab + 状态区可改严重度/处置状态)

详情页 /function/findings/detail?id=<finding_id>:
- 概览 Tab:左栏摘要 + 证据/PoC,右栏状态区(严重等级、漏洞类型、涉及资产、处理状态、所属任务、发现时间)
- 严重等级与处理状态用下拉,选择后直接调后端接口即时修正
- 另有「证据/PoC」「元数据」两个 Tab
- 发现列表页漏洞名称改为链接进入详情

后端:
- GET  /api/exploration/findings/{id}  取单条(含资产解析)
- PATCH /api/exploration/findings/{id} 扩展为部分更新 {status?, severity?}
- db.GetFinding / db.SetFindingSeverity(同步探索节点 payload,保持任务内视图一致)
- db.ValidSeverity 白名单;api 增加 getFinding / setFindingSeverity
- mock 支持单条 GET / PATCH,并回填 finding_id 供 demo 链接

db 测试补充 GetFinding / SetFindingSeverity 往返断言;next build 通过。
This commit is contained in:
Autumn-27 committed 2026-08-18 11:43:50 -04:00
1 parent c69534e09b
commit 64d9c66f6a
7 files changed
+552 -18

No files matched your search

+63
View File
@@ -1,6 +1,7 @@
package db
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
@@ -46,6 +47,23 @@ func ValidFindingStatus(s string) bool {
return false
}
// Finding severity levels (findings.severity).
const (
SeverityCritical = "critical" // 严重
SeverityHigh = "high" // 高
SeverityMedium = "medium" // 中
SeverityLow = "low" // 低
)
// ValidSeverity reports whether s is a known severity level.
func ValidSeverity(s string) bool {
switch s {
case SeverityCritical, SeverityHigh, SeverityMedium, SeverityLow:
return true
}
return false
}
// AddFinding inserts a finding into the standalone findings table. taskID and
// nodeID may be 0 (stored as NULL). name may be "" (frontend falls back to
// vulnclass). Returns the new finding id.
@@ -226,6 +244,27 @@ func (d *DB) FindingStats() (*FindingStats, error) {
return st, rows.Err()
}
// GetFinding returns a single finding row (with task_description joined), or nil
// when no row has that id.
func (d *DB) GetFinding(id int64) (*DBFinding, error) {
rows, err := d.Query(`SELECT `+findingSelectCols+`
FROM findings f
LEFT JOIN tasks t ON f.task_id = t.id
WHERE f.id = $1`, id)
if err != nil {
return nil, err
}
defer rows.Close()
out, err := scanFindings(rows)
if err != nil {
return nil, err
}
if len(out) == 0 {
return nil, nil
}
return out[0], nil
}
// SetFindingStatus updates one finding's triage state. Returns rows affected.
func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id)
@@ -235,6 +274,30 @@ func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
return res.RowsAffected()
}
// SetFindingSeverity updates one finding's severity in the standalone table AND
// keeps the originating exploration node's payload in sync (the per-task view
// reads severity from the node payload, not this table). Returns rows affected
// (0 when no finding has that id). The node sync is best-effort.
func (d *DB) SetFindingSeverity(id int64, severity string) (int64, error) {
var nodeID *int64
err := d.QueryRow(`UPDATE findings SET severity=$1 WHERE id=$2 RETURNING node_id`, severity, id).Scan(&nodeID)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, err
}
if nodeID != nil {
// jsonb_set the {severity} key so the finding node (read by the per-task
// 发现 Tab) shows the same level. Ignore errors — the standalone row is the
// source of truth for the global 发现 page.
_, _ = d.Exec(`UPDATE exploration_nodes
SET payload = jsonb_set(payload, '{severity}', to_jsonb($1::text))
WHERE id = $2`, severity, *nodeID)
}
return 1, nil
}
// FindingMeta is the standalone-row data (id, triage state, anchored assets) the
// per-task view grafts onto its exploration-node findings.
type FindingMeta struct {
+24
View File
@@ -100,4 +100,28 @@ func TestFindingsPageAndStats(t *testing.T) {
if !slices.Contains(st.VulnClasses, vc) {
t.Fatalf("stats vulnclasses missing %q", vc)
}
// GetFinding: single-row fetch round-trips id/name/severity.
one, err := d.GetFinding(ids[0])
if err != nil {
t.Fatal(err)
}
if one == nil || one.ID != ids[0] || one.Severity != "critical" || one.Name != "严重漏洞标题" {
t.Fatalf("GetFinding mismatch: %+v", one)
}
if miss, err := d.GetFinding(-1); err != nil || miss != nil {
t.Fatalf("GetFinding(-1): want nil,nil got %+v,%v", miss, err)
}
// SetFindingSeverity: standalone row updates; 0 rows for unknown id.
if n, err := d.SetFindingSeverity(ids[0], "high"); err != nil || n != 1 {
t.Fatalf("SetFindingSeverity: want 1,nil got %d,%v", n, err)
}
one, _ = d.GetFinding(ids[0])
if one.Severity != "high" {
t.Fatalf("severity not updated: %q", one.Severity)
}
if n, err := d.SetFindingSeverity(-1, "low"); err != nil || n != 0 {
t.Fatalf("SetFindingSeverity(-1): want 0,nil got %d,%v", n, err)
}
}
+65 -10
View File
@@ -607,7 +607,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
mux.HandleFunc("GET /api/exploration/findings", s.findings)
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.setFindingStatus)
mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding)
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding)
mux.HandleFunc("GET /api/exploration/intents", s.intents)
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
mux.HandleFunc("GET /api/exploration/activity", s.activity)
@@ -1464,35 +1465,89 @@ func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, st)
}
// setFindingStatus updates one finding's triage state (pending / false_positive /
// ignored / resolved). The id is the standalone findings-table id (DTO finding_id).
func (s *Server) setFindingStatus(w http.ResponseWriter, r *http.Request) {
// getFinding returns one finding by its standalone-table id (DTO finding_id),
// with anchored assets resolved for display.
func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) {
id := int64(atoiDefault(r.PathValue("id"), 0))
if id <= 0 {
writeErr(w, 400, "bad finding id")
return
}
f, err := s.m.pg.GetFinding(id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if f == nil {
writeErr(w, 404, "finding not found")
return
}
assets := s.resolveAssetIDs(f.AssetIDs)
writeJSON(w, 200, findingFromDB(f, assets))
}
// patchFinding partially updates a finding: any subset of {status, severity}.
// The id is the standalone findings-table id (DTO finding_id). Severity edits are
// mirrored onto the originating exploration node so the per-task view stays in
// sync. Returns the updated finding DTO.
func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) {
id := int64(atoiDefault(r.PathValue("id"), 0))
if id <= 0 {
writeErr(w, 400, "bad finding id")
return
}
var body struct {
Status string `json:"status"`
Status *string `json:"status"`
Severity *string `json:"severity"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, 400, "bad json: "+err.Error())
return
}
if !db.ValidFindingStatus(body.Status) {
writeErr(w, 400, "bad status: "+body.Status)
if body.Status == nil && body.Severity == nil {
writeErr(w, 400, "nothing to update: provide status and/or severity")
return
}
n, err := s.m.pg.SetFindingStatus(id, body.Status)
if body.Status != nil {
if !db.ValidFindingStatus(*body.Status) {
writeErr(w, 400, "bad status: "+*body.Status)
return
}
n, err := s.m.pg.SetFindingStatus(id, *body.Status)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if n == 0 {
writeErr(w, 404, "finding not found")
return
}
}
if body.Severity != nil {
if !db.ValidSeverity(*body.Severity) {
writeErr(w, 400, "bad severity: "+*body.Severity)
return
}
n, err := s.m.pg.SetFindingSeverity(id, *body.Severity)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if n == 0 {
writeErr(w, 404, "finding not found")
return
}
}
f, err := s.m.pg.GetFinding(id)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if n == 0 {
if f == nil {
writeErr(w, 404, "finding not found")
return
}
writeJSON(w, 200, map[string]any{"id": i64s(id), "status": body.Status})
writeJSON(w, 200, findingFromDB(f, s.resolveAssetIDs(f.AssetIDs)))
}
func (s *Server) intents(w http.ResponseWriter, r *http.Request) {
@@ -0,0 +1,360 @@
"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 { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
import { StatusBadge } from "@/components/status-badge";
import { statusMeta } from "@/lib/status";
import { api } from "@/lib/api";
import type { Finding, FindingStatus, Severity } from "@/lib/types";
const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
const FINDING_STATUSES: FindingStatus[] = [
"pending",
"in_progress",
"confirmed",
"resolved",
"false_positive",
"ignored",
"duplicate",
"risk_accepted",
];
function fmtTime(ts: string) {
return new Date(ts).toLocaleString("zh-CN");
}
// FieldRow is one label/value line in the right-hand status panel.
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>
</div>
);
}
function FindingDetailInner() {
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
const [finding, setFinding] = React.useState<Finding | null>(null);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("overview");
const load = React.useCallback(() => {
if (!id) {
setLoaded(true);
return;
}
api
.getFinding(id)
.then((f) => setFinding(f))
.catch(() => setFinding(null))
.finally(() => setLoaded(true));
}, [id]);
React.useEffect(() => {
load();
}, [load]);
const changeSeverity = React.useCallback(
async (next: Severity) => {
if (!finding || next === finding.severity) return;
const prev = finding.severity;
setFinding({ ...finding, severity: next });
try {
const updated = await api.setFindingSeverity(id, next);
setFinding(updated);
toast.success(`严重等级已改为「${statusMeta("severity", next).label}」`);
} catch (e) {
setFinding((cur) => (cur ? { ...cur, severity: prev } : cur));
toast.error("更新失败:" + (e as Error).message);
}
},
[finding, id],
);
const changeStatus = React.useCallback(
async (next: FindingStatus) => {
if (!finding || next === finding.status) return;
const prev = finding.status;
setFinding({ ...finding, status: next });
try {
const updated = await api.setFindingStatus(id, next);
setFinding(updated);
toast.success(`处理状态已改为「${statusMeta("finding", next).label}」`);
} catch (e) {
setFinding((cur) => (cur ? { ...cur, status: prev } : cur));
toast.error("更新失败:" + (e as Error).message);
}
},
[finding, id],
);
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>
{loaded && (
<Button asChild variant="outline">
<Link href="/function/findings">
<ArrowLeftIcon /> 返回发现列表
</Link>
</Button>
)}
</div>
);
}
const title = finding.name || finding.vulnclass || "未分类";
return (
<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">
<SidebarTrigger className="-ml-1" />
<Button asChild variant="ghost" size="icon" className="size-7">
<Link href="/function/findings">
<ArrowLeftIcon />
</Link>
</Button>
<ShieldAlertIcon className="size-4 text-muted-foreground" />
<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>
<Separator orientation="vertical" className="mx-1 h-4" />
<StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot />
</div>
<TabsList>
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="evidence">证据 / PoC</TabsTrigger>
<TabsTrigger value="meta">元数据</TabsTrigger>
</TabsList>
</header>
{/* Tab content */}
<div className="flex-1 p-4 lg:p-6">
{/* 概览:左(摘要 + 证据)/ 右(状态区) */}
<TabsContent value="overview" className="mt-0">
<div className="grid gap-4 lg:grid-cols-3">
{/* 左栏 */}
<div className="flex flex-col gap-4 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="text-sm">摘要</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm leading-relaxed whitespace-pre-wrap">
{finding.summary || "(无摘要)"}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">证据 / PoC</CardTitle>
</CardHeader>
<CardContent>
{finding.evidence ? (
<pre className="max-h-[46vh] overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{finding.evidence}
</pre>
) : (
<p className="text-sm text-muted-foreground">(无证据)</p>
)}
</CardContent>
</Card>
</div>
{/* 右栏:状态区 */}
<Card className="h-fit lg:sticky lg:top-24">
<CardHeader>
<CardTitle className="text-sm">状态</CardTitle>
</CardHeader>
<CardContent className="divide-y">
{/* 严重等级(可改) */}
<FieldRow label="严重等级">
<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">
{SEVERITIES.map((sv) => (
<SelectItem key={sv} value={sv}>
{statusMeta("severity", sv).label}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldRow>
{/* 处理状态(可改) */}
<FieldRow label="处理状态">
<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">
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectContent>
</Select>
</FieldRow>
{/* 漏洞类型 */}
<FieldRow label="漏洞类型">
{finding.vulnclass ? (
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs">
{finding.vulnclass}
</code>
) : (
<span className="text-muted-foreground">—</span>
)}
</FieldRow>
{/* 涉及资产 */}
<FieldRow label="涉及资产">
{finding.assets && finding.assets.length > 0 ? (
<div className="flex flex-wrap justify-end gap-1">
{finding.assets.map((a) => (
<code
key={a.id}
className="max-w-[16rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
title={`${a.type} · ${a.label}`}
>
{a.label}
</code>
))}
</div>
) : (
<span className="text-muted-foreground">—</span>
)}
</FieldRow>
{/* 所属任务 */}
<FieldRow label="所属任务">
{finding.task_id ? (
<Link
href={`/function/tasks/detail?id=${finding.task_id}`}
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>
<ArrowUpRightIcon className="size-3 shrink-0" />
</Link>
) : (
<span className="text-muted-foreground">—(任务已删除)</span>
)}
</FieldRow>
{/* 发现时间 */}
<FieldRow label="发现时间">
<span className="tabular-nums">{fmtTime(finding.ts)}</span>
</FieldRow>
</CardContent>
</Card>
</div>
</TabsContent>
{/* 证据全文 */}
<TabsContent value="evidence" className="mt-0">
<Card>
<CardContent className="pt-6">
{finding.evidence ? (
<pre className="overflow-auto rounded-md bg-muted px-3 py-2 font-mono text-xs whitespace-pre-wrap">
{finding.evidence}
</pre>
) : (
<p className="text-sm text-muted-foreground">本发现暂无证据文本。</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* 元数据 */}
<TabsContent value="meta" className="mt-0">
<Card>
<CardContent className="pt-6">
<dl className="grid grid-cols-1 gap-x-8 gap-y-3 text-sm sm:grid-cols-2">
{[
["发现 ID", finding.id],
["漏洞名称", finding.name || "(空,回退漏洞类型)"],
["漏洞类型", finding.vulnclass || "—"],
["严重等级", statusMeta("severity", finding.severity).label],
["处理状态", statusMeta("finding", finding.status).label],
["所属任务", finding.task_description || finding.task_id || "—"],
["涉及资产数", String(finding.assets?.length ?? 0)],
["发现时间", fmtTime(finding.ts)],
].map(([k, v]) => (
<div key={k} className="flex flex-col gap-0.5">
<dt className="text-xs text-muted-foreground">{k}</dt>
<dd className="break-all">{v}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
</TabsContent>
</div>
</Tabs>
);
}
// useSearchParams must sit under a Suspense boundary for static export.
export default function FindingDetailPage() {
return (
<React.Suspense fallback={null}>
<FindingDetailInner />
</React.Suspense>
);
}
+14 -3
View File
@@ -295,9 +295,20 @@ export default function FindingsPage() {
</TableCell>
<TableCell className="max-w-md">
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate font-medium">
{f.name || f.vulnclass || "未分类"}
</span>
{f.finding_id ? (
<Link
href={`/function/findings/detail?id=${f.finding_id}`}
onClick={(e) => e.stopPropagation()}
className="truncate font-medium hover:text-primary hover:underline"
title="查看发现详情"
>
{f.name || f.vulnclass || "未分类"}
</Link>
) : (
<span className="truncate font-medium">
{f.name || f.vulnclass || "未分类"}
</span>
)}
<span className="truncate text-xs text-muted-foreground">
{f.summary}
</span>
+5 -2
View File
@@ -6,7 +6,7 @@
import { MOCK } from "@/lib/mock/enabled";
import { mockHandle } from "@/lib/mock/handler";
import type {
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, FindingStatus,
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus,
FindingsPage, FindingStats, FindingQuery, Activity,
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
@@ -245,8 +245,11 @@ export const api = {
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
},
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
getFinding: (id: string) => get<Finding>(`/exploration/findings/${id}`),
setFindingStatus: (id: string, status: FindingStatus) =>
patch<{ id: string; status: FindingStatus }>(`/exploration/findings/${id}`, { status }),
patch<Finding>(`/exploration/findings/${id}`, { status }),
setFindingSeverity: (id: string, severity: Severity) =>
patch<Finding>(`/exploration/findings/${id}`, { severity }),
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
tokenStats: (task?: string) =>
get<{ workers: TokenUsage[]; total: TokenTotal }>(`/exploration/tokens${tq(task)}`).then((r) => ({
+21 -3
View File
@@ -85,10 +85,28 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
vulnclasses,
};
}
// 单条 finding:GET 详情 / PATCH 改状态或严重度(demo 直接改内存对象)。
if (
seg[0] === "exploration" &&
seg[1] === "findings" &&
seg.length === 3 &&
seg[2] !== "stats"
) {
const f = D.findings.find((x) => x.id === seg[2]);
if (!f) return {};
if (m === "PATCH") {
if (typeof b.status === "string") f.status = b.status as typeof f.status;
if (typeof b.severity === "string")
f.severity = b.severity as typeof f.severity;
}
return { ...f, finding_id: f.id };
}
if (path === "/exploration/findings") {
if (task) return D.findings.filter((f) => f.task_id === task);
// finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。
const withFid = (f: (typeof D.findings)[number]) => ({ ...f, finding_id: f.id });
if (task) return D.findings.filter((f) => f.task_id === task).map(withFid);
// 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。
if (!q.has("page") && !q.has("limit")) return D.findings;
if (!q.has("page") && !q.has("limit")) return D.findings.map(withFid);
const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const;
let list = D.findings.slice();
const fSev = q.get("severity");
@@ -105,7 +123,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const page = Number(q.get("page") ?? 1);
const pageSize = Number(q.get("limit") ?? 20);
return {
items: list.slice((page - 1) * pageSize, page * pageSize),
items: list.slice((page - 1) * pageSize, page * pageSize).map(withFid),
total: list.length,
page,
page_size: pageSize,