diff --git a/.gitignore b/.gitignore index 64bcef1..6e12987 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ Thumbs.db # 内嵌前端的单二进制产物 /artex /server/webui/dist/ +/dist/ # Docker 部署生成的本地环境(含密码/密钥,不提交) .env \ No newline at end of file diff --git a/db/commands.go b/db/commands.go index 74a92a3..82b9c45 100644 --- a/db/commands.go +++ b/db/commands.go @@ -152,7 +152,7 @@ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`, } // ListLLMRecords returns paginated LLM records with optional filters. -func (d *DB) ListLLMRecords(model, session string, page, size int) ([]LLMRecord, int, error) { +func (d *DB) ListLLMRecords(model, session, task string, page, size int) ([]LLMRecord, int, error) { if size <= 0 { size = 50 } @@ -174,6 +174,11 @@ func (d *DB) ListLLMRecords(model, session string, page, size int) ([]LLMRecord, args = append(args, "%"+session+"%") argN++ } + if task != "" { + where += fmt.Sprintf(` AND COALESCE(task_id,'') = $%d`, argN) + args = append(args, task) + argN++ + } var total int if err := d.QueryRow(`SELECT COUNT(*) FROM llm_records `+where, args...).Scan(&total); err != nil { @@ -204,6 +209,42 @@ func (d *DB) ListLLMRecords(model, session string, page, size int) ([]LLMRecord, return out, total, rows.Err() } +// LLMTask is one distinct task with its LLM-record count. +type LLMTask struct { + TaskID string `json:"task_id"` + Count int `json:"count"` +} + +// LLMTasks returns distinct non-empty task_ids with record counts, most recent +// first — powers the LLM-records page's task picker. +func (d *DB) LLMTasks() ([]LLMTask, error) { + rows, err := d.Query(`SELECT task_id, COUNT(*) AS n FROM llm_records +WHERE COALESCE(task_id,'') <> '' GROUP BY task_id ORDER BY MAX(id) DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []LLMTask + for rows.Next() { + var t LLMTask + if err := rows.Scan(&t.TaskID, &t.Count); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} + +// DeleteLLMRecords removes every LLM record for one exact task_id — the same +// match the page's task picker/filter uses. Returns rows deleted. +func (d *DB) DeleteLLMRecords(task string) (int64, error) { + res, err := d.Exec(`DELETE FROM llm_records WHERE COALESCE(task_id,'') = $1`, task) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + // GetLLMRecord returns a single LLM record with full request/response bodies. func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) { var r LLMRecord diff --git a/server/commands.go b/server/commands.go index 3f63397..bcb35e0 100644 --- a/server/commands.go +++ b/server/commands.go @@ -3,6 +3,7 @@ package server import ( "net/http" "strconv" + "strings" ) // pgListCommands returns tool executions (any tool) from the activity table. @@ -34,8 +35,9 @@ func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) { size := atoiDefault(q.Get("size"), 50) model := q.Get("model") session := q.Get("session") + task := q.Get("task") - records, total, err := s.m.PG().ListLLMRecords(model, session, page, size) + records, total, err := s.m.PG().ListLLMRecords(model, session, task, page, size) if err != nil { writeErr(w, 500, err.Error()) return @@ -43,6 +45,33 @@ func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"records": records, "total": total}) } +// pgLLMTasks returns distinct recorded tasks with counts, for the page's task +// picker (pick a task → filter the list, then delete its conversations). +func (s *Server) pgLLMTasks(w http.ResponseWriter, r *http.Request) { + tasks, err := s.m.PG().LLMTasks() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"tasks": tasks}) +} + +// pgDeleteLLMRecords removes all LLM records for one exact task_id (the same +// match the task picker uses). Empty task → 400. Returns rows deleted. +func (s *Server) pgDeleteLLMRecords(w http.ResponseWriter, r *http.Request) { + task := strings.TrimSpace(r.URL.Query().Get("task")) + if task == "" { + writeErr(w, 400, "missing task") + return + } + n, err := s.m.PG().DeleteLLMRecords(task) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"deleted": n}) +} + // pgGetLLMRecord returns a single LLM record with full request/response bodies. func (s *Server) pgGetLLMRecord(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) diff --git a/server/server.go b/server/server.go index 2295a4c..0729c8d 100644 --- a/server/server.go +++ b/server/server.go @@ -531,9 +531,14 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/audit", s.getAudit) mux.HandleFunc("POST /api/gc", s.gc) mux.HandleFunc("GET /api/traffic", s.getTraffic) + mux.HandleFunc("GET /api/traffic/hosts", s.getTrafficHosts) + mux.HandleFunc("DELETE /api/traffic", s.deleteTraffic) + mux.HandleFunc("DELETE /api/traffic/hosts", s.deleteTrafficHosts) mux.HandleFunc("GET /api/traffic/exchange", s.getTrafficExchange) mux.HandleFunc("GET /api/commands", s.pgListCommands) mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords) + mux.HandleFunc("DELETE /api/llm/records", s.pgDeleteLLMRecords) + mux.HandleFunc("GET /api/llm/records/tasks", s.pgLLMTasks) mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord) mux.HandleFunc("GET /api/settings", s.getSettings) mux.HandleFunc("PUT /api/settings", s.putSettings) @@ -1678,6 +1683,81 @@ func (s *Server) getTraffic(w http.ResponseWriter, r *http.Request) { }) } +// getTrafficHosts returns distinct recorded hosts with counts, for the page's +// target picker (pick a host → filter the list, then delete it). +func (s *Server) getTrafficHosts(w http.ResponseWriter, r *http.Request) { + tr := s.m.Traffic() + if tr == nil { + writeJSON(w, 200, map[string]any{"hosts": []any{}}) + return + } + hosts, err := tr.Hosts() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"hosts": hosts}) +} + +// deleteTraffic removes recorded traffic for every host containing the query's +// host substring (the page's host filter is substring-based, so what you +// filtered is what gets deleted): index rows + each host's file tree, then +// garbage-collects blobs no remaining exchange references. Empty host → 400. +// Returns the number of exchanges deleted. +func (s *Server) deleteTraffic(w http.ResponseWriter, r *http.Request) { + tr := s.m.Traffic() + if tr == nil { + writeErr(w, 404, "traffic disabled") + return + } + host := strings.TrimSpace(r.URL.Query().Get("host")) + if host == "" { + writeErr(w, 400, "missing host") + return + } + n, err := tr.DeleteHost(host) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"deleted": n}) +} + +// deleteTrafficHosts removes traffic for a set of EXACT hosts (JSON body +// {"hosts": [...]}) — the batch path for the page's multi-select delete. Exact +// match, so picking "api.example.com" never sweeps "api.example.com.cn". +// Returns the number of exchanges deleted. +func (s *Server) deleteTrafficHosts(w http.ResponseWriter, r *http.Request) { + tr := s.m.Traffic() + if tr == nil { + writeErr(w, 404, "traffic disabled") + return + } + var req struct { + Hosts []string `json:"hosts"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, 400, "invalid body") + return + } + hosts := make([]string, 0, len(req.Hosts)) + for _, h := range req.Hosts { + if h = strings.TrimSpace(h); h != "" { + hosts = append(hosts, h) + } + } + if len(hosts) == 0 { + writeErr(w, 400, "missing hosts") + return + } + n, err := tr.DeleteHostsExact(hosts) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"deleted": n}) +} + // getTrafficExchange returns the full raw request/response of one exchange, // read on demand from the traffic tree (bodies are not in the paged list). func (s *Server) getTrafficExchange(w http.ResponseWriter, r *http.Request) { diff --git a/traffic/traffic.go b/traffic/traffic.go index 6f57323..ff1d5a3 100644 --- a/traffic/traffic.go +++ b/traffic/traffic.go @@ -11,12 +11,14 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io/fs" "log" "net" "net/http" "net/url" "os" "path/filepath" + "regexp" "strings" "sync" "sync/atomic" @@ -55,7 +57,7 @@ type Traffic struct { dir string addr string db *sql.DB - wmu sync.Mutex + wmu sync.Mutex // serializes record() vs DeleteHost (incl. blob GC) seq atomic.Int64 proxy *mproxy.Proxy // pass is the set of hosts whose MITM interception failed for a proxy/protocol @@ -186,6 +188,10 @@ func proxyCausedErr(err error) bool { } func (t *Traffic) record(f *mproxy.Flow) { + // The write lock covers blob + tree + index writes, so DeleteHost (and its + // blob GC) can run under the same lock without racing a concurrent record. + t.wmu.Lock() + defer t.wmu.Unlock() host := f.Request.URL.Hostname() method := f.Request.Method tmpl := db.TemplatePath(f.Request.URL.EscapedPath()) @@ -221,12 +227,10 @@ func (t *Traffic) record(f *mproxy.Flow) { _ = os.WriteFile(filepath.Join(exDir, "meta.json"), []byte(meta), 0o644) rel, _ := filepath.Rel(t.dir, exDir) - t.wmu.Lock() _, _ = t.db.Exec(`INSERT OR REPLACE INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, id, now.Unix(), host, method, tmpl, f.Request.URL.String(), f.Response.StatusCode, ct, len(f.Request.Body), len(f.Response.Body), rel) - t.wmu.Unlock() } // bodyOrBlob returns the body inline if small, else stores it content-addressed @@ -384,6 +388,32 @@ func (t *Traffic) Get(id string) (req, resp string, err error) { return string(rb), string(pb), nil } +// HostCount is one distinct recorded host plus its exchange count. +type HostCount struct { + Host string `json:"host"` + Count int `json:"count"` +} + +// Hosts returns distinct recorded hosts with exchange counts, most recent +// activity first — powers the page's target picker. +func (t *Traffic) Hosts() ([]HostCount, error) { + rows, err := t.db.Query(`SELECT host, COUNT(*) AS n, MAX(ts) AS last FROM exchanges GROUP BY host ORDER BY last DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []HostCount + for rows.Next() { + var h HostCount + var last int64 + if err := rows.Scan(&h.Host, &h.Count, &last); err != nil { + return nil, err + } + out = append(out, h) + } + return out, rows.Err() +} + // Count returns total recorded exchanges. func (t *Traffic) Count() (int, error) { var n int @@ -391,6 +421,124 @@ func (t *Traffic) Count() (int, error) { return n, err } +// DeleteHost removes every recorded exchange whose host contains the given +// substring — mirroring the UI's host filter, so what you filtered is what gets +// deleted: the index rows plus each matched host's file tree (//…). +// Content-addressed blobs in _blobs are shared across hosts, so instead of +// deleting by host they are garbage-collected afterwards: any blob no longer +// referenced by a remaining exchange tree is removed (frees the data volume). +// Returns rows deleted. +func (t *Traffic) DeleteHost(host string) (int64, error) { + like := "%" + host + "%" + t.wmu.Lock() + defer t.wmu.Unlock() + // Collect distinct matched hosts first (index is the source of truth for + // tree names) so the trees are removed even if the DELETE row count is 0. + rows, err := t.db.Query(`SELECT DISTINCT host FROM exchanges WHERE host LIKE ?`, like) + if err != nil { + return 0, err + } + var hosts []string + for rows.Next() { + var h string + if err := rows.Scan(&h); err != nil { + rows.Close() + return 0, err + } + hosts = append(hosts, h) + } + rows.Close() + if err := rows.Err(); err != nil { + return 0, err + } + res, err := t.db.Exec(`DELETE FROM exchanges WHERE host LIKE ?`, like) + if err != nil { + return 0, err + } + n, _ := res.RowsAffected() + for _, h := range hosts { + os.RemoveAll(filepath.Join(t.dir, sanitize(h))) + } + if n > 0 { + _ = t.gcBlobs() + } + return n, nil +} + +// DeleteHostsExact removes recorded exchanges for a set of EXACT hosts (the +// batch path for the page's multi-select delete): index rows + each host's file +// tree, then one blob-GC pass. Exact match — unlike DeleteHost's substring — +// so picking "api.example.com" never sweeps "api.example.com.cn". Returns rows +// deleted. Duplicate hosts are harmless (idempotent deletes, single tree pass). +func (t *Traffic) DeleteHostsExact(hosts []string) (int64, error) { + t.wmu.Lock() + defer t.wmu.Unlock() + var deleted int64 + removed := make(map[string]bool) + for _, h := range hosts { + res, err := t.db.Exec(`DELETE FROM exchanges WHERE host=?`, h) + if err != nil { + return deleted, err + } + n, _ := res.RowsAffected() + deleted += n + if !removed[h] { + os.RemoveAll(filepath.Join(t.dir, sanitize(h))) + removed[h] = true + } + } + if deleted > 0 { + _ = t.gcBlobs() + } + return deleted, nil +} + +// gcBlobs removes content-addressed blobs in _blobs that no remaining exchange +// tree references. References appear as "@blob sha256:" lines inside the +// tree's request.http/response.http files (see bodyOrBlob). Best-effort: walk +// errors only skip cleanup of the affected files. Callers must hold wmu so this +// never races a concurrent record() writing a fresh blob + tree. +func (t *Traffic) gcBlobs() error { + refs := make(map[string]struct{}) + blobRe := regexp.MustCompile(`@blob sha256:([0-9a-f]{64})`) + err := filepath.WalkDir(t.dir, func(p string, d fs.DirEntry, err error) error { + if err != nil || d == nil { + return nil // skip unreadable entries + } + if d.IsDir() { + // _blobs/_index/_ca never contain references; skip their subtrees. + if p != t.dir && strings.HasPrefix(d.Name(), "_") { + return filepath.SkipDir + } + return nil + } + if n := d.Name(); n != "request.http" && n != "response.http" { + return nil + } + b, err := os.ReadFile(p) + if err != nil { + return nil + } + for _, m := range blobRe.FindAllSubmatch(b, -1) { + refs[string(m[1])] = struct{}{} + } + return nil + }) + if err != nil { + return err + } + return filepath.WalkDir(filepath.Join(t.dir, "_blobs", "sha256"), func(p string, d fs.DirEntry, err error) error { + if err != nil || d == nil || d.IsDir() { + return nil + } + h := strings.TrimSuffix(d.Name(), ".bin") + if _, ok := refs[h]; !ok { + os.Remove(p) + } + return nil + }) +} + // query returns one page of exchange metadata filtered by host and/or a url // substring. Default page size is intentionally small (3) to keep tool results // lightweight and capped at 10; page is 0-based (page*limit offset). diff --git a/traffic/traffic_test.go b/traffic/traffic_test.go new file mode 100644 index 0000000..25d95b9 --- /dev/null +++ b/traffic/traffic_test.go @@ -0,0 +1,246 @@ +package traffic + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// TestDeleteHost verifies the delete contract: rows for hosts containing the +// substring are removed together with their file trees, non-matching hosts are +// untouched, and the count is right. +func TestDeleteHost(t *testing.T) { + dir := t.TempDir() + tr, err := Open(dir, "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer tr.Close() + + // Seed two hosts' index rows + trees directly (record() needs a live Flow). + for i, h := range []string{"a.example.com", "b.example.com"} { + id := fmt.Sprintf("1-%04d", i+1) + exDir := filepath.Join(dir, h, "GET", id) + if err := os.MkdirAll(exDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(exDir, "meta.json"), []byte(fmt.Sprintf(`{"id":%q,"host":%q}`, id, h)), 0o644); err != nil { + t.Fatal(err) + } + if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path) +VALUES(?,?,?,?,?,?,?,?,?,?,?)`, + id, i+1, h, "GET", "/", "http://"+h+"/", 200, "text/html", 0, 0, h+"/GET/"+id); err != nil { + t.Fatal(err) + } + } + + // Substring: "a.example" matches a.example.com only, leaves b.example.com. + n, err := tr.DeleteHost("a.example") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("deleted=%d, want 1", n) + } + // Tree removed for the target, intact for the other host. + if _, err := os.Stat(filepath.Join(dir, "a.example.com")); !os.IsNotExist(err) { + t.Fatalf("a.example.com tree still exists (stat err=%v)", err) + } + if _, err := os.Stat(filepath.Join(dir, "b.example.com")); err != nil { + t.Fatalf("b.example.com tree removed: %v", err) + } + // Index reduced to the other host's single row. + var c int + if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&c); err != nil { + t.Fatal(err) + } + if c != 1 { + t.Fatalf("rows=%d, want 1", c) + } + // A substring matching nothing is a no-op, not an error. + n, err = tr.DeleteHost("nope.example") + if err != nil || n != 0 { + t.Fatalf("DeleteHost(missing)=%d, err=%v; want 0, nil", n, err) + } + // A broader substring sweeps the remaining host too. + if n, err = tr.DeleteHost("example.com"); err != nil || n != 1 { + t.Fatalf("DeleteHost(example.com)=%d, err=%v; want 1, nil", n, err) + } + c = 0 + if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&c); err == nil && c != 0 { + t.Fatalf("rows=%d, want 0 after full sweep", c) + } +} + +// TestHosts verifies the target picker contract: distinct hosts with counts, +// most recent activity first. +func TestHosts(t *testing.T) { + dir := t.TempDir() + tr, err := Open(dir, "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer tr.Close() + for i, row := range []struct { + host string + ts int64 + }{{"old.example.com", 1}, {"new.example.com", 3}, {"old.example.com", 2}} { + id := fmt.Sprintf("1-%04d", i+1) + if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path) +VALUES(?,?,?,?,?,?,?,?,?,?,?)`, + id, row.ts, row.host, "GET", "/", "http://"+row.host+"/", 200, "text/html", 0, 0, row.host+"/GET/"+id); err != nil { + t.Fatal(err) + } + } + hosts, err := tr.Hosts() + if err != nil { + t.Fatal(err) + } + if len(hosts) != 2 { + t.Fatalf("hosts=%d, want 2", len(hosts)) + } + // newest activity (ts=3) first + if hosts[0].Host != "new.example.com" || hosts[0].Count != 1 { + t.Fatalf("hosts[0]=%+v, want new.example.com/1", hosts[0]) + } + if hosts[1].Host != "old.example.com" || hosts[1].Count != 2 { + t.Fatalf("hosts[1]=%+v, want old.example.com/2", hosts[1]) + } +} + +// TestDeleteHostsExact verifies the batch delete: exact host match only — a +// host whose name contains another as a substring is untouched — duplicates in +// the batch are harmless, and the per-host trees are removed. +func TestDeleteHostsExact(t *testing.T) { + dir := t.TempDir() + tr, err := Open(dir, "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer tr.Close() + seed := func(id, h string) { + exDir := filepath.Join(dir, h, "GET", id) + if err := os.MkdirAll(exDir, 0o755); err != nil { + t.Fatal(err) + } + if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path) +VALUES(?,?,?,?,?,?,?,?,?,?,?)`, + id, 1, h, "GET", "/", "http://"+h+"/", 200, "text/html", 0, 0, h+"/GET/"+id); err != nil { + t.Fatal(err) + } + } + // "api.example.com" is a substring of "api.example.com.cn". + seed("1-0001", "api.example.com") + seed("1-0002", "api.example.com.cn") + seed("1-0003", "shop.example.com") + + // Duplicate entry in the batch must not double-delete or error. + n, err := tr.DeleteHostsExact([]string{"api.example.com", "api.example.com", "shop.example.com"}) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("deleted=%d, want 2", n) + } + if _, err := os.Stat(filepath.Join(dir, "api.example.com")); !os.IsNotExist(err) { + t.Fatalf("api.example.com tree still exists: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "shop.example.com")); !os.IsNotExist(err) { + t.Fatalf("shop.example.com tree still exists: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "api.example.com.cn")); err != nil { + t.Fatalf("api.example.com.cn removed by an exact delete that shouldn't match: %v", err) + } + var c int + if err := tr.DB().QueryRow(`SELECT COUNT(*) FROM exchanges`).Scan(&c); err != nil { + t.Fatal(err) + } + if c != 1 { + t.Fatalf("rows=%d, want 1 (api.example.com.cn only)", c) + } +} + +// TestDeleteHostGCBlobs verifies blob garbage collection: after a host's trees +// are removed, blobs referenced by no remaining exchange are deleted, while +// blobs still referenced (including shared ones) survive. +func TestDeleteHostGCBlobs(t *testing.T) { + dir := t.TempDir() + tr, err := Open(dir, "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer tr.Close() + + // Two distinct blobs + one shared blob (referenced by two hosts). + blobA := filepath.Join(dir, "_blobs", "sha256", "aa", "aa", strings.Repeat("a", 64)+".bin") + blobB := filepath.Join(dir, "_blobs", "sha256", "bb", "bb", strings.Repeat("b", 64)+".bin") + blobC := filepath.Join(dir, "_blobs", "sha256", "cc", "cc", strings.Repeat("c", 64)+".bin") + for _, b := range []string{blobA, blobB, blobC} { + if err := os.MkdirAll(filepath.Dir(b), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(b, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + seed := func(id, h, ref string) { + exDir := filepath.Join(dir, h, "GET", id) + if err := os.MkdirAll(exDir, 0o755); err != nil { + t.Fatal(err) + } + body := "no blob" + if ref != "" { + body = "@blob sha256:" + ref + " (len=1)" + } + if err := os.WriteFile(filepath.Join(exDir, "request.http"), []byte("GET / HTTP/1.1\n\n"+body), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(exDir, "response.http"), []byte("HTTP 200 OK\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := tr.DB().Exec(`INSERT INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path) +VALUES(?,?,?,?,?,?,?,?,?,?,?)`, + id, 1, h, "GET", "/", "http://"+h+"/", 200, "text/html", 0, 0, h+"/GET/"+id); err != nil { + t.Fatal(err) + } + } + ha := strings.Repeat("a", 64) + hb := strings.Repeat("b", 64) + hc := strings.Repeat("c", 64) + seed("1-0001", "a.example.com", ha) // sole reference to blobA + seed("1-0002", "b.example.com", hb) // sole reference to blobB + seed("1-0003", "c.example.com", hc) // shares blobC with d + seed("1-0004", "d.example.com", hc) + + // Delete a: blobA orphaned → removed; blobB/blobC still referenced → kept. + if n, err := tr.DeleteHost("a.example"); err != nil || n != 1 { + t.Fatalf("DeleteHost(a.example)=%d, err=%v; want 1, nil", n, err) + } + if _, err := os.Stat(blobA); !os.IsNotExist(err) { + t.Fatalf("orphaned blobA still exists: %v", err) + } + if _, err := os.Stat(blobB); err != nil { + t.Fatalf("referenced blobB removed: %v", err) + } + if _, err := os.Stat(blobC); err != nil { + t.Fatalf("shared blobC removed while d still references it: %v", err) + } + + // Delete c (shares blobC with d): blobC must survive. + if n, err := tr.DeleteHost("c.example"); err != nil || n != 1 { + t.Fatalf("DeleteHost(c.example)=%d, err=%v; want 1, nil", n, err) + } + if _, err := os.Stat(blobC); err != nil { + t.Fatalf("shared blobC removed after deleting one sharer: %v", err) + } + + // Delete d: last reference gone → blobC collected. + if n, err := tr.DeleteHost("d.example"); err != nil || n != 1 { + t.Fatalf("DeleteHost(d.example)=%d, err=%v; want 1, nil", n, err) + } + if _, err := os.Stat(blobC); !os.IsNotExist(err) { + t.Fatalf("blobC still exists after last reference removed: %v", err) + } +} diff --git a/web/src/app/(main)/function/llm-records/page.tsx b/web/src/app/(main)/function/llm-records/page.tsx index 2be4866..cb70c44 100644 --- a/web/src/app/(main)/function/llm-records/page.tsx +++ b/web/src/app/(main)/function/llm-records/page.tsx @@ -8,8 +8,20 @@ import { ChevronRightIcon, Loader2Icon, XIcon, + Trash2Icon, } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + import { Card } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -32,7 +44,7 @@ import { } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { api } from "@/lib/api"; -import type { LLMRecordItem, LLMRecordDetail } from "@/lib/types"; +import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types"; function fmtTime(ts: string) { return new Date(ts).toLocaleString("zh-CN", { @@ -85,6 +97,13 @@ export default function LLMRecordsPage() { const [detail, setDetail] = React.useState(null); const [detailLoading, setDetailLoading] = React.useState(false); + // Per-task delete (task picker + confirm dialog) + const [tasks, setTasks] = React.useState([]); + const [pickedTask, setPickedTask] = React.useState(""); + const [deleteOpen, setDeleteOpen] = React.useState(false); + const [deleting, setDeleting] = React.useState(false); + const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger + // Load recording toggle state on mount. React.useEffect(() => { let alive = true; @@ -117,14 +136,14 @@ export default function LLMRecordsPage() { // Reset page on filter change. React.useEffect(() => { setPage(0); - }, [sessionQ, model, size]); + }, [sessionQ, model, size, pickedTask]); // Load list. React.useEffect(() => { let alive = true; setLoading(true); api - .llmRecords({ model: model || undefined, session: sessionQ || undefined, page, size }) + .llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size }) .then((r) => { if (!alive) return; setRecords(r.records ?? []); @@ -132,8 +151,29 @@ export default function LLMRecordsPage() { }) .catch(() => {}) .finally(() => alive && setLoading(false)); + api + .llmTasks() + .then((r) => { if (alive) setTasks(r.tasks ?? []); }) + .catch(() => {}); return () => { alive = false; }; - }, [page, size, sessionQ, model]); + }, [page, size, sessionQ, model, pickedTask, reloadTick]); + + // Delete every LLM record for the picked task, then refetch. + const confirmDelete = () => { + setDeleting(true); + api + .llmRecordsDeleteTask(pickedTask) + .then(() => { + setDeleteOpen(false); + setSelected(null); + setDetail(null); + setPickedTask(""); + setPage(0); + setReloadTick((t) => t + 1); + }) + .catch(() => {}) + .finally(() => setDeleting(false)); + }; // Lazy-load full request/response when a row is selected. React.useEffect(() => { @@ -184,6 +224,36 @@ export default function LLMRecordsPage() { value={model} onChange={(e) => setModel(e.target.value)} /> + + +
)}
+ + { if (!o) setDeleteMode(null); }}> + + + + {deleteMode === "selected" + ? `删除选中的 ${selectedHosts.length} 个目标的全部流量?` + : "删除该目标的全部流量?"} + + + {deleteMode === "selected" ? ( + <> + 将永久删除{" "} + {selectedHosts.length}{" "} + 个目标( + + {selectedHosts.slice(0, 3).join("、")} + {selectedHosts.length > 3 ? "…" : ""} + + )的所有流量记录(含请求/响应原文),此操作不可撤销。 + + ) : ( + <> + 将永久删除 host 包含{" "} + {hostQ}{" "} + 的所有流量记录(含请求/响应原文),此操作不可撤销。 + + )} + + + + 取消 + { + e.preventDefault(); + confirmDelete(); + }} + disabled={deleting} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {deleting ? "删除中…" : "确认删除"} + + + + ); } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e8ff447..80fc47f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -7,13 +7,13 @@ import { MOCK } from "@/lib/mock/enabled"; import { mockHandle } from "@/lib/mock/handler"; import type { Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Activity, - Audit, TrafficResp, TrafficDetail, Settings, LLMProfile, Agent, AgentDetail, PromptVar, + Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, Tool, Conversation, AgentTrigger, InterceptRule, InterceptPending, InterceptApprovalRow, TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs, WorkspaceListing, WorkspaceFile, - CommandRecord, LLMRecordItem, LLMRecordDetail, + CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, } from "@/lib/types"; function getToken(): string | null { @@ -91,7 +91,7 @@ const get = (p: string) => http(p); const post = (p: string, body?: unknown) => http(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }); const put = (p: string, body?: unknown) => http(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); const patch = (p: string, body?: unknown) => http(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }); -const del = (p: string) => http(p, { method: "DELETE" }); +const del = (p: string, body?: unknown) => http(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined }); // Go serializes nil slices as JSON null — coerce to []. const arr = (x: T[] | null | undefined): T[] => x ?? []; @@ -289,6 +289,11 @@ export const api = { ), trafficExchange: (id: string) => get(`/traffic/exchange?id=${encodeURIComponent(id)}`), + trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`), + trafficDeleteHost: (host: string) => + del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), + trafficDeleteHosts: (hosts: string[]) => + del<{ deleted: number }>(`/traffic/hosts`, { hosts }), // ---- app settings (runtime toggles) ---- settings: () => get(`/settings`), @@ -579,13 +584,17 @@ export const api = { }, // ---- LLM records ---- - llmRecords: (params?: { model?: string; session?: string; page?: number; size?: number }) => { + llmRecords: (params?: { model?: string; session?: string; task?: string; page?: number; size?: number }) => { const sp = new URLSearchParams(); if (params?.model) sp.set("model", params.model); if (params?.session) sp.set("session", params.session); - sp.set("page", String(params?.page ?? 0)); + if (params?.task) sp.set("task", params.task); + if (params?.page !== undefined) sp.set("page", String(params.page)); sp.set("size", String(params?.size ?? 50)); return get<{ records: LLMRecordItem[]; total: number }>(`/llm/records?${sp}`); }, llmRecordDetail: (id: number) => get(`/llm/records/${id}`), + llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`), + llmRecordsDeleteTask: (task: string) => + del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`), }; diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index 4d247b9..0cab0a2 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -8,7 +8,7 @@ import type { ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow, InterceptPending, InterceptRule, LLMProfile, MCPServer, MCPTool, PromptVar, PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal, - TokenUsage, Tool, TrafficResp, TrafficDetail, + TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask, } from "@/lib/types"; const T = (iso: string) => iso; // readability helper for timestamps @@ -341,6 +341,11 @@ export const traffic: TrafficResp = { })), }; +// Distinct hosts with counts, derived from the exchanges above (target picker). +const hostCounts: Record = {}; +for (const [, host] of exchanges) hostCounts[host] = (hostCounts[host] ?? 0) + 1; +export const trafficHosts: TrafficHost[] = Object.entries(hostCounts).map(([host, count]) => ({ host, count })); + export const trafficDetail: TrafficDetail = { req: `GET /v1/orders?id=1002 HTTP/1.1 Host: api.acme.com @@ -641,6 +646,11 @@ export const llmRecords = [ { id: 4, ts: T("2026-08-09T02:16:05Z"), model: "claude-opus-4-8", profile_name: "默认", session_id: "exp1-worker-i18", task_id: "t-001", worker: "worker-2", latency_ms: 900, input_tokens: 7600, output_tokens: 0, cache_read: 5200, cache_write: 0, status: "error", error: "429 Too Many Requests(已退避重试)" }, ]; +// Distinct tasks with counts, derived from llmRecords above (task picker). +const llmTaskCounts: Record = {}; +for (const r of llmRecords) if (r.task_id) llmTaskCounts[r.task_id] = (llmTaskCounts[r.task_id] ?? 0) + 1; +export const llmTasks: LLMTask[] = Object.entries(llmTaskCounts).map(([task_id, count]) => ({ task_id, count })); + export function llmRecordDetail(id: number) { const item = llmRecords.find((r) => r.id === id) ?? llmRecords[0]; return { diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index d548275..1422c4f 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -90,6 +90,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re // ── traffic / audit / settings ── if (path === "/audit") return D.audit; + if (path === "/traffic" && m === "DELETE") return { deleted: 0 }; + if (path === "/traffic/hosts" && m === "DELETE") return { deleted: (b.hosts as unknown[])?.length ?? 0 }; + if (path === "/traffic/hosts") return { hosts: D.trafficHosts }; if (path === "/traffic") return D.traffic; if (path === "/traffic/exchange") return D.trafficDetail; if (path === "/settings" && m === "GET") return D.settings; @@ -104,6 +107,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re // ── LLM ── if (path === "/llm/records" && m === "GET") return { records: D.llmRecords, total: D.llmRecords.length }; + if (path === "/llm/records" && m === "DELETE") return { deleted: 0 }; + if (path === "/llm/records/tasks") return { tasks: D.llmTasks }; if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") return D.llmRecordDetail(Number(seg[2])); if (path === "/llm" && m === "GET") return D.llmConfig; if (path === "/llm" && m === "POST") return { ok: true }; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 8e7e76e..27ec3ec 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -405,6 +405,12 @@ export interface TrafficDetail { resp: string; } +// One distinct recorded host with its exchange count (target picker). +export interface TrafficHost { + host: string; + count: number; +} + // ---- App settings (runtime toggles) ---- export interface Settings { traffic_capture: boolean; @@ -659,3 +665,9 @@ export interface LLMRecordDetail extends LLMRecordItem { request_body: string; response_body: string; } + +// One distinct task with its LLM-record count (task picker on the records page). +export interface LLMTask { + task_id: string; + count: number; +}