mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
Merge pull request #179 from Autumn-27/feat/traffic-search-filter-sort
feat(traffic): 流量列表支持内容检索、路径/状态码/长度筛选与列排序 (#177)
This commit is contained in:
6 files changed
+383
-24
No files matched your search
+13
-1
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/Autumn-27/artex/llmpool"
|
"github.com/Autumn-27/artex/llmpool"
|
||||||
"github.com/Autumn-27/artex/llmrec"
|
"github.com/Autumn-27/artex/llmrec"
|
||||||
"github.com/Autumn-27/artex/report"
|
"github.com/Autumn-27/artex/report"
|
||||||
|
"github.com/Autumn-27/artex/traffic"
|
||||||
"github.com/Autumn-27/norma/llm"
|
"github.com/Autumn-27/norma/llm"
|
||||||
actool "github.com/Autumn-27/norma/tool"
|
actool "github.com/Autumn-27/norma/tool"
|
||||||
"github.com/Autumn-27/norma/transcript"
|
"github.com/Autumn-27/norma/transcript"
|
||||||
@@ -3085,7 +3086,18 @@ func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) {
|
|||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
page := atoiDefault(q.Get("page"), 0)
|
page := atoiDefault(q.Get("page"), 0)
|
||||||
size := atoiDefault(q.Get("size"), 100)
|
size := atoiDefault(q.Get("size"), 100)
|
||||||
ex, matched, _ := tr.Page(q.Get("host"), q.Get("method"), q.Get("q"), page, size)
|
ex, matched, _ := tr.Page(traffic.PageQuery{
|
||||||
|
Host: q.Get("host"),
|
||||||
|
Method: q.Get("method"),
|
||||||
|
Query: q.Get("q"),
|
||||||
|
Body: q.Get("body"),
|
||||||
|
Path: q.Get("path"),
|
||||||
|
Status: q.Get("status"),
|
||||||
|
RespMin: int64(atoiDefault(q.Get("resp_min"), -1)),
|
||||||
|
RespMax: int64(atoiDefault(q.Get("resp_max"), -1)),
|
||||||
|
Sort: q.Get("sort"),
|
||||||
|
Order: q.Get("order"),
|
||||||
|
}, page, size)
|
||||||
count, _ := tr.Count() // global total, for the stat card
|
count, _ := tr.Count() // global total, for the stat card
|
||||||
writeJSON(w, 200, map[string]any{
|
writeJSON(w, 200, map[string]any{
|
||||||
"enabled": s.m.TrafficEnabled(), // reflect the capture toggle
|
"enabled": s.m.TrafficEnabled(), // reflect the capture toggle
|
||||||
|
|||||||
+84
-11
@@ -54,6 +54,8 @@ CREATE TABLE IF NOT EXISTS exchanges (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_ex_host ON exchanges(host);
|
CREATE INDEX IF NOT EXISTS idx_ex_host ON exchanges(host);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ex_tmpl ON exchanges(host, url_template);
|
CREATE INDEX IF NOT EXISTS idx_ex_tmpl ON exchanges(host, url_template);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ex_ts ON exchanges(ts);
|
CREATE INDEX IF NOT EXISTS idx_ex_ts ON exchanges(ts);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ex_status ON exchanges(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ex_resp ON exchanges(resp_len);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS exchange_bodies (
|
CREATE TABLE IF NOT EXISTS exchange_bodies (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -694,13 +696,53 @@ func ftsQuote(s string) string {
|
|||||||
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page returns one page of exchange metadata filtered by an optional host
|
// PageQuery bundles the optional filters and sort order for Page. Every filter
|
||||||
// substring, exact method, and a free-text query q that matches across the
|
// is combined with AND; an empty string skips that filter, and RespMin/RespMax
|
||||||
// indexed metadata columns (host/url/method/content-type/status) and, when the
|
// of -1 skip the response-size bounds. An empty Sort/Order defaults to
|
||||||
// term is long enough for the trigram index, across captured request/response
|
// newest-first by timestamp.
|
||||||
// text as well. Also returns the total number of rows matching that filter (for
|
type PageQuery struct {
|
||||||
// the UI's pagination). Newest first. Bodies are never included in the rows.
|
Host string // host substring
|
||||||
func (t *Traffic) Page(host, method, q string, page, size int) (rows []ExchangeMeta, total int, err error) {
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortColumns whitelists the columns Page may order by, so the caller-supplied
|
||||||
|
// Sort can never reach the SQL as anything but one of these fixed names.
|
||||||
|
var sortColumns = map[string]string{"ts": "ts", "status": "status", "resp_len": "resp_len"}
|
||||||
|
|
||||||
|
// statusFilter turns a status token into a SQL condition: an exact code ("404")
|
||||||
|
// matches that status, an "Nxx" class ("4xx") matches the whole hundreds band.
|
||||||
|
// ok is false for an empty or unrecognized token.
|
||||||
|
func statusFilter(s string) (cond string, args []any, ok bool) {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
if s == "" {
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
if len(s) == 3 && s[0] >= '1' && s[0] <= '5' && s[1] == 'x' && s[2] == 'x' {
|
||||||
|
base := int(s[0]-'0') * 100
|
||||||
|
return "status>=? AND status<?", []any{base, base + 100}, true
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(s); err == nil {
|
||||||
|
return "status=?", []any{n}, true
|
||||||
|
}
|
||||||
|
return "", nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page returns one page of exchange metadata for the traffic list, filtered by
|
||||||
|
// the criteria in f (all optional, combined with AND) and ordered by the
|
||||||
|
// requested column. Query is the broad search box (metadata columns OR, when the
|
||||||
|
// term is long enough for the trigram index, captured request/response text);
|
||||||
|
// Body narrows to exchanges whose captured text matches, via that same index.
|
||||||
|
// Also returns the total number of rows matching the filter (for the UI's
|
||||||
|
// pagination). Bodies are never included in the rows.
|
||||||
|
func (t *Traffic) Page(f PageQuery, page, size int) (rows []ExchangeMeta, total int, err error) {
|
||||||
if size <= 0 || size > 500 {
|
if size <= 0 || size > 500 {
|
||||||
size = 100
|
size = 100
|
||||||
}
|
}
|
||||||
@@ -718,13 +760,25 @@ func (t *Traffic) Page(host, method, q string, page, size int) (rows []ExchangeM
|
|||||||
where += cond
|
where += cond
|
||||||
args = append(args, vs...)
|
args = append(args, vs...)
|
||||||
}
|
}
|
||||||
if h := strings.TrimSpace(host); h != "" {
|
if h := strings.TrimSpace(f.Host); h != "" {
|
||||||
add("host LIKE ?", "%"+h+"%")
|
add("host LIKE ?", "%"+h+"%")
|
||||||
}
|
}
|
||||||
if m := strings.TrimSpace(method); m != "" {
|
if m := strings.TrimSpace(f.Method); m != "" {
|
||||||
add("method=?", strings.ToUpper(m))
|
add("method=?", strings.ToUpper(m))
|
||||||
}
|
}
|
||||||
if s := strings.TrimSpace(q); s != "" {
|
if p := strings.TrimSpace(f.Path); p != "" {
|
||||||
|
add("url_template LIKE ?", "%"+p+"%")
|
||||||
|
}
|
||||||
|
if cond, sargs, ok := statusFilter(f.Status); ok {
|
||||||
|
add(cond, sargs...)
|
||||||
|
}
|
||||||
|
if f.RespMin >= 0 {
|
||||||
|
add("resp_len>=?", f.RespMin)
|
||||||
|
}
|
||||||
|
if f.RespMax >= 0 {
|
||||||
|
add("resp_len<=?", f.RespMax)
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(f.Query); s != "" {
|
||||||
like := "%" + s + "%"
|
like := "%" + s + "%"
|
||||||
const meta = "host LIKE ? OR url LIKE ? OR url_template LIKE ? OR method LIKE ? OR content_type LIKE ? OR CAST(status AS TEXT) LIKE ?"
|
const meta = "host LIKE ? OR url LIKE ? OR url_template LIKE ? OR method LIKE ? OR content_type LIKE ? OR CAST(status AS TEXT) LIKE ?"
|
||||||
// Metadata match OR full-text match: one search box, widest recall. Terms
|
// Metadata match OR full-text match: one search box, widest recall. Terms
|
||||||
@@ -735,11 +789,30 @@ func (t *Traffic) Page(host, method, q string, page, size int) (rows []ExchangeM
|
|||||||
add("("+meta+")", like, like, like, like, like, like)
|
add("("+meta+")", like, like, like, like, like, like)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Body is the dedicated content box: it only searches captured request/response
|
||||||
|
// text, so it goes straight to the full-text index with no metadata fallback. A
|
||||||
|
// term too short for the trigram tokenizer cannot be served and is skipped
|
||||||
|
// rather than guessed at — the UI hints at the three-character minimum.
|
||||||
|
if b := strings.TrimSpace(f.Body); b != "" {
|
||||||
|
if cond, arg, ok := t.ftsFilter(b); ok {
|
||||||
|
add(cond, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err = t.db.QueryRow(`SELECT COUNT(*) FROM exchanges`+where, args...).Scan(&total); err != nil {
|
if err = t.db.QueryRow(`SELECT COUNT(*) FROM exchanges`+where, args...).Scan(&total); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
col := sortColumns[strings.ToLower(strings.TrimSpace(f.Sort))]
|
||||||
|
if col == "" {
|
||||||
|
col = "ts"
|
||||||
|
}
|
||||||
|
dir := "DESC"
|
||||||
|
if strings.EqualFold(strings.TrimSpace(f.Order), "asc") {
|
||||||
|
dir = "ASC"
|
||||||
|
}
|
||||||
|
// id embeds capture timestamp + sequence, so a trailing id DESC makes the order
|
||||||
|
// total and pagination stable even when the sort column has many ties.
|
||||||
sel := `SELECT id,ts,host,method,url_template,url,status,content_type,resp_len,path FROM exchanges` +
|
sel := `SELECT id,ts,host,method,url_template,url,status,content_type,resp_len,path FROM exchanges` +
|
||||||
where + ` ORDER BY ts DESC LIMIT ? OFFSET ?`
|
where + ` ORDER BY ` + col + ` ` + dir + `, id DESC LIMIT ? OFFSET ?`
|
||||||
qargs := append(append([]any{}, args...), size, page*size)
|
qargs := append(append([]any{}, args...), size, page*size)
|
||||||
rs, err := t.db.Query(sel, qargs...)
|
rs, err := t.db.Query(sel, qargs...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -360,7 +360,7 @@ func TestPageSearchesBodies(t *testing.T) {
|
|||||||
tr.record(newFlow("api.example.com", "POST", "/v1/login", nil, []byte(`{"error":"invalid credentials"}`)))
|
tr.record(newFlow("api.example.com", "POST", "/v1/login", nil, []byte(`{"error":"invalid credentials"}`)))
|
||||||
tr.record(newFlow("api.example.com", "GET", "/v1/health", nil, []byte(`{"status":"ok"}`)))
|
tr.record(newFlow("api.example.com", "GET", "/v1/health", nil, []byte(`{"status":"ok"}`)))
|
||||||
|
|
||||||
rows, total, err := tr.Page("", "", "invalid credentials", 0, 100)
|
rows, total, err := tr.Page(PageQuery{Query: "invalid credentials", RespMin: -1, RespMax: -1}, 0, 100)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -368,11 +368,52 @@ func TestPageSearchesBodies(t *testing.T) {
|
|||||||
t.Fatalf("正文关键词命中 total=%d rows=%d,应为 1/1", total, len(rows))
|
t.Fatalf("正文关键词命中 total=%d rows=%d,应为 1/1", total, len(rows))
|
||||||
}
|
}
|
||||||
// Metadata matching still works alongside it.
|
// Metadata matching still works alongside it.
|
||||||
if _, total, err := tr.Page("", "", "health", 0, 100); err != nil || total != 1 {
|
if _, total, err := tr.Page(PageQuery{Query: "health", RespMin: -1, RespMax: -1}, 0, 100); err != nil || total != 1 {
|
||||||
t.Fatalf("URL 关键词 total=%d err=%v,应为 1", total, err)
|
t.Fatalf("URL 关键词 total=%d err=%v,应为 1", total, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPageFiltersAndSort covers the issue #177 additions: status-class/exact
|
||||||
|
// filtering, response-size bounds, path (url_template) filtering, and
|
||||||
|
// server-side sorting by resp_len.
|
||||||
|
func TestPageFiltersAndSort(t *testing.T) {
|
||||||
|
tr, _ := openTraffic(t)
|
||||||
|
status := func(code int) flowOpt { return func(f *mproxy.Flow) { f.Response.StatusCode = code } }
|
||||||
|
// Three exchanges with distinct status codes and response sizes.
|
||||||
|
tr.record(newFlow("api.example.com", "GET", "/api/users", nil, make([]byte, 10), status(200)))
|
||||||
|
tr.record(newFlow("api.example.com", "GET", "/api/admin", nil, make([]byte, 100), status(404)))
|
||||||
|
tr.record(newFlow("api.example.com", "GET", "/api/users/1", nil, make([]byte, 50), status(500)))
|
||||||
|
|
||||||
|
// Status class band.
|
||||||
|
if rows, _, err := tr.Page(PageQuery{Status: "4xx", RespMin: -1, RespMax: -1}, 0, 100); err != nil || len(rows) != 1 || rows[0].Status != 404 {
|
||||||
|
t.Fatalf("status=4xx 应命中 1 条 404,得 %d 条 err=%v", len(rows), err)
|
||||||
|
}
|
||||||
|
// Exact status.
|
||||||
|
if rows, _, err := tr.Page(PageQuery{Status: "500", RespMin: -1, RespMax: -1}, 0, 100); err != nil || len(rows) != 1 || rows[0].Status != 500 {
|
||||||
|
t.Fatalf("status=500 应命中 1 条,得 %d 条 err=%v", len(rows), err)
|
||||||
|
}
|
||||||
|
// Response-size lower bound (>=60 keeps only the 100-byte row).
|
||||||
|
if rows, _, err := tr.Page(PageQuery{RespMin: 60, RespMax: -1}, 0, 100); err != nil || len(rows) != 1 || rows[0].RespLen != 100 {
|
||||||
|
t.Fatalf("resp_min=60 应命中 1 条 100B,得 %d 条 err=%v", len(rows), err)
|
||||||
|
}
|
||||||
|
// Path (url_template) filter narrows to the /api/admin exchange.
|
||||||
|
if rows, _, err := tr.Page(PageQuery{Path: "/api/admin", RespMin: -1, RespMax: -1}, 0, 100); err != nil || len(rows) != 1 || rows[0].Status != 404 {
|
||||||
|
t.Fatalf("path=/api/admin 应命中 1 条,得 %d 条 err=%v", len(rows), err)
|
||||||
|
}
|
||||||
|
// Sort by response length, ascending then descending.
|
||||||
|
asc, _, err := tr.Page(PageQuery{RespMin: -1, RespMax: -1, Sort: "resp_len", Order: "asc"}, 0, 100)
|
||||||
|
if err != nil || len(asc) != 3 {
|
||||||
|
t.Fatalf("resp_len asc 应返回 3 条,得 %d 条 err=%v", len(asc), err)
|
||||||
|
}
|
||||||
|
if asc[0].RespLen != 10 || asc[1].RespLen != 50 || asc[2].RespLen != 100 {
|
||||||
|
t.Fatalf("resp_len asc 顺序错误:%d,%d,%d", asc[0].RespLen, asc[1].RespLen, asc[2].RespLen)
|
||||||
|
}
|
||||||
|
desc, _, err := tr.Page(PageQuery{RespMin: -1, RespMax: -1, Sort: "resp_len", Order: "desc"}, 0, 100)
|
||||||
|
if err != nil || len(desc) != 3 || desc[0].RespLen != 100 || desc[2].RespLen != 10 {
|
||||||
|
t.Fatalf("resp_len desc 顺序错误 err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestQueryHostPortAndURLForms(t *testing.T) {
|
func TestQueryHostPortAndURLForms(t *testing.T) {
|
||||||
tr, _ := openTraffic(t)
|
tr, _ := openTraffic(t)
|
||||||
tr.record(newFlow("api.example.com:8082", "GET", "/admin", nil, []byte("8082")))
|
tr.record(newFlow("api.example.com:8082", "GET", "/admin", nil, []byte("8082")))
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import {
|
|||||||
ArrowUpNarrowWideIcon,
|
ArrowUpNarrowWideIcon,
|
||||||
ChevronLeftIcon,
|
ChevronLeftIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
FilterXIcon,
|
||||||
ListChecksIcon,
|
ListChecksIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
RadioTowerIcon,
|
RadioTowerIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
Trash2Icon,
|
Trash2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
|
||||||
|
|
||||||
import { HttpCodeBlock } from "@/components/http-code-block";
|
import { HttpCodeBlock } from "@/components/http-code-block";
|
||||||
import { LinkTrafficDialog } from "@/components/link-traffic-dialog";
|
import { LinkTrafficDialog } from "@/components/link-traffic-dialog";
|
||||||
@@ -35,10 +35,12 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { useStoredSortPreference } from "@/lib/sort-preference";
|
||||||
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
|
import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -95,6 +97,16 @@ const METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"];
|
|||||||
const PAGE_SIZES = [25, 50, 100, 200];
|
const PAGE_SIZES = [25, 50, 100, 200];
|
||||||
type HostCountSortDirection = "asc" | "desc";
|
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 default function TrafficPage() {
|
export default function TrafficPage() {
|
||||||
const [selectedFlows, setSelectedFlows] = React.useState<Set<string>>(() => new Set());
|
const [selectedFlows, setSelectedFlows] = React.useState<Set<string>>(() => new Set());
|
||||||
const [linking, setLinking] = React.useState(false);
|
const [linking, setLinking] = React.useState(false);
|
||||||
@@ -106,6 +118,20 @@ export default function TrafficPage() {
|
|||||||
const [queryQ, setQueryQ] = React.useState(""); // debounced → server
|
const [queryQ, setQueryQ] = React.useState(""); // debounced → server
|
||||||
const [method, setMethod] = React.useState("all");
|
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 [traffic, setTraffic] = React.useState<TrafficResp | null>(null);
|
||||||
const [selected, setSelected] = React.useState<TrafficExchange | null>(null);
|
const [selected, setSelected] = React.useState<TrafficExchange | null>(null);
|
||||||
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
|
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
|
||||||
@@ -129,12 +155,41 @@ export default function TrafficPage() {
|
|||||||
const t = setTimeout(() => setQueryQ(query.trim()), 300);
|
const t = setTimeout(() => setQueryQ(query.trim()), 300);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [query]);
|
}, [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]);
|
||||||
|
|
||||||
// Any filter/size change resets to the first page.
|
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.
|
// biome-ignore lint/correctness/useExhaustiveDependencies: these values intentionally trigger a page reset.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}, [hostQ, queryQ, method, size]);
|
}, [hostQ, queryQ, method, size, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort]);
|
||||||
|
|
||||||
// Load the current page. Auto-refresh only on page 0 (newest) so paging back
|
// Load the current page. Auto-refresh only on page 0 (newest) so paging back
|
||||||
// through history isn't yanked out from under the user.
|
// through history isn't yanked out from under the user.
|
||||||
@@ -143,7 +198,15 @@ export default function TrafficPage() {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () => {
|
const load = () => {
|
||||||
api
|
api
|
||||||
.traffic(page, size, hostQ, method, queryQ)
|
.traffic(page, size, hostQ, method, queryQ, {
|
||||||
|
body: bodyQ,
|
||||||
|
path: pathQ,
|
||||||
|
status: statusFilter,
|
||||||
|
respMin: respMinQ,
|
||||||
|
respMax: respMaxQ,
|
||||||
|
sort: sort.field,
|
||||||
|
order: sort.direction,
|
||||||
|
})
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (alive) setTraffic(r);
|
if (alive) setTraffic(r);
|
||||||
})
|
})
|
||||||
@@ -168,7 +231,7 @@ export default function TrafficPage() {
|
|||||||
alive = false;
|
alive = false;
|
||||||
clearInterval(t);
|
clearInterval(t);
|
||||||
};
|
};
|
||||||
}, [page, size, hostQ, method, queryQ, reloadTick]);
|
}, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick]);
|
||||||
|
|
||||||
// Delete traffic for the current host filter (substring) or the checked
|
// Delete traffic for the current host filter (substring) or the checked
|
||||||
// hosts (exact batch), then refetch.
|
// hosts (exact batch), then refetch.
|
||||||
@@ -227,6 +290,15 @@ export default function TrafficPage() {
|
|||||||
};
|
};
|
||||||
}, [selected]);
|
}, [selected]);
|
||||||
|
|
||||||
|
// 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 exchanges = React.useMemo(() => traffic?.exchanges ?? [], [traffic]);
|
||||||
const total = traffic?.total ?? exchanges.length;
|
const total = traffic?.total ?? exchanges.length;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / size));
|
const pageCount = Math.max(1, Math.ceil(total / size));
|
||||||
@@ -425,6 +497,66 @@ export default function TrafficPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm text-muted-foreground">已选 {selectedFlows.size} 条流量</span>
|
<span className="text-sm text-muted-foreground">已选 {selectedFlows.size} 条流量</span>
|
||||||
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
|
<Button variant="outline" size="sm" disabled={selectedFlows.size === 0} onClick={() => setLinking(true)}>
|
||||||
@@ -459,13 +591,35 @@ export default function TrafficPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-36">时间</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-44">host</TableHead>
|
||||||
<TableHead className="w-20">方法</TableHead>
|
<TableHead className="w-20">方法</TableHead>
|
||||||
<TableHead>URL</TableHead>
|
<TableHead>URL</TableHead>
|
||||||
<TableHead className="w-20">状态码</TableHead>
|
<SortableHead
|
||||||
|
field="status"
|
||||||
|
label="状态码"
|
||||||
|
activeField={sort.field}
|
||||||
|
direction={sort.direction}
|
||||||
|
onSort={toggleSort}
|
||||||
|
className="w-20"
|
||||||
|
/>
|
||||||
<TableHead className="w-36">content-type</TableHead>
|
<TableHead className="w-36">content-type</TableHead>
|
||||||
<TableHead className="w-24 text-right">响应长度</TableHead>
|
<SortableHead
|
||||||
|
field="resp_len"
|
||||||
|
label="响应长度"
|
||||||
|
activeField={sort.field}
|
||||||
|
direction={sort.direction}
|
||||||
|
onSort={toggleSort}
|
||||||
|
align="right"
|
||||||
|
className="w-24 text-right"
|
||||||
|
/>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ArrowDownIcon, ArrowUpDownIcon, ArrowUpIcon } from "lucide-react";
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { TableHead } from "@/components/ui/table";
|
||||||
|
import type { SortDirection } from "@/lib/sort-preference";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// SortableHead is a table header cell that toggles a column's sort on click.
|
||||||
|
// It renders a neutral up/down glyph when inactive and a directional arrow when
|
||||||
|
// its field is the active sort, mirroring the pattern first used on the tasks
|
||||||
|
// table so sortable columns look and behave the same across the app.
|
||||||
|
export function SortableHead<Field extends string>({
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
activeField,
|
||||||
|
direction,
|
||||||
|
align = "left",
|
||||||
|
className,
|
||||||
|
onSort,
|
||||||
|
}: {
|
||||||
|
field: Field;
|
||||||
|
label: string;
|
||||||
|
activeField: Field | null;
|
||||||
|
direction: SortDirection;
|
||||||
|
align?: "left" | "right";
|
||||||
|
className?: string;
|
||||||
|
onSort: (field: Field) => void;
|
||||||
|
}) {
|
||||||
|
const active = activeField === field;
|
||||||
|
let ariaSort: React.AriaAttributes["aria-sort"] = "none";
|
||||||
|
if (active) ariaSort = direction === "asc" ? "ascending" : "descending";
|
||||||
|
|
||||||
|
let actionLabel = `按${label}倒序排序`;
|
||||||
|
if (active) actionLabel = `${label}当前${direction === "asc" ? "正序" : "倒序"},点击切换排序方向`;
|
||||||
|
|
||||||
|
let icon = <ArrowUpDownIcon className="size-3.5 opacity-40 transition-opacity group-hover/sort:opacity-100" />;
|
||||||
|
if (active) icon = direction === "asc" ? <ArrowUpIcon className="size-3.5" /> : <ArrowDownIcon className="size-3.5" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableHead className={className} aria-sort={ariaSort}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"group/sort inline-flex h-full w-full items-center gap-1 outline-none focus-visible:underline",
|
||||||
|
align === "right" && "justify-end",
|
||||||
|
)}
|
||||||
|
aria-label={actionLabel}
|
||||||
|
onClick={() => onSort(field)}
|
||||||
|
>
|
||||||
|
<span>{label}</span>
|
||||||
|
{icon}
|
||||||
|
</button>
|
||||||
|
</TableHead>
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-2
@@ -758,12 +758,34 @@ export const api = {
|
|||||||
|
|
||||||
// ---- traffic / audit / report / chat ----
|
// ---- traffic / audit / report / chat ----
|
||||||
audit: (task?: string) => get<Audit>(`/audit${tq(task)}`),
|
audit: (task?: string) => get<Audit>(`/audit${tq(task)}`),
|
||||||
traffic: (page = 0, size = 100, host = "", method = "", q = "") =>
|
traffic: (
|
||||||
|
page = 0,
|
||||||
|
size = 100,
|
||||||
|
host = "",
|
||||||
|
method = "",
|
||||||
|
q = "",
|
||||||
|
opts: {
|
||||||
|
body?: string;
|
||||||
|
path?: string;
|
||||||
|
status?: string;
|
||||||
|
respMin?: string;
|
||||||
|
respMax?: string;
|
||||||
|
sort?: string;
|
||||||
|
order?: string;
|
||||||
|
} = {},
|
||||||
|
) =>
|
||||||
get<TrafficResp>(
|
get<TrafficResp>(
|
||||||
`/traffic?page=${page}&size=${size}` +
|
`/traffic?page=${page}&size=${size}` +
|
||||||
(host ? `&host=${encodeURIComponent(host)}` : "") +
|
(host ? `&host=${encodeURIComponent(host)}` : "") +
|
||||||
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
|
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
|
||||||
(q ? `&q=${encodeURIComponent(q)}` : ""),
|
(q ? `&q=${encodeURIComponent(q)}` : "") +
|
||||||
|
(opts.body ? `&body=${encodeURIComponent(opts.body)}` : "") +
|
||||||
|
(opts.path ? `&path=${encodeURIComponent(opts.path)}` : "") +
|
||||||
|
(opts.status && opts.status !== "all" ? `&status=${encodeURIComponent(opts.status)}` : "") +
|
||||||
|
(opts.respMin ? `&resp_min=${encodeURIComponent(opts.respMin)}` : "") +
|
||||||
|
(opts.respMax ? `&resp_max=${encodeURIComponent(opts.respMax)}` : "") +
|
||||||
|
(opts.sort ? `&sort=${encodeURIComponent(opts.sort)}` : "") +
|
||||||
|
(opts.order ? `&order=${encodeURIComponent(opts.order)}` : ""),
|
||||||
),
|
),
|
||||||
trafficExchange: (id: string) => get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
trafficExchange: (id: string) => get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
||||||
trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),
|
trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),
|
||||||
|
|||||||
Reference in new issue
Block a user