feat: search approval history by content and date

This commit is contained in:
ji ruo committed 2026-10-12 12:38:31 +08:00
1 parent e33eedeb17
commit 2f2b78cc78
9 files changed
+255 -11

No files matched your search

+48 -2
View File
@@ -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
}
+26
View File
@@ -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)
}
}
+1
View File
@@ -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.
+29 -1
View File
@@ -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:
+39 -1
View File
@@ -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())
}
+82 -2
View File
@@ -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<InterceptApprovalFilter>({});
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 }) {
刷新
</Button>
</div>
<p className="text-muted-foreground text-sm">展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。</p>
<p className="text-muted-foreground text-sm">
展开记录查看工具请求和审批裁决,以及当时的上下文与执行结果。输入 #编号 可精确定位审批。
</p>
<FieldGroup className="flex-row flex-wrap items-end gap-3" aria-label="审批记录筛选">
<Field className="w-full sm:min-w-80 sm:flex-1">
<FieldLabel htmlFor={`${filterID}-search`}>搜索审批内容</FieldLabel>
<div className="flex gap-2">
<div className="relative min-w-0 flex-1">
<SearchIcon className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${filterID}-search`}
className="pl-8"
value={searchText}
maxLength={200}
placeholder="编号、标题、URL、命令或脚本内容"
onChange={(event) => setSearchText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
submitSearch();
}
}}
/>
</div>
<Button type="button" variant="outline" onClick={submitSearch}>
搜索
</Button>
</div>
</Field>
<Field className="w-full sm:w-40">
<FieldLabel htmlFor={`${filterID}-from`}>申请时间从</FieldLabel>
<Input
id={`${filterID}-from`}
type="date"
value={fromDate}
onChange={(event) => changeDate("from", event.target.value)}
/>
</Field>
<Field className="w-full sm:w-40">
<FieldLabel htmlFor={`${filterID}-to`}>申请时间至</FieldLabel>
<Input
id={`${filterID}-to`}
type="date"
value={toDate}
onChange={(event) => changeDate("to", event.target.value)}
/>
</Field>
<Field className="w-full sm:w-40">
<FieldLabel htmlFor={`${filterID}-status`}>审批状态</FieldLabel>
<Select
@@ -980,6 +1057,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
<section className="rounded-xl border">
<div className="border-b px-4 py-3 font-medium text-sm">
{filtered ? "筛选结果" : "全部记录"}({total})
{filter.q ? (
<span className="ml-2 break-all font-normal text-muted-foreground text-xs">搜索:{filter.q}</span>
) : null}
</div>
{loading ? (
<div className="flex flex-col gap-3 p-4">
+4 -1
View File
@@ -84,7 +84,6 @@ import type {
SSTask,
Stats,
Task,
TaskSchedule,
TaskArchive,
TaskArchivePage,
TaskAssetMutation,
@@ -94,6 +93,7 @@ import type {
TaskGoal,
TaskLLMResolutions,
TaskNode,
TaskSchedule,
TaskScopeRow,
TaskTemplate,
TokenTotal,
@@ -243,6 +243,9 @@ function interceptPageQuery(page: number, size: number, filter: InterceptApprova
const query = new URLSearchParams({ page: String(page), size: String(size) });
if (filter.status) query.set("status", filter.status);
if (filter.decision_source) query.set("decision_source", filter.decision_source);
if (filter.q?.trim()) query.set("q", filter.q.trim());
if (filter.from) query.set("from", filter.from);
if (filter.to) query.set("to", filter.to);
return query.toString();
}
+23 -4
View File
@@ -2461,18 +2461,37 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/intercept/history" || (seg[0] === "intercept" && seg[1] === "task")) {
const status = q.get("status") || "";
const decisionSource = q.get("decision_source") || "";
const search = (q.get("q") || "").trim();
const from = q.get("from") || "";
const to = q.get("to") || "";
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态");
if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("无效判定来源");
if ([...search].length > 200) throw new Error("搜索关键词不能超过 200 个字符");
if (search.startsWith("#") && !/^#[1-9]\d*$/.test(search)) throw new Error("审批编号必须为 # 后接正整数");
for (const value of [from, to]) {
if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) throw new Error("日期必须是 YYYY-MM-DD");
}
if (from && to && from > to) throw new Error("开始日期不能晚于结束日期");
const filtered = mockInterceptHistory.filter((row) => {
const source =
row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown");
let source = row.decision_source;
if (!source) {
if (row.rule_id) source = "rule";
else if (row.reason?.startsWith("[模型]")) source = "model";
else source = "unknown";
}
const haystack = JSON.stringify({ ...row, audit: mockInterceptDetails[row.id] ?? null }).toLowerCase();
const day = row.created_at.slice(0, 10);
return (
(seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
(!status || row.status === status) &&
(!decisionSource || source === decisionSource)
(!decisionSource || source === decisionSource) &&
(!from || day >= from) &&
(!to || day <= to) &&
(!search ||
(search.startsWith("#") ? row.id === Number(search.slice(1)) : haystack.includes(search.toLowerCase())))
);
});
if (!q.has("page") && !q.has("size") && !status && !decisionSource)
if (!q.has("page") && !q.has("size") && !status && !decisionSource && !search && !from && !to)
return { items: filtered, total: filtered.length };
const page = Math.max(1, Number(q.get("page")) || 1);
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
+3
View File
@@ -1386,6 +1386,9 @@ export interface JudgeUsage {
export interface InterceptApprovalFilter {
status?: InterceptPending["status"];
decision_source?: "rule" | "model" | "unknown";
q?: string;
from?: string;
to?: string;
}
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.