feat: add task traffic history and report recovery

This commit is contained in:
ji ruo committed 2026-10-29 10:18:32 +08:00
1 parent b0a29236f8
commit 7946869762
10 files changed
+1236 -899

No files matched your search

+49 -1
View File
@@ -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)
+88
View File
@@ -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()
}
+55 -4
View File
@@ -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
+72 -10
View File
@@ -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
+19
View File
@@ -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")))
@@ -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<Finding | null>(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 (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
@@ -226,16 +244,27 @@ function FindingDetailInner() {
readOnly={finding.inherited}
onChanged={load}
/>
<FindingRetestPanel
key={`retest:${id}`}
findingId={id}
readOnly={finding.inherited}
onCompleted={load}
/>
<Card>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm">详细报告</CardTitle>
{finding.report && <CopyButton text={finding.report} successMessage="已复制详细报告" />}
<div className="flex flex-wrap items-center justify-end gap-2">
{finding.report && <CopyButton text={finding.report} successMessage="已复制详细报告" />}
{!finding.inherited ? (
<Button
variant="outline"
size="sm"
onClick={() => void generateReport()}
disabled={reportGenerating}
>
{reportGenerating ? (
<Loader2Icon data-icon="inline-start" className="animate-spin" />
) : (
<FileTextIcon data-icon="inline-start" />
)}
{finding.report ? "重新生成步骤与报告" : "生成步骤与报告"}
</Button>
) : null}
</div>
</CardHeader>
<CardContent>
{finding.report_stale ? (
@@ -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<LLMProfile[]>([]);
const [archiving, setArchiving] = React.useState(false);
@@ -441,6 +446,9 @@ function TaskDetailInner() {
<TabsContent value="sessions" className="mt-0">
<SessionsTab key={id} taskId={id} requestedSession={searchParams.get("session") ?? ""} />
</TabsContent>
<TabsContent value="traffic" className="mt-0">
<TrafficHistory taskId={id} />
</TabsContent>
<TabsContent value="overview" className="mt-0">
<OverviewTab taskId={id} />
</TabsContent>
+2 -872
View File
@@ -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 <Badge className="shrink-0 font-mono">{method}</Badge>;
}
// 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<Map<string, TrafficExchange>>(() => 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<SortField>(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc");
const [traffic, setTraffic] = React.useState<TrafficResp | null>(null);
const [selected, setSelected] = React.useState<TrafficExchange | null>(null);
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
const [hosts, setHosts] = React.useState<TrafficHost[]>([]); // target picker
const [selectedHosts, setSelectedHosts] = React.useState<string[]>([]); // checked in picker
const [pickerOpen, setPickerOpen] = React.useState(false);
const [hostCountSortDirection, setHostCountSortDirection] = React.useState<HostCountSortDirection>("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 (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">流量</h1>
<p className="text-muted-foreground text-sm">全局录制代理 · 所有 HTTP 往来</p>
</div>
<div className="flex items-center gap-4 text-sm">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
traffic?.enabled
? "border-emerald-500/20 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "border-transparent bg-muted text-muted-foreground",
)}
>
<RadioTowerIcon className="size-3.5" />
{traffic?.enabled ? "录制中" : "已停用"}
</span>
{traffic?.proxy && <span className="font-mono text-xs text-muted-foreground">{traffic.proxy}</span>}
<span className="text-xs text-muted-foreground">
共 <span className="tabular-nums">{traffic?.count ?? 0}</span> 条
</span>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<ListChecksIcon className="size-3.5" />
{selectedHosts.length > 0 ? `选择目标(${selectedHosts.length})` : "选择目标…"}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[calc(100vw-2rem)] p-0 data-open:animate-none data-closed:animate-none sm:w-80"
align="start"
collisionPadding={16}
>
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">按目标批量删除</span>
<div className="flex items-center gap-1">
{hosts.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setHostCountSortDirection((current) => (current === "desc" ? "asc" : "desc"))}
aria-label={
hostCountSortDirection === "desc"
? "数据包数量当前倒序,点击切换为正序"
: "数据包数量当前正序,点击切换为倒序"
}
>
{hostCountSortDirection === "desc" ? <ArrowDownWideNarrowIcon /> : <ArrowUpNarrowWideIcon />}
</Button>
</TooltipTrigger>
<TooltipContent>按数据包数量{hostCountSortDirection === "desc" ? "倒序" : "正序"}</TooltipContent>
</Tooltip>
)}
{hosts.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSelectedHosts(allSelected ? [] : hosts.map((h) => h.host))}
>
{allSelected ? "取消全选" : "全选"}
</Button>
)}
</div>
</div>
<div className="max-h-64 overflow-y-auto">
{hosts.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">暂无流量记录</div>
) : (
sortedHosts.map((h, index) => (
<label
key={h.host}
htmlFor={`traffic-host-${index}`}
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
>
<Checkbox
id={`traffic-host-${index}`}
checked={selectedHosts.includes(h.host)}
onCheckedChange={() =>
setSelectedHosts((prev) =>
prev.includes(h.host) ? prev.filter((x) => x !== h.host) : [...prev, h.host],
)
}
/>
<span className="truncate font-mono">{h.host}</span>
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">{h.count}</span>
</label>
))
)}
</div>
<div className="border-t p-2">
<Button
variant="destructive"
size="sm"
className="w-full"
disabled={selectedHosts.length === 0}
onClick={() => {
setDeleteMode("selected");
setPickerOpen(false);
}}
>
删除选中({selectedHosts.length})
</Button>
</div>
</PopoverContent>
</Popover>
<div className="relative w-48">
<Input placeholder="host…" value={host} onChange={(e) => setHost(e.target.value)} className="h-8" />
</div>
<Button
variant="destructive"
size="sm"
className="h-8"
disabled={!hostQ || deleting}
title={hostQ ? undefined : "先在左侧选择目标或输入 host"}
onClick={() => setDeleteMode("filter")}
>
<Trash2Icon className="size-3.5" />
删除该目标
</Button>
{/* Outline rather than a second destructive button: this one ignores every
filter, so it must not look one mis-click away from "删除该目标". */}
<Button
variant="outline"
size="sm"
className="h-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={!traffic?.count || deleting}
title={traffic?.count ? "删除全部流量并压实存储" : "当前没有流量记录"}
onClick={() => setDeleteMode("all")}
>
<EraserIcon className="size-3.5" />
清空全部
</Button>
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索全部(URL / 方法 / 类型 / 状态码…)"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="方法" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部方法</SelectItem>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= pageCount}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */}
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 px-2 py-1.5">
<span className="pl-1 text-xs font-medium text-muted-foreground">高级筛选</span>
<div className="relative w-56">
<Input
placeholder="响应内容(正文关键词,≥3字)"
value={body}
onChange={(e) => setBody(e.target.value)}
className="h-8"
/>
</div>
<div className="relative w-52">
<Input
placeholder="路径(如 /api/user/…)"
value={path}
onChange={(e) => setPath(e.target.value)}
className="h-8"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger size="sm" className="w-28">
<SelectValue placeholder="状态码" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态码</SelectItem>
{STATUS_BUCKETS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>响应长度</span>
<Input
type="number"
min={0}
placeholder="最小(B)"
value={respMin}
onChange={(e) => setRespMin(e.target.value)}
className="h-8 w-24"
/>
<span>–</span>
<Input
type="number"
min={0}
placeholder="最大(B)"
value={respMax}
onChange={(e) => setRespMax(e.target.value)}
className="h-8 w-24"
/>
</div>
{hasAdvancedFilter ? (
<Button variant="ghost" size="sm" className="h-8" onClick={resetAdvancedFilters}>
<FilterXIcon className="size-3.5" />
清除筛选
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">已选 {selectedFlows.size} 条流量</span>
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
关联到漏洞
</Button>
<Button
variant="outline"
size="sm"
disabled={selectedFlows.size === 0 || importing}
onClick={() => void openRepeater([...selectedFlows.values()])}
>
{importing ? "转入中…" : "送入请求重发"}
</Button>
{selectedFlows.size > 0 ? (
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Map())}>
清空选择
</Button>
) : null}
</div>
{/* History table */}
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-10">
<Checkbox
aria-label="选择本页流量"
checked={exchanges.length > 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;
})
}
/>
</TableHead>
<SortableHead
field="ts"
label="时间"
activeField={sort.field}
direction={sort.direction}
onSort={toggleSort}
className="w-36"
/>
<TableHead className="w-44">host</TableHead>
<TableHead className="w-20">方法</TableHead>
<TableHead>URL</TableHead>
<SortableHead
field="status"
label="状态码"
activeField={sort.field}
direction={sort.direction}
onSort={toggleSort}
className="w-20"
/>
<TableHead className="w-36">content-type</TableHead>
<SortableHead
field="resp_len"
label="响应长度"
activeField={sort.field}
direction={sort.direction}
onSort={toggleSort}
align="right"
className="w-24 text-right"
/>
</TableRow>
</TableHeader>
<TableBody>
{exchanges.map((e) => (
<TableRow
key={e.id}
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
onClick={() => setSelected(e)}
>
<TableCell>
<Checkbox
aria-label={`选择流量 ${e.id}`}
checked={selectedFlows.has(e.id)}
onClick={(event) => 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;
})
}
/>
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
<TableCell className="font-mono text-xs">{e.host}</TableCell>
<TableCell>
<MethodBadge method={e.method} />
</TableCell>
<TableCell className="max-w-0">
<span className="block truncate font-mono text-xs">{e.url}</span>
</TableCell>
<TableCell>
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(e.status))}>
{e.status}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{e.content_type}</TableCell>
<TableCell className="text-right text-xs tabular-nums">{fmtBytes(e.resp_len)}</TableCell>
</TableRow>
))}
{exchanges.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
{traffic === null ? "加载中…" : "没有匹配的流量。"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
</div>
{linking ? (
<LinkTrafficDialog
trafficIds={[...selectedFlows.keys()]}
onClose={() => setLinking(false)}
onBound={() => setSelectedFlows(new Map())}
/>
) : null}
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[72rem]! sm:max-w-[72rem]!">
{selected && (
<>
<SheetHeader className="border-b px-5 py-4">
<div className="flex items-center gap-2 pr-8">
<MethodBadge method={selected.method} />
<Badge variant="secondary" className={cn("font-mono tabular-nums", statusTone(selected.status))}>
{selected.status}
</Badge>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{fmtTime(selected.ts)}</span>
</div>
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
<Button
variant="outline"
size="sm"
className="mt-2 w-fit"
disabled={importing}
onClick={() => void openRepeater([selected])}
>
{importing ? "转入中…" : "送入请求重发"}
</Button>
</SheetHeader>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-2 overflow-hidden p-2 sm:grid-cols-2">
{[
["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)],
["response", "响应 Response", detail?.resp ?? ""],
].map(([key, label, raw]) => (
<div key={key} className="flex min-h-0 min-w-0 flex-col gap-1 overflow-hidden">
<span className="px-1 text-[11px] font-medium text-muted-foreground">{label}</span>
{detailLoading ? (
<div className="flex min-h-0 flex-1 items-center justify-center gap-2 rounded-md border bg-muted/10 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<HttpCodeBlock raw={raw} />
)}
</div>
))}
</div>
</>
)}
</SheetContent>
</Sheet>
<AlertDialog
open={deleteMode !== null}
onOpenChange={(o) => {
if (!o) setDeleteMode(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>
{deleteMode === "all" && (
<>
将永久删除全部 <span className="font-semibold tabular-nums">{traffic?.count ?? 0}</span>{" "}
条流量记录(含请求/响应原文),忽略当前的筛选条件,此操作不可撤销。已绑定到漏洞的流量证据保存在独立的证据库中,不受影响。
<br />
<span className="text-muted-foreground">
清空后会顺带压实存储,把索引占用的磁盘空间还给系统;这期间流量录制会短暂暂停。
</span>
</>
)}
{deleteMode === "selected" && (
<>
将永久删除 <span className="font-semibold tabular-nums">{selectedHosts.length}</span> 个目标(
<span className="font-mono">
{selectedHosts.slice(0, 3).join("、")}
{selectedHosts.length > 3 ? "…" : ""}
</span>
)的所有流量记录(含请求/响应原文),此操作不可撤销。
</>
)}
{deleteMode === "filter" && (
<>
将永久删除 host 包含 <span className="font-mono font-semibold">{hostQ}</span>{" "}
的所有流量记录(含请求/响应原文),此操作不可撤销。
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? `${deleteVerb}中…` : `确认${deleteVerb}`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
return <TrafficHistory />;
}
+894
View File
@@ -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 <Badge className="shrink-0 font-mono">{method}</Badge>;
}
// 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<Map<string, TrafficExchange>>(() => 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<SortField>(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc");
const [traffic, setTraffic] = React.useState<TrafficResp | null>(null);
const [selected, setSelected] = React.useState<TrafficExchange | null>(null);
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
const [hosts, setHosts] = React.useState<TrafficHost[]>([]); // target picker
const [selectedHosts, setSelectedHosts] = React.useState<string[]>([]); // checked in picker
const [pickerOpen, setPickerOpen] = React.useState(false);
const [hostCountSortDirection, setHostCountSortDirection] = React.useState<HostCountSortDirection>("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 (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-semibold tracking-tight">流量</h1>
<p className="text-muted-foreground text-sm">
{taskScoped ? "当前任务资产范围内的 HTTP 往来" : "全局录制代理 · 所有 HTTP 往来"}
</p>
{taskScoped && traffic && !traffic.enabled ? (
<p className="text-amber-600 text-xs dark:text-amber-400">流量录制已关闭,历史记录仍可查看。</p>
) : null}
</div>
<div className="flex items-center gap-4 text-sm">
<span
className={cn(
"inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-xs font-medium",
traffic?.enabled
? "border-emerald-500/20 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "border-transparent bg-muted text-muted-foreground",
)}
>
<RadioTowerIcon className="size-3.5" />
{traffic?.enabled ? "录制中" : "已停用"}
</span>
{traffic?.proxy && <span className="font-mono text-xs text-muted-foreground">{traffic.proxy}</span>}
<span className="text-xs text-muted-foreground">
共 <span className="tabular-nums">{traffic?.count ?? 0}</span> 条
</span>
</div>
</div>
{/* Toolbar */}
<div className="flex flex-wrap items-center gap-2">
{!taskScoped && (
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<ListChecksIcon className="size-3.5" />
{selectedHosts.length > 0 ? `选择目标(${selectedHosts.length})` : "选择目标…"}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[calc(100vw-2rem)] p-0 data-open:animate-none data-closed:animate-none sm:w-80"
align="start"
collisionPadding={16}
>
<div className="flex items-center justify-between border-b px-3 py-2">
<span className="text-xs font-medium text-muted-foreground">按目标批量删除</span>
<div className="flex items-center gap-1">
{hosts.length > 0 && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => setHostCountSortDirection((current) => (current === "desc" ? "asc" : "desc"))}
aria-label={
hostCountSortDirection === "desc"
? "数据包数量当前倒序,点击切换为正序"
: "数据包数量当前正序,点击切换为倒序"
}
>
{hostCountSortDirection === "desc" ? <ArrowDownWideNarrowIcon /> : <ArrowUpNarrowWideIcon />}
</Button>
</TooltipTrigger>
<TooltipContent>按数据包数量{hostCountSortDirection === "desc" ? "倒序" : "正序"}</TooltipContent>
</Tooltip>
)}
{hosts.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs"
onClick={() => setSelectedHosts(allSelected ? [] : hosts.map((h) => h.host))}
>
{allSelected ? "取消全选" : "全选"}
</Button>
)}
</div>
</div>
<div className="max-h-64 overflow-y-auto">
{hosts.length === 0 ? (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">暂无流量记录</div>
) : (
sortedHosts.map((h, index) => (
<label
key={h.host}
htmlFor={`traffic-host-${index}`}
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
>
<Checkbox
id={`traffic-host-${index}`}
checked={selectedHosts.includes(h.host)}
onCheckedChange={() =>
setSelectedHosts((prev) =>
prev.includes(h.host) ? prev.filter((x) => x !== h.host) : [...prev, h.host],
)
}
/>
<span className="truncate font-mono">{h.host}</span>
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">{h.count}</span>
</label>
))
)}
</div>
<div className="border-t p-2">
<Button
variant="destructive"
size="sm"
className="w-full"
disabled={selectedHosts.length === 0}
onClick={() => {
setDeleteMode("selected");
setPickerOpen(false);
}}
>
删除选中({selectedHosts.length})
</Button>
</div>
</PopoverContent>
</Popover>
)}
<div className="relative w-48">
<Input placeholder="host…" value={host} onChange={(e) => setHost(e.target.value)} className="h-8" />
</div>
{!taskScoped && (
<Button
variant="destructive"
size="sm"
className="h-8"
disabled={!hostQ || deleting}
title={hostQ ? undefined : "先在左侧选择目标或输入 host"}
onClick={() => setDeleteMode("filter")}
>
<Trash2Icon className="size-3.5" />
删除该目标
</Button>
)}
{/* Outline rather than a second destructive button: this one ignores every
filter, so it must not look one mis-click away from "删除该目标". */}
{!taskScoped && (
<Button
variant="outline"
size="sm"
className="h-8 text-destructive hover:bg-destructive/10 hover:text-destructive"
disabled={!traffic?.count || deleting}
title={traffic?.count ? "删除全部流量并压实存储" : "当前没有流量记录"}
onClick={() => setDeleteMode("all")}
>
<EraserIcon className="size-3.5" />
清空全部
</Button>
)}
<div className="relative max-w-sm flex-1">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="搜索全部(URL / 方法 / 类型 / 状态码…)"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-8 pl-8"
/>
</div>
<Select value={method} onValueChange={setMethod}>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="方法" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部方法</SelectItem>
{METHODS.map((m) => (
<SelectItem key={m} value={m}>
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
<SelectTrigger size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PAGE_SIZES.map((n) => (
<SelectItem key={n} value={String(n)}>
{n} / 页
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<span className="tabular-nums">
{rangeStart}–{rangeEnd} / {total}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<ChevronLeftIcon />
</Button>
<span className="tabular-nums">
{page + 1} / {pageCount}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
disabled={page + 1 >= pageCount}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
<ChevronRightIcon />
</Button>
</div>
</div>
{/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */}
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 px-2 py-1.5">
<span className="pl-1 text-xs font-medium text-muted-foreground">高级筛选</span>
<div className="relative w-56">
<Input
placeholder="响应内容(正文关键词,≥3字)"
value={body}
onChange={(e) => setBody(e.target.value)}
className="h-8"
/>
</div>
<div className="relative w-52">
<Input
placeholder="路径(如 /api/user/…)"
value={path}
onChange={(e) => setPath(e.target.value)}
className="h-8"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger size="sm" className="w-28">
<SelectValue placeholder="状态码" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态码</SelectItem>
{STATUS_BUCKETS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<span>响应长度</span>
<Input
type="number"
min={0}
placeholder="最小(B)"
value={respMin}
onChange={(e) => setRespMin(e.target.value)}
className="h-8 w-24"
/>
<span>–</span>
<Input
type="number"
min={0}
placeholder="最大(B)"
value={respMax}
onChange={(e) => setRespMax(e.target.value)}
className="h-8 w-24"
/>
</div>
{hasAdvancedFilter ? (
<Button variant="ghost" size="sm" className="h-8" onClick={resetAdvancedFilters}>
<FilterXIcon className="size-3.5" />
清除筛选
</Button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">已选 {selectedFlows.size} 条流量</span>
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
关联到漏洞
</Button>
<Button
variant="outline"
size="sm"
disabled={selectedFlows.size === 0 || importing}
onClick={() => void openRepeater([...selectedFlows.values()])}
>
{importing ? "转入中…" : "送入请求重发"}
</Button>
{selectedFlows.size > 0 ? (
<Button variant="ghost" size="sm" onClick={() => setSelectedFlows(new Map())}>
清空选择
</Button>
) : null}
</div>
{/* History table */}
<div className="flex h-[calc(100vh-15rem)] min-h-0 flex-col">
<Card className="flex min-h-0 flex-1 flex-col overflow-hidden py-0">
<div className="min-h-0 flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-card">
<TableRow>
<TableHead className="w-10">
<Checkbox
aria-label="选择本页流量"
checked={exchanges.length > 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;
})
}
/>
</TableHead>
<SortableHead
field="ts"
label="时间"
activeField={sort.field}
direction={sort.direction}
onSort={toggleSort}
className="w-36"
/>
<TableHead className="w-44">host</TableHead>
<TableHead className="w-20">方法</TableHead>
<TableHead>URL</TableHead>
<SortableHead
field="status"
label="状态码"
activeField={sort.field}
direction={sort.direction}
onSort={toggleSort}
className="w-20"
/>
<TableHead className="w-36">content-type</TableHead>
<SortableHead
field="resp_len"
label="响应长度"
activeField={sort.field}
direction={sort.direction}
onSort={toggleSort}
align="right"
className="w-24 text-right"
/>
</TableRow>
</TableHeader>
<TableBody>
{exchanges.map((e) => (
<TableRow
key={e.id}
className={cn("cursor-pointer", selected?.id === e.id && "bg-accent hover:bg-accent")}
onClick={() => setSelected(e)}
>
<TableCell>
<Checkbox
aria-label={`选择流量 ${e.id}`}
checked={selectedFlows.has(e.id)}
onClick={(event) => 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;
})
}
/>
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(e.ts)}</TableCell>
<TableCell className="font-mono text-xs">{e.host}</TableCell>
<TableCell>
<MethodBadge method={e.method} />
</TableCell>
<TableCell className="max-w-0">
<span className="block truncate font-mono text-xs">{e.url}</span>
</TableCell>
<TableCell>
<span className={cn("font-mono text-xs font-semibold tabular-nums", statusTone(e.status))}>
{e.status}
</span>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{e.content_type}</TableCell>
<TableCell className="text-right text-xs tabular-nums">{fmtBytes(e.resp_len)}</TableCell>
</TableRow>
))}
{exchanges.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="py-12 text-center text-sm text-muted-foreground">
{traffic === null ? "加载中…" : "没有匹配的流量。"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</Card>
</div>
{linking ? (
<LinkTrafficDialog
trafficIds={[...selectedFlows.keys()]}
onClose={() => setLinking(false)}
onBound={() => setSelectedFlows(new Map())}
/>
) : null}
<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full! max-w-none! gap-0 p-0 sm:w-[72rem]! sm:max-w-[72rem]!">
{selected && (
<>
<SheetHeader className="border-b px-5 py-4">
<div className="flex items-center gap-2 pr-8">
<MethodBadge method={selected.method} />
<Badge variant="secondary" className={cn("font-mono tabular-nums", statusTone(selected.status))}>
{selected.status}
</Badge>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{fmtTime(selected.ts)}</span>
</div>
<SheetTitle className="break-all font-mono">{selected.host}</SheetTitle>
<SheetDescription className="break-all font-mono">{selected.url}</SheetDescription>
<Button
variant="outline"
size="sm"
className="mt-2 w-fit"
disabled={importing}
onClick={() => void openRepeater([selected])}
>
{importing ? "转入中…" : "送入请求重发"}
</Button>
</SheetHeader>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-2 overflow-hidden p-2 sm:grid-cols-2">
{[
["request", "请求 Request", requestWithHost(detail?.req ?? "", selected)],
["response", "响应 Response", detail?.resp ?? ""],
].map(([key, label, raw]) => (
<div key={key} className="flex min-h-0 min-w-0 flex-col gap-1 overflow-hidden">
<span className="px-1 text-[11px] font-medium text-muted-foreground">{label}</span>
{detailLoading ? (
<div className="flex min-h-0 flex-1 items-center justify-center gap-2 rounded-md border bg-muted/10 text-xs text-muted-foreground">
<Loader2Icon className="size-3.5 animate-spin" />
加载报文…
</div>
) : (
<HttpCodeBlock raw={raw} />
)}
</div>
))}
</div>
</>
)}
</SheetContent>
</Sheet>
<AlertDialog
open={deleteMode !== null}
onOpenChange={(o) => {
if (!o) setDeleteMode(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>
{deleteMode === "all" && (
<>
将永久删除全部 <span className="font-semibold tabular-nums">{traffic?.count ?? 0}</span>{" "}
条流量记录(含请求/响应原文),忽略当前的筛选条件,此操作不可撤销。已绑定到漏洞的流量证据保存在独立的证据库中,不受影响。
<br />
<span className="text-muted-foreground">
清空后会顺带压实存储,把索引占用的磁盘空间还给系统;这期间流量录制会短暂暂停。
</span>
</>
)}
{deleteMode === "selected" && (
<>
将永久删除 <span className="font-semibold tabular-nums">{selectedHosts.length}</span> 个目标(
<span className="font-mono">
{selectedHosts.slice(0, 3).join("、")}
{selectedHosts.length > 3 ? "…" : ""}
</span>
)的所有流量记录(含请求/响应原文),此操作不可撤销。
</>
)}
{deleteMode === "filter" && (
<>
将永久删除 host 包含 <span className="font-mono font-semibold">{hostQ}</span>{" "}
的所有流量记录(含请求/响应原文),此操作不可撤销。
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
confirmDelete();
}}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? `${deleteVerb}中…` : `确认${deleteVerb}`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
+9 -1
View File
@@ -714,6 +714,11 @@ export const api = {
get<Finding>(
`/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<FindingTraffic>(
`/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<TrafficResp>(
`/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<TrafficDetail>(`/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