From 79468697622a50a45a7bdffb2d28a14ca910d4e0 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:18:32 +0800 Subject: [PATCH] feat: add task traffic history and report recovery --- db/task_assets_context.go | 50 +- server/finding_report.go | 88 ++ server/server.go | 59 +- traffic/traffic.go | 82 +- traffic/traffic_store_test.go | 19 + .../(main)/function/findings/detail/page.tsx | 45 +- .../app/(main)/function/tasks/detail/page.tsx | 14 +- web/src/app/(main)/function/traffic/page.tsx | 874 +---------------- web/src/components/traffic-history.tsx | 894 ++++++++++++++++++ web/src/lib/api.ts | 10 +- 10 files changed, 1236 insertions(+), 899 deletions(-) create mode 100644 server/finding_report.go create mode 100644 web/src/components/traffic-history.tsx diff --git a/db/task_assets_context.go b/db/task_assets_context.go index cb2ddbe..aed6076 100644 --- a/db/task_assets_context.go +++ b/db/task_assets_context.go @@ -229,7 +229,6 @@ FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID) if err != nil { return nil, err } - defer rows.Close() hosts := map[string]struct{}{} add := func(host string) { host = strings.TrimSpace(strings.ToLower(host)) @@ -251,6 +250,55 @@ FROM assets a JOIN context_assets ctx ON ctx.id=a.id`, taskID) if err := rows.Err(); err != nil { return nil, err } + if err := rows.Close(); err != nil { + return nil, err + } + // A task may be scoped to domains before the asset graph has materialized + // every matching subdomain/service row. Include the declared scope values so + // task HTTP history still sees the real recorded traffic. Root domains use a + // wildcard marker understood by traffic.Page and therefore include subdomains. + scopes, err := s.ListTaskScopeWithSources(taskID) + if err != nil { + return nil, err + } + rootDomains := map[string]bool{} + for _, scope := range scopes { + domain := strings.TrimSpace(strings.ToLower(scope.Domain)) + switch scope.Kind { + case "root_domain": + if domain != "" { + add(domain) + rootDomains[domain] = false + } + case "subdomain": + if domain != "" { + add(domain) + } + case "ip", "cidr": + add(strings.TrimSpace(strings.ToLower(scope.Value))) + network := strings.TrimSpace(strings.ToLower(scope.Net)) + add(network) + if slash := strings.IndexByte(network, '/'); slash > 0 { + add(network[:slash]) + } + } + } + for _, scope := range scopes { + if scope.Kind != "subdomain" { + continue + } + domain := strings.TrimSpace(strings.ToLower(scope.Domain)) + for root := range rootDomains { + if domain == root || strings.HasSuffix(domain, "."+root) { + rootDomains[root] = true + } + } + } + for root, hasChildren := range rootDomains { + if !hasChildren { + add("*." + root) + } + } out := make([]string, 0, len(hosts)) for host := range hosts { out = append(out, host) diff --git a/server/finding_report.go b/server/finding_report.go new file mode 100644 index 0000000..6a66c86 --- /dev/null +++ b/server/finding_report.go @@ -0,0 +1,88 @@ +package server + +import ( + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/Autumn-27/artex/db" +) + +// generateFindingReport requeues the existing reporter workflow for a finding. +// This is intentionally separate from report_finding: a restart can leave a +// finding persisted while the one-shot trigger conversation is gone. +func (s *Server) generateFindingReport(w http.ResponseWriter, r *http.Request) { + id, ok := pathInt(r, "id") + if !ok || id <= 0 { + writeErr(w, http.StatusBadRequest, "bad finding id") + return + } + f, err := s.m.pg.GetFinding(id) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if f == nil { + writeErr(w, http.StatusNotFound, "finding not found") + return + } + if f.NodeID == nil || *f.NodeID <= 0 { + writeErr(w, http.StatusConflict, "漏洞没有可用的探索节点,无法生成复现步骤") + return + } + reporter, err := s.m.pg.GetAgentByKey("reporter") + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if reporter == nil || !reporter.Enabled { + writeErr(w, http.StatusConflict, "报告 Agent 未启用,请在 Agent 管理中启用 reporter") + return + } + if s.resolveChatAgent(&db.Conversation{AgentKey: reporter.Key}) == nil { + writeErr(w, http.StatusServiceUnavailable, s.chatUnavailableReason()) + return + } + if s.ctx.Err() != nil { + writeErr(w, http.StatusServiceUnavailable, "服务正在停止") + return + } + + var taskID int64 + var taskDesc, taskGoal string + if f.TaskID != nil { + taskID = *f.TaskID + if task := s.m.ResolveTask(strconv.FormatInt(taskID, 10)); task != nil { + taskDesc, taskGoal = task.Description, task.Goal + } + } + message := manualFindingReportMessage(f, taskID) + s.StartTriggeredRun( + "reporter", + fmt.Sprintf("人工生成步骤与报告 · finding#%d", f.ID), + message, + taskID, + false, + taskDesc, + taskGoal, + ) + writeJSON(w, http.StatusAccepted, map[string]any{ + "queued": true, + "finding_id": f.ID, + "finding_node_id": *f.NodeID, + }) +} + +func manualFindingReportMessage(f *db.DBFinding, taskID int64) string { + var b strings.Builder + b.WriteString("【人工重新生成步骤与报告】这是一个已经登记的漏洞,请补生成该漏洞的结构化复现步骤和完整详细报告;不要创建新的漏洞记录,也不要修改或删除原始证据。\n") + fmt.Fprintf(&b, "独立漏洞记录 ID(finding_id)=%d;探索节点 ID(finding_node_id)=%d。\n", f.ID, *f.NodeID) + if taskID > 0 { + fmt.Fprintf(&b, "来源任务 ID=%d。\n", taskID) + } + b.WriteString("先读取 get_finding_traffic(finding_id) 的当前证据清单及 version,再读取节点详情和任务执行记录;只使用实际保存的请求、响应和执行证据,不臆造步骤或数据包。\n") + b.WriteString("报告必须包含“概述”“影响与危害”“受影响范围”“复现步骤”“根因分析”“修复建议”,并在复现步骤中按“步骤 1、步骤 2 …”完整展开。数据包只能引用该漏洞已绑定的真实流量。\n") + b.WriteString("最后调用 update_finding_report:finding_id 使用探索节点 ID,evidence_version 使用刚读取的证据版本;报告正文保存为 Markdown。") + return b.String() +} diff --git a/server/server.go b/server/server.go index 3f8b2e6..723895e 100644 --- a/server/server.go +++ b/server/server.go @@ -846,6 +846,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding) mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage) mux.HandleFunc("POST /api/exploration/findings/{id}/deepen", s.deepenFinding) + mux.HandleFunc("POST /api/exploration/findings/{id}/generate-report", s.generateFindingReport) mux.HandleFunc("GET /api/exploration/findings/{id}/retests", s.listFindingRetests) mux.HandleFunc("GET /api/exploration/findings/retests/active", s.listActiveFindingRetests) mux.HandleFunc("POST /api/exploration/findings/{id}/retests", s.startFindingRetest) @@ -3273,7 +3274,30 @@ func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() page := atoiDefault(q.Get("page"), 0) size := atoiDefault(q.Get("size"), 100) - ex, matched, _ := tr.Page(traffic.PageQuery{ + var taskHosts []string + taskScoped := false + if rawTaskID := strings.TrimSpace(q.Get("task_id")); rawTaskID != "" { + taskScoped = true + taskID, err := strconv.ParseInt(rawTaskID, 10, 64) + if err != nil || taskID <= 0 || s.m.ResolveTask(rawTaskID) == nil { + writeErr(w, http.StatusNotFound, "task not found") + return + } + taskHosts, err = s.m.Assets().HostsByTaskWithSources(taskID) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + } + if taskScoped && len(taskHosts) == 0 { + writeJSON(w, 200, map[string]any{ + "enabled": s.m.TrafficEnabled(), "proxy": s.m.ProxyAddr(), "count": 0, + "total": 0, "page": page, "size": size, "exchanges": []any{}, + }) + return + } + pageQuery := traffic.PageQuery{ + Hosts: taskHosts, Host: q.Get("host"), Method: q.Get("method"), Query: q.Get("q"), @@ -3284,8 +3308,14 @@ func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) { RespMax: int64(atoiDefault(q.Get("resp_max"), -1)), Sort: q.Get("sort"), Order: q.Get("order"), - }, page, size) + } + ex, matched, _ := tr.Page(pageQuery, page, size) count, _ := tr.Count() // global total, for the stat card + if taskScoped { + // matched already represents the current task scope plus all active + // filters. Reusing it avoids a second full SQLite scan on every refresh. + count = matched + } writeJSON(w, 200, map[string]any{ "enabled": s.m.TrafficEnabled(), // reflect the capture toggle "proxy": s.m.ProxyAddr(), @@ -3305,12 +3335,33 @@ func (s *Server) getTrafficHosts(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"hosts": []any{}}) return } - hosts, err := tr.Hosts() + var hosts []string + if rawTaskID := strings.TrimSpace(r.URL.Query().Get("task_id")); rawTaskID != "" { + taskID, err := strconv.ParseInt(rawTaskID, 10, 64) + if err != nil || taskID <= 0 || s.m.ResolveTask(rawTaskID) == nil { + writeErr(w, http.StatusNotFound, "task not found") + return + } + hosts, err = s.m.Assets().HostsByTaskWithSources(taskID) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + } + var result []traffic.HostCount + var err error + if len(hosts) > 0 { + result, err = tr.HostsForScope(hosts) + } else if r.URL.Query().Get("task_id") != "" { + result = []traffic.HostCount{} + } else { + result, err = tr.Hosts() + } if err != nil { writeErr(w, 500, err.Error()) return } - writeJSON(w, 200, map[string]any{"hosts": hosts}) + writeJSON(w, 200, map[string]any{"hosts": result}) } // deleteTraffic removes recorded traffic for every host containing the query's diff --git a/traffic/traffic.go b/traffic/traffic.go index ecd846b..bebdb1e 100644 --- a/traffic/traffic.go +++ b/traffic/traffic.go @@ -815,16 +815,51 @@ func ftsQuote(s string) string { // of -1 skip the response-size bounds. An empty Sort/Order defaults to // newest-first by timestamp. type PageQuery struct { - Host string // host substring - Method string // exact method (case-insensitive) - Query string // broad search box: metadata columns OR captured text - Body string // content box: captured request/response text only (full-text) - Path string // url_template substring — the vulnerability's path - Status string // exact code ("404") or class bucket ("4xx") - RespMin int64 // minimum resp_len, or -1 when unset - RespMax int64 // maximum resp_len, or -1 when unset - Sort string // ts | status | resp_len (default ts) - Order string // asc | desc (default desc) + Host string // host substring + Hosts []string // task-scoped host candidates (exact host or host:port) + Method string // exact method (case-insensitive) + Query string // broad search box: metadata columns OR captured text + Body string // content box: captured request/response text only (full-text) + Path string // url_template substring — the vulnerability's path + Status string // exact code ("404") or class bucket ("4xx") + RespMin int64 // minimum resp_len, or -1 when unset + RespMax int64 // maximum resp_len, or -1 when unset + Sort string // ts | status | resp_len (default ts) + Order string // asc | desc (default desc) +} + +// hostScopeClause limits traffic to the hosts attached to a task. Captures +// store the request Host header, which may include a port, while asset rows +// store normalized hostnames; match both forms without widening to subdomains. +func hostScopeClause(hosts []string) (string, []any) { + conditions := make([]string, 0, len(hosts)) + args := make([]any, 0, len(hosts)*2) + seen := make(map[string]struct{}, len(hosts)) + for _, raw := range hosts { + host := strings.ToLower(strings.TrimSpace(raw)) + if host == "" { + continue + } + if _, ok := seen[host]; ok { + continue + } + seen[host] = struct{}{} + if strings.HasPrefix(host, "*.") { + base := strings.TrimPrefix(host, "*.") + if base == "" { + continue + } + conditions = append(conditions, "(host=? OR host LIKE ? OR host LIKE ?)") + args = append(args, base, base+":%", "%."+base) + continue + } + conditions = append(conditions, "(host=? OR host LIKE ?)") + args = append(args, host, host+":%") + } + if len(conditions) == 0 { + return "", nil + } + return "(" + strings.Join(conditions, " OR ") + ")", args } // sortColumns whitelists the columns Page may order by, so the caller-supplied @@ -877,6 +912,9 @@ func (t *Traffic) Page(f PageQuery, page, size int) (rows []ExchangeMeta, total if h := strings.TrimSpace(f.Host); h != "" { add("host LIKE ?", "%"+h+"%") } + if cond, scopeArgs := hostScopeClause(f.Hosts); cond != "" { + add(cond, scopeArgs...) + } if m := strings.TrimSpace(f.Method); m != "" { add("method=?", strings.ToUpper(m)) } @@ -1014,6 +1052,30 @@ func (t *Traffic) Hosts() ([]HostCount, error) { return out, rows.Err() } +// HostsForScope returns recorded hosts that belong to a task's normalized host +// scope. It is used by the task HTTP history picker and never mutates traffic. +func (t *Traffic) HostsForScope(scope []string) ([]HostCount, error) { + cond, args := hostScopeClause(scope) + if cond == "" { + return []HostCount{}, nil + } + rows, err := t.db.Query(`SELECT host, COUNT(*) AS n, MAX(ts) AS last FROM exchanges WHERE `+cond+` GROUP BY host ORDER BY last DESC`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []HostCount + for rows.Next() { + var h HostCount + var last int64 + if err := rows.Scan(&h.Host, &h.Count, &last); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + // Count returns total recorded exchanges. func (t *Traffic) Count() (int, error) { var n int diff --git a/traffic/traffic_store_test.go b/traffic/traffic_store_test.go index 647a6e2..718176f 100644 --- a/traffic/traffic_store_test.go +++ b/traffic/traffic_store_test.go @@ -414,6 +414,25 @@ func TestPageFiltersAndSort(t *testing.T) { } } +func TestPageHostScopeMatchesTaskHostsAndPorts(t *testing.T) { + tr, _ := openTraffic(t) + tr.record(newFlow("api.example.com", "GET", "/", nil, []byte("bare"))) + tr.record(newFlow("api.example.com:8443", "GET", "/", nil, []byte("port"))) + tr.record(newFlow("other.example.com", "GET", "/", nil, []byte("other"))) + rows, total, err := tr.Page(PageQuery{Hosts: []string{"api.example.com"}, RespMin: -1, RespMax: -1}, 0, 100) + if err != nil || total != 2 || len(rows) != 2 { + t.Fatalf("task host scope should match bare host and host:port, total=%d rows=%d err=%v", total, len(rows), err) + } + hosts, err := tr.HostsForScope([]string{"api.example.com"}) + if err != nil || len(hosts) != 1 { + t.Fatalf("scoped host picker should return 1 normalized host, len=%d err=%v", len(hosts), err) + } + rows, total, err = tr.Page(PageQuery{Hosts: []string{"*.example.com"}, RespMin: -1, RespMax: -1}, 0, 100) + if err != nil || total != 3 || len(rows) != 3 { + t.Fatalf("wildcard root scope should include subdomains, total=%d rows=%d err=%v", total, len(rows), err) + } +} + func TestQueryHostPortAndURLForms(t *testing.T) { tr, _ := openTraffic(t) tr.record(newFlow("api.example.com:8082", "GET", "/admin", nil, []byte("8082"))) diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index f724e92..bbd241c 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import Link from "next/link"; import { useSearchParams } from "next/navigation"; -import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react"; +import { ArrowLeftIcon, ArrowUpRightIcon, FileTextIcon, Loader2Icon, ShieldAlertIcon } from "lucide-react"; import { toast } from "sonner"; import { CopyButton } from "@/components/copy-button"; @@ -71,6 +71,7 @@ function FindingDetailInner() { const [finding, setFinding] = React.useState(null); const [loaded, setLoaded] = React.useState(false); const [tab, setTab] = React.useState("overview"); + const [reportGenerating, setReportGenerating] = React.useState(false); const side = useSideQuestions(finding?.task_id ? `/api/tasks/${finding.task_id}/chat` : null); const load = React.useCallback(() => { @@ -122,6 +123,23 @@ function FindingDetailInner() { [finding, id], ); + const generateReport = React.useCallback(async () => { + if (!finding || finding.inherited || reportGenerating) return; + setReportGenerating(true); + try { + await api.generateFindingReport(id); + toast.success("已提交生成请求,报告 Agent 将补全步骤与详细报告"); + // The reporter runs asynchronously. Refresh once after it has had a + // chance to persist the report, while keeping the button available if + // the run is delayed or interrupted. + window.setTimeout(() => void load(), 2000); + } catch (e) { + toast.error("提交生成请求失败:" + (e as Error).message); + } finally { + setReportGenerating(false); + } + }, [finding, id, load, reportGenerating]); + if (!finding) { return (
@@ -226,16 +244,27 @@ function FindingDetailInner() { readOnly={finding.inherited} onChanged={load} /> - 详细报告 - {finding.report && } +
+ {finding.report && } + {!finding.inherited ? ( + + ) : null} +
{finding.report_stale ? ( diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index db99f6c..be52acc 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -9,6 +9,7 @@ import { ArchiveIcon, ArrowLeftIcon, BrainIcon, CheckIcon, CircleAlertIcon, Paus import { toast } from "sonner"; import { IntelligencePanel } from "@/components/intelligence-panel"; +import { TrafficHistory } from "@/components/traffic-history"; import { StatusBadge } from "@/components/status-badge"; import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; @@ -59,6 +60,7 @@ const TABS = [ { value: "findings", label: "发现" }, { value: "intelligence", label: "任务情报" }, { value: "retests", label: "复测" }, + { value: "traffic", label: "流量" }, { value: "assets", label: "测试资产" }, { value: "coverage", label: "资产覆盖图" }, { value: "intercept", label: "拦截审批" }, @@ -212,10 +214,13 @@ function TaskDetailInner() { const [paused, setPaused] = React.useState(false); const [loaded, setLoaded] = React.useState(false); const requestedTab = searchParams.get("tab") ?? "sessions"; - const [tab, setTab] = React.useState(TABS.some((item) => item.value === requestedTab) ? requestedTab : "sessions"); + const normalizedRequestedTab = requestedTab === "http-history" ? "traffic" : requestedTab; + const [tab, setTab] = React.useState( + TABS.some((item) => item.value === normalizedRequestedTab) ? normalizedRequestedTab : "sessions", + ); React.useEffect(() => { - if (TABS.some((item) => item.value === requestedTab)) setTab(requestedTab); - }, [requestedTab]); + if (TABS.some((item) => item.value === normalizedRequestedTab)) setTab(normalizedRequestedTab); + }, [normalizedRequestedTab]); const [interceptPendingCount, setInterceptPendingCount] = React.useState(0); const [profiles, setProfiles] = React.useState([]); const [archiving, setArchiving] = React.useState(false); @@ -441,6 +446,9 @@ function TaskDetailInner() { + + + diff --git a/web/src/app/(main)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx index 21306c1..7b6b520 100644 --- a/web/src/app/(main)/function/traffic/page.tsx +++ b/web/src/app/(main)/function/traffic/page.tsx @@ -1,877 +1,7 @@ "use client"; -import * as React from "react"; - -import { useRouter } from "next/navigation"; - -import { - ArrowDownWideNarrowIcon, - ArrowUpNarrowWideIcon, - ChevronLeftIcon, - ChevronRightIcon, - EraserIcon, - FilterXIcon, - ListChecksIcon, - Loader2Icon, - RadioTowerIcon, - SearchIcon, - Trash2Icon, -} from "lucide-react"; -import { toast } from "sonner"; - -import { HttpCodeBlock } from "@/components/http-code-block"; -import { LinkTrafficDialog } from "@/components/link-traffic-dialog"; -import { requestFromTraffic } from "@/components/repeater/repeater-http"; -import { seedFromEvidence } from "@/components/repeater/repeater-store"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card } from "@/components/ui/card"; -import { Checkbox } from "@/components/ui/checkbox"; -import { Input } from "@/components/ui/input"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; -import { SortableHead } from "@/components/ui/sortable-head"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { api } from "@/lib/api"; -import { useStoredSortPreference } from "@/lib/sort-preference"; -import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types"; -import { cn } from "@/lib/utils"; - -function fmtTime(ts: string) { - return new Date(ts).toLocaleString("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - }); -} - -function fmtBytes(n: number) { - if (n <= 0) return "0 B"; - // GB matters for the reclaimed-space figure a full purge reports; a capture-heavy - // instance can hand back several. - const units = ["B", "KB", "MB", "GB"]; - const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); - const v = n / 1024 ** i; - return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`; -} - -function statusTone(status: number) { - if (status >= 500) return "text-red-500"; - if (status >= 400) return "text-amber-500"; - if (status >= 300) return "text-blue-500"; - if (status >= 200) return "text-emerald-500"; - return "text-muted-foreground"; -} - -function MethodBadge({ method }: { method: string }) { - return {method}; -} - -// Older captures may predate Host persistence because net/http keeps Host -// outside Request.Header. Fill it for display while newly recorded traffic is -// fixed at the recorder layer as well. -function requestWithHost(raw: string, exchange: TrafficExchange): string { - if (!raw.trim() || /^host\s*:/im.test(raw)) return raw; - let host = exchange.host; - try { - host = new URL(exchange.url).host || host; - } catch { - // Relative or legacy URLs fall back to the indexed host. - } - const newline = raw.includes("\r\n") ? "\r\n" : "\n"; - const firstLineEnd = raw.indexOf(newline); - if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`; - return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`; -} - -// Fixed method set (server filters exact-match); avoids deriving options from a -// single page, which would only ever list the methods on that page. -const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; -const PAGE_SIZES = [25, 50, 100, 200]; -type HostCountSortDirection = "asc" | "desc"; - -// Server-sortable columns. The list is sent to the backend verbatim as `sort`, -// which whitelists these same names, so keep them in sync with traffic.Page. -const SORT_FIELDS = ["ts", "status", "resp_len"] as const; -type SortField = (typeof SORT_FIELDS)[number]; -const SORT_STORAGE_KEY = "traffic-sort"; - -// Status-class buckets for the filter dropdown; the value is sent as `status`, -// which the backend reads as either an exact code or an "Nxx" class band. -const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"]; +import { TrafficHistory } from "@/components/traffic-history"; export default function TrafficPage() { - const [selectedFlows, setSelectedFlows] = React.useState>(() => new Map()); - const router = useRouter(); - const [importing, setImporting] = React.useState(false); - const importLock = React.useRef(false); - const [linking, setLinking] = React.useState(false); - const [page, setPage] = React.useState(0); - const [size, setSize] = React.useState(50); - const [host, setHost] = React.useState(""); // raw host input - const [hostQ, setHostQ] = React.useState(""); // debounced → server - const [query, setQuery] = React.useState(""); // raw free-text input - const [queryQ, setQueryQ] = React.useState(""); // debounced → server - const [method, setMethod] = React.useState("all"); - - // Advanced filters (issue #177): response-body content, path, status class and - // response-size range. Text inputs are debounced like host/query; the status - // select applies immediately. - const [body, setBody] = React.useState(""); - const [bodyQ, setBodyQ] = React.useState(""); - const [path, setPath] = React.useState(""); - const [pathQ, setPathQ] = React.useState(""); - const [statusFilter, setStatusFilter] = React.useState("all"); - const [respMin, setRespMin] = React.useState(""); - const [respMinQ, setRespMinQ] = React.useState(""); - const [respMax, setRespMax] = React.useState(""); - const [respMaxQ, setRespMaxQ] = React.useState(""); - const [sort, setSort] = useStoredSortPreference(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc"); - - const [traffic, setTraffic] = React.useState(null); - const [selected, setSelected] = React.useState(null); - const [detail, setDetail] = React.useState(null); - const [detailLoading, setDetailLoading] = React.useState(false); - - const [hosts, setHosts] = React.useState([]); // target picker - const [selectedHosts, setSelectedHosts] = React.useState([]); // checked in picker - const [pickerOpen, setPickerOpen] = React.useState(false); - const [hostCountSortDirection, setHostCountSortDirection] = React.useState("desc"); - - const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | "all" | null>(null); // null = dialog closed - const [deleting, setDeleting] = React.useState(false); - const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger - - // Debounce both filters so we don't refetch on every keystroke. - React.useEffect(() => { - const t = setTimeout(() => setHostQ(host.trim()), 300); - return () => clearTimeout(t); - }, [host]); - React.useEffect(() => { - const t = setTimeout(() => setQueryQ(query.trim()), 300); - return () => clearTimeout(t); - }, [query]); - React.useEffect(() => { - const t = setTimeout(() => setBodyQ(body.trim()), 300); - return () => clearTimeout(t); - }, [body]); - React.useEffect(() => { - const t = setTimeout(() => setPathQ(path.trim()), 300); - return () => clearTimeout(t); - }, [path]); - React.useEffect(() => { - const t = setTimeout(() => setRespMinQ(respMin.trim()), 300); - return () => clearTimeout(t); - }, [respMin]); - React.useEffect(() => { - const t = setTimeout(() => setRespMaxQ(respMax.trim()), 300); - return () => clearTimeout(t); - }, [respMax]); - - const hasAdvancedFilter = Boolean(bodyQ || pathQ || respMinQ || respMaxQ) || statusFilter !== "all"; - const resetAdvancedFilters = () => { - setBody(""); - setBodyQ(""); - setPath(""); - setPathQ(""); - setStatusFilter("all"); - setRespMin(""); - setRespMinQ(""); - setRespMax(""); - setRespMaxQ(""); - }; - - // Any filter/size/sort change resets to the first page. - // biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset. - React.useEffect(() => { - setPage(0); - }, [hostQ, queryQ, method, size, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort]); - - // Load the current page. Auto-refresh only on page 0 (newest) so paging back - // through history isn't yanked out from under the user. - // biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger. - React.useEffect(() => { - let alive = true; - const load = () => { - api - .traffic(page, size, hostQ, method, queryQ, { - body: bodyQ, - path: pathQ, - status: statusFilter, - respMin: respMinQ, - respMax: respMaxQ, - sort: sort.field, - order: sort.direction, - }) - .then((r) => { - if (alive) setTraffic(r); - }) - .catch(() => { - // Keep the last successful snapshot during transient refresh failures. - }); - api - .trafficHosts() - .then((r) => { - if (alive) setHosts(r.hosts ?? []); - }) - .catch(() => { - // Keep the last successful host list during transient refresh failures. - }); - }; - load(); - const t = setInterval(() => { - if (page !== 0) return; // only auto-refresh the newest page - load(); - }, 5000); - return () => { - alive = false; - clearInterval(t); - }; - }, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick]); - - // Delete traffic for the current host filter (substring) or the checked - // hosts (exact batch), then refetch. - const allSelected = hosts.length > 0 && hosts.every((h) => selectedHosts.includes(h.host)); - const sortedHosts = React.useMemo( - () => - [...hosts].sort((a, b) => { - const countOrder = hostCountSortDirection === "asc" ? a.count - b.count : b.count - a.count; - return countOrder || a.host.localeCompare(b.host); - }), - [hosts, hostCountSortDirection], - ); - - // "清空" for the unfiltered purge, "删除" for the host-scoped ones — the dialog's - // title and its confirm button both follow from which is in play. - const deleteVerb = deleteMode === "all" ? "清空" : "删除"; - const deleteTitle = deleteMode - ? { - all: "清空全部流量记录?", - selected: `删除选中的 ${selectedHosts.length} 个目标的全部流量?`, - filter: "删除该目标的全部流量?", - }[deleteMode] - : ""; - - // `reclaimed` only comes back from the full purge; the host-scoped deletions - // report the row count alone. - const requestDelete = (mode: "filter" | "selected" | "all"): Promise<{ deleted: number; reclaimed?: number }> => { - if (mode === "all") return api.trafficDeleteAll(); - if (mode === "selected") return api.trafficDeleteHosts(selectedHosts); - return api.trafficDeleteHost(hostQ); - }; - - const confirmDelete = () => { - if (!deleteMode) return; - setDeleting(true); - const mode = deleteMode; - requestDelete(mode) - .then((r) => { - setDeleteMode(null); - setSelected(null); - setDetail(null); - if (mode !== "filter") { - setSelectedHosts([]); - setPickerOpen(false); - } - if (mode === "all") { - // Reclaimed space is the whole point of compacting an emptied index, so say so. - const reclaimed = r.reclaimed ?? 0; - const freed = reclaimed > 0 ? `,释放 ${fmtBytes(reclaimed)} 存储` : ""; - toast.success(`已清空 ${r.deleted} 条流量${freed}`); - } - setPage(0); - setReloadTick((t) => t + 1); - }) - .catch((e) => { - // Keep the confirmation open so the user can retry a failed deletion. - if (mode === "all") toast.error(`清空失败:${(e as Error).message}`); - }) - .finally(() => setDeleting(false)); - }; - - // Lazy-load the raw request/response for the selected exchange. - React.useEffect(() => { - if (!selected) { - setDetail(null); - return; - } - let alive = true; - setDetailLoading(true); - setDetail(null); - api - .trafficExchange(selected.id) - .then((d) => { - if (alive) setDetail(d); - }) - .catch(() => { - if (alive) setDetail({ req: "(无法加载报文)", resp: "" }); - }) - .finally(() => { - if (alive) setDetailLoading(false); - }); - return () => { - alive = false; - }; - }, [selected]); - - async function openRepeater(flows: TrafficExchange[]) { - if (!flows.length || importLock.current) return; - importLock.current = true; - setImporting(true); - let imported = 0; - try { - // Sequential loading limits pressure when a whole page is selected. A bad - // capture does not discard drafts imported successfully before it. - for (const flow of flows) { - try { - const raw = await api.trafficExchange(flow.id); - seedFromEvidence( - requestFromTraffic(raw.req, flow), - { label: `流量 ${flow.id}` }, - { title: `${flow.method} ${flow.host}` }, - ); - imported += 1; - } catch (error) { - toast.error(`${flow.host}:${(error as Error).message}`); - } - } - if (imported) { - toast.success(`已送入 ${imported} 条请求,可编辑后发送`); - setSelected(null); - router.push("/function/repeater"); - } - } finally { - importLock.current = false; - setImporting(false); - } - } - - // Toggle direction when re-clicking the active column, else sort the new column - // newest/largest-first. - const toggleSort = (field: SortField) => - setSort((prev) => - prev.field === field - ? { field, direction: prev.direction === "asc" ? "desc" : "asc" } - : { field, direction: "desc" }, - ); - - const exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]); - const total = traffic?.total ?? exchanges.length; - const pageCount = Math.max(1, Math.ceil(total / size)); - const rangeStart = total === 0 ? 0 : page * size + 1; - const rangeEnd = page * size + exchanges.length; - - return ( -
-
-
-

流量

-

全局录制代理 · 所有 HTTP 往来

-
-
- - - {traffic?.enabled ? "录制中" : "已停用"} - - {traffic?.proxy && {traffic.proxy}} - - 共 {traffic?.count ?? 0} 条 - -
-
- - {/* Toolbar */} -
- - - - - -
- 按目标批量删除 -
- {hosts.length > 0 && ( - - - - - 按数据包数量{hostCountSortDirection === "desc" ? "倒序" : "正序"} - - )} - {hosts.length > 0 && ( - - )} -
-
-
- {hosts.length === 0 ? ( -
暂无流量记录
- ) : ( - sortedHosts.map((h, index) => ( - - )) - )} -
-
- -
-
-
-
- setHost(e.target.value)} className="h-8" /> -
- - {/* Outline rather than a second destructive button: this one ignores every - filter, so it must not look one mis-click away from "删除该目标". */} - -
- - setQuery(e.target.value)} - className="h-8 pl-8" - /> -
- - - -
- - {rangeStart}–{rangeEnd} / {total} - - - - {page + 1} / {pageCount} - - -
-
- - {/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */} -
- 高级筛选 -
- setBody(e.target.value)} - className="h-8" - /> -
-
- setPath(e.target.value)} - className="h-8" - /> -
- -
- 响应长度 - setRespMin(e.target.value)} - className="h-8 w-24" - /> - – - setRespMax(e.target.value)} - className="h-8 w-24" - /> -
- {hasAdvancedFilter ? ( - - ) : null} -
- -
- 已选 {selectedFlows.size} 条流量 - - - {selectedFlows.size > 0 ? ( - - ) : null} -
- {/* History table */} -
- -
- - - - - 0 && exchanges.every((e) => selectedFlows.has(e.id))} - onCheckedChange={(checked) => - setSelectedFlows((previous) => { - const next = new Map(previous); - for (const e of exchanges) { - if (checked === true) next.set(e.id, e); - else next.delete(e.id); - } - return next; - }) - } - /> - - - host - 方法 - URL - - content-type - - - - - {exchanges.map((e) => ( - setSelected(e)} - > - - event.stopPropagation()} - onCheckedChange={(checked) => - setSelectedFlows((previous) => { - const next = new Map(previous); - if (checked === true) next.set(e.id, e); - else next.delete(e.id); - return next; - }) - } - /> - - {fmtTime(e.ts)} - {e.host} - - - - - {e.url} - - - - {e.status} - - - {e.content_type} - {fmtBytes(e.resp_len)} - - ))} - {exchanges.length === 0 && ( - - - {traffic === null ? "加载中…" : "没有匹配的流量。"} - - - )} - -
-
-
-
- - {linking ? ( - setLinking(false)} - onBound={() => setSelectedFlows(new Map())} - /> - ) : null} - !open && setSelected(null)}> - - {selected && ( - <> - -
- - - {selected.status} - - {fmtTime(selected.ts)} -
- {selected.host} - {selected.url} - -
-
- {[ - ["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)], - ["response", "响应 Response", detail?.resp ?? ""], - ].map(([key, label, raw]) => ( -
- {label} - {detailLoading ? ( -
- - 加载报文… -
- ) : ( - - )} -
- ))} -
- - )} -
-
- - { - if (!o) setDeleteMode(null); - }} - > - - - {deleteTitle} - - {deleteMode === "all" && ( - <> - 将永久删除全部 {traffic?.count ?? 0}{" "} - 条流量记录(含请求/响应原文),忽略当前的筛选条件,此操作不可撤销。已绑定到漏洞的流量证据保存在独立的证据库中,不受影响。 -
- - 清空后会顺带压实存储,把索引占用的磁盘空间还给系统;这期间流量录制会短暂暂停。 - - - )} - {deleteMode === "selected" && ( - <> - 将永久删除 {selectedHosts.length} 个目标( - - {selectedHosts.slice(0, 3).join("、")} - {selectedHosts.length > 3 ? "…" : ""} - - )的所有流量记录(含请求/响应原文),此操作不可撤销。 - - )} - {deleteMode === "filter" && ( - <> - 将永久删除 host 包含 {hostQ}{" "} - 的所有流量记录(含请求/响应原文),此操作不可撤销。 - - )} -
-
- - 取消 - { - e.preventDefault(); - confirmDelete(); - }} - disabled={deleting} - className="bg-destructive text-destructive-foreground hover:bg-destructive/90" - > - {deleting ? `${deleteVerb}中…` : `确认${deleteVerb}`} - - -
-
-
- ); + return ; } diff --git a/web/src/components/traffic-history.tsx b/web/src/components/traffic-history.tsx new file mode 100644 index 0000000..47efb5b --- /dev/null +++ b/web/src/components/traffic-history.tsx @@ -0,0 +1,894 @@ +"use client"; + +import * as React from "react"; + +import { useRouter } from "next/navigation"; + +import { + ArrowDownWideNarrowIcon, + ArrowUpNarrowWideIcon, + ChevronLeftIcon, + ChevronRightIcon, + EraserIcon, + FilterXIcon, + ListChecksIcon, + Loader2Icon, + RadioTowerIcon, + SearchIcon, + Trash2Icon, +} from "lucide-react"; +import { toast } from "sonner"; + +import { HttpCodeBlock } from "@/components/http-code-block"; +import { LinkTrafficDialog } from "@/components/link-traffic-dialog"; +import { requestFromTraffic } from "@/components/repeater/repeater-http"; +import { seedFromEvidence } from "@/components/repeater/repeater-store"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; +import { SortableHead } from "@/components/ui/sortable-head"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { api } from "@/lib/api"; +import { useStoredSortPreference } from "@/lib/sort-preference"; +import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +function fmtTime(ts: string) { + return new Date(ts).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function fmtBytes(n: number) { + if (n <= 0) return "0 B"; + // GB matters for the reclaimed-space figure a full purge reports; a capture-heavy + // instance can hand back several. + const units = ["B", "KB", "MB", "GB"]; + const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), units.length - 1); + const v = n / 1024 ** i; + return `${i === 0 ? v : v.toFixed(1)} ${units[i]}`; +} + +function statusTone(status: number) { + if (status >= 500) return "text-red-500"; + if (status >= 400) return "text-amber-500"; + if (status >= 300) return "text-blue-500"; + if (status >= 200) return "text-emerald-500"; + return "text-muted-foreground"; +} + +function MethodBadge({ method }: { method: string }) { + return {method}; +} + +// Older captures may predate Host persistence because net/http keeps Host +// outside Request.Header. Fill it for display while newly recorded traffic is +// fixed at the recorder layer as well. +function requestWithHost(raw: string, exchange: TrafficExchange): string { + if (!raw.trim() || /^host\s*:/im.test(raw)) return raw; + let host = exchange.host; + try { + host = new URL(exchange.url).host || host; + } catch { + // Relative or legacy URLs fall back to the indexed host. + } + const newline = raw.includes("\r\n") ? "\r\n" : "\n"; + const firstLineEnd = raw.indexOf(newline); + if (firstLineEnd < 0) return `${raw}${newline}Host: ${host}`; + return `${raw.slice(0, firstLineEnd + newline.length)}Host: ${host}${newline}${raw.slice(firstLineEnd + newline.length)}`; +} + +// Fixed method set (server filters exact-match); avoids deriving options from a +// single page, which would only ever list the methods on that page. +const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"]; +const PAGE_SIZES = [25, 50, 100, 200]; +type HostCountSortDirection = "asc" | "desc"; + +// Server-sortable columns. The list is sent to the backend verbatim as `sort`, +// which whitelists these same names, so keep them in sync with traffic.Page. +const SORT_FIELDS = ["ts", "status", "resp_len"] as const; +type SortField = (typeof SORT_FIELDS)[number]; +const SORT_STORAGE_KEY = "traffic-sort"; + +// Status-class buckets for the filter dropdown; the value is sent as `status`, +// which the backend reads as either an exact code or an "Nxx" class band. +const STATUS_BUCKETS = ["2xx", "3xx", "4xx", "5xx"]; + +export function TrafficHistory({ taskId }: { taskId?: string }) { + const taskScoped = Boolean(taskId); + const [selectedFlows, setSelectedFlows] = React.useState>(() => new Map()); + const router = useRouter(); + const [importing, setImporting] = React.useState(false); + const importLock = React.useRef(false); + const [linking, setLinking] = React.useState(false); + const [page, setPage] = React.useState(0); + const [size, setSize] = React.useState(50); + const [host, setHost] = React.useState(""); // raw host input + const [hostQ, setHostQ] = React.useState(""); // debounced → server + const [query, setQuery] = React.useState(""); // raw free-text input + const [queryQ, setQueryQ] = React.useState(""); // debounced → server + const [method, setMethod] = React.useState("all"); + + // Advanced filters (issue #177): response-body content, path, status class and + // response-size range. Text inputs are debounced like host/query; the status + // select applies immediately. + const [body, setBody] = React.useState(""); + const [bodyQ, setBodyQ] = React.useState(""); + const [path, setPath] = React.useState(""); + const [pathQ, setPathQ] = React.useState(""); + const [statusFilter, setStatusFilter] = React.useState("all"); + const [respMin, setRespMin] = React.useState(""); + const [respMinQ, setRespMinQ] = React.useState(""); + const [respMax, setRespMax] = React.useState(""); + const [respMaxQ, setRespMaxQ] = React.useState(""); + const [sort, setSort] = useStoredSortPreference(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc"); + + const [traffic, setTraffic] = React.useState(null); + const [selected, setSelected] = React.useState(null); + const [detail, setDetail] = React.useState(null); + const [detailLoading, setDetailLoading] = React.useState(false); + + const [hosts, setHosts] = React.useState([]); // target picker + const [selectedHosts, setSelectedHosts] = React.useState([]); // checked in picker + const [pickerOpen, setPickerOpen] = React.useState(false); + const [hostCountSortDirection, setHostCountSortDirection] = React.useState("desc"); + + const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | "all" | null>(null); // null = dialog closed + const [deleting, setDeleting] = React.useState(false); + const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger + + // Debounce both filters so we don't refetch on every keystroke. + React.useEffect(() => { + const t = setTimeout(() => setHostQ(host.trim()), 300); + return () => clearTimeout(t); + }, [host]); + React.useEffect(() => { + const t = setTimeout(() => setQueryQ(query.trim()), 300); + return () => clearTimeout(t); + }, [query]); + React.useEffect(() => { + const t = setTimeout(() => setBodyQ(body.trim()), 300); + return () => clearTimeout(t); + }, [body]); + React.useEffect(() => { + const t = setTimeout(() => setPathQ(path.trim()), 300); + return () => clearTimeout(t); + }, [path]); + React.useEffect(() => { + const t = setTimeout(() => setRespMinQ(respMin.trim()), 300); + return () => clearTimeout(t); + }, [respMin]); + React.useEffect(() => { + const t = setTimeout(() => setRespMaxQ(respMax.trim()), 300); + return () => clearTimeout(t); + }, [respMax]); + + const hasAdvancedFilter = Boolean(bodyQ || pathQ || respMinQ || respMaxQ) || statusFilter !== "all"; + const resetAdvancedFilters = () => { + setBody(""); + setBodyQ(""); + setPath(""); + setPathQ(""); + setStatusFilter("all"); + setRespMin(""); + setRespMinQ(""); + setRespMax(""); + setRespMaxQ(""); + }; + + // Any filter/size/sort change resets to the first page. + // biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset. + React.useEffect(() => { + setPage(0); + }, [hostQ, queryQ, method, size, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, taskId]); + + // Load the current page. Auto-refresh only on page 0 (newest) so paging back + // through history isn't yanked out from under the user. + // biome-ignore lint/correctness/useExhaustiveDependencies: reloadTick is an explicit manual-refetch trigger. + React.useEffect(() => { + let alive = true; + const load = () => { + api + .traffic(page, size, hostQ, method, queryQ, { + taskId, + body: bodyQ, + path: pathQ, + status: statusFilter, + respMin: respMinQ, + respMax: respMaxQ, + sort: sort.field, + order: sort.direction, + }) + .then((r) => { + if (alive) setTraffic(r); + }) + .catch(() => { + // Keep the last successful snapshot during transient refresh failures. + }); + if (!taskScoped) { + api + .trafficHosts() + .then((r) => { + if (alive) setHosts(r.hosts ?? []); + }) + .catch(() => { + // Keep the last successful host list during transient refresh failures. + }); + } else if (alive) { + setHosts([]); + } + }; + load(); + const t = setInterval(() => { + if (page !== 0) return; // only auto-refresh the newest page + load(); + }, 5000); + return () => { + alive = false; + clearInterval(t); + }; + }, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick, taskId, taskScoped]); + + // Delete traffic for the current host filter (substring) or the checked + // hosts (exact batch), then refetch. + const allSelected = hosts.length > 0 && hosts.every((h) => selectedHosts.includes(h.host)); + const sortedHosts = React.useMemo( + () => + [...hosts].sort((a, b) => { + const countOrder = hostCountSortDirection === "asc" ? a.count - b.count : b.count - a.count; + return countOrder || a.host.localeCompare(b.host); + }), + [hosts, hostCountSortDirection], + ); + + // "清空" for the unfiltered purge, "删除" for the host-scoped ones — the dialog's + // title and its confirm button both follow from which is in play. + const deleteVerb = deleteMode === "all" ? "清空" : "删除"; + const deleteTitle = deleteMode + ? { + all: "清空全部流量记录?", + selected: `删除选中的 ${selectedHosts.length} 个目标的全部流量?`, + filter: "删除该目标的全部流量?", + }[deleteMode] + : ""; + + // `reclaimed` only comes back from the full purge; the host-scoped deletions + // report the row count alone. + const requestDelete = (mode: "filter" | "selected" | "all"): Promise<{ deleted: number; reclaimed?: number }> => { + if (mode === "all") return api.trafficDeleteAll(); + if (mode === "selected") return api.trafficDeleteHosts(selectedHosts); + return api.trafficDeleteHost(hostQ); + }; + + const confirmDelete = () => { + if (!deleteMode) return; + setDeleting(true); + const mode = deleteMode; + requestDelete(mode) + .then((r) => { + setDeleteMode(null); + setSelected(null); + setDetail(null); + if (mode !== "filter") { + setSelectedHosts([]); + setPickerOpen(false); + } + if (mode === "all") { + // Reclaimed space is the whole point of compacting an emptied index, so say so. + const reclaimed = r.reclaimed ?? 0; + const freed = reclaimed > 0 ? `,释放 ${fmtBytes(reclaimed)} 存储` : ""; + toast.success(`已清空 ${r.deleted} 条流量${freed}`); + } + setPage(0); + setReloadTick((t) => t + 1); + }) + .catch((e) => { + // Keep the confirmation open so the user can retry a failed deletion. + if (mode === "all") toast.error(`清空失败:${(e as Error).message}`); + }) + .finally(() => setDeleting(false)); + }; + + // Lazy-load the raw request/response for the selected exchange. + React.useEffect(() => { + if (!selected) { + setDetail(null); + return; + } + let alive = true; + setDetailLoading(true); + setDetail(null); + api + .trafficExchange(selected.id) + .then((d) => { + if (alive) setDetail(d); + }) + .catch(() => { + if (alive) setDetail({ req: "(无法加载报文)", resp: "" }); + }) + .finally(() => { + if (alive) setDetailLoading(false); + }); + return () => { + alive = false; + }; + }, [selected]); + + async function openRepeater(flows: TrafficExchange[]) { + if (!flows.length || importLock.current) return; + importLock.current = true; + setImporting(true); + let imported = 0; + try { + // Sequential loading limits pressure when a whole page is selected. A bad + // capture does not discard drafts imported successfully before it. + for (const flow of flows) { + try { + const raw = await api.trafficExchange(flow.id); + seedFromEvidence( + requestFromTraffic(raw.req, flow), + { label: `流量 ${flow.id}` }, + { title: `${flow.method} ${flow.host}` }, + ); + imported += 1; + } catch (error) { + toast.error(`${flow.host}:${(error as Error).message}`); + } + } + if (imported) { + toast.success(`已送入 ${imported} 条请求,可编辑后发送`); + setSelected(null); + router.push("/function/repeater"); + } + } finally { + importLock.current = false; + setImporting(false); + } + } + + // Toggle direction when re-clicking the active column, else sort the new column + // newest/largest-first. + const toggleSort = (field: SortField) => + setSort((prev) => + prev.field === field + ? { field, direction: prev.direction === "asc" ? "desc" : "asc" } + : { field, direction: "desc" }, + ); + + const exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]); + const total = traffic?.total ?? exchanges.length; + const pageCount = Math.max(1, Math.ceil(total / size)); + const rangeStart = total === 0 ? 0 : page * size + 1; + const rangeEnd = page * size + exchanges.length; + + return ( +
+
+
+

流量

+

+ {taskScoped ? "当前任务资产范围内的 HTTP 往来" : "全局录制代理 · 所有 HTTP 往来"} +

+ {taskScoped && traffic && !traffic.enabled ? ( +

流量录制已关闭,历史记录仍可查看。

+ ) : null} +
+
+ + + {traffic?.enabled ? "录制中" : "已停用"} + + {traffic?.proxy && {traffic.proxy}} + + 共 {traffic?.count ?? 0} 条 + +
+
+ + {/* Toolbar */} +
+ {!taskScoped && ( + + + + + +
+ 按目标批量删除 +
+ {hosts.length > 0 && ( + + + + + 按数据包数量{hostCountSortDirection === "desc" ? "倒序" : "正序"} + + )} + {hosts.length > 0 && ( + + )} +
+
+
+ {hosts.length === 0 ? ( +
暂无流量记录
+ ) : ( + sortedHosts.map((h, index) => ( + + )) + )} +
+
+ +
+
+
+ )} +
+ setHost(e.target.value)} className="h-8" /> +
+ {!taskScoped && ( + + )} + {/* Outline rather than a second destructive button: this one ignores every + filter, so it must not look one mis-click away from "删除该目标". */} + {!taskScoped && ( + + )} +
+ + setQuery(e.target.value)} + className="h-8 pl-8" + /> +
+ + + +
+ + {rangeStart}–{rangeEnd} / {total} + + + + {page + 1} / {pageCount} + + +
+
+ + {/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */} +
+ 高级筛选 +
+ setBody(e.target.value)} + className="h-8" + /> +
+
+ setPath(e.target.value)} + className="h-8" + /> +
+ +
+ 响应长度 + setRespMin(e.target.value)} + className="h-8 w-24" + /> + – + setRespMax(e.target.value)} + className="h-8 w-24" + /> +
+ {hasAdvancedFilter ? ( + + ) : null} +
+ +
+ 已选 {selectedFlows.size} 条流量 + + + {selectedFlows.size > 0 ? ( + + ) : null} +
+ {/* History table */} +
+ +
+ + + + + 0 && exchanges.every((e) => selectedFlows.has(e.id))} + onCheckedChange={(checked) => + setSelectedFlows((previous) => { + const next = new Map(previous); + for (const e of exchanges) { + if (checked === true) next.set(e.id, e); + else next.delete(e.id); + } + return next; + }) + } + /> + + + host + 方法 + URL + + content-type + + + + + {exchanges.map((e) => ( + setSelected(e)} + > + + event.stopPropagation()} + onCheckedChange={(checked) => + setSelectedFlows((previous) => { + const next = new Map(previous); + if (checked === true) next.set(e.id, e); + else next.delete(e.id); + return next; + }) + } + /> + + {fmtTime(e.ts)} + {e.host} + + + + + {e.url} + + + + {e.status} + + + {e.content_type} + {fmtBytes(e.resp_len)} + + ))} + {exchanges.length === 0 && ( + + + {traffic === null ? "加载中…" : "没有匹配的流量。"} + + + )} + +
+
+
+
+ + {linking ? ( + setLinking(false)} + onBound={() => setSelectedFlows(new Map())} + /> + ) : null} + !open && setSelected(null)}> + + {selected && ( + <> + +
+ + + {selected.status} + + {fmtTime(selected.ts)} +
+ {selected.host} + {selected.url} + +
+
+ {[ + ["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)], + ["response", "响应 Response", detail?.resp ?? ""], + ].map(([key, label, raw]) => ( +
+ {label} + {detailLoading ? ( +
+ + 加载报文… +
+ ) : ( + + )} +
+ ))} +
+ + )} +
+
+ + { + if (!o) setDeleteMode(null); + }} + > + + + {deleteTitle} + + {deleteMode === "all" && ( + <> + 将永久删除全部 {traffic?.count ?? 0}{" "} + 条流量记录(含请求/响应原文),忽略当前的筛选条件,此操作不可撤销。已绑定到漏洞的流量证据保存在独立的证据库中,不受影响。 +
+ + 清空后会顺带压实存储,把索引占用的磁盘空间还给系统;这期间流量录制会短暂暂停。 + + + )} + {deleteMode === "selected" && ( + <> + 将永久删除 {selectedHosts.length} 个目标( + + {selectedHosts.slice(0, 3).join("、")} + {selectedHosts.length > 3 ? "…" : ""} + + )的所有流量记录(含请求/响应原文),此操作不可撤销。 + + )} + {deleteMode === "filter" && ( + <> + 将永久删除 host 包含 {hostQ}{" "} + 的所有流量记录(含请求/响应原文),此操作不可撤销。 + + )} +
+
+ + 取消 + { + e.preventDefault(); + confirmDelete(); + }} + disabled={deleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleting ? `${deleteVerb}中…` : `确认${deleteVerb}`} + + +
+
+
+ ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 39b48f3..65914bc 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -714,6 +714,11 @@ export const api = { get( `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`, ), + generateFindingReport: (id: string) => + post<{ queued: boolean; finding_id: number; finding_node_id: number }>( + `/exploration/findings/${encodeURIComponent(id)}/generate-report`, + {}, + ), findingTraffic: (id: string, contextTask?: string) => get( `/exploration/findings/${id}/traffic${contextTask ? `?context_task=${encodeURIComponent(contextTask)}` : ""}`, @@ -902,6 +907,7 @@ export const api = { method = "", q = "", opts: { + taskId?: string; body?: string; path?: string; status?: string; @@ -913,6 +919,7 @@ export const api = { ) => get( `/traffic?page=${page}&size=${size}` + + (opts.taskId ? `&task_id=${encodeURIComponent(opts.taskId)}` : "") + (host ? `&host=${encodeURIComponent(host)}` : "") + (method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") + (q ? `&q=${encodeURIComponent(q)}` : "") + @@ -925,7 +932,8 @@ export const api = { (opts.order ? `&order=${encodeURIComponent(opts.order)}` : ""), ), trafficExchange: (id: string) => get(`/traffic/exchange?id=${encodeURIComponent(id)}`), - trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`), + trafficHosts: (taskId?: string) => + get<{ hosts: TrafficHost[] }>(`/traffic/hosts${taskId ? `?task_id=${encodeURIComponent(taskId)}` : ""}`), trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }), // Purges every exchange and compacts the index; `reclaimed` is the bytes of