feat: filter approval records by status and decision source
This commit is contained in:
1 parent
80a085d157
commit
a91c0a7223
8 files changed
+431
-47
No files matched your search
+32
-13
@@ -225,8 +225,13 @@ func scanInterceptApprovalRow(rows interface{ Scan(...any) error }, r *Intercept
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep legacy rows without decision_source consistent with their displayed source.
|
||||||
|
const approvalDecisionSource = `COALESCE(NULLIF(ip.decision_source,''), CASE
|
||||||
|
WHEN ip.rule_id IS NOT NULL THEN 'rule'
|
||||||
|
WHEN ip.reason LIKE '[模型]%' THEN 'model' ELSE 'unknown' END)`
|
||||||
|
|
||||||
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
const approvalRowColumns = `ip.id, ip.rule_id, ip.conversation_id, ip.task_id, ip.agent_name,
|
||||||
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ip.decision_source,
|
ip.tool_name, ip.tool_input, ip.status, ip.reason, ip.decided_at, ip.created_at, ` + approvalDecisionSource + `,
|
||||||
COALESCE(c.title,'') AS conv_title,
|
COALESCE(c.title,'') AS conv_title,
|
||||||
COALESCE(c.agent_key,'') AS conv_agent_key,
|
COALESCE(c.agent_key,'') AS conv_agent_key,
|
||||||
COALESCE(ir.name,'') AS rule_name`
|
COALESCE(ir.name,'') AS rule_name`
|
||||||
@@ -279,11 +284,16 @@ func (d *DB) ListAllIntercepts(limit int) ([]InterceptApprovalRow, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAllInterceptsPage returns one 1-based page of approval records and the
|
// InterceptApprovalFilter combines exact status and decision-source filters.
|
||||||
// total number of records. Records are ordered newest first, matching the
|
// Empty fields include all values.
|
||||||
// legacy capped list returned by ListAllIntercepts.
|
type InterceptApprovalFilter struct {
|
||||||
func (d *DB) ListAllInterceptsPage(page, size int) ([]InterceptApprovalRow, int, error) {
|
Status string
|
||||||
return d.listInterceptsPage("", page, size)
|
DecisionSource string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllInterceptsPage returns one 1-based page and the total matching count.
|
||||||
|
func (d *DB) ListAllInterceptsPage(page, size int, filter InterceptApprovalFilter) ([]InterceptApprovalRow, int, error) {
|
||||||
|
return d.listInterceptsPage("", page, size, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListTaskIntercepts returns all intercept_pending rows for a specific task (newest first).
|
// ListTaskIntercepts returns all intercept_pending rows for a specific task (newest first).
|
||||||
@@ -305,11 +315,11 @@ func (d *DB) ListTaskIntercepts(taskID string) ([]InterceptApprovalRow, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListTaskInterceptsPage is the paginated variant of ListTaskIntercepts.
|
// ListTaskInterceptsPage is the paginated variant of ListTaskIntercepts.
|
||||||
func (d *DB) ListTaskInterceptsPage(taskID string, page, size int) ([]InterceptApprovalRow, int, error) {
|
func (d *DB) ListTaskInterceptsPage(taskID string, page, size int, filter InterceptApprovalFilter) ([]InterceptApprovalRow, int, error) {
|
||||||
return d.listInterceptsPage(taskID, page, size)
|
return d.listInterceptsPage(taskID, page, size, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) listInterceptsPage(taskID string, page, size int) ([]InterceptApprovalRow, int, error) {
|
func (d *DB) listInterceptsPage(taskID string, page, size int, filter InterceptApprovalFilter) ([]InterceptApprovalRow, int, error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
page = 1
|
page = 1
|
||||||
}
|
}
|
||||||
@@ -321,11 +331,20 @@ func (d *DB) listInterceptsPage(taskID string, page, size int) ([]InterceptAppro
|
|||||||
}
|
}
|
||||||
offset := (page - 1) * size
|
offset := (page - 1) * size
|
||||||
|
|
||||||
where := ""
|
conditions := []string{}
|
||||||
args := []any{}
|
args := []any{}
|
||||||
if taskID != "" {
|
add := func(column, value string) {
|
||||||
where = " WHERE ip.task_id=$1"
|
if value != "" {
|
||||||
args = append(args, taskID)
|
args = append(args, value)
|
||||||
|
conditions = append(conditions, column+"=$"+fmt.Sprint(len(args)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add("ip.task_id", taskID)
|
||||||
|
add("ip.status", filter.Status)
|
||||||
|
add(approvalDecisionSource, filter.DecisionSource)
|
||||||
|
where := ""
|
||||||
|
if len(conditions) > 0 {
|
||||||
|
where = " WHERE " + strings.Join(conditions, " AND ")
|
||||||
}
|
}
|
||||||
var total int
|
var total int
|
||||||
if err := d.QueryRow("SELECT COUNT(*) FROM intercept_pending ip"+where, args...).Scan(&total); err != nil {
|
if err := d.QueryRow("SELECT COUNT(*) FROM intercept_pending ip"+where, args...).Scan(&total); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInterceptApprovalFilters(t *testing.T) {
|
||||||
|
d, err := Open(testDSN(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = d.Close() })
|
||||||
|
scope := "approval-filter-" + t.Name()
|
||||||
|
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id IN ($1,$2)`, scope, scope+"-other") })
|
||||||
|
rule, err := d.CreateInterceptRule("filter fixture", "tool_name", "string", "Bash", "deny", "fixture", 1, true, false, 0, "deny")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_rules WHERE id=$1`, rule.ID) })
|
||||||
|
filter := InterceptApprovalFilter{Status: "denied", DecisionSource: "model"}
|
||||||
|
_, baseline, err := d.ListAllInterceptsPage(1, 20, filter)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expected := []int64{}
|
||||||
|
for _, task := range []string{scope, scope + "-other"} {
|
||||||
|
copies := 3
|
||||||
|
if task != scope {
|
||||||
|
copies = 1
|
||||||
|
}
|
||||||
|
for _, status := range []string{"pending", "allowed", "denied", "timeout"} {
|
||||||
|
for _, source := range []string{"model", "rule", "unknown"} {
|
||||||
|
for i := 0; i < copies; i++ {
|
||||||
|
var ruleID int64
|
||||||
|
reason := "legacy reason"
|
||||||
|
if source == "rule" {
|
||||||
|
ruleID = rule.ID
|
||||||
|
}
|
||||||
|
if source == "model" {
|
||||||
|
reason = "[模型] fixture"
|
||||||
|
}
|
||||||
|
id, err := d.CreateDecidedIntercept(ruleID, 0, task, "test", "Bash", []byte(`{"command":"fixture"}`), status, reason)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Legacy source fields and tied timestamps must filter/page consistently.
|
||||||
|
if i == 0 {
|
||||||
|
if _, err = d.Exec(`UPDATE intercept_pending SET decision_source='' WHERE id=$1`, id); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if task == scope && status == "denied" && source == "model" {
|
||||||
|
expected = append(expected, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := d.Exec(`UPDATE intercept_pending SET created_at='2099-01-01' WHERE task_id IN ($1,$2)`, scope, scope+"-other"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
filter InterceptApprovalFilter
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"all", InterceptApprovalFilter{}, 36},
|
||||||
|
}
|
||||||
|
for _, s := range []string{"pending", "allowed", "denied", "timeout"} {
|
||||||
|
cases = append(cases, struct {
|
||||||
|
name string
|
||||||
|
filter InterceptApprovalFilter
|
||||||
|
want int
|
||||||
|
}{s, InterceptApprovalFilter{Status: s}, 9})
|
||||||
|
}
|
||||||
|
for _, s := range []string{"model", "rule", "unknown"} {
|
||||||
|
cases = append(cases, struct {
|
||||||
|
name string
|
||||||
|
filter InterceptApprovalFilter
|
||||||
|
want int
|
||||||
|
}{s, InterceptApprovalFilter{DecisionSource: s}, 12})
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
rows, total, err := d.ListTaskInterceptsPage(scope, 1, 100, tc.filter)
|
||||||
|
if err != nil || total != tc.want || len(rows) != tc.want {
|
||||||
|
t.Fatalf("count=%d rows=%d err=%v", total, len(rows), err)
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.TaskID == nil || *r.TaskID != scope || tc.filter.Status != "" && r.Status != tc.filter.Status || tc.filter.DecisionSource != "" && r.DecisionSource != tc.filter.DecisionSource {
|
||||||
|
t.Fatalf("nonmatching row: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
slices.Reverse(expected)
|
||||||
|
var seen []int64
|
||||||
|
for page := 1; page <= 2; page++ {
|
||||||
|
rows, total, err := d.ListTaskInterceptsPage(scope, page, 2, filter)
|
||||||
|
if err != nil || total != 3 {
|
||||||
|
t.Fatalf("page %d total=%d err=%v", page, total, err)
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
seen = append(seen, r.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !slices.Equal(seen, expected) {
|
||||||
|
t.Fatalf("unstable combined-filter pagination: got %v want %v", seen, expected)
|
||||||
|
}
|
||||||
|
rows, total, err := d.ListTaskInterceptsPage(scope, 3, 2, filter)
|
||||||
|
if err != nil || len(rows) != 0 || total != 3 {
|
||||||
|
t.Fatalf("empty page count=%d rows=%d err=%v", total, len(rows), err)
|
||||||
|
}
|
||||||
|
_, total, err = d.ListAllInterceptsPage(1, 100, filter)
|
||||||
|
if err != nil || total != baseline+4 {
|
||||||
|
t.Fatalf("global filter count=%d want=%d err=%v", total, baseline+4, err)
|
||||||
|
}
|
||||||
|
// Values must remain SQL parameters even for direct store callers.
|
||||||
|
rows, total, err = d.ListTaskInterceptsPage(scope, 1, 20, InterceptApprovalFilter{Status: "denied' OR 1=1 --"})
|
||||||
|
if err != nil || total != 0 || len(rows) != 0 {
|
||||||
|
t.Fatalf("invalid value escaped filter: %d %v", total, err)
|
||||||
|
}
|
||||||
|
// A rule's persisted source survives deletion of its associated rule.
|
||||||
|
_, err = d.Exec(`UPDATE intercept_pending SET decision_source='rule' WHERE task_id=$1 AND rule_id=$2`, scope, rule.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = d.DeleteInterceptRule(rule.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rows, total, err = d.ListTaskInterceptsPage(scope, 1, 100, InterceptApprovalFilter{DecisionSource: "rule"})
|
||||||
|
if err != nil || total != 12 {
|
||||||
|
t.Fatalf("deleted-rule source count=%d err=%v", total, err)
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.RuleID != nil || r.DecisionSource != "rule" {
|
||||||
|
t.Fatal("deleted rule lost its source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+29
-4
@@ -248,7 +248,12 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
if q.Get("page") == "" && q.Get("size") == "" {
|
filter, err := interceptFilterParams(q)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) {
|
||||||
items, err := pg.ListTaskIntercepts(taskID)
|
items, err := pg.ListTaskIntercepts(taskID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
@@ -261,7 +266,7 @@ func (s *Server) interceptListTaskItems(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
page, size := interceptPageParams(q)
|
page, size := interceptPageParams(q)
|
||||||
items, total, err := pg.ListTaskInterceptsPage(taskID, page, size)
|
items, total, err := pg.ListTaskInterceptsPage(taskID, page, size, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
return
|
return
|
||||||
@@ -278,7 +283,12 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
if q.Get("page") == "" && q.Get("size") == "" {
|
filter, err := interceptFilterParams(q)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.Get("page") == "" && q.Get("size") == "" && filter == (db.InterceptApprovalFilter{}) {
|
||||||
items, err := pg.ListAllIntercepts(200)
|
items, err := pg.ListAllIntercepts(200)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
@@ -291,7 +301,7 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
page, size := interceptPageParams(q)
|
page, size := interceptPageParams(q)
|
||||||
items, total, err := pg.ListAllInterceptsPage(page, size)
|
items, total, err := pg.ListAllInterceptsPage(page, size, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
return
|
return
|
||||||
@@ -302,6 +312,21 @@ func (s *Server) interceptHistory(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
|
writeJSON(w, 200, map[string]any{"items": items, "total": total, "page": page, "page_size": size})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func interceptFilterParams(q url.Values) (db.InterceptApprovalFilter, error) {
|
||||||
|
filter := db.InterceptApprovalFilter{Status: q.Get("status"), DecisionSource: q.Get("decision_source")}
|
||||||
|
switch filter.Status {
|
||||||
|
case "", "pending", "allowed", "denied", "timeout":
|
||||||
|
default:
|
||||||
|
return filter, fmt.Errorf("status 必须是 pending、allowed、denied 或 timeout")
|
||||||
|
}
|
||||||
|
switch filter.DecisionSource {
|
||||||
|
case "", "model", "rule", "unknown":
|
||||||
|
default:
|
||||||
|
return filter, fmt.Errorf("decision_source 必须是 model、rule 或 unknown")
|
||||||
|
}
|
||||||
|
return filter, nil
|
||||||
|
}
|
||||||
|
|
||||||
func interceptPageParams(q url.Values) (int, int) {
|
func interceptPageParams(q url.Values) (int, int) {
|
||||||
page := atoiDefault(q.Get("page"), 1)
|
page := atoiDefault(q.Get("page"), 1)
|
||||||
size := atoiDefault(q.Get("size"), 20)
|
size := atoiDefault(q.Get("size"), 20)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Autumn-27/artex/db"
|
||||||
|
"github.com/Autumn-27/artex/intercept"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInterceptFilterHTTP(t *testing.T) {
|
||||||
|
dsn, _, err := db.DSN()
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("no test database configured")
|
||||||
|
}
|
||||||
|
d, err := db.Open(dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = d.Close() })
|
||||||
|
m := &Manager{pg: d, interceptor: intercept.New(d)}
|
||||||
|
s := &Server{m: m, jwtKey: []byte("approval-filter-test-key")}
|
||||||
|
h := s.Handler()
|
||||||
|
token, err := signJWT(s.jwtKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
scope := "approval-http-filter"
|
||||||
|
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM intercept_pending WHERE task_id IN ($1,$2)`, scope, scope+"-other") })
|
||||||
|
_, baseline, err := d.ListAllInterceptsPage(1, 20, db.InterceptApprovalFilter{Status: "denied", DecisionSource: "model"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, state := range []string{"pending", "allowed", "denied", "timeout"} {
|
||||||
|
if _, err := d.CreateDecidedIntercept(0, 0, scope, "test", "Bash", []byte(`{}`), state, "[模型] fixture"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := d.CreateDecidedIntercept(0, 0, scope+"-other", "test", "Bash", []byte(`{}`), "denied", "[模型] fixture"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
do := func(path string, auth bool) *httptest.ResponseRecorder {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
|
if auth {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
return rec
|
||||||
|
}
|
||||||
|
for _, base := range []string{"/api/intercept/history", "/api/intercept/task/" + scope} {
|
||||||
|
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"} {
|
||||||
|
if rec := do(base+query, true); rec.Code != 400 {
|
||||||
|
t.Fatalf("invalid filter %s: %d %s", query, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Filtering without explicit page parameters must still apply the filters.
|
||||||
|
rec := do(base+"?status=denied&decision_source=model", true)
|
||||||
|
var body struct {
|
||||||
|
Items []db.InterceptApprovalRow `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
}
|
||||||
|
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil {
|
||||||
|
t.Fatalf("list: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
want := baseline + 2
|
||||||
|
if base != "/api/intercept/history" {
|
||||||
|
want = 1
|
||||||
|
}
|
||||||
|
if body.Total != want || body.Page != 1 {
|
||||||
|
t.Fatalf("filter total=%d want=%d page=%d", body.Total, want, body.Page)
|
||||||
|
}
|
||||||
|
for _, r := range body.Items {
|
||||||
|
if r.Status != "denied" || r.DecisionSource != "model" {
|
||||||
|
t.Fatalf("nonmatching item: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
query string
|
||||||
|
total, rows, page int
|
||||||
|
}{
|
||||||
|
{"", 4, 4, 0},
|
||||||
|
{"?page=2&size=2&decision_source=model", 4, 2, 2},
|
||||||
|
{"?page=2&size=1&status=denied&decision_source=model", 1, 0, 2},
|
||||||
|
{"?status=denied&decision_source=unknown", 0, 0, 1},
|
||||||
|
{"?page=0&size=0&status=pending", 1, 1, 1},
|
||||||
|
} {
|
||||||
|
rec := do("/api/intercept/task/"+scope+tc.query, true)
|
||||||
|
var body struct {
|
||||||
|
Items []db.InterceptApprovalRow `json:"items"`
|
||||||
|
Total int `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
}
|
||||||
|
if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &body) != nil || body.Total != tc.total || len(body.Items) != tc.rows || body.Page != tc.page {
|
||||||
|
t.Fatalf("%s: %d %s", tc.query, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,10 +21,18 @@ import { Badge } from "@/components/ui/badge";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||||
|
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||||
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { InterceptApprovalRow, InterceptAudit, InterceptDetail, InterceptReviewInput } from "@/lib/types";
|
import type {
|
||||||
|
InterceptApprovalFilter,
|
||||||
|
InterceptApprovalRow,
|
||||||
|
InterceptAudit,
|
||||||
|
InterceptDetail,
|
||||||
|
InterceptReviewInput,
|
||||||
|
} from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function fmtTime(value?: string) {
|
function fmtTime(value?: string) {
|
||||||
@@ -643,6 +651,7 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
|||||||
const [page, setPage] = React.useState(1);
|
const [page, setPage] = React.useState(1);
|
||||||
const [pageSize, setPageSize] = React.useState(20);
|
const [pageSize, setPageSize] = React.useState(20);
|
||||||
const [total, setTotal] = React.useState(0);
|
const [total, setTotal] = React.useState(0);
|
||||||
|
const [filter, setFilter] = React.useState<InterceptApprovalFilter>({});
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [refreshing, setRefreshing] = React.useState(false);
|
const [refreshing, setRefreshing] = React.useState(false);
|
||||||
const [error, setError] = React.useState("");
|
const [error, setError] = React.useState("");
|
||||||
@@ -650,6 +659,8 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
|||||||
const [revision, setRevision] = React.useState(0);
|
const [revision, setRevision] = React.useState(0);
|
||||||
const request = React.useRef(0);
|
const request = React.useRef(0);
|
||||||
const decisionLock = React.useRef(false);
|
const decisionLock = React.useRef(false);
|
||||||
|
const filterID = React.useId();
|
||||||
|
const filtered = Boolean(filter.status || filter.decision_source);
|
||||||
|
|
||||||
// A task can stay mounted while the user switches between task details.
|
// 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.
|
// Reset the cursor so the new scope always starts at its newest records.
|
||||||
@@ -664,8 +675,8 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
|||||||
if (manual) setRefreshing(true);
|
if (manual) setRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const result = taskId
|
const result = taskId
|
||||||
? await api.interceptTaskPage(taskId, page, pageSize)
|
? await api.interceptTaskPage(taskId, page, pageSize, filter)
|
||||||
: await api.interceptHistoryPage(page, pageSize);
|
: await api.interceptHistoryPage(page, pageSize, filter);
|
||||||
if (id !== request.current) return;
|
if (id !== request.current) return;
|
||||||
setRows(result.items);
|
setRows(result.items);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
@@ -680,8 +691,10 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[taskId, page, pageSize],
|
[taskId, page, pageSize, filter],
|
||||||
);
|
);
|
||||||
|
const latestLoad = React.useRef(load);
|
||||||
|
latestLoad.current = load;
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -697,6 +710,17 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeFilter = (next: InterceptApprovalFilter) => {
|
||||||
|
// An in-flight response for the old filter must not repopulate the table.
|
||||||
|
request.current++;
|
||||||
|
setFilter(next);
|
||||||
|
setPage(1);
|
||||||
|
setRows([]);
|
||||||
|
setTotal(0);
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
};
|
||||||
|
|
||||||
const decide: Decide = async (id, decision) => {
|
const decide: Decide = async (id, decision) => {
|
||||||
if (decisionLock.current) return;
|
if (decisionLock.current) return;
|
||||||
decisionLock.current = true;
|
decisionLock.current = true;
|
||||||
@@ -705,14 +729,13 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
|||||||
await api.interceptDecide(id, decision);
|
await api.interceptDecide(id, decision);
|
||||||
// Invalidate a list request started before this decision.
|
// Invalidate a list request started before this decision.
|
||||||
request.current++;
|
request.current++;
|
||||||
setRows((prev) =>
|
|
||||||
prev.map((row) => (row.id === id ? { ...row, status: decision, decided_at: new Date().toISOString() } : row)),
|
|
||||||
);
|
|
||||||
setRevision((v) => v + 1);
|
setRevision((v) => v + 1);
|
||||||
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
toast.success(decision === "allowed" ? "已允许执行" : "已拒绝执行");
|
||||||
|
// Re-fetch counts and rows: a decided item may no longer match the filter.
|
||||||
|
await latestLoad.current(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error((e as Error).message);
|
toast.error((e as Error).message);
|
||||||
await load(true);
|
await latestLoad.current(true);
|
||||||
} finally {
|
} finally {
|
||||||
decisionLock.current = false;
|
decisionLock.current = false;
|
||||||
setDeciding(false);
|
setDeciding(false);
|
||||||
@@ -735,6 +758,62 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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:w-40">
|
||||||
|
<FieldLabel htmlFor={`${filterID}-status`}>审批状态</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={filter.status ?? "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
changeFilter({
|
||||||
|
...filter,
|
||||||
|
status: value === "all" ? undefined : (value as InterceptApprovalFilter["status"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`${filterID}-status`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value="denied">已拒绝</SelectItem>
|
||||||
|
<SelectItem value="pending">待审批</SelectItem>
|
||||||
|
<SelectItem value="allowed">已允许</SelectItem>
|
||||||
|
<SelectItem value="timeout">已超时</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field className="w-full sm:w-40">
|
||||||
|
<FieldLabel htmlFor={`${filterID}-source`}>判定来源</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={filter.decision_source ?? "all"}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
changeFilter({
|
||||||
|
...filter,
|
||||||
|
decision_source: value === "all" ? undefined : (value as InterceptApprovalFilter["decision_source"]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={`${filterID}-source`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="all">全部来源</SelectItem>
|
||||||
|
<SelectItem value="model">模型判定</SelectItem>
|
||||||
|
<SelectItem value="rule">规则判定</SelectItem>
|
||||||
|
<SelectItem value="unknown">来源未知</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
{filtered ? (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => changeFilter({})}>
|
||||||
|
清除筛选
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</FieldGroup>
|
||||||
{error ? (
|
{error ? (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>记录加载失败:{error}。请点击刷新重试。</AlertDescription>
|
<AlertDescription>记录加载失败:{error}。请点击刷新重试。</AlertDescription>
|
||||||
@@ -750,7 +829,9 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
|||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
<section className="overflow-hidden rounded-xl border">
|
<section className="overflow-hidden rounded-xl border">
|
||||||
<div className="border-b px-4 py-3 font-medium text-sm">全部记录({total})</div>
|
<div className="border-b px-4 py-3 font-medium text-sm">
|
||||||
|
{filtered ? "筛选结果" : "全部记录"}({total})
|
||||||
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col gap-3 p-4">
|
<div className="flex flex-col gap-3 p-4">
|
||||||
<Skeleton className="h-10 w-full" />
|
<Skeleton className="h-10 w-full" />
|
||||||
@@ -764,8 +845,12 @@ export function ApprovalRecords({ taskId }: { taskId?: string }) {
|
|||||||
<EmptyMedia variant="icon">
|
<EmptyMedia variant="icon">
|
||||||
<ClipboardListIcon />
|
<ClipboardListIcon />
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
<EmptyTitle>暂无审批记录</EmptyTitle>
|
<EmptyTitle>{filtered ? "没有符合筛选条件的审批记录" : "暂无审批记录"}</EmptyTitle>
|
||||||
<EmptyDescription>规则或模型作出审批决定后,记录将显示在这里。</EmptyDescription>
|
<EmptyDescription>
|
||||||
|
{filtered
|
||||||
|
? "请调整审批状态或判定来源,或清除筛选查看全部记录。"
|
||||||
|
: "规则或模型作出审批决定后,记录将显示在这里。"}
|
||||||
|
</EmptyDescription>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>
|
</Empty>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+12
-4
@@ -42,6 +42,7 @@ import type {
|
|||||||
FindingTraffic,
|
FindingTraffic,
|
||||||
FindingTrafficDetail,
|
FindingTrafficDetail,
|
||||||
IntentAsset,
|
IntentAsset,
|
||||||
|
InterceptApprovalFilter,
|
||||||
InterceptApprovalRow,
|
InterceptApprovalRow,
|
||||||
InterceptDetail,
|
InterceptDetail,
|
||||||
InterceptPending,
|
InterceptPending,
|
||||||
@@ -204,6 +205,13 @@ function findingFilterParams(q: Omit<FindingQuery, "page" | "pageSize">): URLSea
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function interceptPageQuery(page: number, size: number, filter: InterceptApprovalFilter) {
|
||||||
|
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);
|
||||||
|
return query.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
// 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。
|
// 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。
|
||||||
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
|
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
|
||||||
@@ -1065,8 +1073,8 @@ export const api = {
|
|||||||
get<import("@/lib/types").InterceptExecution>(`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`),
|
get<import("@/lib/types").InterceptExecution>(`/intercept/history/${id}/execution${conversationId ? `?conversation=${conversationId}` : ""}`),
|
||||||
interceptDetail: (id: number) => get<InterceptDetail>(`/intercept/history/${id}`),
|
interceptDetail: (id: number) => get<InterceptDetail>(`/intercept/history/${id}`),
|
||||||
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
||||||
interceptHistoryPage: (page = 1, size = 20) =>
|
interceptHistoryPage: (page = 1, size = 20, filter: InterceptApprovalFilter = {}) =>
|
||||||
get<{ items: InterceptApprovalRow[]; total?: number }>(`/intercept/history?page=${page}&size=${size}`).then(
|
get<{ items: InterceptApprovalRow[]; total?: number }>(`/intercept/history?${interceptPageQuery(page, size, filter)}`).then(
|
||||||
(r) => ({
|
(r) => ({
|
||||||
items: arr(r.items),
|
items: arr(r.items),
|
||||||
total: r.total ?? r.items?.length ?? 0,
|
total: r.total ?? r.items?.length ?? 0,
|
||||||
@@ -1074,9 +1082,9 @@ export const api = {
|
|||||||
),
|
),
|
||||||
interceptTask: (taskId: string) =>
|
interceptTask: (taskId: string) =>
|
||||||
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||||
interceptTaskPage: (taskId: string, page = 1, size = 20) =>
|
interceptTaskPage: (taskId: string, page = 1, size = 20, filter: InterceptApprovalFilter = {}) =>
|
||||||
get<{ items: InterceptApprovalRow[]; total?: number }>(
|
get<{ items: InterceptApprovalRow[]; total?: number }>(
|
||||||
`/intercept/task/${encodeURIComponent(taskId)}?page=${page}&size=${size}`,
|
`/intercept/task/${encodeURIComponent(taskId)}?${interceptPageQuery(page, size, filter)}`,
|
||||||
).then((r) => ({
|
).then((r) => ({
|
||||||
items: arr(r.items),
|
items: arr(r.items),
|
||||||
total: r.total ?? r.items?.length ?? 0,
|
total: r.total ?? r.items?.length ?? 0,
|
||||||
|
|||||||
+11
-15
@@ -2076,21 +2076,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
}
|
}
|
||||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
|
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
|
||||||
return mockInterceptPending.find((p) => p.id === Number(seg[2])) ?? null;
|
return mockInterceptPending.find((p) => p.id === Number(seg[2])) ?? null;
|
||||||
if (path === "/intercept/history") {
|
if (path === "/intercept/history" || (seg[0] === "intercept" && seg[1] === "task")) {
|
||||||
if (!q.has("page") && !q.has("size")) return { items: mockInterceptHistory, total: mockInterceptHistory.length };
|
const status = q.get("status") || "";
|
||||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
const decisionSource = q.get("decision_source") || "";
|
||||||
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
|
if (status && !["pending", "allowed", "denied", "timeout"].includes(status)) throw new Error("无效审批状态");
|
||||||
const offset = (page - 1) * size;
|
if (decisionSource && !["model", "rule", "unknown"].includes(decisionSource)) throw new Error("无效判定来源");
|
||||||
return {
|
const filtered = mockInterceptHistory.filter((row) => {
|
||||||
items: mockInterceptHistory.slice(offset, offset + size),
|
const source = row.decision_source || (row.rule_id ? "rule" : row.reason?.startsWith("[模型]") ? "model" : "unknown");
|
||||||
total: mockInterceptHistory.length,
|
return (seg[1] !== "task" || row.task_id === decodeURIComponent(seg[2])) &&
|
||||||
page,
|
(!status || row.status === status) && (!decisionSource || source === decisionSource);
|
||||||
page_size: size,
|
});
|
||||||
};
|
if (!q.has("page") && !q.has("size") && !status && !decisionSource) return { items: filtered, total: filtered.length };
|
||||||
}
|
|
||||||
if (seg[0] === "intercept" && seg[1] === "task") {
|
|
||||||
const filtered = mockInterceptHistory.filter((r) => r.task_id === seg[2]);
|
|
||||||
if (!q.has("page") && !q.has("size")) return { items: filtered, total: filtered.length };
|
|
||||||
const page = Math.max(1, Number(q.get("page")) || 1);
|
const page = Math.max(1, Number(q.get("page")) || 1);
|
||||||
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
|
const size = Math.min(100, Math.max(1, Number(q.get("size")) || 20));
|
||||||
const offset = (page - 1) * size;
|
const offset = (page - 1) * size;
|
||||||
|
|||||||
@@ -1195,6 +1195,11 @@ export interface JudgeConfig {
|
|||||||
ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作
|
ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InterceptApprovalFilter {
|
||||||
|
status?: InterceptPending["status"];
|
||||||
|
decision_source?: "rule" | "model" | "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||||
export interface InterceptApprovalRow extends InterceptPending {
|
export interface InterceptApprovalRow extends InterceptPending {
|
||||||
conv_title: string; // "" if no linked conversation
|
conv_title: string; // "" if no linked conversation
|
||||||
|
|||||||
Reference in new issue
Block a user