mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-02 18:53:50 +08:00
feat: add task traffic history and report recovery
This commit is contained in:
1 parent
b0a29236f8
commit
7946869762
10 files changed
+1236
-899
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user