Merge pull request #179 from Autumn-27/feat/traffic-search-filter-sort

feat(traffic): 流量列表支持内容检索、路径/状态码/长度筛选与列排序 (#177)
This commit is contained in:
Autumn authored and GitHub committed 2026-09-30 10:04:15 +08:00
commit ca5512b48f
6 files changed
+383 -24

No files matched your search

+13 -1
View File
@@ -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
View File
@@ -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 {
+43 -2
View File
@@ -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")))
+162 -8
View File
@@ -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>
+57
View File
@@ -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
View File
@@ -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`),