From 8ee94f22158044df9ddc55def71b96cc2c152764 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Mon, 9 Nov 2026 16:11:15 +0800 Subject: [PATCH] feat: expose traffic IDs and filter finding statistics --- agent/finding_workflow.go | 2 +- agent/finding_workflow_test.go | 3 + db/findings.go | 36 ++++++++ db/findings_filtered_stats_test.go | 74 +++++++++++++++++ server/findings_filtered_stats_test.go | 37 +++++++++ server/server.go | 10 +-- traffic/traffic.go | 10 +-- traffic/traffic_id_test.go | 48 +++++++++++ web/src/app/(main)/function/findings/page.tsx | 83 +++++++++---------- web/src/components/traffic-history.tsx | 54 +++++++++++- web/src/lib/api.ts | 3 +- web/src/lib/mock/finding-time-sort.test.cjs | 27 ++++++ web/src/lib/mock/handler.ts | 14 ++-- web/src/lib/types.ts | 2 +- 14 files changed, 333 insertions(+), 70 deletions(-) create mode 100644 db/findings_filtered_stats_test.go create mode 100644 server/findings_filtered_stats_test.go create mode 100644 traffic/traffic_id_test.go diff --git a/agent/finding_workflow.go b/agent/finding_workflow.go index 7b86c7d..424361b 100644 --- a/agent/finding_workflow.go +++ b/agent/finding_workflow.go @@ -77,7 +77,7 @@ func findingWorkflowTools(agentKey string, tools []actool.CoreTool) ([]actool.Co guidance += "\n\n**报告前自动关联流量(已开启)**:你负责为本次触发的漏洞核对并绑定流量,再撰写报告。先从 report_finding 返回 JSON 或 get_task_node_detail / list_task_findings 取得明确的 finding_id 与 finding_node_id。读取漏洞详情、对应意图的执行记录及已有证据清单,优先使用上报者交接的真实 ID。若本次验证为 HTTP 且流量工具可用,用 traffic_search 筛选候选,再用 traffic_get 逐条核实请求/响应确实支持该漏洞;域名和时间只用于筛选,不证明归属。将确认的证据按复现顺序用 bind_finding_traffic(finding_id, traffic_refs) 关联,选择 baseline / proof / verification / supporting 并说明用途。只能操作本次漏洞,不重复创建漏洞或重新探测目标。绑定成功后重新调用 get_finding_traffic 获取最新 version,读取所需正文,再将实际读取的 version 作为 evidence_version 传给 update_finding_report(其 finding_id 参数仍用 finding_node_id)。已有绑定不必重复追加。TCP、未采集、工具不可用或没有确切匹配时,跳过自动绑定,依据文字/命令证据正常写报告并说明原因,不得为凑齐流量而猜测。绑定失败不宣称成功;保留已有证据并在报告说明未绑定原因。" } if guidance != "" || has["get_finding_traffic"] || has["update_finding_report"] { - guidance += findingIDGuidance + guidance += "\n\n**流量 ID 约定**:流量页面展示/复制的流量 ID 就是 traffic_search 返回的 id,也是 traffic_get(id) 和 traffic_refs[].traffic_id 使用的原始字符串。它不是列表行号,不随分页、排序或筛选变化。用户交接完整 ID 时优先直接用 traffic_get 读取并核实,不要改写、截短或猜测 ID;报告引用流量证据时保留对应完整 ID,方便从流量页面检索。" + findingIDGuidance } return out, guidance } diff --git a/agent/finding_workflow_test.go b/agent/finding_workflow_test.go index fd2df65..042e02e 100644 --- a/agent/finding_workflow_test.go +++ b/agent/finding_workflow_test.go @@ -38,6 +38,9 @@ func TestFindingWorkflowSharedAssemblyAndSwitch(t *testing.T) { if on && (!strings.Contains(system[0], "TCP") || !strings.Contains(system[0], "evidence_hint_id")) { t.Fatal("missing optional/handoff contract") } + if on && (!strings.Contains(system[0], "不是列表行号") || !strings.Contains(system[0], "完整 ID")) { + t.Fatal("missing stable traffic ID handoff contract") + } for _, tool := range out { if !strings.HasPrefix(tool.Description(), "CUSTOM DESCRIPTION") { t.Fatal("custom description replaced") diff --git a/db/findings.go b/db/findings.go index efc1931..6804357 100644 --- a/db/findings.go +++ b/db/findings.go @@ -656,6 +656,42 @@ func (d *DB) FindingStats() (*FindingStats, error) { return st, nil } +// FilteredFindingStats uses the same row and asset filters as the findings list. +// Options remain global so choosing a filter never removes other choices. +// Counts cover matching hot rows, exactly like ListFindingCases; archive-only +// aggregates cannot be filtered by record content and are not list results. +func (d *DB) FilteredFindingStats(filter FindingFilter) (*FindingStats, error) { + st, err := d.FindingStats() + if err != nil { + return nil, err + } + filter, err = d.applyAssetScope(filter) + if err != nil { + return nil, err + } + where, args := filter.where() + st.Distinct = new(FindingDistinctStats) + err = d.QueryRow(`SELECT COUNT(*), + COUNT(*) FILTER (WHERE f.status='pending'), + COUNT(*) FILTER (WHERE f.severity='critical'), + COUNT(*) FILTER (WHERE f.severity='high'), + COUNT(*) FILTER (WHERE f.severity='medium'), + COUNT(*) FILTER (WHERE f.severity='low'), + COUNT(DISTINCT CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END) + FROM findings f LEFT JOIN tasks t ON t.id=f.task_id + LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where, args...).Scan( + &st.Total, &st.Pending, &st.Critical, &st.High, &st.Medium, &st.Low, &st.Distinct.Total) + if err != nil { + return nil, err + } + // Severity buckets describe matching original reports, not a folder's rating. + st.Distinct.Reports = st.Total + st.Distinct.Critical, st.Distinct.High = st.Critical, st.High + st.Distinct.Medium, st.Distinct.Low = st.Medium, st.Low + st.Distinct.Unassessed = st.Total - st.Critical - st.High - st.Medium - st.Low + return st, nil +} + // GetFinding returns a single finding row (with task_description joined and the // full Markdown report), or nil when no row has that id. Unlike the list queries // it also selects `report` — that column is only needed on the detail page. diff --git a/db/findings_filtered_stats_test.go b/db/findings_filtered_stats_test.go new file mode 100644 index 0000000..0a7fccf --- /dev/null +++ b/db/findings_filtered_stats_test.go @@ -0,0 +1,74 @@ +package db + +import ( + "context" + "fmt" + "slices" + "testing" +) + +func TestFilteredFindingStatsMatchesList(t *testing.T) { + d, task, ids := timeSortFixture(t) + if _, err := d.MergeFindingCase(context.Background(), task.ID, ids[:3], "same defect", "verified shared root cause", "test"); err != nil { + t.Fatal(err) + } + if _, err := d.SetFindingStatus(ids[0], FindingResolved); err != nil { + t.Fatal(err) + } + if _, err := d.Exec(`UPDATE findings SET name='needle' WHERE id=$1`, ids[1]); err != nil { + t.Fatal(err) + } + asset := seedTreeAsset(t, d, "root_domain", map[string]any{"domain": "issue125.example", "root_domain": "issue125.example"}) + t.Cleanup(func() { d.Exec(`DELETE FROM assets WHERE id=$1`, asset) }) + if _, err := d.Exec(`UPDATE findings SET asset_ids=$2::jsonb WHERE id=$1`, ids[1], fmt.Sprintf("[%d]", asset)); err != nil { + t.Fatal(err) + } + base := FindingFilter{TaskID: fmt.Sprint(task.ID)} + for _, tc := range []struct { + name string + filter FindingFilter + reports, defects, critical, high, low int + }{ + {"task", base, 4, 2, 1, 2, 1}, + {"severity", FindingFilter{TaskID: base.TaskID, Severity: "high"}, 2, 2, 0, 2, 0}, + {"status", FindingFilter{TaskID: base.TaskID, Status: FindingResolved}, 1, 1, 1, 0, 0}, + {"query", FindingFilter{TaskID: base.TaskID, Query: "needle"}, 1, 1, 0, 1, 0}, + {"class", FindingFilter{TaskID: base.TaskID, VulnClass: "IDOR"}, 4, 2, 1, 2, 1}, + {"dates", FindingFilter{TaskID: base.TaskID, From: "2026-10-26T00:00:00Z", To: "2026-10-27T23:59:59Z"}, 2, 1, 0, 1, 1}, + {"combined", FindingFilter{TaskID: base.TaskID, Severity: "high", Status: FindingPending, Query: "needle", VulnClass: "IDOR", From: "2026-10-26T00:00:00Z"}, 1, 1, 0, 1, 0}, + {"asset", FindingFilter{TaskID: base.TaskID, AssetScope: fmt.Sprintf("a:%d", asset)}, 1, 1, 0, 1, 0}, + {"unassigned asset", FindingFilter{TaskID: base.TaskID, AssetScope: FindingUnassignedAsset}, 3, 2, 1, 1, 1}, + {"empty", FindingFilter{TaskID: base.TaskID, Query: "no matching finding"}, 0, 0, 0, 0, 0}, + } { + t.Run(tc.name, func(t *testing.T) { + st, err := d.FilteredFindingStats(tc.filter) + if err != nil { + t.Fatal(err) + } + if st.Total != tc.reports || st.Distinct.Total != tc.defects || st.Critical != tc.critical || st.High != tc.high || st.Low != tc.low { + t.Fatalf("stats=%+v distinct=%+v", st, st.Distinct) + } + _, total, err := d.ListFindingCases(tc.filter, 1, 1) + if err != nil || total != st.Distinct.Total { + t.Fatalf("list total=%d stats=%d err=%v", total, st.Distinct.Total, err) + } + _, reports, err := d.ListFindingsPage(tc.filter, 1, 1) + if err != nil || reports != st.Total { + t.Fatalf("reports=%d stats=%d err=%v", reports, st.Total, err) + } + if !slices.Contains(st.VulnClasses, "IDOR") || !slices.ContainsFunc(st.Tasks, func(option FindingTaskOption) bool { return option.ID == task.ID && option.Count == 4 }) { + t.Fatal("filtered counts removed global options") + } + }) + } + // Deleted-task records remain selectable through the unassigned sentinel. + orphan, err := d.AddFinding(0, 0, "__issue125_orphan__", "orphan", "medium", "", "", "test", nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { d.DeleteFinding(orphan) }) + st, err := d.FilteredFindingStats(FindingFilter{TaskID: FindingUnassignedTask, VulnClass: "__issue125_orphan__"}) + if err != nil || st.Total != 1 || st.Medium != 1 || st.Distinct.Total != 1 { + t.Fatalf("orphan stats=%+v err=%v", st, err) + } +} diff --git a/server/findings_filtered_stats_test.go b/server/findings_filtered_stats_test.go new file mode 100644 index 0000000..0d57177 --- /dev/null +++ b/server/findings_filtered_stats_test.go @@ -0,0 +1,37 @@ +package server + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/Autumn-27/artex/db" +) + +func TestFindingStatsFiltersHTTP(t *testing.T) { + s, recorded, request := trafficEvidenceServer(t) + finding, err := s.m.pg.GetFinding(recorded.FindingID) + if err != nil || finding == nil { + t.Fatal("missing fixture finding", err) + } + filter := fmt.Sprintf("task_id=%d&severity=low&status=pending&vulnclass=TEST&q=bound&from=2000-01-01T00:00:00Z&to=2100-01-01T00:00:00Z", *finding.TaskID) + for _, suffix := range []string{"", "&page=2&limit=1"} { + r := request("GET", "/api/exploration/findings/stats?"+filter+suffix, "") + var st db.FindingStats + if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &st) != nil || st.Total != 1 || st.Low != 1 || st.High != 0 || st.Distinct == nil || st.Distinct.Total != 1 { + t.Fatalf("status=%d stats=%s", r.Code, r.Body) + } + } + r := request("GET", "/api/exploration/findings/stats?"+filter+"&asset_scope=__none__", "") + if r.Code != 200 { + t.Fatal(r.Code, r.Body) + } + r = request("GET", fmt.Sprintf("/api/exploration/findings/stats?task_id=%d&severity=high", *finding.TaskID), "") + var st db.FindingStats + if r.Code != 200 || json.Unmarshal(r.Body.Bytes(), &st) != nil || st.Total != 0 || st.Distinct.Total != 0 { + t.Fatalf("empty filtered stats=%s", r.Body) + } + if len(st.Tasks) == 0 || len(st.VulnClasses) == 0 { + t.Fatal("empty result removed filter choices") + } +} diff --git a/server/server.go b/server/server.go index 99f669c..206f082 100644 --- a/server/server.go +++ b/server/server.go @@ -2337,15 +2337,9 @@ func (s *Server) resolveAssetIDs(ids []int64) map[int64]*db.Asset { return assets } -// findingStats serves the whole-table aggregates (stat cards + vuln-class filter) -// for the paginated 发现 page. +// findingStats serves filtered, pagination-independent counts and global options. func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) { - st, err := s.m.pg.FindingStats() - if err != nil { - writeErr(w, 500, err.Error()) - return - } - st.Distinct, err = s.m.pg.DistinctFindingStats("") + st, err := s.m.pg.FilteredFindingStats(findingFilterFromQuery(r.URL.Query())) if err != nil { writeErr(w, 500, err.Error()) return diff --git a/traffic/traffic.go b/traffic/traffic.go index 72a250a..b3968f8 100644 --- a/traffic/traffic.go +++ b/traffic/traffic.go @@ -962,13 +962,13 @@ func (t *Traffic) Page(f PageQuery, page, size int) (rows []ExchangeMeta, total } 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 ?" + const meta = "id LIKE ? OR 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 // too short for trigram silently fall back to metadata only. if cond, arg, ok := t.ftsFilter(s); ok { - add("(("+meta+") OR "+cond+")", like, like, like, like, like, like, arg) + add("(("+meta+") OR "+cond+")", like, like, like, like, like, like, like, arg) } else { - add("("+meta+")", like, like, like, like, like, like) + add("("+meta+")", like, like, like, like, like, like, like) } } // Body is the dedicated content box: it only searches captured request/response @@ -2148,10 +2148,10 @@ func (t *Traffic) Tools() []actool.CoreTool { get := actool.Build(actool.Spec{ Name: "traffic_get", - Description: "按 id 取一条已抓流量的请求/响应原文(过大会截断)。配合 traffic_search 用,避免重复 curl。", + Description: "按 id 取一条已抓流量的请求/响应原文(过大会截断)。id 与流量页面展示的流量 ID、traffic_search 返回的 id 相同,须完整保留,不是列表行号。已知 ID 时直接读取,无需重新搜索或请求目标。", Schema: map[string]any{ "type": "object", - "properties": map[string]any{"id": map[string]any{"type": "string", "description": "traffic_search 返回的 id"}}, + "properties": map[string]any{"id": map[string]any{"type": "string", "description": "流量页面或 traffic_search 返回的完整流量 ID(不是列表行号)"}}, "required": []any{"id"}, }, ReadOnly: ro, diff --git a/traffic/traffic_id_test.go b/traffic/traffic_id_test.go new file mode 100644 index 0000000..bd1f4ae --- /dev/null +++ b/traffic/traffic_id_test.go @@ -0,0 +1,48 @@ +package traffic + +import ( + "context" + "encoding/json" + "strings" + "testing" +) + +func TestTrafficIDSearchAndToolReference(t *testing.T) { + tr, _ := openTraffic(t) + tr.record(newFlow("id.example", "GET", "/proof", nil, []byte("verified proof"))) + tr.record(newFlow("other.example", "GET", "/other", nil, []byte("other response"))) + rows, _, err := tr.Page(PageQuery{Host: "id.example", RespMin: -1, RespMax: -1}, 0, 10) + if err != nil || len(rows) != 1 { + t.Fatalf("rows=%v err=%v", rows, err) + } + id := rows[0].ID + for _, fts := range []bool{true, false} { + if fts && !tr.fts { + continue + } + previous := tr.fts + tr.fts = fts + for _, query := range []string{id, id[len(id)-2:]} { + found, total, err := tr.Page(PageQuery{Host: "id.example", Query: query, RespMin: -1, RespMax: -1}, 0, 1) + if err != nil || total != 1 || len(found) != 1 || found[0].ID != id { + t.Fatalf("fts=%v query=%s rows=%v total=%d err=%v", fts, query, found, total, err) + } + } + tr.fts = previous + } + for _, tool := range tr.Tools() { + if tool.Name() != "traffic_get" { + continue + } + input, _ := json.Marshal(map[string]string{"id": id}) + result, err := tool.Call(context.Background(), input, nil) + if err != nil || result.IsError || !strings.Contains(result.Flatten(), "verified proof") { + t.Fatalf("traffic_get result=%s err=%v", result.Flatten(), err) + } + } + // An ID search still respects host and method filters. + _, total, err := tr.Page(PageQuery{Query: id, Host: "other.example", RespMin: -1, RespMax: -1}, 0, 10) + if err != nil || total != 0 { + t.Fatalf("cross-host ID search total=%d err=%v", total, err) + } +} diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 76e89d0..4dd3359 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -630,30 +630,38 @@ export default function FindingsPage() { if (flatPage > lastPage) setFlatPage(lastPage); }, [flat.loaded, flat.total, flatPage, flatPageSize]); - // Whole-table aggregates (stat cards + vuln-class options) — independent of the - // current page, so they stay exact. + const statsQuery = React.useMemo( + () => ({ severity, status, vulnclass, task, query, ...dateRange, assetScope: activeAssetScope ?? undefined }), + [severity, status, vulnclass, task, query, dateRange, activeAssetScope], + ); + const statsFingerprint = JSON.stringify(statsQuery); + const activeStatsFingerprint = React.useRef(statsFingerprint); + activeStatsFingerprint.current = statsFingerprint; + const statsRequest = React.useRef(0); + const refreshStats = React.useCallback(async () => { + if (activeStatsFingerprint.current !== statsFingerprint) return; + const request = ++statsRequest.current; + try { + const result = await api.findingStats(statsQuery); + if (request !== statsRequest.current || activeStatsFingerprint.current !== statsFingerprint) return; + setStats(result); + setStatsLoaded(true); + } catch { + // Retain the previous snapshot during transient refresh failures. + } + }, [statsQuery, statsFingerprint]); + + // Counts use the current filters and asset subtree, independently of pagination. React.useEffect(() => { - let alive = true; - const load = () => { - api - .findingStats() - .then((s) => { - if (alive) { - setStats(s); - setStatsLoaded(true); - } - }) - .catch(() => { - // Keep the previous aggregate snapshot until the next poll. - }); - }; - load(); - const t = setInterval(load, 5000); + if (!preferencesHydrated) return; + void activeRetestFingerprint; + void refreshStats(); + const t = setInterval(() => void refreshStats(), 5000); return () => { - alive = false; + statsRequest.current++; clearInterval(t); }; - }, []); + }, [preferencesHydrated, refreshStats, activeRetestFingerprint]); React.useEffect(() => { if (!statsLoaded) return; @@ -677,12 +685,7 @@ export default function FindingsPage() { await api.setFindingStatus(f.finding_id, next); toast.success(`已标记为「${statusMeta("finding", next).label}」`); // refresh stat cards (pending count) and drop the row if it no longer matches the status filter - api - .findingStats() - .then(setStats) - .catch(() => { - // The row update remains valid even if the aggregate refresh fails. - }); + void refreshStats(); if (status !== "all" && next !== status) { setFindings((cur) => cur.filter((x) => !isSameFinding(x, f))); setTotal((t) => Math.max(0, t - 1)); @@ -694,7 +697,7 @@ export default function FindingsPage() { toast.error(`更新失败:${(e as Error).message}`); } }, - [refreshAfterMutation, setFindings, status], + [refreshAfterMutation, refreshStats, setFindings, status], ); // 行内展开的详细报告缓存按全局稳定行键存。report 是大段 Markdown,列表查询不带它, @@ -747,12 +750,7 @@ export default function FindingsPage() { ), ); toast.success("已保存"); - api - .findingStats() - .then(setStats) - .catch(() => { - // The edit remains valid even if the aggregate refresh fails. - }); + void refreshStats(); refreshAfterMutation(f); } catch (e) { toast.error(`保存失败:${(e as Error).message}`); @@ -760,7 +758,7 @@ export default function FindingsPage() { setSaving(false); } }, - [edit, refreshAfterMutation, setFindings], + [edit, refreshAfterMutation, refreshStats, setFindings], ); // deleteFinding 删除一个漏洞(需二次确认):删成功后从列表移除、收起行、刷新统计。 @@ -780,18 +778,13 @@ export default function FindingsPage() { const rowKey = findingRowKey(f); setExpanded((cur) => (cur === rowKey ? null : cur)); toast.success("已删除漏洞"); - api - .findingStats() - .then(setStats) - .catch(() => { - // The deletion remains valid even if the aggregate refresh fails. - }); + void refreshStats(); refreshAfterMutation(f, true); } catch (e) { toast.error(`删除失败:${(e as Error).message}`); } }, - [refreshAfterMutation, setFindings], + [refreshAfterMutation, refreshStats, setFindings], ); const openDeepen = React.useCallback((f: Finding) => { @@ -822,10 +815,10 @@ export default function FindingsPage() { const statCards = [ { label: "独立漏洞", value: stats.distinct?.total ?? stats.total, icon: BugIcon }, { label: "上报记录", value: stats.total, tone: "text-amber-500", icon: ClockIcon }, - { label: "严重", value: stats.distinct?.critical ?? stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon }, - { label: "高危", value: stats.distinct?.high ?? stats.high, tone: "text-red-500", icon: TriangleAlertIcon }, - { label: "中危", value: stats.distinct?.medium ?? stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon }, - { label: "低危", value: stats.distinct?.low ?? stats.low, tone: "text-slate-500", icon: InfoIcon }, + { label: "严重", value: stats.critical, tone: "text-rose-600", icon: ShieldAlertIcon }, + { label: "高危", value: stats.high, tone: "text-red-500", icon: TriangleAlertIcon }, + { label: "中危", value: stats.medium, tone: "text-amber-500", icon: TriangleAlertIcon }, + { label: "低危", value: stats.low, tone: "text-slate-500", icon: InfoIcon }, ]; // 导出弹窗里「当前筛选」的条数:两个视图的筛选一致,只是统计口径来源不同。 diff --git a/web/src/components/traffic-history.tsx b/web/src/components/traffic-history.tsx index 47efb5b..a1752bd 100644 --- a/web/src/components/traffic-history.tsx +++ b/web/src/components/traffic-history.tsx @@ -9,6 +9,7 @@ import { ArrowUpNarrowWideIcon, ChevronLeftIcon, ChevronRightIcon, + CopyIcon, EraserIcon, FilterXIcon, ListChecksIcon, @@ -81,6 +82,30 @@ function MethodBadge({ method }: { method: string }) { return {method}; } +function TrafficID({ id }: { id: string }) { + return ( + + ); +} + // Older captures may predate Host persistence because net/http keeps Host // outside Request.Header. Fill it for display while newly recorded traffic is // fixed at the recorder layer as well. @@ -247,7 +272,22 @@ export function TrafficHistory({ taskId }: { taskId?: string }) { alive = false; clearInterval(t); }; - }, [page, size, hostQ, method, queryQ, bodyQ, pathQ, statusFilter, respMinQ, respMaxQ, sort, reloadTick, taskId, taskScoped]); + }, [ + page, + size, + hostQ, + method, + queryQ, + bodyQ, + pathQ, + statusFilter, + respMinQ, + respMaxQ, + sort, + reloadTick, + taskId, + taskScoped, + ]); // Delete traffic for the current host filter (substring) or the checked // hosts (exact batch), then refetch. @@ -537,7 +577,7 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
setQuery(e.target.value)} className="h-8 pl-8" @@ -699,6 +739,7 @@ export function TrafficHistory({ taskId }: { taskId?: string }) { } /> + 流量 ID + + + {fmtTime(e.ts)} {e.host} @@ -771,7 +815,7 @@ export function TrafficHistory({ taskId }: { taskId?: string }) { ))} {exchanges.length === 0 && ( - + {traffic === null ? "加载中…" : "没有匹配的流量。"} @@ -803,6 +847,10 @@ export function TrafficHistory({ taskId }: { taskId?: string }) {
{selected.host} {selected.url} +
+ 流量 ID: + +