diff --git a/server/server.go b/server/server.go index 66a26ad..e14414c 100644 --- a/server/server.go +++ b/server/server.go @@ -25,6 +25,7 @@ import ( "github.com/Autumn-27/artex/llmpool" "github.com/Autumn-27/artex/llmrec" "github.com/Autumn-27/artex/report" + "github.com/Autumn-27/artex/traffic" "github.com/Autumn-27/norma/llm" actool "github.com/Autumn-27/norma/tool" "github.com/Autumn-27/norma/transcript" @@ -3085,7 +3086,18 @@ 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(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 writeJSON(w, 200, map[string]any{ "enabled": s.m.TrafficEnabled(), // reflect the capture toggle diff --git a/traffic/traffic.go b/traffic/traffic.go index e1d00f7..d8cc2b4 100644 --- a/traffic/traffic.go +++ b/traffic/traffic.go @@ -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_tmpl ON exchanges(host, url_template); 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 ( id TEXT PRIMARY KEY, @@ -694,13 +696,53 @@ func ftsQuote(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } -// Page returns one page of exchange metadata filtered by an optional host -// substring, exact method, and a free-text query q that matches across the -// indexed metadata columns (host/url/method/content-type/status) and, when the -// term is long enough for the trigram index, across captured request/response -// text as well. Also returns the total number of rows matching that filter (for -// the UI's pagination). Newest first. Bodies are never included in the rows. -func (t *Traffic) Page(host, method, q string, page, size int) (rows []ExchangeMeta, total int, err error) { +// PageQuery bundles the optional filters and sort order for Page. Every filter +// is combined with AND; an empty string skips that filter, and RespMin/RespMax +// 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) +} + +// 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 500 { size = 100 } @@ -718,13 +760,25 @@ func (t *Traffic) Page(host, method, q string, page, size int) (rows []ExchangeM where += cond args = append(args, vs...) } - if h := strings.TrimSpace(host); h != "" { + if h := strings.TrimSpace(f.Host); h != "" { add("host LIKE ?", "%"+h+"%") } - if m := strings.TrimSpace(method); m != "" { + if m := strings.TrimSpace(f.Method); 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 + "%" 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 @@ -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) } } + // 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 { 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` + - where + ` ORDER BY ts DESC LIMIT ? OFFSET ?` + where + ` ORDER BY ` + col + ` ` + dir + `, id DESC LIMIT ? OFFSET ?` qargs := append(append([]any{}, args...), size, page*size) rs, err := t.db.Query(sel, qargs...) if err != nil { diff --git a/traffic/traffic_store_test.go b/traffic/traffic_store_test.go index c13974c..647a6e2 100644 --- a/traffic/traffic_store_test.go +++ b/traffic/traffic_store_test.go @@ -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", "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 { t.Fatal(err) } @@ -368,11 +368,52 @@ func TestPageSearchesBodies(t *testing.T) { t.Fatalf("正文关键词命中 total=%d rows=%d,应为 1/1", total, len(rows)) } // 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) } } +// 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) { tr, _ := openTraffic(t) tr.record(newFlow("api.example.com:8082", "GET", "/admin", nil, []byte("8082"))) diff --git a/web/src/app/(main)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx index c9efafd..42e389a 100644 --- a/web/src/app/(main)/function/traffic/page.tsx +++ b/web/src/app/(main)/function/traffic/page.tsx @@ -7,13 +7,13 @@ import { ArrowUpNarrowWideIcon, ChevronLeftIcon, ChevronRightIcon, + 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"; @@ -35,10 +35,12 @@ 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 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"; @@ -95,6 +97,16 @@ 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 default function TrafficPage() { const [selectedFlows, setSelectedFlows] = React.useState>(() => new Set()); const [linking, setLinking] = React.useState(false); @@ -106,6 +118,20 @@ export default function TrafficPage() { const [queryQ, setQueryQ] = React.useState(""); // debounced → server const [method, setMethod] = React.useState("all"); + // Advanced filters (issue #177): response-body content, path, status class and + // response-size range. Text inputs are debounced like host/query; the status + // select applies immediately. + const [body, setBody] = React.useState(""); + const [bodyQ, setBodyQ] = React.useState(""); + const [path, setPath] = React.useState(""); + const [pathQ, setPathQ] = React.useState(""); + const [statusFilter, setStatusFilter] = React.useState("all"); + const [respMin, setRespMin] = React.useState(""); + const [respMinQ, setRespMinQ] = React.useState(""); + const [respMax, setRespMax] = React.useState(""); + const [respMaxQ, setRespMaxQ] = React.useState(""); + const [sort, setSort] = useStoredSortPreference(SORT_STORAGE_KEY, SORT_FIELDS, "ts", "desc"); + const [traffic, setTraffic] = React.useState(null); const [selected, setSelected] = React.useState(null); const [detail, setDetail] = React.useState(null); @@ -129,12 +155,41 @@ export default function TrafficPage() { 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]); - // 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. React.useEffect(() => { 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 // through history isn't yanked out from under the user. @@ -143,7 +198,15 @@ export default function TrafficPage() { let alive = true; const load = () => { 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) => { if (alive) setTraffic(r); }) @@ -168,7 +231,7 @@ export default function TrafficPage() { alive = false; 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 // hosts (exact batch), then refetch. @@ -227,6 +290,15 @@ export default function TrafficPage() { }; }, [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 total = traffic?.total ?? exchanges.length; const pageCount = Math.max(1, Math.ceil(total / size)); @@ -425,6 +497,66 @@ export default function TrafficPage() { + {/* Advanced filters (issue #177): narrow 660k+ exchanges down to the one packet. */} +
+ 高级筛选 +
+ setBody(e.target.value)} + className="h-8" + /> +
+
+ setPath(e.target.value)} + className="h-8" + /> +
+ +
+ 响应长度 + setRespMin(e.target.value)} + className="h-8 w-24" + /> + – + setRespMax(e.target.value)} + className="h-8 w-24" + /> +
+ {hasAdvancedFilter ? ( + + ) : null} +
+
已选 {selectedFlows.size} 条流量 + + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9fd5109..21431a2 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -758,12 +758,34 @@ export const api = { // ---- traffic / audit / report / chat ---- audit: (task?: string) => get(`/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( `/traffic?page=${page}&size=${size}` + (host ? `&host=${encodeURIComponent(host)}` : "") + (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(`/traffic/exchange?id=${encodeURIComponent(id)}`), trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),