This is an automated email from the ASF dual-hosted git repository. hsluoyz pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
commit ad0af43be220f5e5e7044f2f01694e1fb5c8c72e Author: Yang Luo <[email protected]> AuthorDate: Fri Sep 4 11:46:38 2026 +0800 feat(monitor): keep agent activity records in the database --- agentmonitor/monitor.go | 12 +- agentmonitor/record.go | 10 +- agentmonitor/sink.go | 236 ++----------------------- conf/app.conf | 4 +- conf/conf.go | 6 +- controllers/agent.go | 21 +-- controllers/setting.go | 1 - main.go | 7 +- object/agent_record.go | 346 +++++++++++++++++++++++++++++++++++++ object/ormer.go | 7 + web/src/locales/en/data.json | 4 +- web/src/locales/zh/data.json | 4 +- web/src/pages/AgentRecordsPage.tsx | 4 +- web/src/types.ts | 2 +- 14 files changed, 404 insertions(+), 260 deletions(-) diff --git a/agentmonitor/monitor.go b/agentmonitor/monitor.go index aa1d6d2..a9f60af 100644 --- a/agentmonitor/monitor.go +++ b/agentmonitor/monitor.go @@ -41,10 +41,10 @@ var monitorConfig = struct { pollInterval time.Duration }{stateDir: DefaultStateDir, pollInterval: DefaultPollInterval} -// Configure selects the directory used for declaration and cursor JSON files, -// how often local logs are rescanned, and how many records the live window -// holds. It must be called before Start when the defaults are unsuitable. -func Configure(stateDir string, pollInterval time.Duration, recordCapacity int) { +// Configure selects the directory used for declaration and cursor JSON files +// and how often local logs are rescanned. It must be called before Start when +// the defaults are unsuitable. +func Configure(stateDir string, pollInterval time.Duration) { monitorConfig.Lock() if strings.TrimSpace(stateDir) == "" { monitorConfig.stateDir = DefaultStateDir @@ -57,8 +57,6 @@ func Configure(stateDir string, pollInterval time.Duration, recordCapacity int) monitorConfig.pollInterval = pollInterval } monitorConfig.Unlock() - - SetRecordCapacity(recordCapacity) } func monitorStatePath(name string) string { @@ -81,7 +79,7 @@ func Start() error { return errors.Join(startCoworkMonitor(), codexMonitor.start(), openAgentMonitor.start()) } -// Stop stops every local tailer. It does not persist behaviour records. +// Stop stops every local tailer. func Stop() { stopCoworkMonitor() codexMonitor.stopMonitor() diff --git a/agentmonitor/record.go b/agentmonitor/record.go index 68390f0..5a20d21 100644 --- a/agentmonitor/record.go +++ b/agentmonitor/record.go @@ -18,7 +18,6 @@ import ( "time" "github.com/apache/casbin-gateway/auditutil" - "github.com/google/uuid" ) // IngestTokenHeader carries the per-installation secret that a hook or MCP @@ -26,10 +25,10 @@ import ( // packages and the patcher packages can share it without importing each other. const IngestTokenHeader = "X-Casbin-Gateway-Agent-Token" -// Record is one observed agent behaviour. It is deliberately independent from -// Gateway's HTTP traffic record and is retained only in the live monitor store. +// Record is one observed agent behaviour, as the tailers and the hooks report +// it. It is deliberately independent from Gateway's HTTP traffic record, and +// carries no identity of its own: the sink is what gives a stored record one. type Record struct { - Id string `json:"id"` CreatedTime string `json:"createdTime"` Agent string `json:"agent"` @@ -56,9 +55,6 @@ type Record struct { } func normalizeRecord(record *Record) { - if record.Id == "" { - record.Id = uuid.NewString() - } if record.CreatedTime == "" { record.CreatedTime = time.Now().Format(time.RFC3339Nano) } diff --git a/agentmonitor/sink.go b/agentmonitor/sink.go index 1659a3b..48ef4b4 100644 --- a/agentmonitor/sink.go +++ b/agentmonitor/sink.go @@ -14,232 +14,34 @@ package agentmonitor -import ( - "sort" - "strings" - "sync" - "time" -) +import "sync" -// DefaultRecordCapacity is the live diagnostic window retained by Gateway when -// no capacity is configured. Records live only in memory, so this value bounds -// how much a busy host can retain: each record may hold up to -// auditutil.MaxPayloadBytes of sanitized payload. -const DefaultRecordCapacity = 1000 +// RecordSink is where observed behaviour is kept. Storage lives outside this +// package so that tailing an agent's logs does not depend on the database. +type RecordSink func(*Record) -// RecordQuery narrows the live diagnostic window. Empty fields match all -// values and a non-positive Limit returns every matching record. -type RecordQuery struct { - Agent string - EventType string - Outcome string - Session string - Limit int +var recordSink struct { + sync.RWMutex + sink RecordSink } -// SessionSummary is the session-level view derived from the live records. -type SessionSummary struct { - SessionKey string `json:"sessionKey"` - Agent string `json:"agent"` - Title string `json:"title"` - RecordCount int `json:"recordCount"` - FirstTime string `json:"firstTime"` - LastTime string `json:"lastTime"` +// SetRecordSink installs the store every record is written to. A record +// observed before one is installed is dropped. +func SetRecordSink(sink RecordSink) { + recordSink.Lock() + recordSink.sink = sink + recordSink.Unlock() } -type storedRecord struct { - record Record - sequence uint64 -} - -// Store keeps a bounded, thread-safe window of recent monitoring records. -type Store struct { - mutex sync.RWMutex - entries []storedRecord - next int - count int - sequence uint64 -} - -var recordStore = newStore(DefaultRecordCapacity) - -func newStore(capacity int) *Store { - if capacity <= 0 { - capacity = DefaultRecordCapacity - } - return &Store{entries: make([]storedRecord, capacity)} -} - -// SetRecordCapacity resizes the live window, keeping the most recent records -// that still fit. It is a no-op when the capacity is unchanged. -func SetRecordCapacity(capacity int) { - recordStore.SetCapacity(capacity) -} - -// SetCapacity resizes the ring buffer in place, preserving arrival order. -func (store *Store) SetCapacity(capacity int) { - if capacity <= 0 { - capacity = DefaultRecordCapacity - } - - store.mutex.Lock() - defer store.mutex.Unlock() - if capacity == len(store.entries) { - return - } - - retained := store.snapshotLocked() - if len(retained) > capacity { - retained = retained[len(retained)-capacity:] - } - store.entries = make([]storedRecord, capacity) - copy(store.entries, retained) - store.count = len(retained) - store.next = store.count % capacity -} - -// AddRecord appends one event to Gateway's process-local monitoring window. +// AddRecord normalizes one observed behaviour and hands it to the sink. func AddRecord(record *Record) { - recordStore.Add(record) -} - -// Add normalizes and appends one record, replacing the oldest record once the -// capacity is reached. -func (store *Store) Add(record *Record) { value := *record normalizeRecord(&value) - store.mutex.Lock() - store.sequence++ - store.entries[store.next] = storedRecord{record: value, sequence: store.sequence} - store.next = (store.next + 1) % len(store.entries) - if store.count < len(store.entries) { - store.count++ - } - store.mutex.Unlock() -} - -// ListRecords returns matching records ordered by their reported time, newest -// first. Ties preserve their arrival order. -func ListRecords(query RecordQuery) []Record { - return recordStore.List(query) -} - -// List returns matching records ordered by reported time, newest first. -func (store *Store) List(query RecordQuery) []Record { - store.mutex.RLock() - matches := make([]storedRecord, 0, store.count) - for _, entry := range store.snapshotLocked() { - if matchesRecord(entry.record, query) { - matches = append(matches, entry) - } - } - store.mutex.RUnlock() - - sort.SliceStable(matches, func(left, right int) bool { - leftTime, rightTime := recordTime(matches[left].record), recordTime(matches[right].record) - if leftTime.Equal(rightTime) { - return matches[left].sequence > matches[right].sequence - } - return leftTime.After(rightTime) - }) - if query.Limit > 0 && len(matches) > query.Limit { - matches = matches[:query.Limit] - } - result := make([]Record, len(matches)) - for index, entry := range matches { - result[index] = entry.record - } - return result -} - -// ListSessions returns session summaries newest first. -func ListSessions(query RecordQuery) []SessionSummary { - return recordStore.Sessions(query) -} - -// Sessions groups matching records by their session key. -func (store *Store) Sessions(query RecordQuery) []SessionSummary { - query.Limit = 0 - records := store.List(query) - groups := map[string][]Record{} - order := make([]string, 0) - for _, record := range records { - if record.SessionKey == "" { - continue - } - key := record.Agent + "\x00" + record.SessionKey - if _, found := groups[key]; !found { - order = append(order, key) - } - groups[key] = append(groups[key], record) - } - - result := make([]SessionSummary, 0, len(order)) - for _, key := range order { - group := groups[key] - newest, oldest := group[0], group[len(group)-1] - result = append(result, SessionSummary{ - SessionKey: newest.SessionKey, - Agent: newest.Agent, - Title: sessionTitle(group), - RecordCount: len(group), - FirstTime: oldest.CreatedTime, - LastTime: newest.CreatedTime, - }) - } - return result -} - -func (store *Store) snapshotLocked() []storedRecord { - result := make([]storedRecord, 0, store.count) - start := 0 - if store.count == len(store.entries) { - start = store.next - } - for index := 0; index < store.count; index++ { - result = append(result, store.entries[(start+index)%len(store.entries)]) - } - return result -} - -func matchesRecord(record Record, query RecordQuery) bool { - return matchesFilter(record.Agent, query.Agent) && - matchesFilter(record.EventType, query.EventType) && - matchesFilter(record.Outcome, query.Outcome) && - matchesFilter(record.SessionKey, query.Session) -} - -func matchesFilter(value, filter string) bool { - return filter == "" || strings.EqualFold(value, filter) -} - -func recordTime(record Record) time.Time { - parsed, err := time.Parse(time.RFC3339Nano, record.CreatedTime) - if err != nil { - return time.Time{} - } - return parsed -} - -func sessionTitle(newestFirst []Record) string { - for _, record := range newestFirst { - if record.Title != "" { - return record.Title - } - } - for index := len(newestFirst) - 1; index >= 0; index-- { - record := newestFirst[index] - if record.McpServer != "" { - if record.McpTool != "" { - return record.McpServer + " / " + record.McpTool - } - return record.McpServer - } - if record.ToolName != "" { - return record.ToolName - } + recordSink.RLock() + sink := recordSink.sink + recordSink.RUnlock() + if sink != nil { + sink(&value) } - oldest := newestFirst[len(newestFirst)-1] - return strings.TrimSpace(oldest.EventType + " " + oldest.Action) } diff --git a/conf/app.conf b/conf/app.conf index 5d5266e..0f93c1e 100644 --- a/conf/app.conf +++ b/conf/app.conf @@ -63,8 +63,8 @@ isDemoMode = false ; Agent patch manifests, config backups, ingest tokens and file-tail cursors. agentPatchStateDir = "./data/agent-patches" -; How many agent monitoring records to keep. They are never persisted, so this -; is also the memory bound for the live window. +; How many agent monitoring records to keep. They are stored in the database, +; and the oldest are pruned once the table is over this many rows. agentRecordCapacity = 1000 ; How often to rescan the append-only logs of file-tailed agents, in seconds. agentMonitorPollSeconds = 5 diff --git a/conf/conf.go b/conf/conf.go index dce2299..f6cc65b 100644 --- a/conf/conf.go +++ b/conf/conf.go @@ -313,9 +313,9 @@ func GetAgentPatchStateDir() string { return dir } -// GetAgentRecordCapacity is how many agent monitoring records Gateway keeps in -// memory. Records are never written to disk, so this value alone bounds the -// memory the live window can occupy. +// GetAgentRecordCapacity is how many agent monitoring records Gateway keeps. +// They are stored in the database, and the oldest are pruned once the table is +// over this many rows. func GetAgentRecordCapacity() int { res, err := strconv.Atoi(GetConfigString("agentRecordCapacity")) if err != nil || res <= 0 { diff --git a/controllers/agent.go b/controllers/agent.go index 0b0dc79..c4d124c 100644 --- a/controllers/agent.go +++ b/controllers/agent.go @@ -240,7 +240,7 @@ func (c *ApiController) UnpatchAgent() { c.ResponseOk(agentpatch.StatusOf(target)) } -// GetAgentRecords returns the current process's in-memory agent activity. +// GetAgentRecords returns the stored agent activity. func (c *ApiController) GetAgentRecords() { if c.RequireAdmin() { return @@ -255,7 +255,7 @@ func (c *ApiController) GetAgentRecords() { } limit = parsed } - c.ResponseOk(agentmonitor.ListRecords(agentmonitor.RecordQuery{ + c.ResponseOk(object.GetAgentRecords(object.AgentRecordFilter{ Agent: c.Input().Get("agent"), EventType: c.Input().Get("eventType"), Outcome: c.Input().Get("outcome"), @@ -264,30 +264,21 @@ func (c *ApiController) GetAgentRecords() { })) } -// GetAgentSessions groups the current in-memory records by agent session. The -// optional agent filter is what an agent's own detail page asks for. +// GetAgentSessions groups the stored records by agent session. The optional +// agent filter is what an agent's own detail page asks for. func (c *ApiController) GetAgentSessions() { if c.RequireAdmin() { return } agentId := c.Input().Get("agent") - live := agentmonitor.ListSessions(agentmonitor.RecordQuery{Agent: agentId}) + sessions := object.GetAgentRecordSessions(agentId) // The transcripts on disk are the sessions that already happened, so they // are listed next to the monitored ones rather than only after Patch. - sessions := make([]agenthistory.Session, 0, len(live)) seen := map[string]bool{} - for _, session := range live { + for _, session := range sessions { seen[sessionSeenKey(session.Agent, session.SessionKey)] = true - sessions = append(sessions, agenthistory.Session{ - Agent: session.Agent, - SessionKey: session.SessionKey, - Title: session.Title, - RecordCount: session.RecordCount, - FirstTime: session.FirstTime, - LastTime: session.LastTime, - }) } for _, session := range object.HistoricalSessions(agentId) { if seen[sessionSeenKey(session.Agent, session.SessionKey)] { diff --git a/controllers/setting.go b/controllers/setting.go index fd97713..0a66b1b 100644 --- a/controllers/setting.go +++ b/controllers/setting.go @@ -110,7 +110,6 @@ func applySetting(previous *object.Setting, setting *object.Setting) error { agentmonitor.Configure( conf.GetAgentPatchStateDir(), time.Duration(conf.GetAgentMonitorPollSeconds())*time.Second, - conf.GetAgentRecordCapacity(), ) if setting.LlmRecordMode == conf.LlmRecordOff { diff --git a/main.go b/main.go index e7802c0..3639947 100644 --- a/main.go +++ b/main.go @@ -105,8 +105,12 @@ func main() { agentmonitor.Configure( conf.GetAgentPatchStateDir(), time.Duration(conf.GetAgentMonitorPollSeconds())*time.Second, - conf.GetAgentRecordCapacity(), ) + // Monitoring observes; the database is what keeps what it saw, so records + // outlive the process that collected them. + object.StartAgentRecordWriter() + defer object.StopAgentRecordWriter() + agentmonitor.SetRecordSink(object.AddAgentRecord) if err := agentmonitor.Start(); err != nil { beego.Error("agent monitor could not start:", err) } @@ -125,6 +129,7 @@ func main() { // calls above would have flushed has to be flushed there instead. version.BeforeRestart = func() { agentmonitor.Stop() + object.StopAgentRecordWriter() object.StopLlmRecordWriter() } diff --git a/object/agent_record.go b/object/agent_record.go new file mode 100644 index 0000000..f930f0a --- /dev/null +++ b/object/agent_record.go @@ -0,0 +1,346 @@ +// Copyright 2026 The casbin Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package object + +import ( + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/apache/casbin-gateway/agenthistory" + "github.com/apache/casbin-gateway/agentmonitor" + "github.com/apache/casbin-gateway/conf" + "github.com/beego/beego" + "github.com/xorm-io/xorm" +) + +// agentRecordQueueCapacity bounds the records waiting to be written. A tailer +// must never wait on the database, so a full queue drops instead. +const agentRecordQueueCapacity = 1000 + +// AgentRecord is one observed agent behaviour, reported by a log tailer, a hook +// or the MCP recorder. Unlike LlmRecord it says nothing about what Gateway +// relayed: an agent talking to its vendor directly is recorded here all the same. +type AgentRecord struct { + Id int64 `xorm:"int notnull pk autoincr" json:"id"` + CreatedTime string `xorm:"varchar(100) notnull" json:"createdTime"` + // SortKey is the created time in Unix nanoseconds. A tailer reports times in + // whichever zone the log it reads is written in, so the text column alone + // does not order rows the way a clock would. + SortKey int64 `xorm:"bigint index" json:"-"` + + Agent string `xorm:"varchar(201) index" json:"agent"` + AgentPath string `xorm:"varchar(500)" json:"agentPath,omitempty"` + ClientIp string `xorm:"varchar(100)" json:"clientIp,omitempty"` + User string `xorm:"varchar(201)" json:"user,omitempty"` + + EventType string `xorm:"varchar(50) index" json:"eventType"` + Action string `xorm:"varchar(100)" json:"action"` + Outcome string `xorm:"varchar(50) index" json:"outcome,omitempty"` + SessionKey string `xorm:"varchar(201) index" json:"sessionKey,omitempty"` + Title string `xorm:"varchar(500)" json:"title,omitempty"` + + PromptId string `xorm:"varchar(201)" json:"promptId,omitempty"` + ToolUseId string `xorm:"varchar(201)" json:"toolUseId,omitempty"` + ToolName string `xorm:"varchar(201)" json:"toolName,omitempty"` + McpServer string `xorm:"varchar(201)" json:"mcpServer,omitempty"` + McpTool string `xorm:"varchar(201)" json:"mcpTool,omitempty"` + Model string `xorm:"varchar(255)" json:"model,omitempty"` + DurationMs int64 `xorm:"bigint" json:"durationMs,omitempty"` + + Object string `xorm:"mediumtext" json:"object,omitempty"` + Detail string `xorm:"mediumtext" json:"detail,omitempty"` +} + +// AgentRecordFilter narrows the record list. Empty fields match everything and +// a non-positive Limit returns every matching record. +type AgentRecordFilter struct { + Agent string + EventType string + Outcome string + Session string + Limit int +} + +// agentSessionColumns are what grouping records into sessions reads. A record +// may carry tens of kilobytes of payload, so the summary never selects one. +var agentSessionColumns = []string{ + "agent", "session_key", "title", "created_time", + "event_type", "action", "tool_name", "mcp_server", "mcp_tool", +} + +type agentRecordWriter struct { + mutex sync.RWMutex + queue chan *AgentRecord + done chan struct{} + + dropped atomic.Int64 + dropLogTime atomic.Int64 +} + +var agentWriter agentRecordWriter + +// StartAgentRecordWriter starts the goroutine that owns every agent record +// write, so a tailer or a hook never blocks on the database. +func StartAgentRecordWriter() { + agentWriter.mutex.Lock() + defer agentWriter.mutex.Unlock() + if agentWriter.queue != nil { + return + } + agentWriter.queue = make(chan *AgentRecord, agentRecordQueueCapacity) + agentWriter.done = make(chan struct{}) + go agentWriter.run(agentWriter.queue, agentWriter.done) +} + +// StopAgentRecordWriter drains the records already accepted. +func StopAgentRecordWriter() { + agentWriter.mutex.Lock() + queue, done := agentWriter.queue, agentWriter.done + agentWriter.queue, agentWriter.done = nil, nil + agentWriter.mutex.Unlock() + + if queue == nil { + return + } + close(queue) + <-done +} + +// AddAgentRecord queues one observed behaviour. It is the sink agentmonitor +// reports to, and is best effort: a full queue drops the record rather than +// holding a tailer up. +func AddAgentRecord(record *agentmonitor.Record) { + if record == nil { + return + } + + agentWriter.mutex.RLock() + defer agentWriter.mutex.RUnlock() + if agentWriter.queue == nil { + return + } + select { + case agentWriter.queue <- newAgentRecord(record): + default: + agentWriter.noteDrop() + } +} + +func newAgentRecord(record *agentmonitor.Record) *AgentRecord { + return &AgentRecord{ + CreatedTime: record.CreatedTime, + SortKey: agentRecordSortKey(record.CreatedTime), + Agent: record.Agent, + AgentPath: record.AgentPath, + ClientIp: record.ClientIp, + User: record.User, + EventType: record.EventType, + Action: record.Action, + Outcome: record.Outcome, + SessionKey: record.SessionKey, + Title: record.Title, + PromptId: record.PromptId, + ToolUseId: record.ToolUseId, + ToolName: record.ToolName, + McpServer: record.McpServer, + McpTool: record.McpTool, + Model: record.Model, + DurationMs: record.DurationMs, + Object: record.Object, + Detail: record.Detail, + } +} + +func agentRecordSortKey(createdTime string) int64 { + parsed, err := time.Parse(time.RFC3339Nano, createdTime) + if err != nil { + return time.Now().UnixNano() + } + return parsed.UnixNano() +} + +func (writer *agentRecordWriter) noteDrop() { + dropped := writer.dropped.Add(1) + last := writer.dropLogTime.Load() + now := time.Now().Unix() + if now-last < 60 || !writer.dropLogTime.CompareAndSwap(last, now) { + return + } + beego.Error("agent record queue is full, dropped records so far:", dropped) +} + +func (writer *agentRecordWriter) run(queue chan *AgentRecord, done chan struct{}) { + defer close(done) + + writer.prune() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case record, ok := <-queue: + if !ok { + return + } + writer.write(record) + case <-ticker.C: + writer.prune() + } + } +} + +func (writer *agentRecordWriter) write(record *AgentRecord) { + if ormer == nil || ormer.Engine == nil { + return + } + if _, err := ormer.Engine.Insert(record); err != nil { + beego.Error("agent record write failed:", err) + } +} + +// prune keeps the newest agentRecordCapacity rows. The records are a monitoring +// window rather than an audit trail, so the cap is the only retention rule. +func (writer *agentRecordWriter) prune() { + if ormer == nil || ormer.Engine == nil { + return + } + + count, err := ormer.Engine.Count(&AgentRecord{}) + if err != nil { + beego.Error("agent record retention count failed:", err) + return + } + maximum := int64(conf.GetAgentRecordCapacity()) + if count <= maximum { + return + } + + oldest := []AgentRecord{} + if err := ormer.Engine.Cols("id").Asc("id").Limit(int(count - maximum)).Find(&oldest); err != nil { + beego.Error("agent record retention lookup failed:", err) + return + } + if len(oldest) == 0 { + return + } + ids := make([]int64, 0, len(oldest)) + for _, record := range oldest { + ids = append(ids, record.Id) + } + if _, err := ormer.Engine.In("id", ids).Delete(&AgentRecord{}); err != nil { + beego.Error("agent record retention deletion failed:", err) + } +} + +// GetAgentRecords returns matching records newest first. +func GetAgentRecords(filter AgentRecordFilter) []*AgentRecord { + records := []*AgentRecord{} + if ormer == nil || ormer.Engine == nil { + return records + } + + session := agentRecordSession(filter).Desc("sort_key").Desc("id") + if filter.Limit > 0 { + session = session.Limit(filter.Limit) + } + if err := session.Find(&records); err != nil { + beego.Error("agent record lookup failed:", err) + return []*AgentRecord{} + } + return records +} + +// GetAgentRecordSessions groups the stored records into the sessions they +// belong to, newest first. It is the monitored half of the Sessions page, the +// other half being the transcripts on disk. +func GetAgentRecordSessions(agentId string) []agenthistory.Session { + sessions := []agenthistory.Session{} + if ormer == nil || ormer.Engine == nil { + return sessions + } + + records := []*AgentRecord{} + query := agentRecordSession(AgentRecordFilter{Agent: agentId}).Cols(agentSessionColumns...) + if err := query.And("session_key <> ?", "").Desc("sort_key").Desc("id").Find(&records); err != nil { + beego.Error("agent session lookup failed:", err) + return sessions + } + + groups := map[string][]*AgentRecord{} + order := []string{} + for _, record := range records { + key := record.Agent + "\x00" + record.SessionKey + if _, found := groups[key]; !found { + order = append(order, key) + } + groups[key] = append(groups[key], record) + } + + for _, key := range order { + group := groups[key] + newest, oldest := group[0], group[len(group)-1] + sessions = append(sessions, agenthistory.Session{ + Agent: newest.Agent, + SessionKey: newest.SessionKey, + Title: agentSessionTitle(group), + RecordCount: len(group), + FirstTime: oldest.CreatedTime, + LastTime: newest.CreatedTime, + }) + } + return sessions +} + +func agentRecordSession(filter AgentRecordFilter) *xorm.Session { + session := ormer.Engine.NewSession() + if filter.Agent != "" { + session = session.And("lower(agent) = ?", strings.ToLower(filter.Agent)) + } + if filter.EventType != "" { + session = session.And("lower(event_type) = ?", strings.ToLower(filter.EventType)) + } + if filter.Outcome != "" { + session = session.And("lower(outcome) = ?", strings.ToLower(filter.Outcome)) + } + if filter.Session != "" { + session = session.And("lower(session_key) = ?", strings.ToLower(filter.Session)) + } + return session +} + +// agentSessionTitle names a session by what it was seen doing: the title the +// agent reported, or failing that the first thing it called. +func agentSessionTitle(newestFirst []*AgentRecord) string { + for _, record := range newestFirst { + if record.Title != "" { + return record.Title + } + } + for index := len(newestFirst) - 1; index >= 0; index-- { + record := newestFirst[index] + if record.McpServer != "" { + if record.McpTool != "" { + return record.McpServer + " / " + record.McpTool + } + return record.McpServer + } + if record.ToolName != "" { + return record.ToolName + } + } + oldest := newestFirst[len(newestFirst)-1] + return strings.TrimSpace(oldest.EventType + " " + oldest.Action) +} diff --git a/object/ormer.go b/object/ormer.go index fc51463..a26aba8 100644 --- a/object/ormer.go +++ b/object/ormer.go @@ -288,6 +288,13 @@ func (a *Ormer) createTable() { panic(err) } + // What the monitored agents on this host were seen doing, which is what the + // Activity page is made of. + err = a.Engine.Sync2(new(AgentRecord)) + if err != nil { + panic(err) + } + // What an active probe found the last few times it asked a provider's // upstream the questions the passive records cannot answer. err = a.Engine.Sync2(new(ProviderProbe)) diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json index f32cea6..fe1fa13 100644 --- a/web/src/locales/en/data.json +++ b/web/src/locales/en/data.json @@ -197,7 +197,7 @@ "Read many files at once": "Read many files at once", "Reading the project": "Reading the project", "Records": "Records", - "Records are kept in memory only and are lost when Gateway restarts": "Records are kept in memory only and are lost when Gateway restarts", + "Records are stored in the database and survive a Gateway restart": "Records are stored in the database and survive a Gateway restart", "Remember something": "Remember something", "Remove instance": "Remove instance", "Remove instance hint": "Gateway forgets this instance. Its state directory, and the account signed in to it, are left on disk.", @@ -1042,7 +1042,7 @@ "Added": "Added", "Agent poll seconds": "Agent poll interval, in seconds", "Agent record capacity": "Agent records kept", - "Agent record capacity hint": "Agent records are never written to disk, so this also bounds the memory they use.", + "Agent record capacity hint": "The oldest records are pruned once the table is over this many rows.", "Agent routing": "Agent routing", "Agent state dir": "Agent state directory", "Agent state dir hint": "Patch manifests, config backups, ingest tokens and file-tail cursors.", diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json index b695a8c..d443060 100644 --- a/web/src/locales/zh/data.json +++ b/web/src/locales/zh/data.json @@ -197,7 +197,7 @@ "Read many files at once": "一次读取多个文件", "Reading the project": "读取项目", "Records": "记录数", - "Records are kept in memory only and are lost when Gateway restarts": "记录仅保存在内存中,Gateway 重启后会丢失", + "Records are stored in the database and survive a Gateway restart": "记录保存在数据库中,Gateway 重启后依然存在", "Remember something": "记住信息", "Remove instance": "移除实例", "Remove instance hint": "Gateway 将不再管理该实例。它的状态目录以及其中登录的账号仍保留在磁盘上。", @@ -1114,7 +1114,7 @@ "Added": "新增", "Agent poll seconds": "Agent 轮询间隔(秒)", "Agent record capacity": "Agent 记录条数", - "Agent record capacity hint": "Agent 记录不会落盘,因此这个值也决定了它们占用的内存上限。", + "Agent record capacity hint": "记录条数超过这个值后,最旧的记录会被清理。", "Agent routing": "代理路由", "Agent state dir": "Agent 状态目录", "Agent state dir hint": "补丁清单、配置备份、上报令牌和文件读取位置。", diff --git a/web/src/pages/AgentRecordsPage.tsx b/web/src/pages/AgentRecordsPage.tsx index 96d6b6a..4daf118 100644 --- a/web/src/pages/AgentRecordsPage.tsx +++ b/web/src/pages/AgentRecordsPage.tsx @@ -33,7 +33,7 @@ import {Label} from "@/components/ui/label"; import {Switch} from "@/components/ui/switch"; import type {Account, AgentRecord} from "@/types"; -// The server keeps a bounded in-memory window; these are the slices of it the +// The server keeps a bounded window of rows; these are the slices of it the // page can ask for. Without a control here the UI could only ever reach the // first 200 of the records the backend was holding. const limitOptions = [200, 500, 1000, 5000]; @@ -268,7 +268,7 @@ export default function AgentRecordsPage({account}: {account: Account}) { <PageContainer> <PageHeader title={i18next.t("agent:Agent Records")} - description={i18next.t("agent:Records are kept in memory only and are lost when Gateway restarts")} + description={i18next.t("agent:Records are stored in the database and survive a Gateway restart")} actions={ <> <Label className="text-sm font-normal"> diff --git a/web/src/types.ts b/web/src/types.ts index c7ef833..9291c12 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -340,7 +340,7 @@ export interface ProviderHealth { } export interface AgentRecord { - id: string; + id: number; createdTime: string; agent: string; agentPath?: string; --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
