feat: 发现列表改为服务端分页 + 全表聚合(去掉 500 上限)
- db.ListFindingsPage:WHERE(严重度/状态/类型)+ ORDER BY(严重度 CASE 或时间)+ LIMIT/OFFSET,返回当前页与匹配总数
- db.FindingStats:全表 COUNT FILTER 聚合(总数/待处理/高/中/低)+ DISTINCT 漏洞类型,供统计卡与类型下拉,不受分页影响
- GET /api/exploration/findings 带 page/limit → 分页对象{items,total,page,page_size};不带 → 保留裸数组(dashboard 汇总,与 intents 端点兼容策略一致)
- 新增 GET /api/exploration/findings/stats
- 筛选/排序全部下推到 SQL,前端不再一次拉全量再本地切片
- 资产按当前页所需批量解析;per-task 视图经 node_id 关联独立行,复用同一状态并补齐绑定资产
- 前端:发现页改为服务端驱动分页/筛选/排序,统计卡与类型下拉走 stats 接口
- db 层加 findings 分页/筛选/聚合测试(隔离标记 vulnclass,自清理)
This commit is contained in:
1 parent
05f0b69469
commit
30dc09ae08
9 files changed
+512
-140
No files matched your search
+162
-36
@@ -2,6 +2,8 @@ package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -61,23 +63,18 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, severity, summary, evid
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ListFindings returns all findings (newest first), joined with task description.
|
||||
func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := d.Query(`
|
||||
SELECT f.id, f.task_id, f.node_id, f.vulnclass, f.severity, f.summary,
|
||||
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
|
||||
COALESCE(t.description, '') AS task_description
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
// findingSelectCols is the column list (with task_description join) every finding
|
||||
// list query selects, so scanFinding stays in sync across callers.
|
||||
const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, f.severity, f.summary,
|
||||
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
|
||||
COALESCE(t.description, '') AS task_description`
|
||||
|
||||
// scanFindings materializes rows selected via findingSelectCols.
|
||||
func scanFindings(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
}) ([]*DBFinding, error) {
|
||||
var out []*DBFinding
|
||||
for rows.Next() {
|
||||
f := &DBFinding{}
|
||||
@@ -92,6 +89,134 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListFindings returns all findings (newest first), joined with task description.
|
||||
// Kept for the dashboard's summary; the paginated 发现 page uses ListFindingsPage.
|
||||
func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := d.Query(`
|
||||
SELECT `+findingSelectCols+`
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFindings(rows)
|
||||
}
|
||||
|
||||
// FindingFilter narrows a paginated findings query. Empty-string fields mean "no
|
||||
// filter on that column". Sort is "severity" (severity desc, then newest) or
|
||||
// anything else (newest first).
|
||||
type FindingFilter struct {
|
||||
Severity string // high | medium | low
|
||||
Status string // pending | false_positive | ignored | resolved
|
||||
VulnClass string
|
||||
Sort string // "severity" | "time"
|
||||
}
|
||||
|
||||
// where builds the WHERE clause (shared by the page and count queries) plus its
|
||||
// positional args. Only equality filters, all parameterized.
|
||||
func (f FindingFilter) where() (string, []any) {
|
||||
var conds []string
|
||||
var args []any
|
||||
add := func(col, val string) {
|
||||
if val == "" {
|
||||
return
|
||||
}
|
||||
args = append(args, val)
|
||||
conds = append(conds, fmt.Sprintf("f.%s = $%d", col, len(args)))
|
||||
}
|
||||
add("severity", f.Severity)
|
||||
add("status", f.Status)
|
||||
add("vulnclass", f.VulnClass)
|
||||
if len(conds) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
||||
// ListFindingsPage returns one page of findings matching the filter, plus the
|
||||
// total count of matching rows (for the frontend pager). page is 1-based.
|
||||
func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
where, args := f.where()
|
||||
|
||||
var total int
|
||||
if err := d.QueryRow(`SELECT COUNT(*) FROM findings f`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
order := "f.created_at DESC"
|
||||
if f.Sort == "severity" {
|
||||
// high > medium > low > 其它, then newest first.
|
||||
order = `CASE f.severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC`
|
||||
}
|
||||
pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
|
||||
q := fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id%s
|
||||
ORDER BY %s
|
||||
LIMIT $%d OFFSET $%d`, findingSelectCols, where, order, len(args)+1, len(args)+2)
|
||||
rows, err := d.Query(q, pageArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out, err := scanFindings(rows)
|
||||
return out, total, err
|
||||
}
|
||||
|
||||
// FindingStats is the whole-table aggregate powering the 发现 page's stat cards
|
||||
// and vuln-class filter — computed server-side so it stays exact regardless of
|
||||
// pagination.
|
||||
type FindingStats struct {
|
||||
Total int `json:"total"`
|
||||
Pending int `json:"pending"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
VulnClasses []string `json:"vulnclasses"`
|
||||
}
|
||||
|
||||
// FindingStats returns whole-table counts (by severity + pending) and the sorted
|
||||
// set of distinct vuln classes.
|
||||
func (d *DB) FindingStats() (*FindingStats, error) {
|
||||
st := &FindingStats{VulnClasses: []string{}}
|
||||
err := d.QueryRow(`SELECT
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status = 'pending'),
|
||||
COUNT(*) FILTER (WHERE severity = 'high'),
|
||||
COUNT(*) FILTER (WHERE severity = 'medium'),
|
||||
COUNT(*) FILTER (WHERE severity = 'low')
|
||||
FROM findings`).Scan(&st.Total, &st.Pending, &st.High, &st.Medium, &st.Low)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := d.Query(`SELECT DISTINCT vulnclass FROM findings WHERE vulnclass <> '' ORDER BY vulnclass`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var vc string
|
||||
if err := rows.Scan(&vc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.VulnClasses = append(st.VulnClasses, vc)
|
||||
}
|
||||
return st, rows.Err()
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -101,36 +226,37 @@ func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// FindingStatusByNodeID maps a task's finding node ids to their standalone-row
|
||||
// id and triage state, so the per-task view (which reads exploration nodes) can
|
||||
// show and edit the same status as the global 发现 page.
|
||||
func (d *DB) FindingStatusByNodeID(taskID int64) (map[int64]struct {
|
||||
ID int64
|
||||
Status string
|
||||
}, error) {
|
||||
out := map[int64]struct {
|
||||
ID int64
|
||||
Status string
|
||||
}{}
|
||||
// FindingMeta is the standalone-row data (id, triage state, anchored assets) the
|
||||
// per-task view grafts onto its exploration-node findings.
|
||||
type FindingMeta struct {
|
||||
ID int64
|
||||
Status string
|
||||
AssetIDs []int64
|
||||
}
|
||||
|
||||
// FindingMetaByNodeID maps a task's finding node ids to their standalone-row
|
||||
// metadata, so the per-task view (which reads exploration nodes) can show and
|
||||
// edit the same status — and the same anchored assets — as the global 发现 page.
|
||||
func (d *DB) FindingMetaByNodeID(taskID int64) (map[int64]FindingMeta, error) {
|
||||
out := map[int64]FindingMeta{}
|
||||
if taskID <= 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := d.Query(`SELECT node_id, id, COALESCE(status,'pending') FROM findings
|
||||
rows, err := d.Query(`SELECT node_id, id, COALESCE(status,'pending'), asset_ids FROM findings
|
||||
WHERE task_id=$1 AND node_id IS NOT NULL`, taskID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var nid, id int64
|
||||
var st string
|
||||
if err := rows.Scan(&nid, &id, &st); err != nil {
|
||||
var nid int64
|
||||
var m FindingMeta
|
||||
var aidsJSON string
|
||||
if err := rows.Scan(&nid, &m.ID, &m.Status, &aidsJSON); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out[nid] = struct {
|
||||
ID int64
|
||||
Status string
|
||||
}{ID: id, Status: st}
|
||||
_ = json.Unmarshal([]byte(aidsJSON), &m.AssetIDs)
|
||||
out[nid] = m
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFindingsPageAndStats exercises ListFindingsPage (filter/sort/paging) and
|
||||
// FindingStats against the live dev PG. It tags its rows with a unique vulnclass
|
||||
// so assertions are isolated from any pre-existing data, and cleans up after.
|
||||
func TestFindingsPageAndStats(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
const vc = "__test_vc_pagination__"
|
||||
// clean any leftovers from a prior aborted run, and clean up on exit
|
||||
cleanup := func() { _, _ = d.Exec(`DELETE FROM findings WHERE vulnclass=$1`, vc) }
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
// Seed 5 findings under the marker vulnclass: 3 high, 2 low; 2 pending, 3 resolved.
|
||||
seed := []struct {
|
||||
sev, status string
|
||||
}{
|
||||
{"high", "pending"},
|
||||
{"high", "resolved"},
|
||||
{"high", "resolved"},
|
||||
{"low", "pending"},
|
||||
{"low", "resolved"},
|
||||
}
|
||||
var ids []int64
|
||||
for i, s := range seed {
|
||||
id, err := d.AddFinding(0, 0, vc, s.sev, "summary", "poc", "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddFinding[%d]: %v", i, err)
|
||||
}
|
||||
if _, err := d.SetFindingStatus(id, s.status); err != nil {
|
||||
t.Fatalf("SetFindingStatus[%d]: %v", i, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// Filter by our vulnclass → exactly the 5 seeded rows, paged 2 per page.
|
||||
p1, total, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, Sort: "severity"}, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 {
|
||||
t.Fatalf("total: want 5, got %d", total)
|
||||
}
|
||||
if len(p1) != 2 {
|
||||
t.Fatalf("page1 size: want 2, got %d", len(p1))
|
||||
}
|
||||
// severity sort → highs first
|
||||
if p1[0].Severity != "high" || p1[1].Severity != "high" {
|
||||
t.Fatalf("severity sort broken: %q, %q", p1[0].Severity, p1[1].Severity)
|
||||
}
|
||||
p3, _, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, Sort: "severity"}, 3, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p3) != 1 {
|
||||
t.Fatalf("page3 size: want 1 (5 rows / 2), got %d", len(p3))
|
||||
}
|
||||
|
||||
// Combined filter: vulnclass + status=pending → 2 rows.
|
||||
pend, total, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, Status: FindingPending}, 1, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(pend) != 2 {
|
||||
t.Fatalf("pending filter: want 2/2, got %d/%d", total, len(pend))
|
||||
}
|
||||
|
||||
// Combined filter: vulnclass + severity=high → 3 rows.
|
||||
_, total, err = d.ListFindingsPage(FindingFilter{VulnClass: vc, Severity: "high"}, 1, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("high filter: want 3, got %d", total)
|
||||
}
|
||||
|
||||
// Stats: whole-table, so assert our contribution is reflected (>=) and the
|
||||
// marker vulnclass is present.
|
||||
st, err := d.FindingStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Total < 5 || st.High < 3 || st.Low < 2 || st.Pending < 2 {
|
||||
t.Fatalf("stats undercount: %+v", st)
|
||||
}
|
||||
if !slices.Contains(st.VulnClasses, vc) {
|
||||
t.Fatalf("stats vulnclasses missing %q", vc)
|
||||
}
|
||||
}
|
||||
+22
-17
@@ -250,27 +250,38 @@ func findingDTO(n *db.Node) FindingDTO {
|
||||
|
||||
// findingDTOsForTask converts a task's finding nodes to DTOs, stamping each with
|
||||
// the owning task's id/description so the global 发现 page can group across tasks.
|
||||
// triage maps node id → the standalone findings row (id + status), so the per-task
|
||||
// view shows the same triage state as the global page; nodes with no row keep the
|
||||
// 'pending' default and no finding_id (status is then not editable).
|
||||
func findingDTOsForTask(t *Task, in []*db.Node, triage map[int64]struct {
|
||||
ID int64
|
||||
Status string
|
||||
}) []FindingDTO {
|
||||
// meta maps node id → the standalone findings row (id + status + asset ids), so the
|
||||
// per-task view shows the same triage state and anchored assets as the global page;
|
||||
// nodes with no row keep the 'pending' default and no finding_id (not editable).
|
||||
// assets pre-resolves the anchored asset rows for label rendering.
|
||||
func findingDTOsForTask(t *Task, in []*db.Node, meta map[int64]db.FindingMeta, assets map[int64]*db.Asset) []FindingDTO {
|
||||
out := make([]FindingDTO, 0, len(in))
|
||||
for _, n := range in {
|
||||
d := findingDTO(n)
|
||||
d.TaskID = t.ID
|
||||
d.TaskDescription = t.Description
|
||||
if r, ok := triage[n.ID]; ok {
|
||||
d.FindingID = i64s(r.ID)
|
||||
d.Status = r.Status
|
||||
if m, ok := meta[n.ID]; ok {
|
||||
d.FindingID = i64s(m.ID)
|
||||
d.Status = m.Status
|
||||
d.Assets = findingAssetDTOs(m.AssetIDs, assets)
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findingAssetDTOs maps anchored asset ids to display DTOs, skipping ids whose
|
||||
// asset row is missing (e.g. deleted).
|
||||
func findingAssetDTOs(ids []int64, assets map[int64]*db.Asset) []FindingAssetDTO {
|
||||
var out []FindingAssetDTO
|
||||
for _, aid := range ids {
|
||||
if a := assets[aid]; a != nil {
|
||||
out = append(out, FindingAssetDTO{ID: i64s(a.ID), Type: a.Type, Label: assetLabel(a)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findingFromDB converts a standalone DBFinding row to a FindingDTO. task_id and
|
||||
// task_description are empty when the originating task has been deleted (NULL).
|
||||
func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
|
||||
@@ -288,13 +299,7 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
|
||||
Evidence: f.Evidence,
|
||||
TS: rfc3339(f.CreatedAt),
|
||||
}
|
||||
for _, aid := range f.AssetIDs {
|
||||
a := assets[aid]
|
||||
if a == nil {
|
||||
continue
|
||||
}
|
||||
d.Assets = append(d.Assets, FindingAssetDTO{ID: i64s(a.ID), Type: a.Type, Label: assetLabel(a)})
|
||||
}
|
||||
d.Assets = findingAssetDTOs(f.AssetIDs, assets)
|
||||
if f.TaskID != nil {
|
||||
d.TaskID = i64s(*f.TaskID)
|
||||
d.TaskDescription = f.TaskDescription
|
||||
|
||||
+89
-25
@@ -601,6 +601,7 @@ 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/intents", s.intents)
|
||||
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
|
||||
@@ -1349,36 +1350,40 @@ func (s *Server) frontier(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) findings(w http.ResponseWriter, r *http.Request) {
|
||||
// 无 task 参数 → 全局「发现」页:从独立 findings 表读取(任务删除后 finding 依然保留)。
|
||||
// 带 task 参数 → 仅该任务(任务概览/发现 Tab 用),从 exploration_nodes 读(任务在则节点在)。
|
||||
taskParam := r.URL.Query().Get("task")
|
||||
q := r.URL.Query()
|
||||
taskParam := q.Get("task")
|
||||
if taskParam == "" {
|
||||
fs, _ := s.m.pg.ListFindings(500)
|
||||
// Resolve every anchored asset in one query so each finding can carry a
|
||||
// display label for its assets.
|
||||
seen := map[int64]bool{}
|
||||
var ids []int64
|
||||
for _, f := range fs {
|
||||
for _, aid := range f.AssetIDs {
|
||||
if aid > 0 && !seen[aid] {
|
||||
seen[aid] = true
|
||||
ids = append(ids, aid)
|
||||
}
|
||||
// 带 page/limit → 服务端分页 {items,total,...};不带 → 裸数组(dashboard 汇总用,
|
||||
// 与 intents 端点的兼容策略一致)。筛选/排序统一下推到 SQL。
|
||||
if q.Get("page") == "" && q.Get("limit") == "" {
|
||||
fs, _ := s.m.pg.ListFindings(500)
|
||||
assets := s.resolveFindingAssets(fs)
|
||||
out := make([]FindingDTO, 0, len(fs))
|
||||
for _, f := range fs {
|
||||
out = append(out, findingFromDB(f, assets))
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
return
|
||||
}
|
||||
assets := map[int64]*db.Asset{}
|
||||
if len(ids) > 0 {
|
||||
list, err := s.m.pg.Assets().GetByIDs(ids)
|
||||
if err != nil {
|
||||
log.Printf("[findings] resolve assets: %v", err)
|
||||
}
|
||||
for _, a := range list {
|
||||
assets[a.ID] = a
|
||||
}
|
||||
page := atoiDefault(q.Get("page"), 1)
|
||||
limit := min(atoiDefault(q.Get("limit"), 20), 200)
|
||||
filter := db.FindingFilter{
|
||||
Severity: normFilter(q.Get("severity")),
|
||||
Status: normFilter(q.Get("status")),
|
||||
VulnClass: normFilter(q.Get("vulnclass")),
|
||||
Sort: q.Get("sort"),
|
||||
}
|
||||
fs, total, err := s.m.pg.ListFindingsPage(filter, page, limit)
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
assets := s.resolveFindingAssets(fs)
|
||||
out := make([]FindingDTO, 0, len(fs))
|
||||
for _, f := range fs {
|
||||
out = append(out, findingFromDB(f, assets))
|
||||
}
|
||||
writeJSON(w, 200, out)
|
||||
writeJSON(w, 200, map[string]any{"items": out, "total": total, "page": page, "page_size": limit})
|
||||
return
|
||||
}
|
||||
t := s.m.ResolveTask(taskParam)
|
||||
@@ -1388,11 +1393,70 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
f, _ := t.Store.ListByKind(db.KindFinding, 200)
|
||||
tid, _ := strconv.ParseInt(t.ID, 10, 64)
|
||||
triage, err := s.m.pg.FindingStatusByNodeID(tid)
|
||||
meta, err := s.m.pg.FindingMetaByNodeID(tid)
|
||||
if err != nil {
|
||||
log.Printf("[findings] task=%s triage: %v", t.ID, err)
|
||||
log.Printf("[findings] task=%s meta: %v", t.ID, err)
|
||||
}
|
||||
writeJSON(w, 200, findingDTOsForTask(t, f, triage))
|
||||
var aidSet []int64
|
||||
for _, m := range meta {
|
||||
aidSet = append(aidSet, m.AssetIDs...)
|
||||
}
|
||||
assets := s.resolveAssetIDs(aidSet)
|
||||
writeJSON(w, 200, findingDTOsForTask(t, f, meta, assets))
|
||||
}
|
||||
|
||||
// normFilter maps the frontend's "all" sentinel (and empty) to "" so the DB layer
|
||||
// treats it as no filter.
|
||||
func normFilter(v string) string {
|
||||
if v == "all" {
|
||||
return ""
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// resolveFindingAssets loads every asset anchored by the given findings, keyed by
|
||||
// id, so each finding DTO can render its assets' labels.
|
||||
func (s *Server) resolveFindingAssets(fs []*db.DBFinding) map[int64]*db.Asset {
|
||||
var ids []int64
|
||||
for _, f := range fs {
|
||||
ids = append(ids, f.AssetIDs...)
|
||||
}
|
||||
return s.resolveAssetIDs(ids)
|
||||
}
|
||||
|
||||
// resolveAssetIDs de-dupes ids and loads their asset rows into an id→asset map.
|
||||
func (s *Server) resolveAssetIDs(ids []int64) map[int64]*db.Asset {
|
||||
assets := map[int64]*db.Asset{}
|
||||
seen := map[int64]bool{}
|
||||
var uniq []int64
|
||||
for _, id := range ids {
|
||||
if id > 0 && !seen[id] {
|
||||
seen[id] = true
|
||||
uniq = append(uniq, id)
|
||||
}
|
||||
}
|
||||
if len(uniq) == 0 {
|
||||
return assets
|
||||
}
|
||||
list, err := s.m.pg.Assets().GetByIDs(uniq)
|
||||
if err != nil {
|
||||
log.Printf("[findings] resolve assets: %v", err)
|
||||
}
|
||||
for _, a := range list {
|
||||
assets[a.ID] = a
|
||||
}
|
||||
return assets
|
||||
}
|
||||
|
||||
// findingStats serves the whole-table aggregates (stat cards + vuln-class filter)
|
||||
// for the paginated 发现 page.
|
||||
func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := s.m.pg.FindingStats()
|
||||
if err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, st)
|
||||
}
|
||||
|
||||
// setFindingStatus updates one finding's triage state (pending / false_positive /
|
||||
|
||||
@@ -36,9 +36,7 @@ import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/api";
|
||||
import { toast } from "sonner";
|
||||
import { statusMeta } from "@/lib/status";
|
||||
import type { Finding, FindingStatus, Severity } from "@/lib/types";
|
||||
|
||||
const SEVERITY_ORDER: Record<Severity, number> = { high: 3, medium: 2, low: 1 };
|
||||
import type { Finding, FindingStats, FindingStatus, Severity } from "@/lib/types";
|
||||
|
||||
const FINDING_STATUSES: FindingStatus[] = [
|
||||
"pending",
|
||||
@@ -47,6 +45,15 @@ const FINDING_STATUSES: FindingStatus[] = [
|
||||
"resolved",
|
||||
];
|
||||
|
||||
const EMPTY_STATS: FindingStats = {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
vulnclasses: [],
|
||||
};
|
||||
|
||||
function fmtTime(ts: string) {
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
@@ -63,16 +70,47 @@ export default function FindingsPage() {
|
||||
const [sort, setSort] = React.useState<"severity" | "time">("severity");
|
||||
const [expanded, setExpanded] = React.useState<string | null>(null);
|
||||
const [findings, setFindings] = React.useState<Finding[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [stats, setStats] = React.useState<FindingStats>(EMPTY_STATS);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [pageSize, setPageSize] = React.useState(20);
|
||||
|
||||
// reset to page 1 whenever filters change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [severity, status, vulnclass, sort]);
|
||||
|
||||
// Server-driven list: paging + filtering + sorting all happen in SQL. Polls the
|
||||
// current page every 5s, and refetches immediately when any query input changes.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
api
|
||||
.findings()
|
||||
.then((fs) => {
|
||||
if (alive) setFindings(fs);
|
||||
.findingsPage({ page, pageSize, severity, status, vulnclass, sort })
|
||||
.then((res) => {
|
||||
if (!alive) return;
|
||||
setFindings(res.items);
|
||||
setTotal(res.total);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const t = setInterval(load, 5000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(t);
|
||||
};
|
||||
}, [page, pageSize, severity, status, vulnclass, sort]);
|
||||
|
||||
// Whole-table aggregates (stat cards + vuln-class options) — independent of the
|
||||
// current page, so they stay exact.
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = () => {
|
||||
api
|
||||
.findingStats()
|
||||
.then((s) => {
|
||||
if (alive) setStats(s);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
@@ -95,6 +133,12 @@ export default function FindingsPage() {
|
||||
try {
|
||||
await api.setFindingStatus(f.finding_id, next);
|
||||
toast.success(`已标记为「${statusMeta("finding", next).label}」`);
|
||||
// refresh stat cards (pending count) and drop the row if it no longer matches the status filter
|
||||
api.findingStats().then(setStats).catch(() => {});
|
||||
if (status !== "all" && next !== status) {
|
||||
setFindings((cur) => cur.filter((x) => x.id !== f.id));
|
||||
setTotal((t) => Math.max(0, t - 1));
|
||||
}
|
||||
} catch (e) {
|
||||
setFindings((cur) =>
|
||||
cur.map((x) => (x.id === f.id ? { ...x, status: prev } : x)),
|
||||
@@ -102,53 +146,15 @@ export default function FindingsPage() {
|
||||
toast.error("更新失败:" + (e as Error).message);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const vulnclasses = React.useMemo(
|
||||
() => Array.from(new Set(findings.map((f) => f.vulnclass))).sort(),
|
||||
[findings],
|
||||
);
|
||||
|
||||
const counts = React.useMemo(() => {
|
||||
const c = { high: 0, medium: 0, low: 0 };
|
||||
for (const f of findings) c[f.severity]++;
|
||||
const pending = findings.filter((f) => f.status === "pending").length;
|
||||
return { ...c, total: findings.length, pending };
|
||||
}, [findings]);
|
||||
|
||||
const rows = React.useMemo(() => {
|
||||
let list = findings.slice();
|
||||
if (severity !== "all") list = list.filter((f) => f.severity === severity);
|
||||
if (status !== "all") list = list.filter((f) => f.status === status);
|
||||
if (vulnclass !== "all")
|
||||
list = list.filter((f) => f.vulnclass === vulnclass);
|
||||
list.sort((a, b) => {
|
||||
if (sort === "severity") {
|
||||
const d = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
|
||||
if (d !== 0) return d;
|
||||
}
|
||||
return new Date(b.ts).getTime() - new Date(a.ts).getTime();
|
||||
});
|
||||
return list;
|
||||
}, [findings, severity, status, vulnclass, sort]);
|
||||
|
||||
// reset to page 1 whenever filters change
|
||||
React.useEffect(() => {
|
||||
setPage(1);
|
||||
}, [severity, status, vulnclass, sort]);
|
||||
|
||||
const paginated = React.useMemo(
|
||||
() => rows.slice((page - 1) * pageSize, page * pageSize),
|
||||
[rows, page, pageSize],
|
||||
[status],
|
||||
);
|
||||
|
||||
const statCards: { label: string; value: number; tone?: string }[] = [
|
||||
{ label: "发现总数", value: counts.total },
|
||||
{ label: "待处理", value: counts.pending, tone: "text-amber-500" },
|
||||
{ label: "高危", value: counts.high, tone: "text-red-500" },
|
||||
{ label: "中危", value: counts.medium, tone: "text-amber-500" },
|
||||
{ label: "低危", value: counts.low, tone: "text-slate-500" },
|
||||
{ label: "发现总数", value: stats.total },
|
||||
{ label: "待处理", value: stats.pending, tone: "text-amber-500" },
|
||||
{ label: "高危", value: stats.high, tone: "text-red-500" },
|
||||
{ label: "中危", value: stats.medium, tone: "text-amber-500" },
|
||||
{ label: "低危", value: stats.low, tone: "text-slate-500" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -183,6 +189,7 @@ export default function FindingsPage() {
|
||||
).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => setSeverity(val)}
|
||||
className={cn(
|
||||
"rounded px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
@@ -219,7 +226,7 @@ export default function FindingsPage() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
{vulnclasses.map((vc) => (
|
||||
{stats.vulnclasses.map((vc) => (
|
||||
<SelectItem key={vc} value={vc}>
|
||||
{vc}
|
||||
</SelectItem>
|
||||
@@ -241,7 +248,7 @@ export default function FindingsPage() {
|
||||
</Select>
|
||||
|
||||
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
|
||||
共 {rows.length} 条
|
||||
共 {total} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +267,7 @@ export default function FindingsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paginated.map((f) => {
|
||||
{findings.map((f) => {
|
||||
const open = expanded === f.id;
|
||||
return (
|
||||
<React.Fragment key={f.id}>
|
||||
@@ -338,11 +345,7 @@ export default function FindingsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<StatusBadge
|
||||
domain="finding"
|
||||
value={f.status}
|
||||
dot
|
||||
/>
|
||||
<StatusBadge domain="finding" value={f.status} dot />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="w-36 max-w-[9rem]">
|
||||
@@ -403,7 +406,7 @@ export default function FindingsPage() {
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{rows.length === 0 && (
|
||||
{findings.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
@@ -418,7 +421,7 @@ export default function FindingsPage() {
|
||||
<TablePagination
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={rows.length}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
|
||||
@@ -35,6 +35,7 @@ function Row({
|
||||
<div className="border-b last:border-b-0">
|
||||
<div className="flex w-full items-center gap-3 px-4 py-3 text-sm hover:bg-accent/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
>
|
||||
|
||||
+11
-1
@@ -6,7 +6,8 @@
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { mockHandle } from "@/lib/mock/handler";
|
||||
import type {
|
||||
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, FindingStatus, Activity,
|
||||
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, FindingStatus,
|
||||
FindingsPage, FindingStats, FindingQuery, Activity,
|
||||
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
|
||||
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
|
||||
Tool, Conversation, AgentTrigger, ChatAttachment,
|
||||
@@ -232,6 +233,15 @@ export const api = {
|
||||
// ---- exploration (per task) ----
|
||||
frontier: (task?: string) => get<TaskNode[]>(`/exploration/frontier${tq(task)}`).then(arr),
|
||||
findings: (task?: string) => get<Finding[]>(`/exploration/findings${tq(task)}`).then(arr),
|
||||
findingsPage: (q: FindingQuery) => {
|
||||
const p = new URLSearchParams({ page: String(q.page), limit: String(q.pageSize) });
|
||||
if (q.severity && q.severity !== "all") p.set("severity", q.severity);
|
||||
if (q.status && q.status !== "all") p.set("status", q.status);
|
||||
if (q.vulnclass && q.vulnclass !== "all") p.set("vulnclass", q.vulnclass);
|
||||
if (q.sort) p.set("sort", q.sort);
|
||||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
||||
},
|
||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||
setFindingStatus: (id: string, status: FindingStatus) =>
|
||||
patch<{ id: string; status: FindingStatus }>(`/exploration/findings/${id}`, { status }),
|
||||
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
|
||||
|
||||
@@ -73,7 +73,43 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
|
||||
// ── exploration ──
|
||||
if (path === "/exploration/frontier") return D.frontier;
|
||||
if (path === "/exploration/findings") return task ? D.findings.filter((f) => f.task_id === task) : D.findings;
|
||||
if (path === "/exploration/findings/stats") {
|
||||
const vulnclasses = Array.from(new Set(D.findings.map((f) => f.vulnclass))).sort();
|
||||
return {
|
||||
total: D.findings.length,
|
||||
pending: D.findings.filter((f) => f.status === "pending").length,
|
||||
high: D.findings.filter((f) => f.severity === "high").length,
|
||||
medium: D.findings.filter((f) => f.severity === "medium").length,
|
||||
low: D.findings.filter((f) => f.severity === "low").length,
|
||||
vulnclasses,
|
||||
};
|
||||
}
|
||||
if (path === "/exploration/findings") {
|
||||
if (task) return D.findings.filter((f) => f.task_id === task);
|
||||
// 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。
|
||||
if (!q.has("page") && !q.has("limit")) return D.findings;
|
||||
const sev = { high: 3, medium: 2, low: 1 } as const;
|
||||
let list = D.findings.slice();
|
||||
const fSev = q.get("severity");
|
||||
const fStatus = q.get("status");
|
||||
const fVuln = q.get("vulnclass");
|
||||
if (fSev) list = list.filter((f) => f.severity === fSev);
|
||||
if (fStatus) list = list.filter((f) => f.status === fStatus);
|
||||
if (fVuln) list = list.filter((f) => f.vulnclass === fVuln);
|
||||
list.sort((a, b) =>
|
||||
q.get("sort") === "severity"
|
||||
? sev[b.severity] - sev[a.severity] || +new Date(b.ts) - +new Date(a.ts)
|
||||
: +new Date(b.ts) - +new Date(a.ts),
|
||||
);
|
||||
const page = Number(q.get("page") ?? 1);
|
||||
const pageSize = Number(q.get("limit") ?? 20);
|
||||
return {
|
||||
items: list.slice((page - 1) * pageSize, page * pageSize),
|
||||
total: list.length,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
}
|
||||
if (path === "/exploration/intents") return D.intents;
|
||||
if (path === "/exploration/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal };
|
||||
if (path === "/exploration/graph") return D.explorationGraph;
|
||||
|
||||
@@ -265,6 +265,34 @@ export interface Finding {
|
||||
ts: string;
|
||||
}
|
||||
|
||||
// FindingsPage 是发现列表的服务端分页响应。
|
||||
export interface FindingsPage {
|
||||
items: Finding[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
// FindingStats 是发现全表聚合(统计卡 + 漏洞类型下拉),服务端计算,不受分页影响。
|
||||
export interface FindingStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
high: number;
|
||||
medium: number;
|
||||
low: number;
|
||||
vulnclasses: string[];
|
||||
}
|
||||
|
||||
// FindingQuery 是发现列表分页/筛选/排序参数。
|
||||
export interface FindingQuery {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
severity?: "all" | Severity;
|
||||
status?: "all" | FindingStatus;
|
||||
vulnclass?: string;
|
||||
sort?: "severity" | "time";
|
||||
}
|
||||
|
||||
// ---- Activity / sessions ----
|
||||
export type ActivityKind =
|
||||
| "tool_use"
|
||||
|
||||
Reference in new issue
Block a user