diff --git a/db/findings.go b/db/findings.go index 14fc1fa..325b02d 100644 --- a/db/findings.go +++ b/db/findings.go @@ -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() } diff --git a/db/findings_test.go b/db/findings_test.go new file mode 100644 index 0000000..aca4fc7 --- /dev/null +++ b/db/findings_test.go @@ -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) + } +} diff --git a/server/dto.go b/server/dto.go index 6bab084..46cc2bb 100644 --- a/server/dto.go +++ b/server/dto.go @@ -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 diff --git a/server/server.go b/server/server.go index 0ffbfc9..c89a5cd 100644 --- a/server/server.go +++ b/server/server.go @@ -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 / diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index cd6cdf6..e40480c 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -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 = { 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(null); const [findings, setFindings] = React.useState([]); + const [total, setTotal] = React.useState(0); + const [stats, setStats] = React.useState(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]) => (