diff --git a/framework/logstore/rdb.go b/framework/logstore/rdb.go index d6b0cc87597..1a962ef52a4 100644 --- a/framework/logstore/rdb.go +++ b/framework/logstore/rdb.go @@ -908,6 +908,7 @@ func stripNonBillingPayloadBytes(l *Log) { l.ImageGenerationOutput = "" } +// searchLogs runs scoped log searches with the requested projection. func (s *RDBLogStore) searchLogs(ctx context.Context, filters SearchFilters, pagination PaginationOptions, selectColumns string) (*SearchResult, error) { // Build order clause up front (needed by the data goroutine). direction := "DESC" @@ -1184,6 +1185,7 @@ func (s *RDBLogStore) GetSessionSummary(ctx context.Context, sessionID string) ( }, nil } +// normalizeAggregateTimestamp normalizes driver-specific timestamp values for aggregate responses. func normalizeAggregateTimestamp(value any) string { switch v := value.(type) { case nil: @@ -2460,6 +2462,7 @@ type latencyHistogramBucketData struct { overheads []float64 } +// toBucket converts accumulated latency values into the response bucket. func (bd *latencyHistogramBucketData) toBucket(ts int64) LatencyHistogramBucket { b := LatencyHistogramBucket{ Timestamp: time.Unix(ts, 0).UTC(), @@ -4591,6 +4594,25 @@ func (s *RDBLogStore) DeleteLogs(ctx context.Context, ids []string) error { // applyMCPFilters applies search filters to a GORM query for MCP tool logs func (s *RDBLogStore) applyMCPFilters(baseQuery *gorm.DB, filters MCPToolLogSearchFilters) *gorm.DB { + if len(filters.UserIDs) > 0 { + baseQuery = baseQuery.Where("user_id IN ?", filters.UserIDs) + } + if len(filters.TeamIDs) > 0 { + baseQuery = baseQuery.Where("team_id IN ?", filters.TeamIDs) + } + if len(filters.CustomerIDs) > 0 { + baseQuery = baseQuery.Where("customer_id IN ?", filters.CustomerIDs) + } + if len(filters.BusinessUnitIDs) > 0 { + baseQuery = baseQuery.Where("business_unit_id IN ?", filters.BusinessUnitIDs) + } + if len(filters.ProjectIDs) > 0 { + baseQuery = baseQuery.Where("project_id IN ?", filters.ProjectIDs) + } + if len(filters.DeviceIDs) > 0 { + baseQuery = baseQuery.Where("device_id IN ?", filters.DeviceIDs) + } + if len(filters.ToolNames) > 0 { baseQuery = baseQuery.Where("tool_name IN ?", filters.ToolNames) } @@ -4895,6 +4917,7 @@ func (s *RDBLogStore) GetAvailableToolNames(ctx context.Context, limit int, quer return toolNames, nil } +// GetAvailableServerLabels lists MCP server labels matching the filter search. func (s *RDBLogStore) GetAvailableServerLabels(ctx context.Context, limit int, query string) ([]string, error) { cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays) var serverLabels []string @@ -4943,6 +4966,7 @@ func (s *RDBLogStore) GetAvailableMCPApps(ctx context.Context, limit int, query return apps, nil } +// GetAvailableMCPVirtualKeys lists virtual keys represented in visible MCP logs. func (s *RDBLogStore) GetAvailableMCPVirtualKeys(ctx context.Context, limit int, query string) ([]MCPToolLog, error) { cutoff := time.Now().UTC().AddDate(0, 0, -defaultFilterDataCutoffDays) var logs []MCPToolLog diff --git a/framework/logstore/rdb_perf_test.go b/framework/logstore/rdbperf_test.go similarity index 100% rename from framework/logstore/rdb_perf_test.go rename to framework/logstore/rdbperf_test.go diff --git a/framework/logstore/rdb_postgres_perf_test.go b/framework/logstore/rdbpostgres_perf_test.go similarity index 100% rename from framework/logstore/rdb_postgres_perf_test.go rename to framework/logstore/rdbpostgres_perf_test.go diff --git a/framework/logstore/rdb_ranking_limit_test.go b/framework/logstore/rdbranking_limit_test.go similarity index 100% rename from framework/logstore/rdb_ranking_limit_test.go rename to framework/logstore/rdbranking_limit_test.go diff --git a/framework/logstore/rdb_requestid_test.go b/framework/logstore/rdbrequestid_test.go similarity index 100% rename from framework/logstore/rdb_requestid_test.go rename to framework/logstore/rdbrequestid_test.go diff --git a/framework/logstore/rdb_rootsonly_test.go b/framework/logstore/rdbrootsonly_test.go similarity index 100% rename from framework/logstore/rdb_rootsonly_test.go rename to framework/logstore/rdbrootsonly_test.go diff --git a/framework/logstore/rdb_stats_test.go b/framework/logstore/rdbstats_test.go similarity index 54% rename from framework/logstore/rdb_stats_test.go rename to framework/logstore/rdbstats_test.go index d68f84d7583..79d8a7f28ed 100644 --- a/framework/logstore/rdb_stats_test.go +++ b/framework/logstore/rdbstats_test.go @@ -56,3 +56,26 @@ func TestGetStatsTokenSplit(t *testing.T) { require.Equal(t, int64(70), stats.CompletionTokens, "completion = 10+20+40") require.Equal(t, stats.TotalTokens, stats.PromptTokens+stats.CompletionTokens, "split sums to total") } + +// TestMCPAttributionFiltersApplyToRowsAndStats checks each stored scope filters both records and aggregates. +func TestMCPAttributionFiltersApplyToRowsAndStats(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&MCPToolLog{})) + store := &RDBLogStore{db: db, logger: bifrost.NewDefaultLogger(schemas.LogLevelInfo)} + ctx := context.Background() + a, b := "a", "b" + now := time.Now() + require.NoError(t, db.Create(&MCPToolLog{ID: a, ToolName: "Read", Timestamp: now, Status: "success", UserID: &a, TeamID: &a, CustomerID: &a, BusinessUnitID: &a, ProjectID: &a, DeviceID: &a}).Error) + require.NoError(t, db.Create(&MCPToolLog{ID: b, ToolName: "Read", Timestamp: now, Status: "error", UserID: &b, TeamID: &b, CustomerID: &b, BusinessUnitID: &b, ProjectID: &b, DeviceID: &b}).Error) + for _, filters := range []MCPToolLogSearchFilters{{UserIDs: []string{a}}, {TeamIDs: []string{a}}, {CustomerIDs: []string{a}}, {BusinessUnitIDs: []string{a}}, {ProjectIDs: []string{a}}, {DeviceIDs: []string{a}}} { + result, err := store.SearchMCPToolLogs(ctx, filters, PaginationOptions{Limit: 10, SortBy: "timestamp", Order: "desc"}) + require.NoError(t, err) + require.Len(t, result.Logs, 1) + require.Equal(t, a, result.Logs[0].ID) + stats, err := store.GetMCPToolLogStats(ctx, filters) + require.NoError(t, err) + require.EqualValues(t, 1, stats.TotalExecutions) + require.EqualValues(t, 100, stats.SuccessRate) + } +} diff --git a/framework/logstore/rdb_toolcallnames_test.go b/framework/logstore/rdbtoolcallnames_test.go similarity index 100% rename from framework/logstore/rdb_toolcallnames_test.go rename to framework/logstore/rdbtoolcallnames_test.go diff --git a/framework/logstore/safe_jsonb_test.go b/framework/logstore/safejsonb_test.go similarity index 100% rename from framework/logstore/safe_jsonb_test.go rename to framework/logstore/safejsonb_test.go diff --git a/framework/logstore/tables.go b/framework/logstore/tables.go index 9b225e1469f..af398cb7731 100644 --- a/framework/logstore/tables.go +++ b/framework/logstore/tables.go @@ -1354,6 +1354,12 @@ func costsReconcile(a, b float64) bool { // MCPToolLog represents a log entry for MCP tool executions // This is separate from the main Log table since MCP tool calls have different fields type MCPToolLog struct { + // Display names are resolved after scoped reads; IDs remain the recorded attribution. + UserName *string `gorm:"-" json:"user_name,omitempty"` + TeamName *string `gorm:"-" json:"team_name,omitempty"` + CustomerName *string `gorm:"-" json:"customer_name,omitempty"` + BusinessUnitName *string `gorm:"-" json:"business_unit_name,omitempty"` + ID string `gorm:"primaryKey;type:varchar(255)" json:"id"` RequestID string `gorm:"type:varchar(255);column:request_id;index:idx_mcp_logs_request_id" json:"request_id,omitempty"` // The original request ID from context LLMRequestID *string `gorm:"type:varchar(255);column:llm_request_id;index:idx_mcp_logs_llm_request_id" json:"llm_request_id,omitempty"` // Links to the LLM request that triggered this tool call @@ -1388,7 +1394,7 @@ type MCPToolLog struct { // Endpoint-agent context. These are populated for tool calls observed on a // developer machine by the Bifrost Edge agent (rather than proxied by the // gateway). Source distinguishes the origin: empty/null for gateway-proxied - // calls, "endpoint" for agent-observed calls. + // calls, "endpoint" for agent-observed MCP calls, "native" for harness tools. DeviceID *string `gorm:"type:varchar(255);index:idx_mcp_logs_device_id" json:"device_id,omitempty"` AppKey *string `gorm:"type:varchar(64)" json:"app_key,omitempty"` // Canonical policy key of the detected client app (schemas.AppKeyFromName), e.g. "claude-code"; a slug like App, not a secret or credential Decision *string `gorm:"type:varchar(16)" json:"decision,omitempty"` @@ -1694,6 +1700,13 @@ type WebhookDeliverySearchFilters struct { // MCPToolLogSearchFilters represents the available filters for MCP tool log searches type MCPToolLogSearchFilters struct { + UserIDs []string `json:"user_ids,omitempty"` + TeamIDs []string `json:"team_ids,omitempty"` + CustomerIDs []string `json:"customer_ids,omitempty"` + BusinessUnitIDs []string `json:"business_unit_ids,omitempty"` + ProjectIDs []string `json:"project_ids,omitempty"` + DeviceIDs []string `json:"device_ids,omitempty"` + ToolNames []string `json:"tool_names,omitempty"` ServerLabels []string `json:"server_labels,omitempty"` Status []string `json:"status,omitempty"` @@ -2315,6 +2328,7 @@ var dimensionColumns = map[RankingDimension]dimensionColumnDef{ RankingDimensionUserAgent: {IDCol: "user_agent", NameCol: "user_agent"}, } +// DimensionColumnDef returns the column pair for a supported ranking dimension. func DimensionColumnDef(d RankingDimension) (idCol, nameCol string, ok bool) { def, exists := dimensionColumns[d] return def.IDCol, def.NameCol, exists diff --git a/plugins/governance/main.go b/plugins/governance/main.go index 39eeed5f40a..3d429b60913 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -1322,7 +1322,7 @@ func (p *GovernancePlugin) PreMCPHook(ctx *schemas.BifrostContext, req *schemas. // disagree. What is left is a question about the tool, which the access answers whatever granted it. // A request carrying no access is unrestricted and may execute any tool, as it always could. access := ctx.Grant().Access() - if access != nil && !access.IsMCPToolAllowed(toolName) { + if access != nil && !access.IsMCPToolAllowed(toolName) && !hasMCPExecutionAuthorization(ctx, req) { ctx.SetValue(governanceRejectedContextKey, true) return req, &schemas.MCPPluginShortCircuit{Error: &schemas.BifrostError{ Type: bifrost.Ptr(string(DecisionMCPToolBlocked)), diff --git a/plugins/governance/mcpauthorization.go b/plugins/governance/mcpauthorization.go new file mode 100644 index 00000000000..ecf83d44482 --- /dev/null +++ b/plugins/governance/mcpauthorization.go @@ -0,0 +1,21 @@ +package governance + +import "github.com/maximhq/bifrost/core/schemas" + +const mcpAuthorizationContextKey schemas.BifrostContextKey = "bf-governance-mcp-authorization" + +// mcpAuthorization binds trusted transport approval to one exact execution target. +type mcpAuthorization struct{ clientName, toolName string } + +// SetMCPExecutionAuthorization records a transport-verified approval for one MCP call. +// Only trusted handlers may call this after checking their own authorization policy; +// it replaces the MCP tool permit check, never identity, headers or usage limits. +func SetMCPExecutionAuthorization(ctx *schemas.BifrostContext, clientName, toolName string) { + ctx.SetValue(mcpAuthorizationContextKey, mcpAuthorization{clientName, toolName}) +} + +// hasMCPExecutionAuthorization prevents approval from following a retargeted request. +func hasMCPExecutionAuthorization(ctx *schemas.BifrostContext, req *schemas.BifrostMCPRequest) bool { + approval, ok := ctx.Value(mcpAuthorizationContextKey).(mcpAuthorization) + return ok && approval.clientName != "" && approval.toolName != "" && req.ClientName == approval.clientName && req.GetToolName() == approval.toolName +} diff --git a/plugins/governance/mcpauthorization_test.go b/plugins/governance/mcpauthorization_test.go new file mode 100644 index 00000000000..5810a4a2682 --- /dev/null +++ b/plugins/governance/mcpauthorization_test.go @@ -0,0 +1,53 @@ +package governance + +import ( + "testing" + + "github.com/maximhq/bifrost/core/schemas" + "github.com/stretchr/testify/require" +) + +// TestMCPExecutionAuthorization preserves governance while replacing only the exact tool permit. +func TestMCPExecutionAuthorization(t *testing.T) { + p := newPluginForMCPStamping(t, buildVKForMCPStamping(nil), false) + for _, tc := range []struct { + name, client, tool, key string + approved, allowed bool + }{ + {"gateway", "local", "local-read", mcpTestVKValue, false, false}, + {"approved", "local", "local-read", mcpTestVKValue, true, true}, + {"other tool", "local", "local-write", mcpTestVKValue, true, false}, + {"other client", "other", "local-read", mcpTestVKValue, true, false}, + {"invalid identity", "local", "local-read", "invalid", true, false}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx := presentCtx(tc.key) + if tc.approved { + SetMCPExecutionAuthorization(ctx, "local", "local-read") + } + req := &schemas.BifrostMCPRequest{RequestType: schemas.MCPRequestTypeChatToolCall, ClientName: tc.client, ChatAssistantMessageToolCall: &schemas.ChatAssistantMessageToolCall{Function: schemas.ChatAssistantMessageToolCallFunction{Name: &tc.tool, Arguments: "{}"}}} + _, short, err := p.PreMCPHook(ctx, req) + require.NoError(t, err) + if tc.allowed { + require.Nil(t, short) + } else { + require.NotNil(t, short) + require.NotNil(t, short.Error) + } + }) + } +} + +// TestMCPExecutionAuthorizationRequiresHeaders keeps transport requirements ahead of tool approval. +func TestMCPExecutionAuthorizationRequiresHeaders(t *testing.T) { + p := newPluginForMCPStamping(t, buildVKForMCPStamping(nil), false) + p.requiredHeaders = &[]string{"x-required"} + ctx := presentCtx(mcpTestVKValue) + SetMCPExecutionAuthorization(ctx, "local", "local-read") + name := "local-read" + req := &schemas.BifrostMCPRequest{RequestType: schemas.MCPRequestTypeChatToolCall, ClientName: "local", ChatAssistantMessageToolCall: &schemas.ChatAssistantMessageToolCall{Function: schemas.ChatAssistantMessageToolCallFunction{Name: &name, Arguments: "{}"}}} + _, short, err := p.PreMCPHook(ctx, req) + require.NoError(t, err) + require.NotNil(t, short) + require.Equal(t, "missing_required_headers", *short.Error.Type) +} diff --git a/plugins/logging/writer.go b/plugins/logging/writer.go index 7cd5605d8a4..8a6797e54fc 100644 --- a/plugins/logging/writer.go +++ b/plugins/logging/writer.go @@ -312,6 +312,14 @@ func (p *LoggerPlugin) EnqueueLogEntry(entry *logstore.Log) { p.enqueueLogEntry(entry, p.makePostWriteCallback(nil)) } +// EnqueueMCPToolLogEntry pushes a completed MCP log through the normal async write queue. +func (p *LoggerPlugin) EnqueueMCPToolLogEntry(entry *logstore.MCPToolLog) { + p.mu.Lock() + callback := p.mcpToolLogCallback + p.mu.Unlock() + p.enqueueMCPToolLogEntry(entry, callback) +} + // enqueueMCPToolLogEntry pushes a complete MCP tool log entry to the write queue. // If the queue is full, the entry is dropped to prevent store slowness from // cascading into request handling goroutines. diff --git a/transports/bifrost-http/handlers/logging.go b/transports/bifrost-http/handlers/logging.go index ccc014a68f9..47e37379f82 100644 --- a/transports/bifrost-http/handlers/logging.go +++ b/transports/bifrost-http/handlers/logging.go @@ -301,16 +301,19 @@ func (c *filterDataCache) load(key string) (*filterDataCacheEntry, map[string]in return entry, nil, false } +// store publishes a completed filter-data cache result. func (c *filterDataCache) store(entry *filterDataCacheEntry, payload map[string]interface{}) { entry.payload = payload entry.expiresAt = time.Now().Add(filterDataCacheTTL) entry.mu.Unlock() } +// release releases a filter-data cache entry after a fetch attempt. func (c *filterDataCache) release(entry *filterDataCacheEntry) { entry.mu.Unlock() } +// parseParentRequestIDFilter reads the parent request ID filter from the request. func parseParentRequestIDFilter(ctx *fasthttp.RequestCtx) string { if parentRequestID := string(ctx.QueryArgs().Peek("parent_request_id")); strings.TrimSpace(parentRequestID) != "" { return parentRequestID @@ -359,6 +362,7 @@ func (h *LoggingHandler) SetMCPLogRedactionMappingResolver(resolver MCPLogRedact h.mcpLogRedactionMappingResolver = resolver } +// shouldHideDeletedVirtualKeysInFilters reads the configured deleted-key visibility policy. func (h *LoggingHandler) shouldHideDeletedVirtualKeysInFilters() bool { if h == nil || h.config == nil { return false @@ -413,6 +417,7 @@ func (h *LoggingHandler) RegisterRoutes(r *router.Router, middlewares ...schemas r.DELETE("/api/mcp-logs", lib.ChainMiddlewares(h.deleteMCPLogs, middlewares...)) } +// listUserAgentMappings returns configured client identification mappings. func (h *LoggingHandler) listUserAgentMappings(ctx *fasthttp.RequestCtx) { mappings, err := h.logManager.ListUserAgentMappings(ctx) if err != nil { @@ -422,6 +427,7 @@ func (h *LoggingHandler) listUserAgentMappings(ctx *fasthttp.RequestCtx) { SendJSON(ctx, map[string]any{"mappings": mappings}) } +// createUserAgentMapping validates and creates a client identification mapping. func (h *LoggingHandler) createUserAgentMapping(ctx *fasthttp.RequestCtx) { var mapping logstore.UserAgentMapping if err := sonic.Unmarshal(ctx.PostBody(), &mapping); err != nil { @@ -440,6 +446,7 @@ func (h *LoggingHandler) createUserAgentMapping(ctx *fasthttp.RequestCtx) { SendJSON(ctx, created) } +// updateUserAgentMapping updates an identified client mapping after request validation. func (h *LoggingHandler) updateUserAgentMapping(ctx *fasthttp.RequestCtx) { id, ok := ctx.UserValue("id").(string) if !ok || strings.TrimSpace(id) == "" { @@ -467,6 +474,7 @@ func (h *LoggingHandler) updateUserAgentMapping(ctx *fasthttp.RequestCtx) { SendJSON(ctx, updated) } +// deleteUserAgentMapping deletes an identified client mapping and reports missing records. func (h *LoggingHandler) deleteUserAgentMapping(ctx *fasthttp.RequestCtx) { id, ok := ctx.UserValue("id").(string) if !ok || strings.TrimSpace(id) == "" { @@ -1538,6 +1546,7 @@ func (h *LoggingHandler) getModelRankings(ctx *fasthttp.RequestCtx) { SendJSON(ctx, result) } +// getDimensionRankings validates and serves rankings for the requested attribution dimension. func (h *LoggingHandler) getDimensionRankings(ctx *fasthttp.RequestCtx) { dim := logstore.RankingDimension(string(ctx.QueryArgs().Peek("dimension"))) if dim == "" { @@ -2420,6 +2429,7 @@ func recalcJobStatusFromRow(job *tables.TableSidekiqJob) recalcJobStatus { // Helper functions +// findRedactedKey matches a redacted provider key or returns a deleted-key placeholder. func findRedactedKey(redactedKeys []schemas.Key, id string, name string) *schemas.Key { if len(redactedKeys) == 0 { return &schemas.Key{ @@ -2450,6 +2460,7 @@ func findRedactedKey(redactedKeys []schemas.Key, id string, name string) *schema } } +// findRedactedVirtualKey matches a redacted virtual key or returns a deleted-key placeholder. func findRedactedVirtualKey(redactedVirtualKeys []tables.TableVirtualKey, id string, name string) *tables.TableVirtualKey { if len(redactedVirtualKeys) == 0 { return &tables.TableVirtualKey{ @@ -2480,6 +2491,7 @@ func findRedactedVirtualKey(redactedVirtualKeys []tables.TableVirtualKey, id str } } +// findRedactedRoutingRule matches a redacted routing rule or returns a deleted-rule placeholder. func findRedactedRoutingRule(redactedRoutingRules []tables.TableRoutingRule, id string, name string) *tables.TableRoutingRule { if len(redactedRoutingRules) == 0 { return &tables.TableRoutingRule{ @@ -2585,6 +2597,13 @@ type recalculateCostFilters struct { // Returns an error if any required parsing fails (e.g., invalid time format, invalid number format). func parseMCPFiltersAndPagination(ctx *fasthttp.RequestCtx) (*logstore.MCPToolLogSearchFilters, *logstore.PaginationOptions, error) { filters := &logstore.MCPToolLogSearchFilters{} + filters.UserIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("user_ids"))) + filters.TeamIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("team_ids"))) + filters.CustomerIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("customer_ids"))) + filters.BusinessUnitIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("business_unit_ids"))) + filters.ProjectIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("project_ids"))) + filters.DeviceIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("device_ids"))) + pagination := &logstore.PaginationOptions{} // Extract filters from query parameters @@ -2712,6 +2731,12 @@ func parseMCPFiltersAndPagination(ctx *fasthttp.RequestCtx) (*logstore.MCPToolLo // Returns an error if any required parsing fails. func parseMCPFilters(ctx *fasthttp.RequestCtx) (*logstore.MCPToolLogSearchFilters, error) { filters := &logstore.MCPToolLogSearchFilters{} + filters.UserIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("user_ids"))) + filters.TeamIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("team_ids"))) + filters.CustomerIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("customer_ids"))) + filters.BusinessUnitIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("business_unit_ids"))) + filters.ProjectIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("project_ids"))) + filters.DeviceIDs = parseCommaSeparated(string(ctx.QueryArgs().Peek("device_ids"))) // Extract filters from query parameters if toolNames := string(ctx.QueryArgs().Peek("tool_names")); toolNames != "" { diff --git a/transports/bifrost-http/handlers/logging_test.go b/transports/bifrost-http/handlers/logging_test.go index 0e14b4b6bf8..8d758d99d03 100644 --- a/transports/bifrost-http/handlers/logging_test.go +++ b/transports/bifrost-http/handlers/logging_test.go @@ -274,6 +274,7 @@ func TestFilterDataCacheIdentity_PartitionsPerCaller(t *testing.T) { } } +// TestGetDashboard verifies get dashboard. func TestGetDashboard(t *testing.T) { tests := []struct { name string @@ -378,6 +379,7 @@ func TestGetDashboard(t *testing.T) { } } +// TestRecalculateLogCostsResolvesPeriodFilter verifies recalculate log costs resolves period filter. func TestRecalculateLogCostsResolvesPeriodFilter(t *testing.T) { SetLogger(&mockLogger{}) @@ -415,6 +417,7 @@ func TestRecalculateLogCostsResolvesPeriodFilter(t *testing.T) { } } +// TestRecalculateLogCostsRejectsDuplicateJob verifies recalculate log costs rejects duplicate job. func TestRecalculateLogCostsRejectsDuplicateJob(t *testing.T) { SetLogger(&mockLogger{}) @@ -450,6 +453,7 @@ func TestRecalculateLogCostsRejectsDuplicateJob(t *testing.T) { } } +// TestCancelRecalculateCost verifies cancel recalculate cost. func TestCancelRecalculateCost(t *testing.T) { SetLogger(&mockLogger{}) @@ -577,16 +581,19 @@ type fakeSidekiqStore struct { inFlight *tables.TableSidekiqJob } +// newFakeSidekiqStore verifies new fake sidekiq store. func newFakeSidekiqStore() *fakeSidekiqStore { return &fakeSidekiqStore{jobs: make(map[string]*tables.TableSidekiqJob)} } +// createdCount implements the test double used by logging handler tests. func (s *fakeSidekiqStore) createdCount() int { s.mu.Lock() defer s.mu.Unlock() return s.created } +// CreateSidekiqJob implements the test double used by logging handler tests. func (s *fakeSidekiqStore) CreateSidekiqJob(ctx context.Context, job *tables.TableSidekiqJob) error { s.mu.Lock() defer s.mu.Unlock() @@ -596,6 +603,7 @@ func (s *fakeSidekiqStore) CreateSidekiqJob(ctx context.Context, job *tables.Tab return nil } +// GetSidekiqJob implements the test double used by logging handler tests. func (s *fakeSidekiqStore) GetSidekiqJob(ctx context.Context, id string) (*tables.TableSidekiqJob, error) { s.mu.Lock() defer s.mu.Unlock() @@ -606,6 +614,7 @@ func (s *fakeSidekiqStore) GetSidekiqJob(ctx context.Context, id string) (*table return nil, nil } +// GetInFlightSidekiqJobByKind implements the test double used by logging handler tests. func (s *fakeSidekiqStore) GetInFlightSidekiqJobByKind(ctx context.Context, kind string) (*tables.TableSidekiqJob, error) { s.mu.Lock() defer s.mu.Unlock() @@ -616,27 +625,42 @@ func (s *fakeSidekiqStore) GetInFlightSidekiqJobByKind(ctx context.Context, kind return nil, nil } +// ClaimSidekiqJob implements the test double used by logging handler tests. func (s *fakeSidekiqStore) ClaimSidekiqJob(ctx context.Context, id, runnerID string, staleBefore time.Time) (bool, error) { return true, nil } + +// ClaimPartitionedSidekiqJob implements the test double used by logging handler tests. func (s *fakeSidekiqStore) ClaimPartitionedSidekiqJob(ctx context.Context, id, runnerID string, staleBefore time.Time, partitioningKey string, createdAt time.Time) (bool, error) { return true, nil } + +// HeartbeatSidekiqJob implements the test double used by logging handler tests. func (s *fakeSidekiqStore) HeartbeatSidekiqJob(ctx context.Context, id, runnerID string) (bool, error) { return true, nil } + +// UpdateSidekiqJobProgress implements the test double used by logging handler tests. func (s *fakeSidekiqStore) UpdateSidekiqJobProgress(ctx context.Context, id, runnerID, metadata string) error { return nil } + +// CompleteSidekiqJob implements the test double used by logging handler tests. func (s *fakeSidekiqStore) CompleteSidekiqJob(ctx context.Context, id, runnerID, metadata string) error { return nil } + +// FailSidekiqJob implements the test double used by logging handler tests. func (s *fakeSidekiqStore) FailSidekiqJob(ctx context.Context, id, runnerID, metadata, lastErr string) error { return nil } + +// ListClaimableSidekiqJobs implements the test double used by logging handler tests. func (s *fakeSidekiqStore) ListClaimableSidekiqJobs(ctx context.Context, staleBefore time.Time) ([]tables.TableSidekiqJob, error) { return nil, nil } + +// CancelSidekiqJob implements the test double used by logging handler tests. func (s *fakeSidekiqStore) CancelSidekiqJob(ctx context.Context, id string) (bool, error) { s.mu.Lock() defer s.mu.Unlock() @@ -650,6 +674,8 @@ func (s *fakeSidekiqStore) CancelSidekiqJob(ctx context.Context, id string) (boo } return true, nil } + +// FinalizeCancelledSidekiqJob implements the test double used by logging handler tests. func (s *fakeSidekiqStore) FinalizeCancelledSidekiqJob(ctx context.Context, id, runnerID, metadata string) error { s.mu.Lock() defer s.mu.Unlock() @@ -676,19 +702,28 @@ type dashboardLogManager struct { lastRecalculateContext chan context.Context } +// GetLog implements the test double used by logging handler tests. func (m *dashboardLogManager) GetLog(ctx context.Context, id string) (*logstore.Log, error) { return nil, nil } + +// Search implements the test double used by logging handler tests. func (m *dashboardLogManager) Search(ctx context.Context, filters *logstore.SearchFilters, pagination *logstore.PaginationOptions) (*logstore.SearchResult, error) { m.lastLLMFilters = *filters return &logstore.SearchResult{}, nil } + +// GetSessionLogs implements the test double used by logging handler tests. func (m *dashboardLogManager) GetSessionLogs(ctx context.Context, sessionID string, pagination *logstore.PaginationOptions) (*logstore.SessionDetailResult, error) { return nil, nil } + +// GetSessionSummary implements the test double used by logging handler tests. func (m *dashboardLogManager) GetSessionSummary(ctx context.Context, sessionID string) (*logstore.SessionSummaryResult, error) { return nil, nil } + +// GetStats implements the test double used by logging handler tests. func (m *dashboardLogManager) GetStats(ctx context.Context, filters *logstore.SearchFilters) (*logstore.SearchStats, error) { m.lastLLMFilters = *filters m.statsCalls = append(m.statsCalls, *filters) @@ -700,100 +735,168 @@ func (m *dashboardLogManager) GetStats(ctx context.Context, filters *logstore.Se } return &logstore.SearchStats{}, nil } + +// GetHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.HistogramResult, error) { return &logstore.HistogramResult{}, nil } + +// GetTokenHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetTokenHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.TokenHistogramResult, error) { return &logstore.TokenHistogramResult{}, nil } + +// GetCostHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetCostHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.CostHistogramResult, error) { return &logstore.CostHistogramResult{}, nil } + +// GetModelHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetModelHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.ModelHistogramResult, error) { return &logstore.ModelHistogramResult{}, nil } + +// GetLatencyHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetLatencyHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.LatencyHistogramResult, error) { return &logstore.LatencyHistogramResult{}, nil } + +// GetProviderCostHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetProviderCostHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.ProviderCostHistogramResult, error) { return &logstore.ProviderCostHistogramResult{}, nil } + +// GetProviderTokenHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetProviderTokenHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.ProviderTokenHistogramResult, error) { return &logstore.ProviderTokenHistogramResult{}, nil } + +// GetProviderLatencyHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetProviderLatencyHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.ProviderLatencyHistogramResult, error) { return &logstore.ProviderLatencyHistogramResult{}, nil } + +// GetThroughputHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetThroughputHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.ThroughputHistogramResult, error) { return &logstore.ThroughputHistogramResult{}, nil } + +// GetProviderThroughputHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetProviderThroughputHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64) (*logstore.ProviderThroughputHistogramResult, error) { return &logstore.ProviderThroughputHistogramResult{}, nil } + +// GetModelRankings implements the test double used by logging handler tests. func (m *dashboardLogManager) GetModelRankings(ctx context.Context, filters *logstore.SearchFilters) (*logstore.ModelRankingResult, error) { return &logstore.ModelRankingResult{}, nil } + +// GetDimensionRankings implements the test double used by logging handler tests. func (m *dashboardLogManager) GetDimensionRankings(ctx context.Context, filters *logstore.SearchFilters, dimension logstore.RankingDimension) (*logstore.DimensionRankingResult, error) { return &logstore.DimensionRankingResult{Dimension: dimension}, nil } + +// GetDroppedRequests implements the test double used by logging handler tests. func (m *dashboardLogManager) GetDroppedRequests(ctx context.Context) int64 { return 0 } + +// GetAvailableModels implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableModels(ctx context.Context, limit int, query string) ([]string, error) { return nil, nil } + +// GetAvailableAliases implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableAliases(ctx context.Context, limit int, query string) ([]string, error) { return nil, nil } + +// GetAvailableSelectedKeys implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableSelectedKeys(ctx context.Context, limit int, query string) ([]loggingplugin.KeyPair, error) { return nil, nil } + +// GetAvailableVirtualKeys implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableVirtualKeys(ctx context.Context, limit int, query string) ([]loggingplugin.KeyPair, error) { return nil, nil } + +// GetAvailableRoutingRules implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableRoutingRules(ctx context.Context, limit int, query string) ([]loggingplugin.KeyPair, error) { return nil, nil } + +// GetAvailableRoutingEngines implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableRoutingEngines(ctx context.Context, limit int, query string) ([]string, error) { return nil, nil } + +// GetAvailableStopReasons implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableStopReasons(ctx context.Context, limit int, query string) ([]string, error) { return nil, nil } + +// GetAvailableToolCallNames implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableToolCallNames(ctx context.Context, limit int, query string) ([]string, error) { return nil, nil } + +// GetAvailableTeams implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableTeams(ctx context.Context, limit int, query string) ([]loggingplugin.KeyPair, error) { return nil, nil } + +// GetAvailableCustomers implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableCustomers(ctx context.Context, limit int, query string) ([]loggingplugin.KeyPair, error) { return nil, nil } + +// GetAvailableUsers implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableUsers(ctx context.Context, limit int, query string) ([]loggingplugin.KeyPair, error) { return nil, nil } + +// GetAvailableBusinessUnits implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableBusinessUnits(ctx context.Context, limit int, query string) ([]loggingplugin.KeyPair, error) { return nil, nil } + +// GetAvailableProjects implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableProjects(ctx context.Context, limit int, query string) ([]loggingplugin.KeyPair, error) { return m.projects, nil } + +// GetAvailableMetadataKeys implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableMetadataKeys(ctx context.Context, limit int, query string) (map[string][]string, error) { return nil, nil } + +// GetDimensionCostHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetDimensionCostHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionCostHistogramResult, error) { return nil, nil } + +// GetDimensionTokenHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetDimensionTokenHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionTokenHistogramResult, error) { return nil, nil } + +// GetDimensionLatencyHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetDimensionLatencyHistogram(ctx context.Context, filters *logstore.SearchFilters, bucketSizeSeconds int64, dimension logstore.HistogramDimension) (*logstore.DimensionLatencyHistogramResult, error) { return nil, nil } -func (m *dashboardLogManager) DeleteLog(ctx context.Context, id string) error { return nil } + +// DeleteLog implements the test double used by logging handler tests. +func (m *dashboardLogManager) DeleteLog(ctx context.Context, id string) error { return nil } + +// DeleteLogs implements the test double used by logging handler tests. func (m *dashboardLogManager) DeleteLogs(ctx context.Context, ids []string) error { return nil } + +// RecalculateCosts implements the test double used by logging handler tests. func (m *dashboardLogManager) RecalculateCosts(ctx context.Context, filters *logstore.SearchFilters, limit int) (*loggingplugin.RecalculateCostResult, error) { m.lastRecalculateFilters = *filters return &loggingplugin.RecalculateCostResult{}, nil } + +// RecalculateCostsWithProgress implements the test double used by logging handler tests. func (m *dashboardLogManager) RecalculateCostsWithProgress(ctx context.Context, filters *logstore.SearchFilters, limit int, progress func(loggingplugin.RecalculateCostProgress)) (*loggingplugin.RecalculateCostResult, error) { m.lastRecalculateFilters = *filters if m.lastRecalculateContext != nil { @@ -801,13 +904,19 @@ func (m *dashboardLogManager) RecalculateCostsWithProgress(ctx context.Context, } return nil, nil } + +// BuildCostRecalcJobMeta implements the test double used by logging handler tests. func (m *dashboardLogManager) BuildCostRecalcJobMeta(ctx context.Context, filters logstore.SearchFilters, missingCostOnly bool) (string, error) { m.lastRecalculateFilters = filters return "{}", nil } + +// RunCostRecalcJob implements the test double used by logging handler tests. func (m *dashboardLogManager) RunCostRecalcJob(ctx context.Context, metaJSON string, checkpoint func(string) error) (string, error) { return metaJSON, nil } + +// GetMCPToolLog implements the test double used by logging handler tests. func (m *dashboardLogManager) GetMCPToolLog(ctx context.Context, id string) (*logstore.MCPToolLog, error) { if m.mcpLog == nil { return nil, nil @@ -815,32 +924,49 @@ func (m *dashboardLogManager) GetMCPToolLog(ctx context.Context, id string) (*lo entry := *m.mcpLog return &entry, nil } + +// SearchMCPToolLogs implements the test double used by logging handler tests. func (m *dashboardLogManager) SearchMCPToolLogs(ctx context.Context, filters *logstore.MCPToolLogSearchFilters, pagination *logstore.PaginationOptions) (*logstore.MCPToolLogSearchResult, error) { return nil, nil } + +// GetMCPToolLogStats implements the test double used by logging handler tests. func (m *dashboardLogManager) GetMCPToolLogStats(ctx context.Context, filters *logstore.MCPToolLogSearchFilters) (*logstore.MCPToolLogStats, error) { return nil, nil } + +// GetAvailableToolNames implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableToolNames(ctx context.Context, limit int, query string) ([]string, error) { return nil, nil } + +// GetAvailableServerLabels implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableServerLabels(ctx context.Context, limit int, query string) ([]string, error) { return nil, nil } + +// GetAvailableMCPVirtualKeys implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableMCPVirtualKeys(ctx context.Context, limit int, query string) ([]loggingplugin.KeyPair, error) { return nil, nil } + +// GetMCPHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetMCPHistogram(ctx context.Context, filters logstore.MCPToolLogSearchFilters, bucketSizeSeconds int64) (*logstore.MCPHistogramResult, error) { m.lastMCPFilters = filters return &logstore.MCPHistogramResult{}, nil } + +// GetMCPCostHistogram implements the test double used by logging handler tests. func (m *dashboardLogManager) GetMCPCostHistogram(ctx context.Context, filters logstore.MCPToolLogSearchFilters, bucketSizeSeconds int64) (*logstore.MCPCostHistogramResult, error) { return &logstore.MCPCostHistogramResult{}, nil } + +// GetMCPTopTools implements the test double used by logging handler tests. func (m *dashboardLogManager) GetMCPTopTools(ctx context.Context, filters logstore.MCPToolLogSearchFilters, limit int) (*logstore.MCPTopToolsResult, error) { return &logstore.MCPTopToolsResult{}, nil } +// DeleteMCPToolLogs implements the test double used by logging handler tests. func (m *dashboardLogManager) DeleteMCPToolLogs(ctx context.Context, ids []string) error { return nil } // staticMCPLogRedactionResolver records calls and returns a configured reveal result. @@ -856,34 +982,42 @@ func (r *staticMCPLogRedactionResolver) ResolveMCPLogRedactionMapping(_ *fasthtt return r.mapping, r.err } +// CreateUserAgentMapping implements the test double used by logging handler tests. func (m *dashboardLogManager) CreateUserAgentMapping(ctx context.Context, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error) { return nil, nil } +// DeleteUserAgentMapping implements the test double used by logging handler tests. func (m *dashboardLogManager) DeleteUserAgentMapping(ctx context.Context, id string) error { return nil } +// UpdateUserAgentMapping implements the test double used by logging handler tests. func (m *dashboardLogManager) UpdateUserAgentMapping(ctx context.Context, id string, mapping *logstore.UserAgentMapping) (*logstore.UserAgentMapping, error) { return nil, nil } +// ListUserAgentMappings implements the test double used by logging handler tests. func (m *dashboardLogManager) ListUserAgentMappings(ctx context.Context) ([]logstore.UserAgentMapping, error) { return nil, nil } +// GetAvailableUserAgents implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableUserAgents(ctx context.Context, _ int, _ string) ([]string, error) { return nil, nil } +// GetAvailableApps implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableApps(ctx context.Context, _ int, _ string) ([]string, error) { return nil, nil } +// GetAvailableMCPApps implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableMCPApps(ctx context.Context, _ int, _ string) ([]string, error) { return nil, nil } +// GetAvailableMCPUserAgents implements the test double used by logging handler tests. func (m *dashboardLogManager) GetAvailableMCPUserAgents(ctx context.Context, _ int, _ string) ([]string, error) { return nil, nil } @@ -892,10 +1026,15 @@ func (m *dashboardLogManager) GetAvailableMCPUserAgents(ctx context.Context, _ i // search result produces. type noRedactedKeys struct{} +// GetAllRedactedKeys implements the test double used by logging handler tests. func (noRedactedKeys) GetAllRedactedKeys(ctx context.Context, ids []string) []schemas.Key { return nil } + +// GetAllRedactedVirtualKeys implements the test double used by logging handler tests. func (noRedactedKeys) GetAllRedactedVirtualKeys(ctx context.Context, ids []string) []tables.TableVirtualKey { return nil } + +// GetAllRedactedRoutingRules implements the test double used by logging handler tests. func (noRedactedKeys) GetAllRedactedRoutingRules(ctx context.Context, ids []string) []tables.TableRoutingRule { return nil } @@ -962,3 +1101,26 @@ func TestFilterDataListsProjects(t *testing.T) { t.Fatalf("expected the project pair under \"projects\", got %s", ctx.Response.Body()) } } + +// TestMCPAttributionFilterParsing keeps detail-link filters identical across list and analytics endpoints. +func TestMCPAttributionFilterParsing(t *testing.T) { + var ctx fasthttp.RequestCtx + ctx.Request.SetRequestURI("/api/mcp-logs?user_ids=u1,u2&team_ids=t1&customer_ids=c1&business_unit_ids=b1&project_ids=p1&device_ids=d1") + list, _, err := parseMCPFiltersAndPagination(&ctx) + if err != nil { + t.Fatal(err) + } + stats, err := parseMCPFilters(&ctx) + if err != nil { + t.Fatal(err) + } + histogram, err := parseMCPHistogramFilters(&ctx) + if err != nil { + t.Fatal(err) + } + for _, filters := range []*logstore.MCPToolLogSearchFilters{list, stats, histogram} { + if !reflect.DeepEqual(filters.UserIDs, []string{"u1", "u2"}) || !reflect.DeepEqual(filters.TeamIDs, []string{"t1"}) || !reflect.DeepEqual(filters.CustomerIDs, []string{"c1"}) || !reflect.DeepEqual(filters.BusinessUnitIDs, []string{"b1"}) || !reflect.DeepEqual(filters.ProjectIDs, []string{"p1"}) || !reflect.DeepEqual(filters.DeviceIDs, []string{"d1"}) { + t.Fatalf("lost attribution filters: %+v", filters) + } + } +} diff --git a/ui/app/_fallbacks/enterprise/lib/constants/edgeApps.tsx b/ui/app/_fallbacks/enterprise/lib/constants/edgeApps.tsx new file mode 100644 index 00000000000..03294312838 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/constants/edgeApps.tsx @@ -0,0 +1,7 @@ +import { MonitorSmartphone } from "lucide-react"; + +// OSS stub for the Edge OS icon - there are no platform icons in OSS, so +// always render the generic device glyph. +export function OsIcon({ className }: { platform?: string; className?: string }) { + return ; +} \ No newline at end of file diff --git a/ui/app/_fallbacks/enterprise/lib/store/apis/edgeControlApi.ts b/ui/app/_fallbacks/enterprise/lib/store/apis/edgeControlApi.ts new file mode 100644 index 00000000000..80e4a982348 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/store/apis/edgeControlApi.ts @@ -0,0 +1,16 @@ +// OSS build has no Edge device backend - return undefined so consumers fall +// back to rendering the raw device id. +export const useGetDeviceQuery = ( + _id: string, + _opts?: { skip?: boolean }, +): { + data: { device: { id: string; hostname: string; platform: string; os_version: string; arch: string } } | undefined; + isLoading: boolean; + isError: boolean; + error: null; +} => ({ + data: undefined, + isLoading: false, + isError: false, + error: null, +}); \ No newline at end of file diff --git a/ui/app/workspace/logs/views/columns.tsx b/ui/app/workspace/logs/views/columns.tsx index 8af557ff999..401f628b7e9 100644 --- a/ui/app/workspace/logs/views/columns.tsx +++ b/ui/app/workspace/logs/views/columns.tsx @@ -1,4 +1,5 @@ import { formatCost, formatLatency } from "@/app/workspace/dashboard/utils/chartUtils"; +import { AttributionCell } from "@/components/logAttributionCell"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu"; @@ -245,43 +246,6 @@ export function LogMessageCell({ log, contentClassName = "max-w-full" }: { log: ); } -const MAX_ATTRIBUTION_LINES = 1; - -// AttributionCell resolves an attribution value using a plural-first fallback: -// plural names -> singular name -> plural ids -> singular id. When a plural -// (array) source is used, values render one per line, capped at -// MAX_ATTRIBUTION_LINES with a "+N more" indicator for the remainder. -function AttributionCell({ names, name, ids, id }: { names?: string[]; name?: string | null; ids?: string[]; id?: string | null }) { - let values: string[] = []; - if (Array.isArray(names) && names.filter(Boolean).length > 0) { - values = names.filter(Boolean); - } else if (name) { - values = [name]; - } else if (Array.isArray(ids) && ids.filter(Boolean).length > 0) { - values = ids.filter(Boolean); - } else if (id) { - values = [id]; - } - - if (values.length === 0) { - return
-
; - } - - const visible = values.slice(0, MAX_ATTRIBUTION_LINES); - const remaining = values.length - visible.length; - - return ( -
- {visible.map((value, index) => ( - - {value} - - ))} - {remaining > 0 && +{remaining} more} -
- ); -} - export const createColumns = ( onDelete: (log: LogEntry) => void, hasDeleteAccess = true, diff --git a/ui/app/workspace/mcp-logs/page.tsx b/ui/app/workspace/mcp-logs/page.tsx index 892da826c3f..8f0fa7f8fc2 100644 --- a/ui/app/workspace/mcp-logs/page.tsx +++ b/ui/app/workspace/mcp-logs/page.tsx @@ -52,6 +52,14 @@ export default function MCPLogsPage() { const [urlState, setUrlState] = useQueryStates( { tool_names: parseAsArrayOf(parseAsString).withDefault([]), + user_ids: parseAsArrayOf(parseAsString).withDefault([]), + team_ids: parseAsArrayOf(parseAsString).withDefault([]), + customer_ids: parseAsArrayOf(parseAsString).withDefault([]), + business_unit_ids: parseAsArrayOf(parseAsString).withDefault([]), + project_ids: parseAsArrayOf(parseAsString).withDefault([]), + device_ids: parseAsArrayOf(parseAsString).withDefault([]), + apps: parseAsArrayOf(parseAsString).withDefault([]), + server_labels: parseAsArrayOf(parseAsString).withDefault([]), status: parseAsArrayOf(parseAsString).withDefault([]), virtual_key_ids: parseAsArrayOf(parseAsString).withDefault([]), @@ -82,6 +90,14 @@ export default function MCPLogsPage() { const filters: MCPToolLogFilters = useMemo( () => ({ tool_names: urlState.tool_names, + user_ids: urlState.user_ids, + team_ids: urlState.team_ids, + customer_ids: urlState.customer_ids, + business_unit_ids: urlState.business_unit_ids, + project_ids: urlState.project_ids, + device_ids: urlState.device_ids, + apps: urlState.apps, + server_labels: urlState.server_labels, status: urlState.status, virtual_key_ids: urlState.virtual_key_ids, @@ -95,6 +111,13 @@ export default function MCPLogsPage() { }), [ urlState.tool_names, + urlState.user_ids, + urlState.team_ids, + urlState.customer_ids, + urlState.business_unit_ids, + urlState.project_ids, + urlState.device_ids, + urlState.apps, urlState.server_labels, urlState.status, urlState.virtual_key_ids, @@ -218,6 +241,14 @@ export default function MCPLogsPage() { setUrlState({ ...(timeChanged && { period: "" }), tool_names: newFilters.tool_names || [], + user_ids: newFilters.user_ids || [], + team_ids: newFilters.team_ids || [], + customer_ids: newFilters.customer_ids || [], + business_unit_ids: newFilters.business_unit_ids || [], + project_ids: newFilters.project_ids || [], + device_ids: newFilters.device_ids || [], + apps: newFilters.apps || [], + server_labels: newFilters.server_labels || [], status: newFilters.status || [], virtual_key_ids: newFilters.virtual_key_ids || [], @@ -293,7 +324,7 @@ export default function MCPLogsPage() { const statCards = useMemo( () => [ { - title: "Total Executions", + title: "Total Records", value: , icon: , }, @@ -363,7 +394,7 @@ export default function MCPLogsPage() { columnIds, paramName: "mcp_cols", storageKey: "bifrost.mcp_logs.cols", - defaultHidden: ["virtual_key"], + defaultHidden: ["virtual_key", "customer", "business_unit", "project", "device"], fixedColumns: hasDeleteAccess ? { right: ["actions"] } : undefined, }); @@ -372,6 +403,13 @@ export default function MCPLogsPage() { timestamp: "Time", tool_name: "Tool Name", server_label: "Server", + source: "Source", + user: "User", + team: "Team", + customer: "Customer", + business_unit: "Business Unit", + project: "Project", + device: "Device", latency: "Latency", cost: "Cost", virtual_key: "Virtual Key", diff --git a/ui/app/workspace/mcp-logs/views/columns.tsx b/ui/app/workspace/mcp-logs/views/columns.tsx index e696e0b0fc4..538483ca6b3 100644 --- a/ui/app/workspace/mcp-logs/views/columns.tsx +++ b/ui/app/workspace/mcp-logs/views/columns.tsx @@ -1,10 +1,12 @@ +import { getMCPLogPresentation, getMCPArgumentPreview } from "@/lib/utils/mcpLogPresentation"; +import { AttributionCell } from "@/components/logAttributionCell"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdownMenu"; import { mapAppToClientApp, mapUserAgentToApp, Status, StatusBarColors, Statuses } from "@/lib/constants/logs"; import type { MCPToolLogEntry } from "@/lib/types/logs"; import { ColumnDef, Row } from "@tanstack/react-table"; -import { format, isValid } from "date-fns"; +import { format, formatDistanceToNow, isValid } from "date-fns"; import { ArrowUpDown, MoreHorizontal, Trash2 } from "lucide-react"; // Helper function to validate status and return a safe Status value @@ -13,6 +15,7 @@ const getValidatedStatus = (status: string): Status => { if (Statuses.includes(status as Status)) { return status as Status; } + if (status === "unknown") return "cancelled"; // Fallback to "processing" for unknown statuses return "processing"; }; @@ -29,7 +32,14 @@ export const createMCPColumns = ( maxSize: 8, cell: ({ row }) => { const status = getValidatedStatus(row.original.status); - return
; + const presentation = getMCPLogPresentation(row.original); + return ( +
+ ); }, }, { @@ -40,11 +50,19 @@ export const createMCPColumns = ( ), - size: 230, + size: 130, cell: ({ row }) => { const timestamp = row.original.timestamp; - const date = new Date(timestamp); - return
{isValid(date) ? format(date, "yyyy-MM-dd hh:mm:ss aa (XXX)") : "Invalid date"}
; + const date = timestamp ? new Date(timestamp) : null; + if (!date || !isValid(date)) { + return
N/A
; + } + return ( +
+ {format(date, "MMM dd HH:mm:ss")} + {formatDistanceToNow(date, { addSuffix: true })} +
+ ); }, }, { @@ -53,15 +71,38 @@ export const createMCPColumns = ( size: 300, cell: ({ row }) => { const toolName = row.getValue("tool_name") as string; - return {toolName}; + const presentation = getMCPLogPresentation(row.original); + const preview = getMCPArgumentPreview(row.original); + return ( +
+ {toolName} + {preview && ( + + {preview} + + )} + + {presentation.label} + +
+ ); }, }, + { + accessorKey: "source", + header: "Source", + size: 90, + cell: ({ row }) => {row.original.source === "native" ? "Native" : "MCP"}, + }, { accessorKey: "server_label", header: "Server", size: 150, cell: ({ row }) => { - const serverLabel = row.getValue("server_label") as string; + const serverLabel = row.original.source === "native" ? "Local" : (row.getValue("server_label") as string); return serverLabel ? ( {serverLabel} @@ -77,8 +118,9 @@ export const createMCPColumns = ( header: "App", size: 140, cell: ({ row }) => { - const app = row.original.app ? mapAppToClientApp(row.original.app) : mapUserAgentToApp(row.original.user_agent); - const icon = row.original.app ? customAppIcons[row.original.app] || app.icon : app.icon; + const appKey = row.original.app || row.original.app_key; + const app = appKey ? mapAppToClientApp(appKey) : mapUserAgentToApp(row.original.user_agent); + const icon = appKey ? customAppIcons[appKey] || app.icon : app.icon; return (
{icon ? {app.name} : null} @@ -98,8 +140,20 @@ export const createMCPColumns = ( size: 120, cell: ({ row }) => { const latency = row.original.latency; + const presentation = getMCPLogPresentation(row.original); return ( -
{latency === undefined || latency === null ? "N/A" : `${latency.toLocaleString()}ms`}
+
+ + {latency != null + ? `${latency.toLocaleString()}ms` + : presentation.inspectionDuration != null + ? `${presentation.inspectionDuration}ms` + : "Not recorded"} + + + {latency != null ? "Execution" : presentation.policy ? "Policy check" : "Execution time"} + +
); }, }, @@ -122,6 +176,27 @@ export const createMCPColumns = ( return
{value || "-"}
; }, }, + { id: "user", header: "User", size: 150, cell: ({ row }) => }, + { id: "team", header: "Team", size: 150, cell: ({ row }) => }, + { + id: "customer", + header: "Customer", + size: 150, + cell: ({ row }) => , + }, + { + id: "business_unit", + header: "Business Unit", + size: 150, + cell: ({ row }) => , + }, + { + id: "project", + header: "Project", + size: 150, + cell: ({ row }) => , + }, + { id: "device", header: "Device", size: 150, cell: ({ row }) => }, ...(hasDeleteAccess ? [ { @@ -159,4 +234,4 @@ export const createMCPColumns = ( }, ] : []), -]; +]; \ No newline at end of file diff --git a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx index f4eff3b87c6..849cdb94ff0 100644 --- a/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx +++ b/ui/app/workspace/mcp-logs/views/mcpLogDetailsSheet.tsx @@ -1,3 +1,5 @@ +import { formatLatency } from "@/app/workspace/dashboard/utils/chartUtils"; +import { getMCPLogPillTone, getMCPLogPresentation, getMCPLogTimeline, type MCPLogPillTone } from "@/lib/utils/mcpLogPresentation"; import { AlertDialog, AlertDialogAction, @@ -8,6 +10,9 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alertDialog"; +import LogEntryDetailsView from "@/app/workspace/logs/views/logEntryDetailsView"; +import BlockHeader from "@/app/workspace/logs/views/blockHeader"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { CodeEditor } from "@/components/ui/codeEditor"; @@ -22,17 +27,21 @@ import { DottedSeparator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Status, StatusColors, Statuses } from "@/lib/constants/logs"; -import { useGetMCPLogByIdQuery } from "@/lib/store"; +import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; +import { OsIcon } from "@enterprise/lib/constants/edgeApps"; +import { useGetDeviceQuery } from "@enterprise/lib/store/apis/edgeControlApi"; +import { mapAppToClientApp, mapUserAgentToApp } from "@/lib/constants/logs"; +import { cn } from "@/lib/utils"; +import { useGetMCPLogByIdQuery, useGetUserAgentMappingsQuery } from "@/lib/store"; import type { MCPToolLogEntry } from "@/lib/types/logs"; import { downloadAsJson } from "@/lib/utils/browser-download"; import { applyRedactionMappingToValue, hasRedactionMappingEntries, mergeRedactionMappings } from "@/lib/utils/redaction"; import PluginLogsView from "@/app/workspace/logs/views/pluginLogsView"; import { Link } from "@tanstack/react-router"; -import { addMilliseconds, format, isValid } from "date-fns"; +import { format, isValid } from "date-fns"; import { SheetNavigationButtons } from "@/components/sheetNavigationButtons"; import { useSheetNavigation } from "@/hooks/useSheetNavigation"; -import { Download, Loader2, MoreVertical, Trash2 } from "lucide-react"; +import { ChevronDown, Clipboard, Download, Loader2, MoreVertical, Trash2 } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { toast } from "sonner"; @@ -47,31 +56,76 @@ interface MCPLogDetailSheetProps { hasNext?: boolean; } -const LogEntryDetailsView = ({ label, value, className }: { label: string; value: React.ReactNode; className?: string }) => ( -
-
{label}
-
{value}
-
-); +const pillStyles: Record = { + success: "border-chart-success/30 bg-chart-success/10 text-chart-success-ink", + error: "border-chart-error/30 bg-chart-error/10 text-chart-error-ink", + processing: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-900", + approved: "bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-400 dark:border-blue-900", + neutral: "bg-gray-50 text-gray-700 border-gray-200 dark:bg-gray-900/40 dark:text-gray-400 dark:border-gray-800", +}; + +const pillDotStyles: Record = { + success: "bg-chart-success", + error: "bg-chart-error", + processing: "bg-blue-500", + approved: "bg-blue-500", + neutral: "bg-gray-400", +}; -const BlockHeader = ({ title, icon }: { title: string; icon?: ReactNode }) => { +function StatusPill({ label, tone }: { label: string; tone: MCPLogPillTone }) { return ( -
- {icon} -
{title}
-
+ + + {label} + ); -}; +} -// Helper function to validate status and return a safe Status value -const getValidatedStatus = (status: string): Status => { - // Check if status is a valid Status by checking against Statuses array - if (Statuses.includes(status as Status)) { - return status as Status; - } - // Fallback to "processing" for unknown statuses - return "processing"; -}; +function CopyInlineButton({ text, testId }: { text: string; testId?: string }) { + const { copy } = useCopyToClipboard({ successMessage: "Copied" }); + return ( + + ); +} + +function HeroStat({ + label, + value, + sub, + mono = false, + valueClass, + hasRightBorder = false, +}: { + label: string; + value: ReactNode; + sub?: ReactNode; + mono?: boolean; + valueClass?: string; + hasRightBorder?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+ {sub ?
{sub}
: null} +
+ ); +} function getPluginLogCount(pluginLogs?: string): number { if (!pluginLogs) return 0; @@ -96,6 +150,7 @@ export function MCPLogDetailSheet({ }: MCPLogDetailSheetProps) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); + const { data: userAgentMappings } = useGetUserAgentMappingsQuery(); const [showRevealedValues, setShowRevealedValues] = useState(false); const { data: fullLog, @@ -105,6 +160,11 @@ export function MCPLogDetailSheet({ skip: !open || !log?.id, }); + // Device metadata (hostname, OS) comes from the Edge device registry; fall back to the raw id when absent. + const deviceId = fullLog?.device_id ?? log?.device_id ?? ""; + const { data: deviceData } = useGetDeviceQuery(deviceId, { skip: !open || !deviceId }); + const device = deviceData?.device; + // Keyboard navigation: arrow up/down to navigate between logs const { prev: prevKeys, next: nextKeys } = useSheetNavigation({ enabled: open, @@ -131,7 +191,7 @@ export function MCPLogDetailSheet({ if (!isFullDataReady) { return ( - +
Loading MCP log details @@ -141,31 +201,55 @@ export function MCPLogDetailSheet({ ); } + const appKey = displayLog.app || displayLog.app_key; + const app = appKey ? mapAppToClientApp(appKey) : mapUserAgentToApp(displayLog.user_agent); + const mapping = userAgentMappings?.mappings.find((item) => item.app === appKey && item.logo && item.logo_mime); + const appIcon = mapping ? `data:${mapping.logo_mime};base64,${mapping.logo}` : app.icon; const displayedArguments = applyRedactionMappingToValue(displayLog.arguments, inputRevealMapping); const displayedResult = applyRedactionMappingToValue(displayLog.result, outputRevealMapping); const displayedErrorDetails = applyRedactionMappingToValue(displayLog.error_details, mixedRevealMapping); const pluginLogCount = getPluginLogCount(displayLog.plugin_logs); + const presentation = getMCPLogPresentation(displayLog); + const { durationMs, startTimestamp, endTimestamp } = getMCPLogTimeline(displayLog, presentation); + const durationLabel = presentation.policy ? "Inspection time" : "Execution time"; + const endLabel = presentation.policy ? "Policy Check Timestamp" : displayLog.source === "native" ? "Observed Timestamp" : "End Timestamp"; + const pillTone = getMCPLogPillTone(displayLog, presentation); + const requestId = displayLog.request_id || displayLog.id; + const durationSub = (() => { + const startStr = startTimestamp && isValid(startTimestamp) ? format(startTimestamp, "HH:mm:ss") : null; + const endStr = endTimestamp && isValid(endTimestamp) ? format(endTimestamp, "HH:mm:ss") : null; + if (startStr && endStr && startStr !== endStr) return `${startStr} → ${endStr}`; + return startStr ?? endStr ?? ""; + })(); + const scopeLinks = ( + [ + ["User", "user_ids", displayLog.user_name, displayLog.user_id], + ["Team", "team_ids", displayLog.team_name, displayLog.team_id], + ["Customer", "customer_ids", displayLog.customer_name, displayLog.customer_id], + ["Business Unit", "business_unit_ids", displayLog.business_unit_name, displayLog.business_unit_id], + ["Project", "project_ids", displayLog.project_name, displayLog.project_id], + ["Device", "device_ids", null, displayLog.device_id], + ] as const + ).filter(([, , , id]) => id); + const metadataEntries = Object.entries(displayLog.metadata ?? {}); return ( - - -
+ + +
+ onNavigate?.(dir)} + prevKeys={prevKeys} + nextKeys={nextKeys} + entityLabel="log" + /> - {displayLog.id &&

Request ID: {displayLog.id}

} - - {displayLog.status} - + Request details
- onNavigate?.(dir)} - prevKeys={prevKeys} - nextKeys={nextKeys} - entityLabel="log" - /> {revealAvailable && (