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:
1 parent
c69534e09b
commit
64d9c66f6a
7 files changed
+552
-18
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user