mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 14:16:30 +00:00
feat: add token usage tracking, cost analytics, budget enforcement, wake API, and activity audit trail (#142)
- A1+C2: Include token usage in run.completed event payload for WS clients
- A2: Cost tracking with model pricing config, cost calculation, and cost summary API
- A3: Budget enforcement per agent with monthly budget limits (migration 000015)
- C1: External wake/trigger API (POST /v1/agents/{id}/wake) for orchestrators
- C3: Activity audit trail with structured logging and queryable API
- UI: Activity page, cost stat card on overview, budget section in agent detail
- i18n: Complete en/vi/zh translations for all new features
This commit is contained in:
1 parent
bef428c124
commit
0926d053b0
50 files changed
+1090
-32
No files matched your search
@@ -603,6 +603,9 @@ func runGateway() {
|
||||
if tracesH != nil {
|
||||
server.SetTracesHandler(tracesH)
|
||||
}
|
||||
// External wake/trigger API
|
||||
wakeH := httpapi.NewWakeHandler(agentRouter, cfg.Gateway.Token)
|
||||
server.SetWakeHandler(wakeH)
|
||||
if mcpH != nil {
|
||||
server.SetMCPHandler(mcpH)
|
||||
}
|
||||
@@ -630,6 +633,11 @@ func runGateway() {
|
||||
server.SetPendingMessagesHandler(pendingMessagesH)
|
||||
}
|
||||
|
||||
// Activity audit log API
|
||||
if pgStores.Activity != nil {
|
||||
server.SetActivityHandler(httpapi.NewActivityHandler(pgStores.Activity, cfg.Gateway.Token))
|
||||
}
|
||||
|
||||
// Memory management API (wired directly, only needs MemoryStore + token)
|
||||
if pgStores != nil && pgStores.Memory != nil {
|
||||
server.SetMemoryHandler(httpapi.NewMemoryHandler(pgStores.Memory, cfg.Gateway.Token))
|
||||
|
||||
@@ -28,6 +28,9 @@ func wireHTTP(stores *store.Stores, token string, msgBus *bus.MessageBus, toolsR
|
||||
summoner = httpapi.NewAgentSummoner(stores.Agents, providerReg, msgBus)
|
||||
}
|
||||
agentsH = httpapi.NewAgentsHandler(stores.Agents, token, msgBus, summoner, isOwner)
|
||||
if stores.Activity != nil {
|
||||
agentsH.SetActivityStore(stores.Activity)
|
||||
}
|
||||
}
|
||||
|
||||
if stores != nil && stores.Skills != nil {
|
||||
|
||||
@@ -152,6 +152,8 @@ func wireExtras(
|
||||
MCPPool: mcpPool,
|
||||
GroupWriterCache: groupWriterCache,
|
||||
MediaStore: mediaStore,
|
||||
ModelPricing: appCfg.Telemetry.ModelPricing,
|
||||
TracingStore: stores.Tracing,
|
||||
OnEvent: func(event agent.AgentEvent) {
|
||||
msgBus.Broadcast(bus.Event{
|
||||
Name: protocol.EventAgent,
|
||||
|
||||
@@ -369,6 +369,19 @@ func (l *Loop) runLoop(ctx context.Context, req RunRequest) (*RunResult, error)
|
||||
maxIter = req.MaxIterations
|
||||
}
|
||||
|
||||
// Budget check: query monthly spent once before starting iterations.
|
||||
if l.budgetMonthlyCents > 0 && l.tracingStore != nil && l.agentUUID != uuid.Nil {
|
||||
now := time.Now().UTC()
|
||||
spent, err := l.tracingStore.GetMonthlyAgentCost(ctx, l.agentUUID, now.Year(), now.Month())
|
||||
if err == nil {
|
||||
spentCents := int(spent * 100)
|
||||
if spentCents >= l.budgetMonthlyCents {
|
||||
slog.Warn("agent budget exceeded", "agent", l.id, "spent_cents", spentCents, "budget_cents", l.budgetMonthlyCents)
|
||||
return nil, fmt.Errorf("monthly budget exceeded ($%.2f / $%.2f)", spent, float64(l.budgetMonthlyCents)/100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for iteration < maxIter {
|
||||
iteration++
|
||||
|
||||
|
||||
@@ -157,11 +157,21 @@ func (l *Loop) Run(ctx context.Context, req RunRequest) (*RunResult, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
completedPayload := map[string]any{"content": result.Content}
|
||||
if result.Usage != nil {
|
||||
completedPayload["usage"] = map[string]any{
|
||||
"prompt_tokens": result.Usage.PromptTokens,
|
||||
"completion_tokens": result.Usage.CompletionTokens,
|
||||
"total_tokens": result.Usage.TotalTokens,
|
||||
"cache_creation_tokens": result.Usage.CacheCreationTokens,
|
||||
"cache_read_tokens": result.Usage.CacheReadTokens,
|
||||
}
|
||||
}
|
||||
emitRun(AgentEvent{
|
||||
Type: protocol.AgentEventRunCompleted,
|
||||
AgentID: l.id,
|
||||
RunID: req.RunID,
|
||||
Payload: map[string]any{"content": result.Content},
|
||||
Payload: completedPayload,
|
||||
})
|
||||
if !isChildTrace && l.traceCollector != nil && traceID != uuid.Nil {
|
||||
l.traceCollector.FinishTrace(ctx, traceID, store.TraceStatusCompleted, "", truncateStr(result.Content, 500))
|
||||
|
||||
@@ -131,6 +131,13 @@ func (l *Loop) emitLLMSpanEnd(ctx context.Context, spanID uuid.UUID, start time.
|
||||
}
|
||||
}
|
||||
}
|
||||
// Calculate cost if pricing config is available.
|
||||
if pricing := tracing.LookupPricing(l.modelPricing, l.provider.Name(), l.model); pricing != nil {
|
||||
cost := tracing.CalculateCost(pricing, resp.Usage)
|
||||
if cost > 0 {
|
||||
updates["total_cost"] = cost
|
||||
}
|
||||
}
|
||||
updates["finish_reason"] = resp.FinishReason
|
||||
verbose := collector.Verbose()
|
||||
if verbose {
|
||||
@@ -237,6 +244,15 @@ func (l *Loop) emitToolSpanEnd(ctx context.Context, spanID uuid.UUID, start time
|
||||
updates["metadata"] = b
|
||||
}
|
||||
}
|
||||
// Calculate cost for tool's internal LLM calls.
|
||||
provider := result.Provider
|
||||
model := result.Model
|
||||
if pricing := tracing.LookupPricing(l.modelPricing, provider, model); pricing != nil {
|
||||
cost := tracing.CalculateCost(pricing, result.Usage)
|
||||
if cost > 0 {
|
||||
updates["total_cost"] = cost
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collector.EmitSpanUpdate(spanID, traceID, updates)
|
||||
|
||||
@@ -109,6 +109,13 @@ type Loop struct {
|
||||
|
||||
// Persistent media storage for cross-turn image/document access
|
||||
mediaStore *media.Store
|
||||
|
||||
// Model pricing config for cost tracking (nil = no cost calculation)
|
||||
modelPricing map[string]*config.ModelPricing
|
||||
|
||||
// Budget enforcement: monthly spending limit in cents (0 = unlimited)
|
||||
budgetMonthlyCents int
|
||||
tracingStore store.TracingStore
|
||||
}
|
||||
|
||||
// AgentEvent is emitted during agent execution for WS broadcasting.
|
||||
@@ -199,6 +206,13 @@ type LoopConfig struct {
|
||||
|
||||
// Persistent media storage for cross-turn image/document access
|
||||
MediaStore *media.Store
|
||||
|
||||
// Model pricing for cost tracking (key = "provider/model" or "model")
|
||||
ModelPricing map[string]*config.ModelPricing
|
||||
|
||||
// Budget enforcement
|
||||
BudgetMonthlyCents int
|
||||
TracingStore store.TracingStore
|
||||
}
|
||||
|
||||
func NewLoop(cfg LoopConfig) *Loop {
|
||||
@@ -263,6 +277,9 @@ func NewLoop(cfg LoopConfig) *Loop {
|
||||
groupWriterCache: cfg.GroupWriterCache,
|
||||
teamStore: cfg.TeamStore,
|
||||
mediaStore: cfg.MediaStore,
|
||||
modelPricing: cfg.ModelPricing,
|
||||
budgetMonthlyCents: cfg.BudgetMonthlyCents,
|
||||
tracingStore: cfg.TracingStore,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,12 @@ type ResolverDeps struct {
|
||||
|
||||
// Persistent media storage for cross-turn image/document access
|
||||
MediaStore *media.Store
|
||||
|
||||
// Model pricing for cost tracking
|
||||
ModelPricing map[string]*config.ModelPricing
|
||||
|
||||
// Tracing store for budget enforcement queries
|
||||
TracingStore store.TracingStore
|
||||
}
|
||||
|
||||
// NewManagedResolver creates a ResolverFunc that builds Loops from DB agent data.
|
||||
@@ -375,6 +381,9 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
|
||||
GroupWriterCache: deps.GroupWriterCache,
|
||||
TeamStore: deps.TeamStore,
|
||||
MediaStore: deps.MediaStore,
|
||||
ModelPricing: deps.ModelPricing,
|
||||
BudgetMonthlyCents: derefInt(ag.BudgetMonthlyCents),
|
||||
TracingStore: deps.TracingStore,
|
||||
})
|
||||
|
||||
slog.Info("resolved agent from DB", "agent", agentKey, "model", ag.Model, "provider", ag.Provider)
|
||||
@@ -400,3 +409,10 @@ func (r *Router) InvalidateAll() {
|
||||
slog.Debug("invalidated all agent caches")
|
||||
}
|
||||
|
||||
func derefInt(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ type QuotaUsageResult struct {
|
||||
RequestsToday int `json:"requestsToday"`
|
||||
InputTokensToday int64 `json:"inputTokensToday"`
|
||||
OutputTokensToday int64 `json:"outputTokensToday"`
|
||||
CostToday float64 `json:"costToday"`
|
||||
UniqueUsersToday int `json:"uniqueUsersToday"`
|
||||
Entries []QuotaUsageEntry `json:"entries"`
|
||||
}
|
||||
@@ -276,11 +277,12 @@ func QueryTodaySummary(ctx context.Context, db *sql.DB, result *QuotaUsageResult
|
||||
COUNT(*),
|
||||
COALESCE(SUM(total_input_tokens), 0),
|
||||
COALESCE(SUM(total_output_tokens), 0),
|
||||
COALESCE(SUM(total_cost), 0),
|
||||
COUNT(DISTINCT user_id)
|
||||
FROM traces
|
||||
WHERE parent_trace_id IS NULL AND created_at >= $1`,
|
||||
startOfDay,
|
||||
).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.UniqueUsersToday)
|
||||
).Scan(&result.RequestsToday, &result.InputTokensToday, &result.OutputTokensToday, &result.CostToday, &result.UniqueUsersToday)
|
||||
if err != nil {
|
||||
slog.Warn("quota.usage: failed to query today summary", "error", err)
|
||||
}
|
||||
|
||||
@@ -296,16 +296,25 @@ func (sc *SandboxConfig) ToSandboxConfig() sandbox.Config {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// ModelPricing defines per-million-token pricing for a model.
|
||||
type ModelPricing struct {
|
||||
InputPerMillion float64 `json:"input_per_million"`
|
||||
OutputPerMillion float64 `json:"output_per_million"`
|
||||
CacheReadPerMillion float64 `json:"cache_read_per_million,omitempty"`
|
||||
CacheCreatePerMillion float64 `json:"cache_create_per_million,omitempty"`
|
||||
}
|
||||
|
||||
// TelemetryConfig configures OpenTelemetry export for traces and spans.
|
||||
// When enabled, spans are exported to an OTLP-compatible backend (Jaeger, Tempo, Datadog, etc.)
|
||||
// in addition to PostgreSQL storage.
|
||||
type TelemetryConfig struct {
|
||||
Enabled bool `json:"enabled,omitempty"` // enable OTLP export (default false)
|
||||
Endpoint string `json:"endpoint,omitempty"` // OTLP endpoint (e.g. "localhost:4317", "https://otel.example.com:4318")
|
||||
Protocol string `json:"protocol,omitempty"` // "grpc" (default) or "http"
|
||||
Insecure bool `json:"insecure,omitempty"` // skip TLS verification (default false, set true for local dev)
|
||||
ServiceName string `json:"service_name,omitempty"` // OTEL service name (default "goclaw-gateway")
|
||||
Headers map[string]string `json:"headers,omitempty"` // extra headers (e.g. auth tokens for cloud backends)
|
||||
Enabled bool `json:"enabled,omitempty"` // enable OTLP export (default false)
|
||||
Endpoint string `json:"endpoint,omitempty"` // OTLP endpoint (e.g. "localhost:4317", "https://otel.example.com:4318")
|
||||
Protocol string `json:"protocol,omitempty"` // "grpc" (default) or "http"
|
||||
Insecure bool `json:"insecure,omitempty"` // skip TLS verification (default false, set true for local dev)
|
||||
ServiceName string `json:"service_name,omitempty"` // OTEL service name (default "goclaw-gateway")
|
||||
Headers map[string]string `json:"headers,omitempty"` // extra headers (e.g. auth tokens for cloud backends)
|
||||
ModelPricing map[string]*ModelPricing `json:"model_pricing,omitempty"` // cost per model, key = "provider/model" or just "model"
|
||||
}
|
||||
|
||||
// CronConfig configures the cron job system.
|
||||
|
||||
@@ -40,6 +40,7 @@ type Server struct {
|
||||
agentsHandler *httpapi.AgentsHandler // agent CRUD API
|
||||
skillsHandler *httpapi.SkillsHandler // skill management API
|
||||
tracesHandler *httpapi.TracesHandler // LLM trace listing API
|
||||
wakeHandler *httpapi.WakeHandler // external wake/trigger API
|
||||
mcpHandler *httpapi.MCPHandler // MCP server management API
|
||||
customToolsHandler *httpapi.CustomToolsHandler // custom tool CRUD API
|
||||
channelInstancesHandler *httpapi.ChannelInstancesHandler // channel instance CRUD API
|
||||
@@ -54,6 +55,7 @@ type Server struct {
|
||||
storageHandler *httpapi.StorageHandler // storage file management
|
||||
mediaUploadHandler *httpapi.MediaUploadHandler // media upload endpoint
|
||||
mediaServeHandler *httpapi.MediaServeHandler // media serve endpoint
|
||||
activityHandler *httpapi.ActivityHandler // activity audit log API
|
||||
agentStore store.AgentStore // for context injection in tools_invoke
|
||||
msgBus *bus.MessageBus // for MCP bridge media delivery
|
||||
|
||||
@@ -175,6 +177,11 @@ func (s *Server) BuildMux() *http.ServeMux {
|
||||
s.tracesHandler.RegisterRoutes(mux)
|
||||
}
|
||||
|
||||
// External wake/trigger API
|
||||
if s.wakeHandler != nil {
|
||||
s.wakeHandler.RegisterRoutes(mux)
|
||||
}
|
||||
|
||||
// MCP server management API
|
||||
if s.mcpHandler != nil {
|
||||
s.mcpHandler.RegisterRoutes(mux)
|
||||
@@ -240,6 +247,10 @@ func (s *Server) BuildMux() *http.ServeMux {
|
||||
s.mediaServeHandler.RegisterRoutes(mux)
|
||||
}
|
||||
|
||||
if s.activityHandler != nil {
|
||||
s.activityHandler.RegisterRoutes(mux)
|
||||
}
|
||||
|
||||
// OAuth endpoints (available in all modes)
|
||||
if s.oauthHandler != nil {
|
||||
s.oauthHandler.RegisterRoutes(mux)
|
||||
@@ -410,6 +421,9 @@ func (s *Server) SetSkillsHandler(h *httpapi.SkillsHandler) { s.skillsHandler =
|
||||
// SetTracesHandler sets the LLM trace listing handler.
|
||||
func (s *Server) SetTracesHandler(h *httpapi.TracesHandler) { s.tracesHandler = h }
|
||||
|
||||
// SetWakeHandler sets the external wake/trigger handler.
|
||||
func (s *Server) SetWakeHandler(h *httpapi.WakeHandler) { s.wakeHandler = h }
|
||||
|
||||
// SetMCPHandler sets the MCP server management handler.
|
||||
func (s *Server) SetMCPHandler(h *httpapi.MCPHandler) { s.mcpHandler = h }
|
||||
|
||||
@@ -458,6 +472,9 @@ func (s *Server) SetMemoryHandler(h *httpapi.MemoryHandler) { s.memoryHandler =
|
||||
// SetKnowledgeGraphHandler sets the knowledge graph handler.
|
||||
func (s *Server) SetKnowledgeGraphHandler(h *httpapi.KnowledgeGraphHandler) { s.kgHandler = h }
|
||||
|
||||
// SetActivityHandler sets the activity audit log handler.
|
||||
func (s *Server) SetActivityHandler(h *httpapi.ActivityHandler) { s.activityHandler = h }
|
||||
|
||||
// SetAgentStore sets the agent store for context injection in tools_invoke.
|
||||
func (s *Server) SetAgentStore(as store.AgentStore) { s.agentStore = as }
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// ActivityHandler handles activity audit log endpoints.
|
||||
type ActivityHandler struct {
|
||||
activity store.ActivityStore
|
||||
token string
|
||||
}
|
||||
|
||||
// NewActivityHandler creates a handler for activity log endpoints.
|
||||
func NewActivityHandler(activity store.ActivityStore, token string) *ActivityHandler {
|
||||
return &ActivityHandler{activity: activity, token: token}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers activity routes on the given mux.
|
||||
func (h *ActivityHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/activity", h.authMiddleware(h.handleList))
|
||||
}
|
||||
|
||||
func (h *ActivityHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if h.token != "" {
|
||||
if extractBearerToken(r) != h.token {
|
||||
locale := extractLocale(r)
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
|
||||
return
|
||||
}
|
||||
}
|
||||
locale := extractLocale(r)
|
||||
ctx := store.WithLocale(r.Context(), locale)
|
||||
r = r.WithContext(ctx)
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ActivityHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
opts := store.ActivityListOpts{
|
||||
Limit: 50,
|
||||
Offset: 0,
|
||||
}
|
||||
|
||||
if v := r.URL.Query().Get("actor_type"); v != "" {
|
||||
opts.ActorType = v
|
||||
}
|
||||
if v := r.URL.Query().Get("actor_id"); v != "" {
|
||||
opts.ActorID = v
|
||||
}
|
||||
if v := r.URL.Query().Get("action"); v != "" {
|
||||
opts.Action = v
|
||||
}
|
||||
if v := r.URL.Query().Get("entity_type"); v != "" {
|
||||
opts.EntityType = v
|
||||
}
|
||||
if v := r.URL.Query().Get("entity_id"); v != "" {
|
||||
opts.EntityID = v
|
||||
}
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 200 {
|
||||
opts.Limit = n
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("offset"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
opts.Offset = n
|
||||
}
|
||||
}
|
||||
|
||||
logs, err := h.activity.List(r.Context(), opts)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
total, _ := h.activity.Count(r.Context(), opts)
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"logs": logs,
|
||||
"total": total,
|
||||
"limit": opts.Limit,
|
||||
"offset": opts.Offset,
|
||||
})
|
||||
}
|
||||
@@ -23,8 +23,12 @@ type AgentsHandler struct {
|
||||
msgBus *bus.MessageBus // for cache invalidation events (nil = no events)
|
||||
summoner *AgentSummoner // LLM-based agent setup (nil = disabled)
|
||||
isOwner func(string) bool // checks if user ID is a system owner (nil = no owners configured)
|
||||
activity store.ActivityStore // optional audit logging (nil = disabled)
|
||||
}
|
||||
|
||||
// SetActivityStore sets the optional activity audit store.
|
||||
func (h *AgentsHandler) SetActivityStore(a store.ActivityStore) { h.activity = a }
|
||||
|
||||
// NewAgentsHandler creates a handler for agent management endpoints.
|
||||
// isOwner is a function that checks if a user ID is in GOCLAW_OWNER_IDS (nil = disabled).
|
||||
func NewAgentsHandler(agents store.AgentStore, token string, msgBus *bus.MessageBus, summoner *AgentSummoner, isOwner func(string) bool) *AgentsHandler {
|
||||
@@ -47,6 +51,21 @@ func (h *AgentsHandler) emitCacheInvalidate(kind, key string) {
|
||||
})
|
||||
}
|
||||
|
||||
// logActivity records an audit log entry if the activity store is set.
|
||||
func (h *AgentsHandler) logActivity(r *http.Request, actorID, action, entityType, entityID string) {
|
||||
if h.activity == nil {
|
||||
return
|
||||
}
|
||||
h.activity.Log(r.Context(), &store.ActivityLog{
|
||||
ActorType: "user",
|
||||
ActorID: actorID,
|
||||
Action: action,
|
||||
EntityType: entityType,
|
||||
EntityID: entityID,
|
||||
IPAddress: r.RemoteAddr,
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all agent management routes on the given mux.
|
||||
func (h *AgentsHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/agents", h.authMiddleware(h.handleList))
|
||||
@@ -184,6 +203,7 @@ func (h *AgentsHandler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
go h.summoner.SummonAgent(req.ID, req.Provider, req.Model, description)
|
||||
}
|
||||
|
||||
h.logActivity(r, userID, "agent.created", "agent", req.ID.String())
|
||||
writeJSON(w, http.StatusCreated, req)
|
||||
}
|
||||
|
||||
@@ -279,6 +299,7 @@ func (h *AgentsHandler) handleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
h.logActivity(r, userID, "agent.updated", "agent", id.String())
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
|
||||
}
|
||||
|
||||
@@ -311,5 +332,6 @@ func (h *AgentsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
h.emitCacheInvalidate(bus.CacheKindAgent, ag.AgentKey)
|
||||
h.emitCacheInvalidate(bus.CacheKindBootstrap, id.String())
|
||||
|
||||
h.logActivity(r, userID, "agent.deleted", "agent", id.String())
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package http
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -25,6 +26,7 @@ func NewTracesHandler(tracing store.TracingStore, token string) *TracesHandler {
|
||||
func (h *TracesHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /v1/traces", h.authMiddleware(h.handleList))
|
||||
mux.HandleFunc("GET /v1/traces/{traceID}", h.authMiddleware(h.handleGet))
|
||||
mux.HandleFunc("GET /v1/costs/summary", h.authMiddleware(h.handleCostSummary))
|
||||
}
|
||||
|
||||
func (h *TracesHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -117,3 +119,32 @@ func (h *TracesHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
"spans": spans,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *TracesHandler) handleCostSummary(w http.ResponseWriter, r *http.Request) {
|
||||
opts := store.CostSummaryOpts{}
|
||||
|
||||
if v := r.URL.Query().Get("agent_id"); v != "" {
|
||||
id, err := uuid.Parse(v)
|
||||
if err == nil {
|
||||
opts.AgentID = &id
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("from"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
opts.From = &t
|
||||
}
|
||||
}
|
||||
if v := r.URL.Query().Get("to"); v != "" {
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
opts.To = &t
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := h.tracing.GetCostSummary(r.Context(), opts)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"rows": rows})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/agent"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/i18n"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/sessions"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// WakeHandler handles POST /v1/agents/{id}/wake — external trigger API.
|
||||
// Allows orchestrators (Paperclip, n8n, etc.) to trigger agent runs via HTTP.
|
||||
type WakeHandler struct {
|
||||
agents *agent.Router
|
||||
token string
|
||||
}
|
||||
|
||||
// NewWakeHandler creates a handler for the wake endpoint.
|
||||
func NewWakeHandler(agents *agent.Router, token string) *WakeHandler {
|
||||
return &WakeHandler{agents: agents, token: token}
|
||||
}
|
||||
|
||||
// RegisterRoutes registers wake routes on the given mux.
|
||||
func (h *WakeHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /v1/agents/{id}/wake", h.handleWake)
|
||||
}
|
||||
|
||||
type wakeRequest struct {
|
||||
Message string `json:"message"`
|
||||
SessionKey string `json:"session_key,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type wakeResponse struct {
|
||||
Content string `json:"content"`
|
||||
RunID string `json:"run_id"`
|
||||
Usage *wakeUsage `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type wakeUsage struct {
|
||||
PromptTokens int `json:"prompt_tokens"`
|
||||
CompletionTokens int `json:"completion_tokens"`
|
||||
TotalTokens int `json:"total_tokens"`
|
||||
}
|
||||
|
||||
func (h *WakeHandler) handleWake(w http.ResponseWriter, r *http.Request) {
|
||||
locale := extractLocale(r)
|
||||
|
||||
// Auth check
|
||||
if h.token != "" && !tokenMatch(extractBearerToken(r), h.token) {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": i18n.T(locale, i18n.MsgUnauthorized)})
|
||||
return
|
||||
}
|
||||
|
||||
agentID := r.PathValue("id")
|
||||
if agentID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "agent")})
|
||||
return
|
||||
}
|
||||
|
||||
// Limit request body size
|
||||
const maxBodySize = 1 << 20 // 1MB
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
|
||||
|
||||
var req wakeRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Message == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"})
|
||||
return
|
||||
}
|
||||
|
||||
loop, err := h.agents.Get(agentID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "agent", agentID)})
|
||||
return
|
||||
}
|
||||
|
||||
// Build session key
|
||||
sessionKey := req.SessionKey
|
||||
if sessionKey == "" {
|
||||
sessionKey = sessions.SessionKey(agentID, "wake-"+uuid.NewString()[:8])
|
||||
}
|
||||
|
||||
userID := req.UserID
|
||||
if userID == "" {
|
||||
userID = extractUserID(r)
|
||||
}
|
||||
|
||||
ctx := store.WithLocale(r.Context(), locale)
|
||||
if userID != "" {
|
||||
ctx = store.WithUserID(ctx, userID)
|
||||
}
|
||||
|
||||
runID := uuid.NewString()
|
||||
slog.Info("wake request", "agent", agentID, "user", userID, "session", sessionKey)
|
||||
|
||||
result, err := loop.Run(ctx, agent.RunRequest{
|
||||
SessionKey: sessionKey,
|
||||
Message: req.Message,
|
||||
Channel: "wake",
|
||||
ChatID: "api",
|
||||
RunID: runID,
|
||||
UserID: userID,
|
||||
Stream: false,
|
||||
})
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("agent run failed: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
resp := wakeResponse{
|
||||
Content: result.Content,
|
||||
RunID: runID,
|
||||
}
|
||||
if result.Usage != nil {
|
||||
resp.Usage = &wakeUsage{
|
||||
PromptTokens: result.Usage.PromptTokens,
|
||||
CompletionTokens: result.Usage.CompletionTokens,
|
||||
TotalTokens: result.Usage.TotalTokens,
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ActivityLog represents a single audit log entry.
|
||||
type ActivityLog struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ActorType string `json:"actor_type"`
|
||||
ActorID string `json:"actor_id"`
|
||||
Action string `json:"action"`
|
||||
EntityType string `json:"entity_type,omitempty"`
|
||||
EntityID string `json:"entity_id,omitempty"`
|
||||
Details json.RawMessage `json:"details,omitempty"`
|
||||
IPAddress string `json:"ip_address,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ActivityListOpts configures activity log listing.
|
||||
type ActivityListOpts struct {
|
||||
ActorType string
|
||||
ActorID string
|
||||
Action string
|
||||
EntityType string
|
||||
EntityID string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
// ActivityStore manages activity audit logs.
|
||||
type ActivityStore interface {
|
||||
Log(ctx context.Context, entry *ActivityLog) error
|
||||
List(ctx context.Context, opts ActivityListOpts) ([]ActivityLog, error)
|
||||
Count(ctx context.Context, opts ActivityListOpts) (int, error)
|
||||
}
|
||||
@@ -39,6 +39,9 @@ type AgentData struct {
|
||||
IsDefault bool `json:"is_default"`
|
||||
Status string `json:"status"`
|
||||
|
||||
// Budget: optional monthly spending limit in cents (nil = unlimited)
|
||||
BudgetMonthlyCents *int `json:"budget_monthly_cents,omitempty"`
|
||||
|
||||
// Per-agent JSONB config (nullable — nil means "use global defaults")
|
||||
ToolsConfig json.RawMessage `json:"tools_config,omitempty"`
|
||||
SandboxConfig json.RawMessage `json:"sandbox_config,omitempty"`
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package pg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
|
||||
// PGActivityStore implements store.ActivityStore backed by Postgres.
|
||||
type PGActivityStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPGActivityStore creates a new PGActivityStore.
|
||||
func NewPGActivityStore(db *sql.DB) *PGActivityStore {
|
||||
return &PGActivityStore{db: db}
|
||||
}
|
||||
|
||||
func (s *PGActivityStore) Log(ctx context.Context, entry *store.ActivityLog) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO activity_logs (actor_type, actor_id, action, entity_type, entity_id, details, ip_address)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
entry.ActorType, entry.ActorID, entry.Action,
|
||||
entry.EntityType, entry.EntityID, entry.Details, entry.IPAddress,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGActivityStore) List(ctx context.Context, opts store.ActivityListOpts) ([]store.ActivityLog, error) {
|
||||
where, args := buildActivityWhere(opts)
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
args = append(args, limit, opts.Offset)
|
||||
|
||||
query := fmt.Sprintf(
|
||||
`SELECT id, actor_type, actor_id, action, COALESCE(entity_type,''), COALESCE(entity_id,''), details, COALESCE(ip_address,''), created_at
|
||||
FROM activity_logs %s ORDER BY created_at DESC LIMIT $%d OFFSET $%d`,
|
||||
where, len(args)-1, len(args),
|
||||
)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.ActivityLog
|
||||
for rows.Next() {
|
||||
var a store.ActivityLog
|
||||
if err := rows.Scan(&a.ID, &a.ActorType, &a.ActorID, &a.Action, &a.EntityType, &a.EntityID, &a.Details, &a.IPAddress, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, a)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGActivityStore) Count(ctx context.Context, opts store.ActivityListOpts) (int, error) {
|
||||
where, args := buildActivityWhere(opts)
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM activity_logs %s", where)
|
||||
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, query, args...).Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
func buildActivityWhere(opts store.ActivityListOpts) (string, []any) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
idx := 1
|
||||
|
||||
if opts.ActorType != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("actor_type = $%d", idx))
|
||||
args = append(args, opts.ActorType)
|
||||
idx++
|
||||
}
|
||||
if opts.ActorID != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("actor_id = $%d", idx))
|
||||
args = append(args, opts.ActorID)
|
||||
idx++
|
||||
}
|
||||
if opts.Action != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("action = $%d", idx))
|
||||
args = append(args, opts.Action)
|
||||
idx++
|
||||
}
|
||||
if opts.EntityType != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("entity_type = $%d", idx))
|
||||
args = append(args, opts.EntityType)
|
||||
idx++
|
||||
}
|
||||
if opts.EntityID != "" {
|
||||
conditions = append(conditions, fmt.Sprintf("entity_id = $%d", idx))
|
||||
args = append(args, opts.EntityID)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(conditions) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return "WHERE " + strings.Join(conditions, " AND "), args
|
||||
}
|
||||
@@ -104,7 +104,7 @@ const agentSelectCols = `id, agent_key, display_name, frontmatter, owner_id, pro
|
||||
context_window, max_tool_iterations, workspace, restrict_to_workspace,
|
||||
tools_config, sandbox_config, subagents_config, memory_config,
|
||||
compaction_config, context_pruning, other_config,
|
||||
agent_type, is_default, status, created_at, updated_at`
|
||||
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at`
|
||||
|
||||
func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error {
|
||||
if agent.ID == uuid.Nil {
|
||||
@@ -118,13 +118,13 @@ func (s *PGAgentStore) Create(ctx context.Context, agent *store.AgentData) error
|
||||
context_window, max_tool_iterations, workspace, restrict_to_workspace,
|
||||
tools_config, sandbox_config, subagents_config, memory_config,
|
||||
compaction_config, context_pruning, other_config,
|
||||
agent_type, is_default, status, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`,
|
||||
agent_type, is_default, status, budget_monthly_cents, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
|
||||
agent.ID, agent.AgentKey, agent.DisplayName, sql.NullString{String: agent.Frontmatter, Valid: agent.Frontmatter != ""}, agent.OwnerID, agent.Provider, agent.Model,
|
||||
agent.ContextWindow, agent.MaxToolIterations, agent.Workspace, agent.RestrictToWorkspace,
|
||||
jsonOrEmpty(agent.ToolsConfig), jsonOrNull(agent.SandboxConfig), jsonOrNull(agent.SubagentsConfig), jsonOrNull(agent.MemoryConfig),
|
||||
jsonOrNull(agent.CompactionConfig), jsonOrNull(agent.ContextPruning), jsonOrEmpty(agent.OtherConfig),
|
||||
agent.AgentType, agent.IsDefault, agent.Status, now, now,
|
||||
agent.AgentType, agent.IsDefault, agent.Status, agent.BudgetMonthlyCents, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -330,7 +330,7 @@ func scanAgentRow(row agentRowScanner) (*store.AgentData, error) {
|
||||
err := row.Scan(&d.ID, &d.AgentKey, &d.DisplayName, &frontmatter, &d.OwnerID, &d.Provider, &d.Model,
|
||||
&d.ContextWindow, &d.MaxToolIterations, &d.Workspace, &d.RestrictToWorkspace,
|
||||
&toolsCfg, &sandboxCfg, &subagentsCfg, &memoryCfg, &compactionCfg, &pruningCfg, &otherCfg,
|
||||
&d.AgentType, &d.IsDefault, &d.Status, &d.CreatedAt, &d.UpdatedAt)
|
||||
&d.AgentType, &d.IsDefault, &d.Status, &d.BudgetMonthlyCents, &d.CreatedAt, &d.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -42,5 +42,6 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
|
||||
PendingMessages: NewPGPendingMessageStore(db),
|
||||
KnowledgeGraph: NewPGKnowledgeGraphStore(db),
|
||||
Contacts: NewPGContactStore(db),
|
||||
Activity: NewPGActivityStore(db),
|
||||
}, nil
|
||||
}
|
||||
@@ -29,14 +29,14 @@ func (s *PGTracingStore) CreateTrace(ctx context.Context, trace *store.TraceData
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO traces (id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
|
||||
duration_ms, name, channel, input_preview, output_preview,
|
||||
total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count,
|
||||
total_input_tokens, total_output_tokens, total_cost, span_count, llm_call_count, tool_call_count,
|
||||
status, error, metadata, tags, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23)`,
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)`,
|
||||
trace.ID, nilUUID(trace.ParentTraceID), nilUUID(trace.AgentID), nilStr(trace.UserID), nilStr(trace.SessionKey),
|
||||
nilStr(trace.RunID), trace.StartTime, nilTime(trace.EndTime),
|
||||
nilInt(trace.DurationMS), nilStr(trace.Name), nilStr(trace.Channel),
|
||||
nilStr(trace.InputPreview), nilStr(trace.OutputPreview),
|
||||
trace.TotalInputTokens, trace.TotalOutputTokens, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount,
|
||||
trace.TotalInputTokens, trace.TotalOutputTokens, trace.TotalCost, trace.SpanCount, trace.LLMCallCount, trace.ToolCallCount,
|
||||
trace.Status, nilStr(trace.Error), jsonOrEmpty(trace.Metadata), pqStringArray(trace.Tags), trace.CreatedAt,
|
||||
)
|
||||
return err
|
||||
@@ -58,12 +58,12 @@ func (s *PGTracingStore) GetTrace(ctx context.Context, traceID uuid.UUID) (*stor
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
|
||||
duration_ms, name, channel, input_preview, output_preview,
|
||||
total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count,
|
||||
total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
|
||||
status, error, metadata, tags, created_at
|
||||
FROM traces WHERE id = $1`, traceID,
|
||||
).Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime,
|
||||
&durationMS, &name, &channel, &inputPreview, &outputPreview,
|
||||
&d.TotalInputTokens, &d.TotalOutputTokens, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
|
||||
&d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
|
||||
&d.Status, &errStr, &metadata, &tags, &d.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -135,7 +135,7 @@ func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpt
|
||||
|
||||
q := `SELECT id, parent_trace_id, agent_id, user_id, session_key, run_id, start_time, end_time,
|
||||
duration_ms, name, channel, input_preview, output_preview,
|
||||
total_input_tokens, total_output_tokens, span_count, llm_call_count, tool_call_count,
|
||||
total_input_tokens, total_output_tokens, COALESCE(total_cost, 0), span_count, llm_call_count, tool_call_count,
|
||||
status, error, metadata, tags, created_at
|
||||
FROM traces` + where
|
||||
|
||||
@@ -163,7 +163,7 @@ func (s *PGTracingStore) ListTraces(ctx context.Context, opts store.TraceListOpt
|
||||
|
||||
if err := rows.Scan(&d.ID, &parentTraceID, &agentID, &userID, &sessionKey, &runID, &d.StartTime, &endTime,
|
||||
&durationMS, &name, &channel, &inputPreview, &outputPreview,
|
||||
&d.TotalInputTokens, &d.TotalOutputTokens, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
|
||||
&d.TotalInputTokens, &d.TotalOutputTokens, &d.TotalCost, &d.SpanCount, &d.LLMCallCount, &d.ToolCallCount,
|
||||
&d.Status, &errStr, &metadata, &tags, &d.CreatedAt); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -346,6 +346,7 @@ func (s *PGTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID
|
||||
tool_call_count = (SELECT COUNT(*) FROM spans WHERE trace_id = $1 AND span_type = 'tool_call'),
|
||||
total_input_tokens = COALESCE((SELECT SUM(input_tokens) FROM spans WHERE trace_id = $1 AND span_type = 'llm_call' AND input_tokens IS NOT NULL), 0),
|
||||
total_output_tokens = COALESCE((SELECT SUM(output_tokens) FROM spans WHERE trace_id = $1 AND span_type = 'llm_call' AND output_tokens IS NOT NULL), 0),
|
||||
total_cost = COALESCE((SELECT SUM(total_cost) FROM spans WHERE trace_id = $1 AND total_cost IS NOT NULL), 0),
|
||||
metadata = (
|
||||
SELECT jsonb_build_object(
|
||||
'total_cache_read_tokens', COALESCE(SUM((metadata->>'cache_read_tokens')::int), 0),
|
||||
@@ -356,3 +357,64 @@ func (s *PGTracingStore) BatchUpdateTraceAggregates(ctx context.Context, traceID
|
||||
WHERE id = $1`, traceID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *PGTracingStore) GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error) {
|
||||
start := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
|
||||
end := start.AddDate(0, 1, 0)
|
||||
var cost float64
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(SUM(total_cost), 0) FROM traces
|
||||
WHERE agent_id = $1 AND created_at >= $2 AND created_at < $3 AND parent_trace_id IS NULL`,
|
||||
agentID, start, end,
|
||||
).Scan(&cost)
|
||||
return cost, err
|
||||
}
|
||||
|
||||
func (s *PGTracingStore) GetCostSummary(ctx context.Context, opts store.CostSummaryOpts) ([]store.CostSummaryRow, error) {
|
||||
var conditions []string
|
||||
var args []any
|
||||
argIdx := 1
|
||||
|
||||
// Only root traces (not delegations)
|
||||
conditions = append(conditions, "parent_trace_id IS NULL")
|
||||
|
||||
if opts.AgentID != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("agent_id = $%d", argIdx))
|
||||
args = append(args, *opts.AgentID)
|
||||
argIdx++
|
||||
}
|
||||
if opts.From != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at >= $%d", argIdx))
|
||||
args = append(args, *opts.From)
|
||||
argIdx++
|
||||
}
|
||||
if opts.To != nil {
|
||||
conditions = append(conditions, fmt.Sprintf("created_at < $%d", argIdx))
|
||||
args = append(args, *opts.To)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
where := " WHERE " + strings.Join(conditions, " AND ")
|
||||
|
||||
q := `SELECT agent_id, COALESCE(SUM(total_cost), 0), COALESCE(SUM(total_input_tokens), 0),
|
||||
COALESCE(SUM(total_output_tokens), 0), COUNT(*)
|
||||
FROM traces` + where + ` GROUP BY agent_id ORDER BY SUM(total_cost) DESC`
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []store.CostSummaryRow
|
||||
for rows.Next() {
|
||||
var r store.CostSummaryRow
|
||||
var agentID *uuid.UUID
|
||||
if err := rows.Scan(&agentID, &r.TotalCost, &r.TotalInputTokens, &r.TotalOutputTokens, &r.TraceCount); err != nil {
|
||||
continue
|
||||
}
|
||||
r.AgentID = agentID
|
||||
result = append(result, r)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -23,4 +23,5 @@ type Stores struct {
|
||||
PendingMessages PendingMessageStore
|
||||
KnowledgeGraph KnowledgeGraphStore
|
||||
Contacts ContactStore
|
||||
Activity ActivityStore
|
||||
}
|
||||
@@ -54,6 +54,7 @@ type TraceData struct {
|
||||
OutputPreview string `json:"output_preview,omitempty"`
|
||||
TotalInputTokens int `json:"total_input_tokens"`
|
||||
TotalOutputTokens int `json:"total_output_tokens"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
SpanCount int `json:"span_count"`
|
||||
LLMCallCount int `json:"llm_call_count"`
|
||||
ToolCallCount int `json:"tool_call_count"`
|
||||
@@ -82,6 +83,7 @@ type SpanData struct {
|
||||
Provider string `json:"provider,omitempty"`
|
||||
InputTokens int `json:"input_tokens,omitempty"`
|
||||
OutputTokens int `json:"output_tokens,omitempty"`
|
||||
TotalCost *float64 `json:"total_cost,omitempty"`
|
||||
FinishReason string `json:"finish_reason,omitempty"`
|
||||
ModelParams json.RawMessage `json:"model_params,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
@@ -102,6 +104,22 @@ type TraceListOpts struct {
|
||||
Offset int
|
||||
}
|
||||
|
||||
// CostSummaryOpts configures cost aggregation queries.
|
||||
type CostSummaryOpts struct {
|
||||
AgentID *uuid.UUID
|
||||
From *time.Time
|
||||
To *time.Time
|
||||
}
|
||||
|
||||
// CostSummaryRow is a single row of aggregated cost data.
|
||||
type CostSummaryRow struct {
|
||||
AgentID *uuid.UUID `json:"agent_id,omitempty"`
|
||||
TotalCost float64 `json:"total_cost"`
|
||||
TotalInputTokens int `json:"total_input_tokens"`
|
||||
TotalOutputTokens int `json:"total_output_tokens"`
|
||||
TraceCount int `json:"trace_count"`
|
||||
}
|
||||
|
||||
// TracingStore manages LLM traces and spans.
|
||||
type TracingStore interface {
|
||||
CreateTrace(ctx context.Context, trace *TraceData) error
|
||||
@@ -117,4 +135,8 @@ type TracingStore interface {
|
||||
// Batch operations (async flush)
|
||||
BatchCreateSpans(ctx context.Context, spans []SpanData) error
|
||||
BatchUpdateTraceAggregates(ctx context.Context, traceID uuid.UUID) error
|
||||
|
||||
// Cost aggregation
|
||||
GetMonthlyAgentCost(ctx context.Context, agentID uuid.UUID, year int, month time.Month) (float64, error)
|
||||
GetCostSummary(ctx context.Context, opts CostSummaryOpts) ([]CostSummaryRow, error)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package tracing
|
||||
|
||||
import (
|
||||
"github.com/nextlevelbuilder/goclaw/internal/config"
|
||||
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
||||
)
|
||||
|
||||
// CalculateCost computes the USD cost for a single LLM call based on token usage and pricing.
|
||||
// Returns 0 if pricing is nil.
|
||||
func CalculateCost(pricing *config.ModelPricing, usage *providers.Usage) float64 {
|
||||
if pricing == nil || usage == nil {
|
||||
return 0
|
||||
}
|
||||
cost := float64(usage.PromptTokens) * pricing.InputPerMillion / 1_000_000
|
||||
cost += float64(usage.CompletionTokens) * pricing.OutputPerMillion / 1_000_000
|
||||
if pricing.CacheReadPerMillion > 0 && usage.CacheReadTokens > 0 {
|
||||
cost += float64(usage.CacheReadTokens) * pricing.CacheReadPerMillion / 1_000_000
|
||||
}
|
||||
if pricing.CacheCreatePerMillion > 0 && usage.CacheCreationTokens > 0 {
|
||||
cost += float64(usage.CacheCreationTokens) * pricing.CacheCreatePerMillion / 1_000_000
|
||||
}
|
||||
return cost
|
||||
}
|
||||
|
||||
// LookupPricing finds the model pricing from config.
|
||||
// Tries "provider/model" first, then just "model".
|
||||
func LookupPricing(pricingMap map[string]*config.ModelPricing, provider, model string) *config.ModelPricing {
|
||||
if pricingMap == nil {
|
||||
return nil
|
||||
}
|
||||
if p, ok := pricingMap[provider+"/"+model]; ok {
|
||||
return p
|
||||
}
|
||||
if p, ok := pricingMap[model]; ok {
|
||||
return p
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -2,4 +2,4 @@ package upgrade
|
||||
|
||||
// RequiredSchemaVersion is the schema migration version this binary requires.
|
||||
// Bump this whenever adding a new SQL migration file.
|
||||
const RequiredSchemaVersion uint = 14
|
||||
const RequiredSchemaVersion uint = 15
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS activity_logs;
|
||||
ALTER TABLE agents DROP COLUMN IF EXISTS budget_monthly_cents;
|
||||
@@ -0,0 +1,18 @@
|
||||
ALTER TABLE agents ADD COLUMN budget_monthly_cents INTEGER;
|
||||
|
||||
CREATE TABLE activity_logs (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
|
||||
actor_type VARCHAR(20) NOT NULL,
|
||||
actor_id VARCHAR(255) NOT NULL,
|
||||
action VARCHAR(100) NOT NULL,
|
||||
entity_type VARCHAR(50),
|
||||
entity_id VARCHAR(255),
|
||||
details JSONB,
|
||||
ip_address VARCHAR(45),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_activity_logs_actor ON activity_logs (actor_type, actor_id);
|
||||
CREATE INDEX idx_activity_logs_action ON activity_logs (action);
|
||||
CREATE INDEX idx_activity_logs_entity ON activity_logs (entity_type, entity_id);
|
||||
CREATE INDEX idx_activity_logs_created ON activity_logs (created_at DESC);
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Volume2,
|
||||
Cpu,
|
||||
ArrowRightLeft,
|
||||
ClipboardList,
|
||||
HardDrive,
|
||||
Inbox,
|
||||
Brain,
|
||||
@@ -106,6 +107,7 @@ export function Sidebar({ collapsed, onNavItemClick }: SidebarProps) {
|
||||
<SidebarItem to={ROUTES.EVENTS} icon={Radar} label={t("nav.realtimeEvents")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.DELEGATIONS} icon={ArrowRightLeft} label={t("nav.delegations")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.USAGE} icon={BarChart3} label={t("nav.usage")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.ACTIVITY} icon={ClipboardList} label={t("nav.activity")} collapsed={collapsed} />
|
||||
<SidebarItem to={ROUTES.LOGS} icon={Terminal} label={t("nav.logs")} collapsed={collapsed} />
|
||||
</SidebarGroup>
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import enMemory from "./locales/en/memory.json";
|
||||
import enStorage from "./locales/en/storage.json";
|
||||
import enPendingMessages from "./locales/en/pending-messages.json";
|
||||
import enContacts from "./locales/en/contacts.json";
|
||||
import enActivity from "./locales/en/activity.json";
|
||||
|
||||
// --- VI namespaces ---
|
||||
import viCommon from "./locales/vi/common.json";
|
||||
@@ -62,6 +63,7 @@ import viMemory from "./locales/vi/memory.json";
|
||||
import viStorage from "./locales/vi/storage.json";
|
||||
import viPendingMessages from "./locales/vi/pending-messages.json";
|
||||
import viContacts from "./locales/vi/contacts.json";
|
||||
import viActivity from "./locales/vi/activity.json";
|
||||
|
||||
// --- ZH namespaces ---
|
||||
import zhCommon from "./locales/zh/common.json";
|
||||
@@ -93,6 +95,7 @@ import zhMemory from "./locales/zh/memory.json";
|
||||
import zhStorage from "./locales/zh/storage.json";
|
||||
import zhPendingMessages from "./locales/zh/pending-messages.json";
|
||||
import zhContacts from "./locales/zh/contacts.json";
|
||||
import zhActivity from "./locales/zh/activity.json";
|
||||
|
||||
const STORAGE_KEY = "goclaw:language";
|
||||
|
||||
@@ -110,7 +113,7 @@ const ns = [
|
||||
"agents", "teams", "sessions", "skills", "cron", "config",
|
||||
"channels", "providers", "traces", "events", "delegations",
|
||||
"usage", "approvals", "nodes", "logs", "tools", "mcp", "tts",
|
||||
"setup", "memory", "storage", "pending-messages", "contacts",
|
||||
"setup", "memory", "storage", "pending-messages", "contacts", "activity",
|
||||
] as const;
|
||||
|
||||
i18n.use(initReactI18next).init({
|
||||
@@ -124,7 +127,7 @@ i18n.use(initReactI18next).init({
|
||||
approvals: enApprovals, nodes: enNodes, logs: enLogs, tools: enTools,
|
||||
mcp: enMcp, tts: enTts, setup: enSetup, memory: enMemory, storage: enStorage,
|
||||
"pending-messages": enPendingMessages,
|
||||
contacts: enContacts,
|
||||
contacts: enContacts, activity: enActivity,
|
||||
},
|
||||
vi: {
|
||||
common: viCommon, sidebar: viSidebar, topbar: viTopbar, login: viLogin,
|
||||
@@ -135,7 +138,7 @@ i18n.use(initReactI18next).init({
|
||||
approvals: viApprovals, nodes: viNodes, logs: viLogs, tools: viTools,
|
||||
mcp: viMcp, tts: viTts, setup: viSetup, memory: viMemory, storage: viStorage,
|
||||
"pending-messages": viPendingMessages,
|
||||
contacts: viContacts,
|
||||
contacts: viContacts, activity: viActivity,
|
||||
},
|
||||
zh: {
|
||||
common: zhCommon, sidebar: zhSidebar, topbar: zhTopbar, login: zhLogin,
|
||||
@@ -146,7 +149,7 @@ i18n.use(initReactI18next).init({
|
||||
approvals: zhApprovals, nodes: zhNodes, logs: zhLogs, tools: zhTools,
|
||||
mcp: zhMcp, tts: zhTts, setup: zhSetup, memory: zhMemory, storage: zhStorage,
|
||||
"pending-messages": zhPendingMessages,
|
||||
contacts: zhContacts,
|
||||
contacts: zhContacts, activity: zhActivity,
|
||||
},
|
||||
},
|
||||
ns: [...ns],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"title": "Activity Log",
|
||||
"description": "Audit trail of admin and configuration changes.",
|
||||
"filters": {
|
||||
"action": "Action",
|
||||
"allActions": "All Actions",
|
||||
"agentCreated": "Agent Created",
|
||||
"agentUpdated": "Agent Updated",
|
||||
"agentDeleted": "Agent Deleted",
|
||||
"entityType": "Entity Type",
|
||||
"allEntities": "All Entities",
|
||||
"agent": "Agent"
|
||||
},
|
||||
"table": {
|
||||
"action": "Action",
|
||||
"actor": "Actor",
|
||||
"entity": "Entity",
|
||||
"entityId": "Entity ID",
|
||||
"ip": "IP Address",
|
||||
"time": "Time"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No activity logs",
|
||||
"description": "Activity logs will appear here when admin actions are performed."
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@
|
||||
"selfEvolutionLabel": "Allow agent to evolve its communication style",
|
||||
"selfEvolutionHint": "When enabled, the agent can update its SOUL.md to refine tone, vocabulary, and response style based on interactions. Identity, name, and operating instructions remain locked.",
|
||||
"selfEvolutionInfo": "Agent will evolve its style over time through SOUL.md updates. Only style and tone are affected — identity and workflow rules stay fixed.",
|
||||
"budget": "Monthly Budget",
|
||||
"budgetLabel": "Budget limit (USD)",
|
||||
"budgetHint": "Optional monthly spending cap. Agent will reject requests when exceeded. Leave empty for unlimited.",
|
||||
"saveChanges": "Save Changes",
|
||||
"saving": "Saving...",
|
||||
"saved": "Saved",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"statCards": {
|
||||
"requestsToday": "Requests Today",
|
||||
"tokensToday": "Tokens Today",
|
||||
"costToday": "Cost Today",
|
||||
"agents": "Agents",
|
||||
"channels": "Channels",
|
||||
"running": "running",
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"config": "Config",
|
||||
"approvals": "Approvals",
|
||||
"nodes": "Nodes",
|
||||
"tts": "TTS"
|
||||
"tts": "TTS",
|
||||
"activity": "Activity"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"title": "Nhật ký hoạt động",
|
||||
"description": "Lịch sử kiểm toán các thay đổi quản trị và cấu hình.",
|
||||
"filters": {
|
||||
"action": "Hành động",
|
||||
"allActions": "Tất cả",
|
||||
"agentCreated": "Tạo Agent",
|
||||
"agentUpdated": "Cập nhật Agent",
|
||||
"agentDeleted": "Xóa Agent",
|
||||
"entityType": "Loại đối tượng",
|
||||
"allEntities": "Tất cả",
|
||||
"agent": "Agent"
|
||||
},
|
||||
"table": {
|
||||
"action": "Hành động",
|
||||
"actor": "Người thực hiện",
|
||||
"entity": "Đối tượng",
|
||||
"entityId": "ID đối tượng",
|
||||
"ip": "Địa chỉ IP",
|
||||
"time": "Thời gian"
|
||||
},
|
||||
"empty": {
|
||||
"title": "Chưa có nhật ký",
|
||||
"description": "Nhật ký hoạt động sẽ xuất hiện khi có thao tác quản trị."
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@
|
||||
"selfEvolutionLabel": "Cho phép agent tiến hóa phong cách giao tiếp",
|
||||
"selfEvolutionHint": "Khi được bật, agent có thể cập nhật SOUL.md để tinh chỉnh giọng điệu, từ vựng và phong cách phản hồi dựa trên tương tác. Nhận dạng, tên và hướng dẫn hoạt động vẫn bị khóa.",
|
||||
"selfEvolutionInfo": "Agent sẽ tiến hóa phong cách theo thời gian qua các cập nhật SOUL.md. Chỉ phong cách và giọng điệu bị ảnh hưởng — nhận dạng và quy tắc quy trình làm việc vẫn cố định.",
|
||||
"budget": "Ngân sách hàng tháng",
|
||||
"budgetLabel": "Giới hạn ngân sách (USD)",
|
||||
"budgetHint": "Giới hạn chi tiêu hàng tháng tùy chọn. Agent sẽ từ chối yêu cầu khi vượt quá. Để trống cho không giới hạn.",
|
||||
"saveChanges": "Lưu thay đổi",
|
||||
"saving": "Đang lưu...",
|
||||
"saved": "Đã lưu",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"requestsToday": "Request hôm nay",
|
||||
"running": "Đang chạy",
|
||||
"tokensToday": "Token hôm nay",
|
||||
"costToday": "Chi phí hôm nay",
|
||||
"users": "Người dùng"
|
||||
},
|
||||
"systemHealth": {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"config": "Cấu hình",
|
||||
"approvals": "Phê duyệt",
|
||||
"nodes": "Nodes",
|
||||
"tts": "TTS"
|
||||
"tts": "TTS",
|
||||
"activity": "Hoạt động"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"title": "活动日志",
|
||||
"description": "管理和配置变更的审计记录。",
|
||||
"filters": {
|
||||
"action": "操作",
|
||||
"allActions": "全部",
|
||||
"agentCreated": "创建 Agent",
|
||||
"agentUpdated": "更新 Agent",
|
||||
"agentDeleted": "删除 Agent",
|
||||
"entityType": "实体类型",
|
||||
"allEntities": "全部",
|
||||
"agent": "Agent"
|
||||
},
|
||||
"table": {
|
||||
"action": "操作",
|
||||
"actor": "操作者",
|
||||
"entity": "实体",
|
||||
"entityId": "实体 ID",
|
||||
"ip": "IP 地址",
|
||||
"time": "时间"
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无活动日志",
|
||||
"description": "执行管理操作后,活动日志将在此显示。"
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@
|
||||
"selfEvolutionLabel": "允许Agent进化其沟通风格",
|
||||
"selfEvolutionHint": "启用后,Agent可以更新其 SOUL.md 以根据交互改善语调、词汇和响应风格。身份、名称和操作指令保持锁定。",
|
||||
"selfEvolutionInfo": "Agent将通过 SOUL.md 更新随时间进化其风格。只有风格和语调受影响 — 身份和工作流规则保持固定。",
|
||||
"budget": "月度预算",
|
||||
"budgetLabel": "预算限额 (USD)",
|
||||
"budgetHint": "可选的月度支出上限。超出后 Agent 将拒绝请求。留空表示无限制。",
|
||||
"saveChanges": "保存更改",
|
||||
"saving": "保存中...",
|
||||
"saved": "已保存",
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
"requestsToday": "今日请求",
|
||||
"running": "运行中",
|
||||
"tokensToday": "今日 Token",
|
||||
"costToday": "今日费用",
|
||||
"users": "用户"
|
||||
},
|
||||
"systemHealth": {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"storage": "存储",
|
||||
"traces": "追踪",
|
||||
"tts": "TTS",
|
||||
"usage": "用量"
|
||||
"usage": "用量",
|
||||
"activity": "活动日志"
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export const ROUTES = {
|
||||
PENDING_MESSAGES: "/pending-messages",
|
||||
MEMORY: "/memory",
|
||||
KNOWLEDGE_GRAPH: "/knowledge-graph",
|
||||
ACTIVITY: "/activity",
|
||||
SETUP: "/setup",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ export function formatTokens(count: number | null | undefined): string {
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
export function formatCost(cost: number | null | undefined): string {
|
||||
if (cost == null || cost === 0) return "$0.00";
|
||||
if (cost < 0.01) return `$${cost.toFixed(4)}`;
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number | undefined | null): string {
|
||||
if (ms == null || isNaN(ms)) return "—";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { ClipboardList, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
import { Pagination } from "@/components/shared/pagination";
|
||||
import { TableSkeleton } from "@/components/shared/loading-skeleton";
|
||||
import { formatDate } from "@/lib/format";
|
||||
import { useActivity } from "./hooks/use-activity";
|
||||
import { useMinLoading } from "@/hooks/use-min-loading";
|
||||
import { useDeferredLoading } from "@/hooks/use-deferred-loading";
|
||||
|
||||
const ACTION_COLORS: Record<string, string> = {
|
||||
"agent.created": "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
|
||||
"agent.updated": "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
|
||||
"agent.deleted": "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
|
||||
};
|
||||
|
||||
export function ActivityPage() {
|
||||
const { t } = useTranslation("activity");
|
||||
const { logs, total, loading, load } = useActivity();
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && logs.length === 0);
|
||||
const [actionFilter, setActionFilter] = useState("all");
|
||||
const [entityFilter, setEntityFilter] = useState("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
useEffect(() => {
|
||||
load({
|
||||
action: actionFilter !== "all" ? actionFilter : undefined,
|
||||
entity_type: entityFilter !== "all" ? entityFilter : undefined,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
});
|
||||
}, [page, pageSize, actionFilter, entityFilter]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
load({
|
||||
action: actionFilter !== "all" ? actionFilter : undefined,
|
||||
entity_type: entityFilter !== "all" ? entityFilter : undefined,
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 sm:p-6">
|
||||
<PageHeader
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
actions={
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={spinning} className="gap-1">
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("common:refresh", "Refresh")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Select value={actionFilter} onValueChange={(v) => { setActionFilter(v); setPage(1); }}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t("filters.action")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.allActions")}</SelectItem>
|
||||
<SelectItem value="agent.created">{t("filters.agentCreated")}</SelectItem>
|
||||
<SelectItem value="agent.updated">{t("filters.agentUpdated")}</SelectItem>
|
||||
<SelectItem value="agent.deleted">{t("filters.agentDeleted")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={entityFilter} onValueChange={(v) => { setEntityFilter(v); setPage(1); }}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder={t("filters.entityType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t("filters.allEntities")}</SelectItem>
|
||||
<SelectItem value="agent">{t("filters.agent")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showSkeleton ? (
|
||||
<TableSkeleton rows={8} />
|
||||
) : logs.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ClipboardList}
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-3 py-2 text-left font-medium">{t("table.action")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("table.actor")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("table.entity")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("table.entityId")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("table.ip")}</th>
|
||||
<th className="px-3 py-2 text-left font-medium">{t("table.time")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((log) => (
|
||||
<tr key={log.id} className="border-b hover:bg-muted/30">
|
||||
<td className="px-3 py-2">
|
||||
<Badge variant="secondary" className={ACTION_COLORS[log.action] ?? ""}>
|
||||
{log.action}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-xs">
|
||||
{log.actor_type}:{log.actor_id}
|
||||
</td>
|
||||
<td className="px-3 py-2">{log.entity_type || "—"}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs max-w-[200px] truncate">
|
||||
{log.entity_id || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs">
|
||||
{log.ip_address || "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground text-xs whitespace-nowrap">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Pagination
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useHttp } from "@/hooks/use-ws";
|
||||
|
||||
export interface ActivityLog {
|
||||
id: string;
|
||||
actor_type: string;
|
||||
actor_id: string;
|
||||
action: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
details: unknown;
|
||||
ip_address: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ActivityListResponse {
|
||||
logs: ActivityLog[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
interface ActivityFilters {
|
||||
action?: string;
|
||||
entity_type?: string;
|
||||
actor_id?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export function useActivity() {
|
||||
const http = useHttp();
|
||||
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(
|
||||
async (filters?: ActivityFilters) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (filters?.action) params.action = filters.action;
|
||||
if (filters?.entity_type) params.entity_type = filters.entity_type;
|
||||
if (filters?.actor_id) params.actor_id = filters.actor_id;
|
||||
if (filters?.limit) params.limit = String(filters.limit);
|
||||
if (filters?.offset) params.offset = String(filters.offset);
|
||||
|
||||
const res = await http.get<ActivityListResponse>("/v1/activity", params);
|
||||
setLogs(res.logs ?? []);
|
||||
setTotal(res.total ?? 0);
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[http],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
return { logs, total, loading, load };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Save, Check, AlertCircle, Sparkles, Info } from "lucide-react";
|
||||
import { Save, Check, AlertCircle, Sparkles, Info, DollarSign } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -32,6 +33,11 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
// Workspace
|
||||
const [restrictToWorkspace, setRestrictToWorkspace] = useState(agent.restrict_to_workspace);
|
||||
|
||||
// Budget (stored in cents, displayed in dollars)
|
||||
const [budgetDollars, setBudgetDollars] = useState(
|
||||
agent.budget_monthly_cents ? String(agent.budget_monthly_cents / 100) : "",
|
||||
);
|
||||
|
||||
// Self-evolve (predefined agents only)
|
||||
const otherCfg = (agent.other_config ?? {}) as Record<string, unknown>;
|
||||
const [selfEvolve, setSelfEvolve] = useState(Boolean(otherCfg.self_evolve));
|
||||
@@ -51,6 +57,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
setSaved(false);
|
||||
try {
|
||||
const updatedOtherConfig = { ...otherCfg, self_evolve: selfEvolve };
|
||||
const budgetCents = budgetDollars ? Math.round(parseFloat(budgetDollars) * 100) : null;
|
||||
await onUpdate({
|
||||
display_name: displayName,
|
||||
frontmatter: frontmatter || null,
|
||||
@@ -62,6 +69,7 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
status,
|
||||
is_default: isDefault,
|
||||
other_config: updatedOtherConfig,
|
||||
budget_monthly_cents: budgetCents,
|
||||
});
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 3000);
|
||||
@@ -110,6 +118,33 @@ export function AgentGeneralTab({ agent, onUpdate }: AgentGeneralTabProps) {
|
||||
onRestrictChange={setRestrictToWorkspace}
|
||||
/>
|
||||
|
||||
{/* Budget */}
|
||||
<Separator />
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<DollarSign className="h-4 w-4 text-emerald-500" />
|
||||
<h3 className="text-sm font-medium">{t("general.budget")}</h3>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="budget" className="text-sm font-normal">
|
||||
{t("general.budgetLabel")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("general.budgetHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
id="budget"
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
placeholder="0.00"
|
||||
value={budgetDollars}
|
||||
onChange={(e) => setBudgetDollars(e.target.value)}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Self-Evolve (predefined agents only) */}
|
||||
{agent.agent_type === "predefined" && (
|
||||
<>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { Activity, Bot, Hash, Radio, AlertTriangle } from "lucide-react";
|
||||
import { Activity, Bot, DollarSign, Hash, Radio, AlertTriangle } from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
@@ -12,7 +12,7 @@ import { useProviders } from "@/pages/providers/hooks/use-providers";
|
||||
import { useTraces } from "@/pages/traces/hooks/use-traces";
|
||||
import { Methods, Events } from "@/api/protocol";
|
||||
import { ROUTES } from "@/lib/constants";
|
||||
import { formatTokens } from "@/lib/format";
|
||||
import { formatTokens, formatCost } from "@/lib/format";
|
||||
|
||||
import type {
|
||||
HealthPayload,
|
||||
@@ -132,7 +132,7 @@ export function OverviewPage() {
|
||||
)}
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label={t("statCards.requestsToday")}
|
||||
@@ -155,6 +155,11 @@ export function OverviewPage() {
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
icon={DollarSign}
|
||||
label={t("statCards.costToday", "Cost Today")}
|
||||
value={formatCost(quota?.costToday)}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Bot}
|
||||
label={t("statCards.agents")}
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface QuotaUsageResult {
|
||||
requestsToday: number;
|
||||
inputTokensToday: number;
|
||||
outputTokensToday: number;
|
||||
costToday: number;
|
||||
uniqueUsersToday: number;
|
||||
entries: QuotaUsageEntry[];
|
||||
}
|
||||
|
||||
@@ -90,6 +90,9 @@ const KnowledgeGraphPage = lazy(() =>
|
||||
const ContactsPage = lazy(() =>
|
||||
import("@/pages/contacts/contacts-page").then((m) => ({ default: m.ContactsPage })),
|
||||
);
|
||||
const ActivityPage = lazy(() =>
|
||||
import("@/pages/activity/activity-page").then((m) => ({ default: m.ActivityPage })),
|
||||
);
|
||||
|
||||
function PageLoader() {
|
||||
return (
|
||||
@@ -145,6 +148,7 @@ export function AppRoutes() {
|
||||
<Route path={ROUTES.EVENTS} element={<EventsPage />} />
|
||||
<Route path={ROUTES.DELEGATIONS} element={<DelegationsPage />} />
|
||||
<Route path={ROUTES.USAGE} element={<UsagePage />} />
|
||||
<Route path={ROUTES.ACTIVITY} element={<ActivityPage />} />
|
||||
<Route path={ROUTES.CHANNELS} element={<ChannelsPage key="list" />} />
|
||||
<Route path={ROUTES.CHANNEL_DETAIL} element={<ChannelsPage key="detail" />} />
|
||||
<Route path={ROUTES.CONTACTS} element={<ContactsPage />} />
|
||||
|
||||
@@ -102,6 +102,7 @@ export interface AgentData {
|
||||
compaction_config?: CompactionConfig | null;
|
||||
context_pruning?: ContextPruningConfig | null;
|
||||
other_config?: Record<string, unknown> | null;
|
||||
budget_monthly_cents?: number | null;
|
||||
}
|
||||
|
||||
export interface AgentShareData {
|
||||
|
||||
Reference in new issue
Block a user