feat: delete traffic targets and LLM task conversations from UI
Add delete controls to the Traffic and LLM Records pages so users can purge recorded data without hand-editing the data volume. Traffic page: - Host target picker (Popover with checkboxes + select-all toggle) - Exact-match batch delete for multiple hosts (DELETE /api/traffic/hosts) - Substring delete for the host-filter input (DELETE /api/traffic?host=) - Orphaned content-addressed blob garbage collection after deletes - Distinct-hosts endpoint (GET /api/traffic/hosts) for the picker LLM Records page: - Task picker dropdown (distinct task_id + record counts) - Per-task delete (DELETE /api/llm/records?task=) Other: - Add /dist/ to .gitignore (build artifact) - Extend traffic_test.go with DeleteHost, DeleteHostsExact, Hosts tests - Fix Popover render bug (replace ScrollArea that broke height calc) - Remove fade/zoom animation on the host picker Popover
This commit is contained in:
1 parent
5962826f2d
commit
c76d0f4c93
12 files changed
+883
-20
No files matched your search
@@ -37,6 +37,7 @@ Thumbs.db
|
|||||||
# 内嵌前端的单二进制产物
|
# 内嵌前端的单二进制产物
|
||||||
/artex
|
/artex
|
||||||
/server/webui/dist/
|
/server/webui/dist/
|
||||||
|
/dist/
|
||||||
|
|
||||||
# Docker 部署生成的本地环境(含密码/密钥,不提交)
|
# Docker 部署生成的本地环境(含密码/密钥,不提交)
|
||||||
.env
|
.env
|
||||||
+42
-1
@@ -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.
|
// 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 {
|
if size <= 0 {
|
||||||
size = 50
|
size = 50
|
||||||
}
|
}
|
||||||
@@ -174,6 +174,11 @@ func (d *DB) ListLLMRecords(model, session string, page, size int) ([]LLMRecord,
|
|||||||
args = append(args, "%"+session+"%")
|
args = append(args, "%"+session+"%")
|
||||||
argN++
|
argN++
|
||||||
}
|
}
|
||||||
|
if task != "" {
|
||||||
|
where += fmt.Sprintf(` AND COALESCE(task_id,'') = $%d`, argN)
|
||||||
|
args = append(args, task)
|
||||||
|
argN++
|
||||||
|
}
|
||||||
|
|
||||||
var total int
|
var total int
|
||||||
if err := d.QueryRow(`SELECT COUNT(*) FROM llm_records `+where, args...).Scan(&total); err != nil {
|
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()
|
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.
|
// GetLLMRecord returns a single LLM record with full request/response bodies.
|
||||||
func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) {
|
func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) {
|
||||||
var r LLMRecord
|
var r LLMRecord
|
||||||
|
|||||||
+30
-1
@@ -3,6 +3,7 @@ package server
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// pgListCommands returns tool executions (any tool) from the activity table.
|
// 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)
|
size := atoiDefault(q.Get("size"), 50)
|
||||||
model := q.Get("model")
|
model := q.Get("model")
|
||||||
session := q.Get("session")
|
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 {
|
if err != nil {
|
||||||
writeErr(w, 500, err.Error())
|
writeErr(w, 500, err.Error())
|
||||||
return
|
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})
|
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.
|
// pgGetLLMRecord returns a single LLM record with full request/response bodies.
|
||||||
func (s *Server) pgGetLLMRecord(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) pgGetLLMRecord(w http.ResponseWriter, r *http.Request) {
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||||
|
|||||||
@@ -530,9 +530,14 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/audit", s.getAudit)
|
mux.HandleFunc("GET /api/audit", s.getAudit)
|
||||||
mux.HandleFunc("POST /api/gc", s.gc)
|
mux.HandleFunc("POST /api/gc", s.gc)
|
||||||
mux.HandleFunc("GET /api/traffic", s.getTraffic)
|
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/traffic/exchange", s.getTrafficExchange)
|
||||||
mux.HandleFunc("GET /api/commands", s.pgListCommands)
|
mux.HandleFunc("GET /api/commands", s.pgListCommands)
|
||||||
mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords)
|
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/llm/records/{id}", s.pgGetLLMRecord)
|
||||||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||||||
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
mux.HandleFunc("PUT /api/settings", s.putSettings)
|
||||||
@@ -1537,6 +1542,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,
|
// getTrafficExchange returns the full raw request/response of one exchange,
|
||||||
// read on demand from the traffic tree (bodies are not in the paged list).
|
// read on demand from the traffic tree (bodies are not in the paged list).
|
||||||
func (s *Server) getTrafficExchange(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) getTrafficExchange(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
+151
-3
@@ -11,12 +11,14 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -55,7 +57,7 @@ type Traffic struct {
|
|||||||
dir string
|
dir string
|
||||||
addr string
|
addr string
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
wmu sync.Mutex
|
wmu sync.Mutex // serializes record() vs DeleteHost (incl. blob GC)
|
||||||
seq atomic.Int64
|
seq atomic.Int64
|
||||||
proxy *mproxy.Proxy
|
proxy *mproxy.Proxy
|
||||||
// pass is the set of hosts whose MITM interception failed for a proxy/protocol
|
// 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) {
|
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()
|
host := f.Request.URL.Hostname()
|
||||||
method := f.Request.Method
|
method := f.Request.Method
|
||||||
tmpl := db.TemplatePath(f.Request.URL.EscapedPath())
|
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)
|
_ = os.WriteFile(filepath.Join(exDir, "meta.json"), []byte(meta), 0o644)
|
||||||
|
|
||||||
rel, _ := filepath.Rel(t.dir, exDir)
|
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)
|
_, _ = t.db.Exec(`INSERT OR REPLACE INTO exchanges(id,ts,host,method,url_template,url,status,content_type,req_len,resp_len,path)
|
||||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
id, now.Unix(), host, method, tmpl, f.Request.URL.String(), f.Response.StatusCode, ct,
|
id, now.Unix(), host, method, tmpl, f.Request.URL.String(), f.Response.StatusCode, ct,
|
||||||
len(f.Request.Body), len(f.Response.Body), rel)
|
len(f.Request.Body), len(f.Response.Body), rel)
|
||||||
t.wmu.Unlock()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// bodyOrBlob returns the body inline if small, else stores it content-addressed
|
// 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
|
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.
|
// Count returns total recorded exchanges.
|
||||||
func (t *Traffic) Count() (int, error) {
|
func (t *Traffic) Count() (int, error) {
|
||||||
var n int
|
var n int
|
||||||
@@ -391,6 +421,124 @@ func (t *Traffic) Count() (int, error) {
|
|||||||
return n, err
|
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 (<dir>/<host>/…).
|
||||||
|
// 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:<hex>" 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
|
// 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
|
// substring. Default page size is intentionally small (3) to keep tool results
|
||||||
// lightweight and capped at 10; page is 0-based (page*limit offset).
|
// lightweight and capped at 10; page is 0-based (page*limit offset).
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,20 @@ import {
|
|||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
XIcon,
|
XIcon,
|
||||||
|
Trash2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -32,7 +44,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { LLMRecordItem, LLMRecordDetail } from "@/lib/types";
|
import type { LLMRecordItem, LLMRecordDetail, LLMTask } from "@/lib/types";
|
||||||
|
|
||||||
function fmtTime(ts: string) {
|
function fmtTime(ts: string) {
|
||||||
return new Date(ts).toLocaleString("zh-CN", {
|
return new Date(ts).toLocaleString("zh-CN", {
|
||||||
@@ -85,6 +97,13 @@ export default function LLMRecordsPage() {
|
|||||||
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
|
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||||
|
|
||||||
|
// Per-task delete (task picker + confirm dialog)
|
||||||
|
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
|
||||||
|
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.
|
// Load recording toggle state on mount.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
@@ -117,14 +136,14 @@ export default function LLMRecordsPage() {
|
|||||||
// Reset page on filter change.
|
// Reset page on filter change.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}, [sessionQ, model, size]);
|
}, [sessionQ, model, size, pickedTask]);
|
||||||
|
|
||||||
// Load list.
|
// Load list.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api
|
api
|
||||||
.llmRecords({ model: model || undefined, session: sessionQ || undefined, page, size })
|
.llmRecords({ model: model || undefined, session: sessionQ || undefined, task: pickedTask || undefined, page, size })
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setRecords(r.records ?? []);
|
setRecords(r.records ?? []);
|
||||||
@@ -132,8 +151,29 @@ export default function LLMRecordsPage() {
|
|||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => alive && setLoading(false));
|
.finally(() => alive && setLoading(false));
|
||||||
|
api
|
||||||
|
.llmTasks()
|
||||||
|
.then((r) => { if (alive) setTasks(r.tasks ?? []); })
|
||||||
|
.catch(() => {});
|
||||||
return () => { alive = false; };
|
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.
|
// Lazy-load full request/response when a row is selected.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -184,6 +224,36 @@ export default function LLMRecordsPage() {
|
|||||||
value={model}
|
value={model}
|
||||||
onChange={(e) => setModel(e.target.value)}
|
onChange={(e) => setModel(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<Select value={pickedTask} onValueChange={setPickedTask}>
|
||||||
|
<SelectTrigger size="sm" className="w-56">
|
||||||
|
<SelectValue placeholder="选择任务…" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tasks.length === 0 ? (
|
||||||
|
<SelectItem value="__none__" disabled>
|
||||||
|
暂无任务记录
|
||||||
|
</SelectItem>
|
||||||
|
) : (
|
||||||
|
tasks.map((t) => (
|
||||||
|
<SelectItem key={t.task_id} value={t.task_id}>
|
||||||
|
<span className="font-mono">#{t.task_id}</span>
|
||||||
|
<span className="ml-2 text-muted-foreground">({t.count})</span>
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!pickedTask || deleting}
|
||||||
|
title={pickedTask ? undefined : "先在上方选择任务"}
|
||||||
|
onClick={() => setDeleteOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
删除任务对话
|
||||||
|
</Button>
|
||||||
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
|
<Select value={String(size)} onValueChange={(v) => setSize(Number(v))}>
|
||||||
<SelectTrigger size="sm" className="w-28">
|
<SelectTrigger size="sm" className="w-28">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
@@ -409,6 +479,30 @@ export default function LLMRecordsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>删除任务「{pickedTask}」的全部 LLM 对话?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
将永久删除该任务的所有 LLM 调用记录(含请求/响应原文),此操作不可撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmDelete();
|
||||||
|
}}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleting ? "删除中…" : "确认删除"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -8,8 +8,21 @@ import {
|
|||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
|
Trash2Icon,
|
||||||
|
ListChecksIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -29,9 +42,15 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import type { TrafficExchange, TrafficResp, TrafficDetail } from "@/lib/types";
|
import type { TrafficExchange, TrafficResp, TrafficDetail, TrafficHost } from "@/lib/types";
|
||||||
|
|
||||||
function fmtTime(ts: string) {
|
function fmtTime(ts: string) {
|
||||||
return new Date(ts).toLocaleString("zh-CN", {
|
return new Date(ts).toLocaleString("zh-CN", {
|
||||||
@@ -78,6 +97,14 @@ export default function TrafficPage() {
|
|||||||
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
|
const [detail, setDetail] = React.useState<TrafficDetail | null>(null);
|
||||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const [hosts, setHosts] = React.useState<TrafficHost[]>([]); // target picker
|
||||||
|
const [selectedHosts, setSelectedHosts] = React.useState<string[]>([]); // checked in picker
|
||||||
|
const [pickerOpen, setPickerOpen] = React.useState(false);
|
||||||
|
|
||||||
|
const [deleteMode, setDeleteMode] = React.useState<"filter" | "selected" | null>(null); // null = dialog closed
|
||||||
|
const [deleting, setDeleting] = React.useState(false);
|
||||||
|
const [reloadTick, setReloadTick] = React.useState(0); // manual refetch trigger
|
||||||
|
|
||||||
// Debounce both filters so we don't refetch on every keystroke.
|
// Debounce both filters so we don't refetch on every keystroke.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const t = setTimeout(() => setHostQ(host.trim()), 300);
|
const t = setTimeout(() => setHostQ(host.trim()), 300);
|
||||||
@@ -97,20 +124,55 @@ export default function TrafficPage() {
|
|||||||
// through history isn't yanked out from under the user.
|
// through history isn't yanked out from under the user.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let alive = true;
|
let alive = true;
|
||||||
const load = () =>
|
const load = () => {
|
||||||
api
|
api
|
||||||
.traffic(page, size, hostQ, method, queryQ)
|
.traffic(page, size, hostQ, method, queryQ)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (alive) setTraffic(r);
|
if (alive) setTraffic(r);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
api
|
||||||
|
.trafficHosts()
|
||||||
|
.then((r) => {
|
||||||
|
if (alive) setHosts(r.hosts ?? []);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
load();
|
load();
|
||||||
const t = page === 0 ? setInterval(load, 5000) : null;
|
const t = setInterval(() => {
|
||||||
|
if (page !== 0) return; // only auto-refresh the newest page
|
||||||
|
load();
|
||||||
|
}, 5000);
|
||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
if (t) clearInterval(t);
|
clearInterval(t);
|
||||||
};
|
};
|
||||||
}, [page, size, hostQ, method, queryQ]);
|
}, [page, size, hostQ, method, queryQ, reloadTick]);
|
||||||
|
|
||||||
|
// Delete traffic for the current host filter (substring) or the checked
|
||||||
|
// hosts (exact batch), then refetch.
|
||||||
|
const allSelected = hosts.length > 0 && hosts.every((h) => selectedHosts.includes(h.host));
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
setDeleting(true);
|
||||||
|
const p =
|
||||||
|
deleteMode === "selected"
|
||||||
|
? api.trafficDeleteHosts(selectedHosts)
|
||||||
|
: api.trafficDeleteHost(hostQ);
|
||||||
|
p.then(() => {
|
||||||
|
setDeleteMode(null);
|
||||||
|
setSelected(null);
|
||||||
|
setDetail(null);
|
||||||
|
if (deleteMode === "selected") {
|
||||||
|
setSelectedHosts([]);
|
||||||
|
setPickerOpen(false);
|
||||||
|
}
|
||||||
|
setPage(0);
|
||||||
|
setReloadTick((t) => t + 1);
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setDeleting(false));
|
||||||
|
};
|
||||||
|
|
||||||
// Lazy-load the raw request/response for the selected exchange.
|
// Lazy-load the raw request/response for the selected exchange.
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -177,6 +239,76 @@ export default function TrafficPage() {
|
|||||||
|
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" className="h-8">
|
||||||
|
<ListChecksIcon className="size-3.5" />
|
||||||
|
{selectedHosts.length > 0 ? `选择目标(${selectedHosts.length})` : "选择目标…"}
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-80 p-0 data-open:animate-none data-closed:animate-none"
|
||||||
|
align="start"
|
||||||
|
collisionPadding={16}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between border-b px-3 py-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">按目标批量删除</span>
|
||||||
|
{hosts.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
onClick={() => setSelectedHosts(allSelected ? [] : hosts.map((h) => h.host))}
|
||||||
|
>
|
||||||
|
{allSelected ? "取消全选" : "全选"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto">
|
||||||
|
{hosts.length === 0 ? (
|
||||||
|
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
|
||||||
|
暂无流量记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
hosts.map((h) => (
|
||||||
|
<label
|
||||||
|
key={h.host}
|
||||||
|
className="flex cursor-pointer items-center gap-2 px-3 py-1.5 text-xs hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedHosts.includes(h.host)}
|
||||||
|
onCheckedChange={() =>
|
||||||
|
setSelectedHosts((prev) =>
|
||||||
|
prev.includes(h.host)
|
||||||
|
? prev.filter((x) => x !== h.host)
|
||||||
|
: [...prev, h.host],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="truncate font-mono">{h.host}</span>
|
||||||
|
<span className="ml-auto shrink-0 tabular-nums text-muted-foreground">
|
||||||
|
{h.count}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="border-t p-2">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
disabled={selectedHosts.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteMode("selected");
|
||||||
|
setPickerOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
删除选中({selectedHosts.length})
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
<div className="relative w-48">
|
<div className="relative w-48">
|
||||||
<Input
|
<Input
|
||||||
placeholder="host…"
|
placeholder="host…"
|
||||||
@@ -185,6 +317,17 @@ export default function TrafficPage() {
|
|||||||
className="h-8"
|
className="h-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
disabled={!hostQ || deleting}
|
||||||
|
title={hostQ ? undefined : "先在左侧选择目标或输入 host"}
|
||||||
|
onClick={() => setDeleteMode("filter")}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-3.5" />
|
||||||
|
删除该目标
|
||||||
|
</Button>
|
||||||
<div className="relative max-w-sm flex-1">
|
<div className="relative max-w-sm flex-1">
|
||||||
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -378,6 +521,51 @@ export default function TrafficPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertDialog open={deleteMode !== null} onOpenChange={(o) => { if (!o) setDeleteMode(null); }}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{deleteMode === "selected"
|
||||||
|
? `删除选中的 ${selectedHosts.length} 个目标的全部流量?`
|
||||||
|
: "删除该目标的全部流量?"}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{deleteMode === "selected" ? (
|
||||||
|
<>
|
||||||
|
将永久删除{" "}
|
||||||
|
<span className="font-semibold tabular-nums">{selectedHosts.length}</span>{" "}
|
||||||
|
个目标(
|
||||||
|
<span className="font-mono">
|
||||||
|
{selectedHosts.slice(0, 3).join("、")}
|
||||||
|
{selectedHosts.length > 3 ? "…" : ""}
|
||||||
|
</span>
|
||||||
|
)的所有流量记录(含请求/响应原文),此操作不可撤销。
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
将永久删除 host 包含{" "}
|
||||||
|
<span className="font-mono font-semibold">{hostQ}</span>{" "}
|
||||||
|
的所有流量记录(含请求/响应原文),此操作不可撤销。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
confirmDelete();
|
||||||
|
}}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleting ? "删除中…" : "确认删除"}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
+14
-5
@@ -7,13 +7,13 @@ import { MOCK } from "@/lib/mock/enabled";
|
|||||||
import { mockHandle } from "@/lib/mock/handler";
|
import { mockHandle } from "@/lib/mock/handler";
|
||||||
import type {
|
import type {
|
||||||
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Activity,
|
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,
|
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
|
||||||
Tool, Conversation, AgentTrigger,
|
Tool, Conversation, AgentTrigger,
|
||||||
InterceptRule, InterceptPending, InterceptApprovalRow,
|
InterceptRule, InterceptPending, InterceptApprovalRow,
|
||||||
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
||||||
WorkspaceListing, WorkspaceFile,
|
WorkspaceListing, WorkspaceFile,
|
||||||
CommandRecord, LLMRecordItem, LLMRecordDetail,
|
CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
@@ -91,7 +91,7 @@ const get = <T>(p: string) => http<T>(p);
|
|||||||
const post = <T>(p: string, body?: unknown) => http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
|
const post = <T>(p: string, body?: unknown) => http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
|
||||||
const put = <T>(p: string, body?: unknown) => http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
|
const put = <T>(p: string, body?: unknown) => http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
|
||||||
const patch = <T>(p: string, body?: unknown) => http<T>(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined });
|
const patch = <T>(p: string, body?: unknown) => http<T>(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined });
|
||||||
const del = <T>(p: string) => http<T>(p, { method: "DELETE" });
|
const del = <T>(p: string, body?: unknown) => http<T>(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined });
|
||||||
|
|
||||||
// Go serializes nil slices as JSON null — coerce to [].
|
// Go serializes nil slices as JSON null — coerce to [].
|
||||||
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
|
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
|
||||||
@@ -253,6 +253,11 @@ export const api = {
|
|||||||
),
|
),
|
||||||
trafficExchange: (id: string) =>
|
trafficExchange: (id: string) =>
|
||||||
get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
get<TrafficDetail>(`/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) ----
|
// ---- app settings (runtime toggles) ----
|
||||||
settings: () => get<Settings>(`/settings`),
|
settings: () => get<Settings>(`/settings`),
|
||||||
@@ -543,13 +548,17 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// ---- LLM records ----
|
// ---- 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();
|
const sp = new URLSearchParams();
|
||||||
if (params?.model) sp.set("model", params.model);
|
if (params?.model) sp.set("model", params.model);
|
||||||
if (params?.session) sp.set("session", params.session);
|
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));
|
sp.set("size", String(params?.size ?? 50));
|
||||||
return get<{ records: LLMRecordItem[]; total: number }>(`/llm/records?${sp}`);
|
return get<{ records: LLMRecordItem[]; total: number }>(`/llm/records?${sp}`);
|
||||||
},
|
},
|
||||||
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
|
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
|
||||||
|
llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`),
|
||||||
|
llmRecordsDeleteTask: (task: string) =>
|
||||||
|
del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
|
||||||
};
|
};
|
||||||
@@ -8,7 +8,7 @@ import type {
|
|||||||
ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow,
|
ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow,
|
||||||
InterceptPending, InterceptRule, LLMProfile, MCPServer, MCPTool, PromptVar,
|
InterceptPending, InterceptRule, LLMProfile, MCPServer, MCPTool, PromptVar,
|
||||||
PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal,
|
PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal,
|
||||||
TokenUsage, Tool, TrafficResp, TrafficDetail,
|
TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
|
||||||
const T = (iso: string) => iso; // readability helper for timestamps
|
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<string, number> = {};
|
||||||
|
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 = {
|
export const trafficDetail: TrafficDetail = {
|
||||||
req: `GET /v1/orders?id=1002 HTTP/1.1
|
req: `GET /v1/orders?id=1002 HTTP/1.1
|
||||||
Host: api.acme.com
|
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(已退避重试)" },
|
{ 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<string, number> = {};
|
||||||
|
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) {
|
export function llmRecordDetail(id: number) {
|
||||||
const item = llmRecords.find((r) => r.id === id) ?? llmRecords[0];
|
const item = llmRecords.find((r) => r.id === id) ?? llmRecords[0];
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -90,6 +90,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
|||||||
|
|
||||||
// ── traffic / audit / settings ──
|
// ── traffic / audit / settings ──
|
||||||
if (path === "/audit") return D.audit;
|
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") return D.traffic;
|
||||||
if (path === "/traffic/exchange") return D.trafficDetail;
|
if (path === "/traffic/exchange") return D.trafficDetail;
|
||||||
if (path === "/settings" && m === "GET") return D.settings;
|
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 ──
|
// ── LLM ──
|
||||||
if (path === "/llm/records" && m === "GET") return { records: D.llmRecords, total: D.llmRecords.length };
|
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 (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 === "GET") return D.llmConfig;
|
||||||
if (path === "/llm" && m === "POST") return { ok: true };
|
if (path === "/llm" && m === "POST") return { ok: true };
|
||||||
|
|||||||
@@ -403,6 +403,12 @@ export interface TrafficDetail {
|
|||||||
resp: string;
|
resp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// One distinct recorded host with its exchange count (target picker).
|
||||||
|
export interface TrafficHost {
|
||||||
|
host: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ---- App settings (runtime toggles) ----
|
// ---- App settings (runtime toggles) ----
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
traffic_capture: boolean;
|
traffic_capture: boolean;
|
||||||
@@ -657,3 +663,9 @@ export interface LLMRecordDetail extends LLMRecordItem {
|
|||||||
request_body: string;
|
request_body: string;
|
||||||
response_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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user