From 2f2b78cc78ac9ca81c47f67ad45841828571a8a5 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Mon, 12 Oct 2026 12:38:31 +0800 Subject: [PATCH] feat: search approval history by content and date --- db/intercept.go | 50 ++++++++++++++- db/intercept_filter_test.go | 26 ++++++++ db/schema.sql | 1 + server/intercept.go | 30 ++++++++- server/intercept_filter_test.go | 40 +++++++++++- web/src/components/approval-records.tsx | 84 ++++++++++++++++++++++++- web/src/lib/api.ts | 5 +- web/src/lib/mock/handler.ts | 27 ++++++-- web/src/lib/types.ts | 3 + 9 files changed, 255 insertions(+), 11 deletions(-) diff --git a/db/intercept.go b/db/intercept.go index 5b0770e..fb7142b 100644 --- a/db/intercept.go +++ b/db/intercept.go @@ -4,6 +4,7 @@ import ( "database/sql" "encoding/json" "fmt" + "strconv" "strings" "time" ) @@ -239,6 +240,7 @@ const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, i const approvalRowJoins = ` FROM intercept_pending ip LEFT JOIN conversations c ON c.id = ip.conversation_id +LEFT JOIN tasks t ON t.id::text = ip.task_id LEFT JOIN intercept_rules ir ON ir.id = ip.rule_id` const approvalRowSelect = `SELECT ` + approvalRowColumns + approvalRowJoins @@ -284,11 +286,14 @@ func (d *DB) ListAllIntercepts(limit int) ([]InterceptApprovalRow, error) { return out, rows.Err() } -// InterceptApprovalFilter combines exact status and decision-source filters. +// InterceptApprovalFilter combines status, decision-source and literal substring search. // Empty fields include all values. type InterceptApprovalFilter struct { Status string DecisionSource string + Search string + CreatedFrom string // inclusive UTC calendar date, YYYY-MM-DD + CreatedTo string // inclusive UTC calendar date, YYYY-MM-DD } // ListAllInterceptsPage returns one 1-based page and the total matching count. @@ -342,12 +347,53 @@ func (d *DB) listInterceptsPage(taskID string, page, size int, filter InterceptA add("ip.task_id", taskID) add("ip.status", filter.Status) add(approvalDecisionSource, filter.DecisionSource) + if filter.CreatedFrom != "" { + args = append(args, filter.CreatedFrom) + conditions = append(conditions, "ip.created_at >= $"+fmt.Sprint(len(args))+"::date") + } + if filter.CreatedTo != "" { + args = append(args, filter.CreatedTo) + conditions = append(conditions, "ip.created_at < ($"+fmt.Sprint(len(args))+"::date + INTERVAL '1 day')") + } + search := strings.TrimSpace(filter.Search) + if strings.HasPrefix(search, "#") { + id, err := strconv.ParseInt(strings.TrimPrefix(search, "#"), 10, 64) + if err != nil || id <= 0 { + return nil, 0, fmt.Errorf("审批编号必须为 # 后接正整数") + } + add("ip.id", strconv.FormatInt(id, 10)) + } else if search != "" { + // Treat SQL LIKE metacharacters literally, including URL percent escapes. + pattern := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(search) + args = append(args, "%"+pattern+"%") + like := "$" + fmt.Sprint(len(args)) + conditions = append(conditions, `( + ip.id::text ILIKE `+like+` + OR ip.conversation_id::text ILIKE `+like+` + OR COALESCE(ip.task_id, '') ILIKE `+like+` + OR COALESCE(ip.agent_name, '') ILIKE `+like+` + OR COALESCE(ip.tool_name, '') ILIKE `+like+` + OR COALESCE(ip.tool_input::text, '') ILIKE `+like+` + OR COALESCE(ip.reason, '') ILIKE `+like+` + OR COALESCE(ip.audit::text, '') ILIKE `+like+` + OR COALESCE(c.title, '') ILIKE `+like+` + OR COALESCE(c.agent_key, '') ILIKE `+like+` + OR COALESCE(t.name, '') ILIKE `+like+` + OR COALESCE(t.description, '') ILIKE `+like+` + OR COALESCE(t.goal, '') ILIKE `+like+` + OR COALESCE(ir.name, '') ILIKE `+like+` + )`) + } where := "" if len(conditions) > 0 { where = " WHERE " + strings.Join(conditions, " AND ") } var total int - if err := d.QueryRow("SELECT COUNT(*) FROM intercept_pending ip"+where, args...).Scan(&total); err != nil { + countFrom := "FROM intercept_pending ip" + if search != "" && !strings.HasPrefix(search, "#") { + countFrom = approvalRowJoins + } + if err := d.QueryRow("SELECT COUNT(*) "+countFrom+where, args...).Scan(&total); err != nil { return nil, 0, err } diff --git a/db/intercept_filter_test.go b/db/intercept_filter_test.go index b34126d..4b23437 100644 --- a/db/intercept_filter_test.go +++ b/db/intercept_filter_test.go @@ -1,7 +1,9 @@ package db import ( + "encoding/json" "slices" + "strconv" "testing" ) @@ -138,4 +140,28 @@ func TestInterceptApprovalFilters(t *testing.T) { t.Fatal("deleted rule lost its source") } } + + modelInput := json.RawMessage(`{"script_files":[{"path":"scripts/check.py","content":"https://example.test/a?x=100%"}]}`) + searchID, err := d.CreateDecidedIntercept(0, 0, scope, "search-agent", "Bash", []byte(`{"command":"python3 scripts/check.py"}`), "denied", "[模型] searched", &InterceptAudit{ModelInput: modelInput, Output: "response contains example.test"}) + if err != nil { + t.Fatal(err) + } + rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{Search: "example.test"}) + if err != nil || total != 1 || len(rows) != 1 || rows[0].ID != searchID { + t.Fatalf("snapshot search total=%d rows=%d err=%v", total, len(rows), err) + } + rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{Search: "100%"}) + if err != nil || total != 1 || len(rows) != 1 { + t.Fatalf("literal percent search total=%d rows=%d err=%v", total, len(rows), err) + } + rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{Search: "#" + strconv.FormatInt(searchID, 10)}) + if err != nil || total != 1 || len(rows) != 1 || rows[0].ID != searchID { + t.Fatalf("id search total=%d rows=%d err=%v", total, len(rows), err) + } + rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{ + Search: "example.test", CreatedFrom: "2099-01-01", CreatedTo: "2099-01-01", + }) + if err != nil || total != 1 || len(rows) != 1 || rows[0].ID != searchID { + t.Fatalf("date search total=%d rows=%d err=%v", total, len(rows), err) + } } diff --git a/db/schema.sql b/db/schema.sql index 4ddc910..4a416e5 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1112,6 +1112,7 @@ CREATE TABLE IF NOT EXISTS intercept_pending ( ); CREATE INDEX IF NOT EXISTS idx_intercept_pending_status ON intercept_pending(status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_intercept_pending_task ON intercept_pending(task_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_intercept_pending_created ON intercept_pending(created_at DESC); -- 补旧库:reason 列(已发版,加列要带 IF NOT EXISTS)。 ALTER TABLE intercept_pending ADD COLUMN IF NOT EXISTS reason TEXT NOT NULL DEFAULT ''; -- Detail payloads are lazy-loaded; NULL preserves the meaning of legacy history. diff --git a/server/intercept.go b/server/intercept.go index fc51497..be1ce8d 100644 --- a/server/intercept.go +++ b/server/intercept.go @@ -10,6 +10,8 @@ import ( "regexp" "strconv" "strings" + "time" + "unicode/utf8" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/guard" @@ -321,7 +323,33 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) { } func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) { - filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source")} + filter := db.InterceptApprovalFilter{ + Status: q.Get("status"), + DecisionSource: q.Get("decision_source"), + Search: strings.TrimSpace(q.Get("q")), + CreatedFrom: q.Get("from"), + CreatedTo: q.Get("to"), + } + if utf8.RuneCountInString(filter.Search) > 200 { + return filter, fmt.Errorf("q 不能超过 200 个字符") + } + if strings.HasPrefix(filter.Search, "#") { + id, err := strconv.ParseInt(strings.TrimPrefix(filter.Search, "#"), 10, 64) + if err != nil || id <= 0 { + return filter, fmt.Errorf("审批编号必须为 # 后接正整数") + } + } + for name, value := range map[string]string{"from": filter.CreatedFrom, "to": filter.CreatedTo} { + if value == "" { + continue + } + if _, err := time.Parse("2006-01-02", value); err != nil { + return filter, fmt.Errorf("%s 必须是 YYYY-MM-DD 日期", name) + } + } + if filter.CreatedFrom != "" && filter.CreatedTo != "" && filter.CreatedFrom > filter.CreatedTo { + return filter, fmt.Errorf("from 不能晚于 to") + } switch filter.Status { case "", "pending", "allowed", "denied", "timeout": default: diff --git a/server/intercept_filter_test.go b/server/intercept_filter_test.go index 6f106c3..c9904db 100644 --- a/server/intercept_filter_test.go +++ b/server/intercept_filter_test.go @@ -4,12 +4,43 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/intercept" ) +func TestInterceptSearchFilterParams(t *testing.T) { + filter, err := interceptFilterParams(url.Values{"q": []string{" https://example.test/a?x=1 "}}) + if err != nil || filter.Search != "https://example.test/a?x=1" { + t.Fatalf("search=%q err=%v", filter.Search, err) + } + if _, err := interceptFilterParams(url.Values{"q": []string{"#0"}}); err == nil { + t.Fatal("zero approval id accepted") + } + if _, err := interceptFilterParams(url.Values{"q": []string{"#abc"}}); err == nil { + t.Fatal("non-numeric approval id accepted") + } + if _, err := interceptFilterParams(url.Values{"q": []string{strings.Repeat("x", 201)}}); err == nil { + t.Fatal("overlong search accepted") + } + filter, err = interceptFilterParams(url.Values{"from": []string{"2026-10-01"}, "to": []string{"2026-10-12"}}) + if err != nil || filter.CreatedFrom != "2026-10-01" || filter.CreatedTo != "2026-10-12" { + t.Fatalf("date filter=%+v err=%v", filter, err) + } + for _, values := range []url.Values{ + {"from": []string{"2026-10-1"}}, + {"to": []string{"not-a-date"}}, + {"from": []string{"2026-10-13"}, "to": []string{"2026-10-12"}}, + } { + if _, err := interceptFilterParams(values); err == nil { + t.Fatalf("invalid date filter accepted: %v", values) + } + } +} + func TestInterceptFilterHTTP(t *testing.T) { dsn, _, err := db.DSN() if err != nil { @@ -54,7 +85,14 @@ func TestInterceptFilterHTTP(t *testing.T) { if rec := do(base+"?status=denied&decision_source=model", false); rec.Code != 401 { t.Fatalf("unprotected list: %d", rec.Code) } - for _, query := range []string{"?status=invalid", "?decision_source=invalid", "?page=1&status=denied%27+OR+1%3D1--", "?size=20&decision_source=MODEL"} { + for _, query := range []string{ + "?status=invalid", + "?decision_source=invalid", + "?page=1&status=denied%27+OR+1%3D1--", + "?size=20&decision_source=MODEL", + "?q=%23abc", + "?from=2026-10-13&to=2026-10-12", + } { if rec := do(base+query, true); rec.Code != 400 { t.Fatalf("invalid filter %s: %d %s", query, rec.Code, rec.Body.String()) } diff --git a/web/src/components/approval-records.tsx b/web/src/components/approval-records.tsx index 7b7df7e..d08711f 100644 --- a/web/src/components/approval-records.tsx +++ b/web/src/components/approval-records.tsx @@ -11,6 +11,7 @@ import { ClipboardListIcon, CopyIcon, RefreshCwIcon, + SearchIcon, ShieldAlertIcon, XIcon, } from "lucide-react"; @@ -24,6 +25,7 @@ import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -764,6 +766,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { const [pageSize, setPageSize] = React.useState(20); const [total, setTotal] = React.useState(0); const [filter, setFilter] = React.useState({}); + const [searchText, setSearchText] = React.useState(""); + const [fromDate, setFromDate] = React.useState(""); + const [toDate, setToDate] = React.useState(""); const [loading, setLoading] = React.useState(true); const [refreshing, setRefreshing] = React.useState(false); const [error, setError] = React.useState(""); @@ -773,7 +778,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { const request = React.useRef(0); const decisionLock = React.useRef(false); const filterID = React.useId(); - const filtered = Boolean(filter.status || filter.decision_source); + const filtered = Boolean(filter.status || filter.decision_source || filter.q || filter.from || filter.to); // A task can stay mounted while the user switches between task details. // Reset the cursor so the new scope always starts at its newest records. @@ -843,6 +848,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { // An in-flight response for the old filter must not repopulate the table. request.current++; setFilter(next); + setSearchText(next.q ?? ""); + setFromDate(next.from ?? ""); + setToDate(next.to ?? ""); setPage(1); setRows([]); setTotal(0); @@ -850,6 +858,30 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { setLoading(true); }; + const submitSearch = () => { + const q = searchText.trim(); + changeFilter({ ...filter, q: q || undefined }); + }; + + const changeDate = (key: "from" | "to", value: string) => { + changeFilter({ ...filter, [key]: value || undefined }); + }; + + React.useEffect(() => { + const q = searchText.trim(); + if (q === (filter.q ?? "")) return; + const timer = setTimeout(() => { + request.current++; + setFilter((previous) => ({ ...previous, q: q || undefined })); + setPage(1); + setRows([]); + setTotal(0); + setError(""); + setLoading(true); + }, 300); + return () => clearTimeout(timer); + }, [searchText, filter.q]); + const decide: Decide = async (id, decision) => { if (decisionLock.current) return; decisionLock.current = true; @@ -901,8 +933,53 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) { 刷新 -

展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。

+

+ 展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。输入 #编号 可精确定位审批。 +

+ + 搜索审批内容 +
+
+ + setSearchText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitSearch(); + } + }} + /> +
+ +
+
+ + 申请时间从 + changeDate("from", event.target.value)} + /> + + + 申请时间至 + changeDate("to", event.target.value)} + /> + 审批状态