mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +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/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
|
||||
|
||||
+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_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<?", []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 {
|
||||
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 {
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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<Set<string>>(() => 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<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);
|
||||
@@ -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() {
|
||||
</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)}>
|
||||
@@ -459,13 +591,35 @@ export default function TrafficPage() {
|
||||
}
|
||||
/>
|
||||
</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-20">方法</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-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>
|
||||
</TableHeader>
|
||||
<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 ----
|
||||
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>(
|
||||
`/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<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
||||
trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),
|
||||
|
||||
Reference in new issue
Block a user