diff --git a/db/findings.go b/db/findings.go index b77ac3f..e1e7afc 100644 --- a/db/findings.go +++ b/db/findings.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "strconv" "strings" "time" ) @@ -141,6 +142,7 @@ type FindingFilter struct { Severity string // high | medium | low Status string // pending | false_positive | ignored | resolved VulnClass string + TaskID string // 任务 id(字符串形式;空/非法 = 不按任务筛选) Sort string // "severity" | "time" } @@ -159,6 +161,11 @@ func (f FindingFilter) where() (string, []any) { add("severity", f.Severity) add("status", f.Status) add("vulnclass", f.VulnClass) + // task_id 是 bigint 列,按整数比较(不能走上面的文本 add);空/非法值忽略。 + if tid, err := strconv.ParseInt(f.TaskID, 10, 64); err == nil && tid > 0 { + args = append(args, tid) + conds = append(conds, fmt.Sprintf("f.task_id = $%d", len(args))) + } if len(conds) == 0 { return "", args } @@ -206,19 +213,30 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding // 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"` - Critical int `json:"critical"` - High int `json:"high"` - Medium int `json:"medium"` - Low int `json:"low"` - VulnClasses []string `json:"vulnclasses"` + Total int `json:"total"` + Pending int `json:"pending"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + VulnClasses []string `json:"vulnclasses"` + Tasks []FindingTaskOption `json:"tasks"` // 有漏洞的任务(供「按任务」下拉) +} + +// FindingTaskOption is one entry in the 发现 page's 任务 filter: a task that has at +// least one finding, with its description and finding count. Description is empty when +// the task has since been deleted (finding rows persist), so the frontend falls back to +// the id. +type FindingTaskOption struct { + ID int64 `json:"id"` + Description string `json:"description"` + Count int `json:"count"` } // 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{}} + st := &FindingStats{VulnClasses: []string{}, Tasks: []FindingTaskOption{}} err := d.QueryRow(`SELECT COUNT(*), COUNT(*) FILTER (WHERE status = 'pending'), @@ -242,7 +260,29 @@ func (d *DB) FindingStats() (*FindingStats, error) { } st.VulnClasses = append(st.VulnClasses, vc) } - return st, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + + // 任务下拉:有漏洞的任务,带描述(任务删除后为空,前端回退 id)和条数,最新有漏洞的排前。 + trows, err := d.Query(`SELECT f.task_id, COALESCE(t.description, ''), COUNT(*) + FROM findings f + LEFT JOIN tasks t ON f.task_id = t.id + WHERE f.task_id IS NOT NULL + GROUP BY f.task_id, t.description + ORDER BY MAX(f.created_at) DESC`) + if err != nil { + return nil, err + } + defer trows.Close() + for trows.Next() { + var opt FindingTaskOption + if err := trows.Scan(&opt.ID, &opt.Description, &opt.Count); err != nil { + return nil, err + } + st.Tasks = append(st.Tasks, opt) + } + return st, trows.Err() } // GetFinding returns a single finding row (with task_description joined and the @@ -288,13 +328,15 @@ func (d *DB) SetFindingReportByNodeID(nodeID int64, report 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) { +// setFindingCol updates one text column on the standalone finding row AND mirrors +// the new value into the originating exploration node's payload under jsonKey, so the +// per-task 发现 Tab (which reads the node payload, not this table) stays in sync. +// Returns rows affected (0 when no finding has that id); the node sync is best-effort. +// col and jsonKey MUST be trusted constants (they are interpolated into SQL) — never +// pass user input. +func (d *DB) setFindingCol(id int64, col, jsonKey, val string) (int64, error) { var nodeID *int64 - err := d.QueryRow(`UPDATE findings SET severity=$1 WHERE id=$2 RETURNING node_id`, severity, id).Scan(&nodeID) + err := d.QueryRow(`UPDATE findings SET `+col+`=$1 WHERE id=$2 RETURNING node_id`, val, id).Scan(&nodeID) if err == sql.ErrNoRows { return 0, nil } @@ -302,16 +344,30 @@ func (d *DB) SetFindingSeverity(id int64, severity string) (int64, error) { 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) + SET payload = jsonb_set(payload, '{`+jsonKey+`}', to_jsonb($1::text)) + WHERE id = $2`, val, *nodeID) } return 1, nil } +// SetFindingSeverity updates one finding's severity (+ node payload sync). Returns +// rows affected (0 when no finding has that id). +func (d *DB) SetFindingSeverity(id int64, severity string) (int64, error) { + return d.setFindingCol(id, "severity", "severity", severity) +} + +// SetFindingName updates one finding's 漏洞名称 (+ node payload sync). Empty name is +// allowed — the frontend falls back to the vuln class for display. +func (d *DB) SetFindingName(id int64, name string) (int64, error) { + return d.setFindingCol(id, "name", "name", name) +} + +// SetFindingVulnClass updates one finding's 漏洞类别 (+ node payload sync). +func (d *DB) SetFindingVulnClass(id int64, vulnclass string) (int64, error) { + return d.setFindingCol(id, "vulnclass", "vulnclass", vulnclass) +} + // FindingMeta is the standalone-row data (id, triage state, anchored assets) the // per-task view grafts onto its exploration-node findings. type FindingMeta struct { diff --git a/server/server.go b/server/server.go index c53fea8..5e8418d 100644 --- a/server/server.go +++ b/server/server.go @@ -1537,15 +1537,17 @@ func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) { return } var body struct { - Status *string `json:"status"` - Severity *string `json:"severity"` + Status *string `json:"status"` + Severity *string `json:"severity"` + Name *string `json:"name"` + VulnClass *string `json:"vulnclass"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, 400, "bad json: "+err.Error()) return } - if body.Status == nil && body.Severity == nil { - writeErr(w, 400, "nothing to update: provide status and/or severity") + if body.Status == nil && body.Severity == nil && body.Name == nil && body.VulnClass == nil { + writeErr(w, 400, "nothing to update: provide status/severity/name/vulnclass") return } if body.Status != nil { @@ -1578,6 +1580,28 @@ func (s *Server) patchFinding(w http.ResponseWriter, r *http.Request) { return } } + if body.Name != nil { + n, err := s.m.pg.SetFindingName(id, strings.TrimSpace(*body.Name)) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if n == 0 { + writeErr(w, 404, "finding not found") + return + } + } + if body.VulnClass != nil { + n, err := s.m.pg.SetFindingVulnClass(id, strings.TrimSpace(*body.VulnClass)) + 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()) diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 035c194..498714d 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -6,9 +6,14 @@ import { ChevronRightIcon, ShieldAlertIcon, ArrowUpRightIcon, + FileTextIcon, } from "lucide-react"; import { StatusBadge } from "@/components/status-badge"; +import { Markdown } from "@/components/markdown"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Card, CardContent, @@ -38,6 +43,8 @@ import { toast } from "sonner"; import { statusMeta } from "@/lib/status"; import type { Finding, FindingStats, FindingStatus, Severity } from "@/lib/types"; +const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"]; + const FINDING_STATUSES: FindingStatus[] = [ "pending", "in_progress", @@ -57,6 +64,7 @@ const EMPTY_STATS: FindingStats = { medium: 0, low: 0, vulnclasses: [], + tasks: [], }; function fmtTime(ts: string) { @@ -72,6 +80,7 @@ export default function FindingsPage() { const [severity, setSeverity] = React.useState<"all" | Severity>("all"); const [status, setStatus] = React.useState<"all" | FindingStatus>("all"); const [vulnclass, setVulnclass] = React.useState("all"); + const [task, setTask] = React.useState("all"); const [sort, setSort] = React.useState<"severity" | "time">("severity"); const [expanded, setExpanded] = React.useState(null); const [findings, setFindings] = React.useState([]); @@ -83,7 +92,7 @@ export default function FindingsPage() { // reset to page 1 whenever filters change React.useEffect(() => { setPage(1); - }, [severity, status, vulnclass, sort]); + }, [severity, status, vulnclass, task, 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. @@ -91,7 +100,7 @@ export default function FindingsPage() { let alive = true; const load = () => { api - .findingsPage({ page, pageSize, severity, status, vulnclass, sort }) + .findingsPage({ page, pageSize, severity, status, vulnclass, task, sort }) .then((res) => { if (!alive) return; setFindings(res.items); @@ -105,7 +114,7 @@ export default function FindingsPage() { alive = false; clearInterval(t); }; - }, [page, pageSize, severity, status, vulnclass, sort]); + }, [page, pageSize, severity, status, vulnclass, task, sort]); // Whole-table aggregates (stat cards + vuln-class options) — independent of the // current page, so they stay exact. @@ -154,6 +163,74 @@ export default function FindingsPage() { [status], ); + // 行内展开的详细报告缓存,按行 key(f.id)存。report 是大段 Markdown,列表查询不带它, + // 故展开时才按 finding_id 单独拉取一次;done 且文本为空 = 该漏洞暂无报告。 + const [reports, setReports] = React.useState< + Record + >({}); + + // 行内可编辑缓冲:当前展开行的名称/类别/严重等级,展开时用该行数据初始化,收起清空。 + // 单行展开,故一份缓冲即可。 + const [edit, setEdit] = React.useState< + { name: string; vulnclass: string; severity: Severity } | null + >(null); + const [saving, setSaving] = React.useState(false); + + // toggle 展开/收起一行;新展开时初始化编辑缓冲,并(尚未取过时)按 finding_id 拉一次报告缓存。 + const toggleRow = React.useCallback( + (f: Finding) => { + const willOpen = expanded !== f.id; + setExpanded(willOpen ? f.id : null); + if (!willOpen) { + setEdit(null); + return; + } + setEdit({ name: f.name ?? "", vulnclass: f.vulnclass ?? "", severity: f.severity }); + if (!f.finding_id || reports[f.id]) return; + const fid = f.finding_id; + const key = f.id; + setReports((r) => ({ ...r, [key]: { status: "loading", text: "" } })); + api + .getFinding(fid) + .then((full) => + setReports((r) => ({ ...r, [key]: { status: "done", text: full.report ?? "" } })), + ) + .catch(() => + setReports((r) => ({ ...r, [key]: { status: "error", text: "" } })), + ); + }, + [expanded, reports], + ); + + // saveEdit 保存当前展开行的名称/类别/严重等级,回写本地列表并刷新统计(类别下拉/严重计数可能变)。 + const saveEdit = React.useCallback( + async (f: Finding) => { + if (!f.finding_id || !edit) return; + setSaving(true); + try { + const updated = await api.updateFinding(f.finding_id, { + name: edit.name.trim(), + vulnclass: edit.vulnclass.trim(), + severity: edit.severity, + }); + setFindings((cur) => + cur.map((x) => + x.id === f.id + ? { ...x, name: updated.name, vulnclass: updated.vulnclass, severity: updated.severity } + : x, + ), + ); + toast.success("已保存"); + api.findingStats().then(setStats).catch(() => {}); + } catch (e) { + toast.error("保存失败:" + (e as Error).message); + } finally { + setSaving(false); + } + }, + [edit], + ); + const statCards: { label: string; value: number; tone?: string }[] = [ { label: "发现总数", value: stats.total }, { label: "待处理", value: stats.pending, tone: "text-amber-500" }, @@ -241,6 +318,31 @@ export default function FindingsPage() { + + + setEdit((s) => (s ? { ...s, name: e.target.value } : s)) + } + placeholder="可读标题,留空回退类别" + /> + +
+ + + setEdit((s) => (s ? { ...s, vulnclass: e.target.value } : s)) + } + placeholder="如 SQL Injection" + /> +
+
+ + +
+ + + )}
- 证据 · {f.id} + 证据 {f.vulnclass && ( · 类型: @@ -425,6 +579,43 @@ export default function FindingsPage() {
                                 {f.evidence}
                               
+ + {/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */} + {f.finding_id && ( +
+
+ + 详细报告 +
+ {(() => { + const rep = reports[f.id]; + if (!rep || rep.status === "loading") + return ( +

加载中…

+ ); + if (rep.status === "error") + return ( +

+ 报告加载失败。 +

+ ); + if (!rep.text.trim()) + return ( +

+ 暂无详细报告。 +

+ ); + return ( + // break-words 会继承到段落/列表,pre 另加 + // whitespace-pre-wrap 让代码块也换行——否则长代码行/长 URL + // 会撑宽 colSpan 单元格,把整张表挤出横向滚动条。 +
+ +
+ ); + })()} +
+ )}
diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 47edca7..a6a668e 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -93,6 +93,10 @@ function appendUploads(desc: string, atts: ChatAttachment[]): string { // ACTIVE_PROFILE is the sentinel Select value for "use the global active profile". const ACTIVE_PROFILE = "__active__"; +// POLL_MS is the task-list refresh interval. Task state moves on the server (planner / +// worker), so the list has to be pulled; 10s is plenty for status / 进度 / token 变化. +const POLL_MS = 10_000; + // fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M). function fmtTokens(n: number): string { if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M"; @@ -148,19 +152,6 @@ const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [ export default function TasksPage() { const [tasks, setTasks] = React.useState([]); - const [description, setDescription] = React.useState(""); - const [goal, setGoal] = React.useState(""); - const [profiles, setProfiles] = React.useState([]); - const [llmProfile, setLlmProfile] = React.useState(ACTIVE_PROFILE); // sentinel = active - const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 - const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) - const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 - const [open, setOpen] = React.useState(false); - // 方式1 文件上传:建任务前把文件暂存到 drafts//uploads/,拿回绝对路径追加进描述。 - const [uploading, setUploading] = React.useState(false); - const [uploadCount, setUploadCount] = React.useState(0); - const draftIdRef = React.useRef(""); - const fileInputRef = React.useRef(null); const [query, setQuery] = React.useState(""); const [statusFilter, setStatusFilter] = React.useState("all"); const [page, setPage] = React.useState(1); @@ -188,31 +179,274 @@ export default function TasksPage() { [filtered, page, pageSize], ); + // lastRef holds the previous poll's serialized payload: the list is re-fetched every + // POLL_MS but usually comes back unchanged, and setTasks on an identical payload would + // re-render the whole page for nothing. Bail out when it matches. + const lastRef = React.useRef(""); + const load = React.useCallback(() => { api.tasks() .then((r) => { - setTasks(r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t))); + const next = r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t)); + const sig = JSON.stringify(next); + if (sig === lastRef.current) return; + lastRef.current = sig; + setTasks(next); }) .catch(() => {}); }, []); React.useEffect(() => { load(); - const i = setInterval(load, 3000); + const i = setInterval(load, POLL_MS); return () => clearInterval(i); }, [load]); + // 只在确有 running 任务时才每秒 tick——其余情况「运行时长」是静态值,空转的 tick 会白白 + // 重渲染整张表。 + const hasRunning = React.useMemo(() => tasks.some((t) => t.status === "running"), [tasks]); + + // tick every second so running tasks' 运行时长 counts up live. + React.useEffect(() => { + if (!hasRunning) return; + setNowSec(Math.floor(Date.now() / 1000)); + const i = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000); + return () => clearInterval(i); + }, [hasRunning]); + + const deleteTask = React.useCallback(async (id: string) => { + try { + await api.deleteTask(id); + toast.success("任务已删除(全局资产图保留)"); + load(); + } catch (e) { + toast.error("删除失败:" + (e as Error).message); + } + }, [load]); + + return ( + + +
+
+ + setQuery(e.target.value)} + className="pl-8" + /> + {query && ( + + )} +
+ + + {filtered.length}/{tasks.length} 条 + + +
+ + {tasks.length === 0 ? ( +
+ 暂无任务,点击右上角「新建任务」开始。 +
+ ) : filtered.length === 0 ? ( +
+ 没有匹配的任务。 +
+ ) : ( + + + + ID + 描述 + 目标 + 状态 + 目标进度 + 创建时间 + 运行时长 + Token + 操作 + + + + {paginated.map((task) => ( + + ))} + +
+ )} + +
+
+ ); +} + +// TaskRow renders one row of the task table. Memoized so the per-second 运行时长 tick and +// the POLL_MS list refresh only re-render the rows whose data actually moved — a table page +// is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild +// wholesale on every parent render. +const TaskRow = React.memo(function TaskRow({ + task, + nowSec, + onDelete, +}: { + task: Task; + nowSec: number; + onDelete: (id: string) => void; +}) { + return ( + + + {task.id} + + +
+ + {task.description} + + {task.active && ( + + )} +
+
+ {task.goal} + + + + + {typeof task.goals_total === "number" && task.goals_total > 0 + ? `${task.goals_met}/${task.goals_total}` + : —} + + + {fmtDateTime(task.created_unix)} + + + {(() => { + const secs = taskDuration(task, nowSec); + if (secs <= 0) return —; + return ( + + {fmtDuration(secs)} + + ); + })()} + + + {task.tokens ? ( + + 入 {fmtTokens(task.tokens.input_tokens)} + {" · 缓 "} + {fmtTokens(task.tokens.cache_read_tokens)} + {" · 出 "} + {fmtTokens(task.tokens.output_tokens)} + + ) : ( + "—" + )} + + +
+ + + + + + + + 确认删除任务 #{task.id}? + + {task.description + ? `「${task.description.length > 80 ? task.description.slice(0, 80) + "…" : task.description}」` + : "该任务"} + 的执行记录、会话与产物将被删除,此操作不可撤销(全局资产图保留)。 + + + + 取消 + onDelete(task.id)}> + 删除 + + + + +
+
+
+ ); +}); + +// CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with +// 描述/目标 held by the page component every keystroke re-rendered the whole task table +// behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as +// input lag. Now typing only re-renders the drawer. +function CreateTaskSheet({ onCreated }: { onCreated: () => void }) { + const [open, setOpen] = React.useState(false); + const [description, setDescription] = React.useState(""); + const [goal, setGoal] = React.useState(""); + const [profiles, setProfiles] = React.useState([]); + const [llmProfile, setLlmProfile] = React.useState(ACTIVE_PROFILE); // sentinel = active + const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时 + const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致) + const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行 + // 方式1 文件上传:建任务前把文件暂存到 drafts//uploads/,拿回绝对路径追加进描述。 + const [uploading, setUploading] = React.useState(false); + const [uploadCount, setUploadCount] = React.useState(0); + const draftIdRef = React.useRef(""); + const fileInputRef = React.useRef(null); + // load LLM profiles once for the create-task profile picker. React.useEffect(() => { api.llmProfiles().then(setProfiles).catch(() => setProfiles([])); }, []); - // tick every second so running tasks' 运行时长 counts up live. - React.useEffect(() => { - const i = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000); - return () => clearInterval(i); - }, []); - // pickFiles uploads the chosen files into this draft's staging dir and appends their // absolute paths to the description; the task's agents open them by path via Read/Bash. async function pickFiles(files: FileList | null) { @@ -256,342 +490,163 @@ export default function TasksPage() { setUploadCount(0); draftIdRef.current = ""; setOpen(false); - load(); + onCreated(); } catch (e) { toast.error("创建失败:" + (e as Error).message); } } - async function deleteTask(id: string) { - try { - await api.deleteTask(id); - toast.success("任务已删除(全局资产图保留)"); - load(); - } catch (e) { - toast.error("删除失败:" + (e as Error).message); - } - } - return ( - - -
-
- - setQuery(e.target.value)} - className="pl-8" - /> - {query && ( - - )} -
- - - {filtered.length}/{tasks.length} 条 - - - - - - {/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。 - 内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */} - - - 新建任务 - 填写测试对象与目标,高级参数可按需展开。 - + + + + + {/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。 + 内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */} + + + 新建任务 + 填写测试对象与目标,高级参数可按需展开。 + -
-
-
- -