diff --git a/core/mcp/toolmanager.go b/core/mcp/toolmanager.go index 029d3fffd4..0527b1d46c 100644 --- a/core/mcp/toolmanager.go +++ b/core/mcp/toolmanager.go @@ -585,7 +585,7 @@ func (m *ToolsManager) executeToolInternal(ctx *schemas.BifrostContext, toolCall return nil, "", "", fmt.Errorf("per-user OAuth requires an OAuth2Provider but none is configured") } virtualKeyID, _ := ctx.Value(schemas.BifrostContextKeyGovernanceVirtualKeyID).(string) - userID, _ := ctx.Value(schemas.BifrostContextKeyGovernanceUserID).(string) + userID, _ := ctx.Value(schemas.BifrostContextKeyUserID).(string) sessionToken, _ := ctx.Value(schemas.BifrostContextKeyMCPUserSession).(string) // Optional X-Bf-User-Id header overrides user identity; if absent, falls back to virtual key diff --git a/core/schemas/bifrost.go b/core/schemas/bifrost.go index c365c2d169..04cca3915a 100644 --- a/core/schemas/bifrost.go +++ b/core/schemas/bifrost.go @@ -183,7 +183,6 @@ const ( BifrostContextKeyGovernanceTeamName BifrostContextKey = "bifrost-governance-team-name" // string (to store the team name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceCustomerID BifrostContextKey = "bifrost-governance-customer-id" // string (to store the customer ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceCustomerName BifrostContextKey = "bifrost-governance-customer-name" // string (to store the customer name (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) - BifrostContextKeyGovernanceUserID BifrostContextKey = "bifrost-governance-user-id" // string (to store the user ID (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceBusinessUnitID BifrostContextKey = "bifrost-governance-business-unit-id" // string (to store the business unit ID (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceBusinessUnitName BifrostContextKey = "bifrost-governance-business-unit-name" // string (to store the business unit name (set by enterprise governance plugin - DO NOT SET THIS MANUALLY)) BifrostContextKeyGovernanceRoutingRuleID BifrostContextKey = "bifrost-governance-routing-rule-id" // string (to store the routing rule ID (set by bifrost governance plugin - DO NOT SET THIS MANUALLY)) @@ -252,7 +251,8 @@ const ( BifrostContextKeyRequestHeaders BifrostContextKey = "bifrost-request-headers" // map[string]string (all request headers with lowercased keys) BifrostContextKeySkipListModelsGovernanceFiltering BifrostContextKey = "bifrost-skip-list-models-governance-filtering" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) BifrostContextKeySCIMClaims BifrostContextKey = "scim_claims" - BifrostContextKeyUserID BifrostContextKey = "user_id" + BifrostContextKeyUserID BifrostContextKey = "bifrost-user-id" // string (to store the user ID (set by enterprise auth middleware - DO NOT SET THIS MANUALLY)) + BifrostContextKeyUserName BifrostContextKey = "bifrost-user-name" // string (to store the user name (set by enterprise auth middleware - DO NOT SET THIS MANUALLY)) BifrostContextKeyTargetUserID BifrostContextKey = "target_user_id" BifrostContextKeyIsAzureUserAgent BifrostContextKey = "bifrost-is-azure-user-agent" // bool (set by bifrost - DO NOT SET THIS MANUALLY)) - whether the request is an Azure user agent (only used in gateway) BifrostContextKeyVideoOutputRequested BifrostContextKey = "bifrost-video-output-requested" diff --git a/framework/logstore/migrations.go b/framework/logstore/migrations.go index 2764758650..9993e2f9df 100644 --- a/framework/logstore/migrations.go +++ b/framework/logstore/migrations.go @@ -236,6 +236,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddSelectedPromptColumns(ctx, db); err != nil { return err } + if err := migrationAddUserNameColumn(ctx, db); err != nil { + return err + } return nil } @@ -2357,6 +2360,40 @@ func migrationAddImageVariationInputColumn(ctx context.Context, db *gorm.DB) err return nil } +// migrationAddUserNameColumn adds the user_name column to the logs table. +// Adding a nullable column is instant in Postgres (metadata-only change, no table rewrite). +func migrationAddUserNameColumn(ctx context.Context, db *gorm.DB) error { + opts := *migrator.DefaultOptions + opts.UseTransaction = true + m := migrator.New(db, &opts, []*migrator.Migration{{ + ID: "logs_add_user_name_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + if !mig.HasColumn(&Log{}, "user_name") { + if err := mig.AddColumn(&Log{}, "user_name"); err != nil { + return err + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mig := tx.Migrator() + if mig.HasColumn(&Log{}, "user_name") { + if err := mig.DropColumn(&Log{}, "user_name"); err != nil { + return err + } + } + return nil + }, + }}) + if err := m.Migrate(); err != nil { + return fmt.Errorf("error while adding user_name column: %s", err.Error()) + } + return nil +} + // migrationAddGovernanceContextColumns adds user_id, team_id, team_name, customer_id, customer_name, // business_unit_id, business_unit_name columns to the logs table. func migrationAddGovernanceContextColumns(ctx context.Context, db *gorm.DB) error { diff --git a/framework/logstore/tables.go b/framework/logstore/tables.go index c38c2bba43..1d7446597e 100644 --- a/framework/logstore/tables.go +++ b/framework/logstore/tables.go @@ -125,6 +125,7 @@ type Log struct { SelectedPromptVersion *string `gorm:"type:varchar(64)" json:"selected_prompt_version"` SelectedPromptID *string `gorm:"type:varchar(36)" json:"selected_prompt_id"` UserID *string `gorm:"type:varchar(255);index:idx_logs_user_id" json:"user_id"` + UserName *string `gorm:"type:varchar(255)" json:"user_name"` TeamID *string `gorm:"type:varchar(255);index:idx_logs_team_id" json:"team_id"` TeamName *string `gorm:"type:varchar(255)" json:"team_name"` CustomerID *string `gorm:"type:varchar(255);index:idx_logs_customer_id" json:"customer_id"` diff --git a/framework/oauth2/main.go b/framework/oauth2/main.go index fcf1ab8f49..06c03669f7 100644 --- a/framework/oauth2/main.go +++ b/framework/oauth2/main.go @@ -808,7 +808,7 @@ func (p *OAuth2Provider) InitiateUserOAuthFlow(ctx context.Context, oauthConfigI // Propagate identity from context so the callback can link the token to the user virtualKeyID, _ := ctx.Value(schemas.BifrostContextKeyGovernanceVirtualKeyID).(string) - userID, _ := ctx.Value(schemas.BifrostContextKeyGovernanceUserID).(string) + userID, _ := ctx.Value(schemas.BifrostContextKeyUserID).(string) // For OSS: prefer X-Bf-User-Id header as user identity if mcpUserID, _ := ctx.Value(schemas.BifrostContextKeyMCPUserID).(string); mcpUserID != "" { userID = mcpUserID diff --git a/plugins/governance/main.go b/plugins/governance/main.go index b7d671e4c9..26bbdd1a44 100644 --- a/plugins/governance/main.go +++ b/plugins/governance/main.go @@ -1267,7 +1267,7 @@ func (p *GovernancePlugin) PreLLMHook(ctx *schemas.BifrostContext, req *schemas. // Extract governance headers and virtual key using utility functions virtualKeyValue := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyVirtualKey) // Extract user ID for enterprise user-level governance - userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceUserID) + userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyUserID) // Getting provider and mode from the request provider, model, _ := req.GetRequestFields() // Create request context for evaluation @@ -1311,7 +1311,7 @@ func (p *GovernancePlugin) PostLLMHook(ctx *schemas.BifrostContext, result *sche virtualKey := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyVirtualKey) requestID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyRequestID) // Extract user ID for enterprise user-level governance - userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceUserID) + userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyUserID) if requestType == schemas.ListModelsRequest && result != nil && result.ListModelsResponse != nil && virtualKey != "" { // filter models which are not supported on this virtual key @@ -1368,7 +1368,7 @@ func (p *GovernancePlugin) PreMCPHook(ctx *schemas.BifrostContext, req *schemas. // Extract governance headers and virtual key using utility functions virtualKeyValue := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyVirtualKey) // Extract user ID for enterprise user-level governance - userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceUserID) + userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyUserID) // Create request context for evaluation (MCP requests don't have provider/model) evaluationRequest := &EvaluationRequest{ @@ -1435,7 +1435,7 @@ func (p *GovernancePlugin) PostMCPHook(ctx *schemas.BifrostContext, resp *schema // Extract governance information virtualKey := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyVirtualKey) requestID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyRequestID) - userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceUserID) + userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyUserID) // When user auth is present, skip VK usage tracking to avoid double-counting if userID != "" { diff --git a/plugins/logging/main.go b/plugins/logging/main.go index ba4229d9a3..025f30a57e 100644 --- a/plugins/logging/main.go +++ b/plugins/logging/main.go @@ -688,7 +688,8 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas. teamName := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceTeamName) customerID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceCustomerID) customerName := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceCustomerName) - userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceUserID) + userID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyUserID) + userName := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyUserName) businessUnitID := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceBusinessUnitID) businessUnitName := bifrost.GetStringFromContext(ctx, schemas.BifrostContextKeyGovernanceBusinessUnitName) numberOfRetries := bifrost.GetIntFromContext(ctx, schemas.BifrostContextKeyNumberOfRetries) @@ -768,7 +769,7 @@ func (p *LoggerPlugin) PostLLMHook(ctx *schemas.BifrostContext, result *schemas. if result != nil { latency = result.GetExtraFields().Latency } - applyOutputFieldsToEntry(entry, selectedKeyID, selectedKeyName, virtualKeyID, virtualKeyName, routingRuleID, routingRuleName, selectedPromptID, selectedPromptName, selectedPromptVersion, teamID, teamName, customerID, customerName, userID, businessUnitID, businessUnitName, numberOfRetries, latency, attemptTrail) + applyOutputFieldsToEntry(entry, selectedKeyID, selectedKeyName, virtualKeyID, virtualKeyName, routingRuleID, routingRuleName, selectedPromptID, selectedPromptName, selectedPromptVersion, teamID, teamName, customerID, customerName, userID, userName, businessUnitID, businessUnitName, numberOfRetries, latency, attemptTrail) entry.MetadataParsed = pending.InitialData.Metadata entry.MetadataParsed = mergeRealtimeMetadata(entry.MetadataParsed, ctx) entry.RoutingEngineLogs = routingEngineLogs diff --git a/plugins/logging/writer.go b/plugins/logging/writer.go index 2aa4417e74..93a20d2827 100644 --- a/plugins/logging/writer.go +++ b/plugins/logging/writer.go @@ -356,7 +356,7 @@ func applyOutputFieldsToEntry( selectedPromptID, selectedPromptName, selectedPromptVersion string, teamID, teamName string, customerID, customerName string, - userID string, + userID, userName string, businessUnitID, businessUnitName string, numberOfRetries int, latency int64, @@ -400,6 +400,9 @@ func applyOutputFieldsToEntry( if userID != "" { entry.UserID = &userID } + if userName != "" { + entry.UserName = &userName + } if businessUnitID != "" { entry.BusinessUnitID = &businessUnitID } diff --git a/transports/bifrost-http/handlers/mcpserver.go b/transports/bifrost-http/handlers/mcpserver.go index f3214e801c..deff6633e6 100644 --- a/transports/bifrost-http/handlers/mcpserver.go +++ b/transports/bifrost-http/handlers/mcpserver.go @@ -102,7 +102,7 @@ func injectMCPSessionIdentity(bifrostCtx *schemas.BifrostContext, session *table } } if session.UserID != nil && *session.UserID != "" { - bifrostCtx.SetValue(schemas.BifrostContextKeyGovernanceUserID, *session.UserID) + bifrostCtx.SetValue(schemas.BifrostContextKeyUserID, *session.UserID) } } } diff --git a/transports/bifrost-http/handlers/realtime_client_secrets.go b/transports/bifrost-http/handlers/realtime_client_secrets.go index 9c761d0692..620a1db0e1 100644 --- a/transports/bifrost-http/handlers/realtime_client_secrets.go +++ b/transports/bifrost-http/handlers/realtime_client_secrets.go @@ -97,8 +97,11 @@ func (h *RealtimeClientSecretsHandler) handleRequest(ctx *fasthttp.RequestCtx) { if route.DefaultProvider == schemas.OpenAI { bifrostCtx.SetValue(schemas.BifrostContextKeyIntegrationType, "openai") } - if governanceUserID, ok := ctx.UserValue(schemas.BifrostContextKeyGovernanceUserID).(string); ok && governanceUserID != "" { - bifrostCtx.SetValue(schemas.BifrostContextKeyGovernanceUserID, governanceUserID) + if governanceUserID, ok := ctx.UserValue(schemas.BifrostContextKeyUserID).(string); ok && governanceUserID != "" { + bifrostCtx.SetValue(schemas.BifrostContextKeyUserID, governanceUserID) + } + if userName, ok := ctx.UserValue(schemas.BifrostContextKeyUserName).(string); ok && userName != "" { + bifrostCtx.SetValue(schemas.BifrostContextKeyUserName, userName) } if bifrostErr := h.evaluateMintingGovernance(bifrostCtx, providerKey, model); bifrostErr != nil { SendBifrostError(ctx, bifrostErr) @@ -179,7 +182,7 @@ func (h *RealtimeClientSecretsHandler) evaluateMintingGovernance( VirtualKey: bifrost.GetStringFromContext(bifrostCtx, schemas.BifrostContextKeyVirtualKey), Provider: providerKey, Model: model, - UserID: bifrost.GetStringFromContext(bifrostCtx, schemas.BifrostContextKeyGovernanceUserID), + UserID: bifrost.GetStringFromContext(bifrostCtx, schemas.BifrostContextKeyUserID), }, schemas.RealtimeRequest) return bifrostErr } diff --git a/transports/bifrost-http/handlers/realtime_client_secrets_test.go b/transports/bifrost-http/handlers/realtime_client_secrets_test.go index 4a23782406..8029622921 100644 --- a/transports/bifrost-http/handlers/realtime_client_secrets_test.go +++ b/transports/bifrost-http/handlers/realtime_client_secrets_test.go @@ -378,7 +378,7 @@ func TestRealtimeClientSecretsEvaluateMintingGovernance_PassesContext(t *testing handler := NewRealtimeClientSecretsHandler(nil, config) bifrostCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) defer bifrostCtx.Done() - bifrostCtx.SetValue(schemas.BifrostContextKeyGovernanceUserID, "user_123") + bifrostCtx.SetValue(schemas.BifrostContextKeyUserID, "user_123") bifrostCtx.SetValue(schemas.BifrostContextKeyVirtualKey, "sk-bf-123") if err := handler.evaluateMintingGovernance(bifrostCtx, schemas.OpenAI, "gpt-realtime"); err != nil { diff --git a/transports/bifrost-http/handlers/webrtc_realtime.go b/transports/bifrost-http/handlers/webrtc_realtime.go index 644dbc593f..9bf547952a 100644 --- a/transports/bifrost-http/handlers/webrtc_realtime.go +++ b/transports/bifrost-http/handlers/webrtc_realtime.go @@ -1062,7 +1062,8 @@ func newRealtimeRelayContext(requestCtx *schemas.BifrostContext) (*schemas.Bifro schemas.BifrostContextKeyGovernanceCustomerName, schemas.BifrostContextKeyGovernanceTeamID, schemas.BifrostContextKeyGovernanceTeamName, - schemas.BifrostContextKeyGovernanceUserID, + schemas.BifrostContextKeyUserID, + schemas.BifrostContextKeyUserName, schemas.BifrostContextKeyGovernanceIncludeOnlyKeys, schemas.BifrostContextKeyGovernancePluginName, schemas.BifrostContextKeySelectedKeyID, diff --git a/ui/app/_fallbacks/enterprise/lib/store/apis/accessProfileApi.ts b/ui/app/_fallbacks/enterprise/lib/store/apis/accessProfileApi.ts new file mode 100644 index 0000000000..5830eed3bb --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/store/apis/accessProfileApi.ts @@ -0,0 +1,18 @@ +import { GetUserAccessProfilesResponse } from "@enterprise/lib/types/accessProfile"; + +// OSS build has no access-profile backend — return undefined data so consumers +// (e.g. useVirtualKeyUsage) fall back to VK-owned budget/rate-limit values. +export const useGetUserAccessProfilesQuery = ( + _userId: string, + _opts?: { skip?: boolean; pollingInterval?: number }, +): { + data: GetUserAccessProfilesResponse | undefined; + isLoading: boolean; + isError: boolean; + error: null; +} => ({ + data: undefined, + isLoading: false, + isError: false, + error: null, +}); diff --git a/ui/app/_fallbacks/enterprise/lib/store/apis/virtualKeyUsersApi.ts b/ui/app/_fallbacks/enterprise/lib/store/apis/virtualKeyUsersApi.ts new file mode 100644 index 0000000000..fc33a9ff78 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/store/apis/virtualKeyUsersApi.ts @@ -0,0 +1,22 @@ +import { User } from "@enterprise/lib/types/user"; + +export interface GetVirtualKeyUsersResponse { + users: User[]; +} + +// OSS build has no VK-user-attachment backend — return undefined data so the +// consumer treats the VK as unassigned (no AP-managed detection happens). +export const useGetVirtualKeyUsersQuery = ( + _vkId: string, + _opts?: { skip?: boolean }, +): { + data: GetVirtualKeyUsersResponse | undefined; + isLoading: boolean; + isError: boolean; + error: null; +} => ({ + data: undefined, + isLoading: false, + isError: false, + error: null, +}); diff --git a/ui/app/_fallbacks/enterprise/lib/types/accessProfile.ts b/ui/app/_fallbacks/enterprise/lib/types/accessProfile.ts new file mode 100644 index 0000000000..a3859fa8c5 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/types/accessProfile.ts @@ -0,0 +1,41 @@ +export interface AccessProfileBudgetLine { + id: string; + scope: string; + max_limit: number; + reset_duration: string; + current_usage: number; + last_reset: string; + alert_thresholds?: number[]; +} + +export interface AccessProfileRateLimitLine { + token_max_limit?: number; + token_reset_duration?: string; + token_current_usage?: number; + token_last_reset?: string; + request_max_limit?: number; + request_reset_duration?: string; + request_current_usage?: number; + request_last_reset?: string; +} + +export interface UserAccessProfile { + id: number; + user_id: string; + parent_profile_id?: number; + virtual_key_ids?: string[]; + virtual_key_values?: Record; + name: string; + is_active: boolean; + expires_at?: string; + provider_configs?: unknown[]; + budget_lines?: AccessProfileBudgetLine[]; + rate_limits?: AccessProfileRateLimitLine; + mcp_configs?: unknown; + created_at: string; + updated_at: string; +} + +export interface GetUserAccessProfilesResponse { + access_profiles: UserAccessProfile[]; +} diff --git a/ui/app/_fallbacks/enterprise/lib/types/user.ts b/ui/app/_fallbacks/enterprise/lib/types/user.ts new file mode 100644 index 0000000000..b5b6727fe9 --- /dev/null +++ b/ui/app/_fallbacks/enterprise/lib/types/user.ts @@ -0,0 +1,30 @@ +import { UserAccessProfile } from "@enterprise/lib/types/accessProfile"; + +export interface User { + id: string; + name: string; + email: string; + role_id?: number; + role?: { + id: number; + name: string; + description: string; + is_system_role: boolean; + }; + profile?: Record; + config?: Record; + claims?: Record; + access_profile?: UserAccessProfile; + teams?: Array<{ id: string; name: string; business_unit_id?: string; business_unit_name?: string }>; + created_at: string; + updated_at: string; +} + +export interface GetUsersResponse { + users: User[]; + total: number; + page: number; + limit: number; + total_pages: number; + has_more: boolean; +} diff --git a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx index b8970d2b6a..d1cac5689f 100644 --- a/ui/app/workspace/dashboard/components/modelRankingsTab.tsx +++ b/ui/app/workspace/dashboard/components/modelRankingsTab.tsx @@ -3,6 +3,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import ProviderIcons, { type ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons"; import type { ModelHistogramResponse, ModelRankingEntry, ModelRankingsResponse } from "@/lib/types/logs"; +import { formatCompactNumber as formatNumber } from "@/lib/utils/governance"; import { ArrowDown, ArrowUp, ArrowUpDown, Minus } from "lucide-react"; import { useCallback, useMemo, useState } from "react"; import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; @@ -22,14 +23,6 @@ interface ModelRankingsTabProps { endTime: number; } -function formatNumber(value: number): string { - if (value >= 1_000_000_000_000) return `${(value / 1_000_000_000_000).toFixed(2)}T`; - if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`; - if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; - if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; - return value.toLocaleString(); -} - function formatCost(value: number): string { if (value >= 1) return `$${value.toFixed(2)}`; if (value >= 0.01) return `$${value.toFixed(3)}`; diff --git a/ui/app/workspace/logs/sheets/logDetailView.tsx b/ui/app/workspace/logs/sheets/logDetailView.tsx index f3fcd3729d..21d9310055 100644 --- a/ui/app/workspace/logs/sheets/logDetailView.tsx +++ b/ui/app/workspace/logs/sheets/logDetailView.tsx @@ -352,14 +352,19 @@ export function LogDetailView({ className="w-full" label="User" value={ - - {log.user_id} - + + + + {log.user_name || log.user_id} + + + {log.user_name ? log.user_id : "Filter by user"} + } /> )} diff --git a/ui/app/workspace/virtual-keys/hooks/useVirtualKeyUsage.ts b/ui/app/workspace/virtual-keys/hooks/useVirtualKeyUsage.ts new file mode 100644 index 0000000000..1554424791 --- /dev/null +++ b/ui/app/workspace/virtual-keys/hooks/useVirtualKeyUsage.ts @@ -0,0 +1,83 @@ +import { Budget, RateLimit, VirtualKey } from "@/lib/types/governance"; +import { UserAccessProfile } from "@enterprise/lib/types/accessProfile"; +import { User } from "@enterprise/lib/types/user"; +import { useGetUserAccessProfilesQuery } from "@enterprise/lib/store/apis/accessProfileApi"; +import { useGetVirtualKeyUsersQuery } from "@enterprise/lib/store/apis/virtualKeyUsersApi"; + +/** + * When a VK is attached to users via an access profile, the governance plugin tracks usage on the + * AP rather than on the VK itself (to avoid double-counting). This hook resolves the managing AP + * for a VK and returns budget/rate-limit values that prefer AP counters, falling back to the VK's + * own when there is no managing profile. + * + * The AP query polls every 5s so bars reflect live usage without manual refresh. + * + * `assignedUsers[0]` is safe: the enterprise schema enforces a uniqueIndex on + * TableVirtualKeyUser.virtual_key_id, so each VK can belong to at most one user. The list is + * either empty or length 1 — [0] is always the sole assignee when one exists. + */ +export function useVirtualKeyUsage(vk: VirtualKey | null | undefined): { + assignedUsers: User[]; + isManagedByProfile: boolean; + managingProfile: UserAccessProfile | undefined; + hasApRateLimit: boolean; + displayBudgets: Budget[] | undefined; + displayRateLimit: RateLimit | undefined; + isExhausted: boolean; +} { + const { data: vkUsersData } = useGetVirtualKeyUsersQuery(vk?.id ?? "", { skip: !vk?.id }); + const assignedUsers = vkUsersData?.users ?? []; + + const managingUserId = assignedUsers[0]?.id; + const { data: userAPsData } = useGetUserAccessProfilesQuery(managingUserId ?? "", { + skip: !managingUserId, + pollingInterval: managingUserId ? 5000 : 0, + }); + const userAPs = userAPsData?.access_profiles ?? []; + + // Only treat the VK as AP-managed when an AP explicitly lists this VK in its virtual_key_ids. + // No fallback to "first active" / "first AP" — that misattributed budgets in multi-AP setups. + const managingProfile = vk ? userAPs.find((p) => p.virtual_key_ids?.includes(vk.id)) : undefined; + const isManagedByProfile = managingProfile !== undefined; + + const displayBudgets: Budget[] | undefined = managingProfile + ? (managingProfile.budget_lines ?? []).map((line) => ({ + id: line.id, + max_limit: line.max_limit, + reset_duration: line.reset_duration, + current_usage: line.current_usage, + last_reset: line.last_reset, + })) + : vk?.budgets; + + const apRL = managingProfile?.rate_limits; + const hasApRateLimit = !!(apRL && (apRL.token_max_limit != null || apRL.request_max_limit != null)); + // When profile-managed, never fall back to raw VK rate limits (that would contradict the + // locked edit/delete UX). If the profile has no rate limit, displayRateLimit is undefined. + const displayRateLimit: RateLimit | undefined = managingProfile + ? hasApRateLimit + ? { + id: "", + token_max_limit: apRL?.token_max_limit, + token_reset_duration: apRL?.token_reset_duration, + token_current_usage: apRL?.token_current_usage ?? 0, + token_last_reset: apRL?.token_last_reset ?? "", + request_max_limit: apRL?.request_max_limit, + request_reset_duration: apRL?.request_reset_duration, + request_current_usage: apRL?.request_current_usage ?? 0, + request_last_reset: apRL?.request_last_reset ?? "", + } + : undefined + : vk?.rate_limit; + + const isExhausted = + (displayBudgets?.some((b) => b.current_usage >= b.max_limit) ?? false) || + (displayRateLimit?.token_current_usage != null && + displayRateLimit?.token_max_limit != null && + displayRateLimit.token_current_usage >= displayRateLimit.token_max_limit) || + (displayRateLimit?.request_current_usage != null && + displayRateLimit?.request_max_limit != null && + displayRateLimit.request_current_usage >= displayRateLimit.request_max_limit); + + return { assignedUsers, isManagedByProfile, managingProfile, hasApRateLimit, displayBudgets, displayRateLimit, isExhausted }; +} diff --git a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx index af74cfc9eb..89586b5bc6 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeyDetailsSheet.tsx @@ -1,12 +1,42 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; +import { Label } from "@/components/ui/label"; +import { Progress } from "@/components/ui/progress"; import { DottedSeparator } from "@/components/ui/separator"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { ProviderIconType, RenderProviderIcon } from "@/lib/constants/icons"; import { ProviderLabels, ProviderName } from "@/lib/constants/logs"; import { VirtualKey } from "@/lib/types/governance"; -import { calculateUsagePercentage, formatCurrency, getUsageVariant, parseResetPeriod } from "@/lib/utils/governance"; +import { cn } from "@/lib/utils"; +import { calculateUsagePercentage, formatCurrency, parseResetPeriod } from "@/lib/utils/governance"; import { formatDistanceToNow } from "date-fns"; +import { Lock, Users } from "lucide-react"; +import { useVirtualKeyUsage } from "../hooks/useVirtualKeyUsage"; + +function usageBarClass(pct: number, exhausted: boolean) { + if (exhausted) return "[&>div]:bg-red-500/70"; + if (pct > 80) return "[&>div]:bg-amber-500/70"; + return "[&>div]:bg-emerald-500/70"; +} + +function UsageLine({ current, max, format }: { current: number; max: number; format: (n: number) => string }) { + const pct = calculateUsagePercentage(current, max); + const exhausted = max > 0 && current >= max; + return ( +
+
+ + {format(current)} / {format(max)} + + 80 ? "text-amber-500" : "text-muted-foreground")}> + {pct}% + +
+ +
+ ); +} interface VirtualKeyDetailSheetProps { virtualKey: VirtualKey; @@ -14,6 +44,9 @@ interface VirtualKeyDetailSheetProps { } export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKeyDetailSheetProps) { + const { assignedUsers, isManagedByProfile, managingProfile, hasApRateLimit, displayBudgets, displayRateLimit } = + useVirtualKeyUsage(virtualKey); + const getEntityInfo = () => { if (virtualKey.team) { return { type: "Team", name: virtualKey.team.name }; @@ -27,15 +60,15 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe const entityInfo = getEntityInfo(); const isExhausted = - // VK-level budget exhausted - virtualKey.budgets?.some((b) => b.current_usage >= b.max_limit) || - // VK-level rate limits exhausted - (virtualKey.rate_limit?.token_current_usage && - virtualKey.rate_limit?.token_max_limit && - virtualKey.rate_limit.token_current_usage >= virtualKey.rate_limit.token_max_limit) || - (virtualKey.rate_limit?.request_current_usage && - virtualKey.rate_limit?.request_max_limit && - virtualKey.rate_limit.request_current_usage >= virtualKey.rate_limit.request_max_limit); + // Budget exhausted (AP-mirrored when managed, VK-own otherwise) + displayBudgets?.some((b) => b.current_usage >= b.max_limit) || + // Rate limits exhausted + (displayRateLimit?.token_current_usage && + displayRateLimit?.token_max_limit && + displayRateLimit.token_current_usage >= displayRateLimit.token_max_limit) || + (displayRateLimit?.request_current_usage && + displayRateLimit?.request_max_limit && + displayRateLimit.request_current_usage >= displayRateLimit.request_max_limit); return ( @@ -46,6 +79,26 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe
+ {isManagedByProfile ? ( + + + + This virtual key is managed by an access profile. You can rename it or update its description from the edit button, but + providers, budgets, rate limits, and MCP access are controlled by the profile and must be changed there. + + + ) : null} + + {assignedUsers.length > 0 ? ( +
+ +
+ + {assignedUsers.map((u) => u.name || u.email).join(", ")} +
+
+ ) : null} + {/* Basic Information */}

Basic Information

@@ -161,35 +214,16 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe

Provider Budgets

{config.budgets.map((b, bIdx) => ( -
-
- Usage -
-
- - {formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)} - - - {calculateUsagePercentage(b.current_usage, b.max_limit)}% - -
-
-
-
- Reset Period -
- {parseResetPeriod(b.reset_duration)} +
+ +
+ + Resets {parseResetPeriod(b.reset_duration)} {virtualKey.calendar_aligned && " (calendar)"} -
-
-
- Last Reset -
- {formatDistanceToNow(new Date(b.last_reset), { addSuffix: true })} -
+ + {b.last_reset ? ( + Last reset {formatDistanceToNow(new Date(b.last_reset), { addSuffix: true })} + ) : null}
))} @@ -205,94 +239,42 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe

Provider Rate Limits

{/* Token Limits */} - {config.rate_limit.token_max_limit && ( + {config.rate_limit.token_max_limit != null ? (
TOKEN LIMITS -
- Usage -
-
- - {config.rate_limit.token_current_usage} / {config.rate_limit.token_max_limit} - - - {calculateUsagePercentage( - config.rate_limit.token_current_usage, - config.rate_limit.token_max_limit, - )} - % - -
-
-
-
- Reset Period -
- {parseResetPeriod(config.rate_limit.token_reset_duration || "")} -
-
-
- Last Reset -
- {formatDistanceToNow(new Date(config.rate_limit.token_last_reset), { addSuffix: true })} -
+ n.toLocaleString()} + /> +
+ Resets {parseResetPeriod(config.rate_limit.token_reset_duration || "")} + {config.rate_limit.token_last_reset ? ( + Last reset {formatDistanceToNow(new Date(config.rate_limit.token_last_reset), { addSuffix: true })} + ) : null}
- )} + ) : null} {/* Request Limits */} - {config.rate_limit.request_max_limit && ( + {config.rate_limit.request_max_limit != null ? (
REQUEST LIMITS -
- Usage -
-
- - {config.rate_limit.request_current_usage} / {config.rate_limit.request_max_limit} - - - {calculateUsagePercentage( - config.rate_limit.request_current_usage, - config.rate_limit.request_max_limit, - )} - % - -
-
-
-
- Reset Period -
- {parseResetPeriod(config.rate_limit.request_reset_duration || "")} -
-
-
- Last Reset -
- {formatDistanceToNow(new Date(config.rate_limit.request_last_reset), { addSuffix: true })} -
+ n.toLocaleString()} + /> +
+ Resets {parseResetPeriod(config.rate_limit.request_reset_duration || "")} + {config.rate_limit.request_last_reset ? ( + Last reset {formatDistanceToNow(new Date(config.rate_limit.request_last_reset), { addSuffix: true })} + ) : null}
- )} + ) : null} - {!config.rate_limit.token_max_limit && !config.rate_limit.request_max_limit && ( + {config.rate_limit.token_max_limit == null && config.rate_limit.request_max_limit == null && (

No rate limits configured for this provider

)}
@@ -358,35 +340,26 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {/* Budget Information */}
-

Budget Information

- - {virtualKey.budgets && virtualKey.budgets.length > 0 ? ( -
- {virtualKey.budgets.map((b, bIdx) => ( -
-
- Usage -
-
- - {formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)} - - - {calculateUsagePercentage(b.current_usage, b.max_limit)}% - -
-
-
-
- Reset Period -
- {parseResetPeriod(b.reset_duration)} +

+ Budget Information + {isManagedByProfile && managingProfile?.budget_lines?.length ? ( + (from {managingProfile.name}) + ) : null} +

+ + {displayBudgets && displayBudgets.length > 0 ? ( +
+ {displayBudgets.map((b, bIdx) => ( +
+ +
+ + Resets {parseResetPeriod(b.reset_duration)} {virtualKey.calendar_aligned && " (calendar)"} -
-
-
- Last Reset -
{formatDistanceToNow(new Date(b.last_reset), { addSuffix: true })}
+ + {b.last_reset ? ( + Last reset {formatDistanceToNow(new Date(b.last_reset), { addSuffix: true })} + ) : null}
))} @@ -398,102 +371,52 @@ export default function VirtualKeyDetailSheet({ virtualKey, onClose }: VirtualKe {/* Rate Limits */}
-

Rate Limits

- - {virtualKey.rate_limit ? ( +

+ Rate Limits + {isManagedByProfile && hasApRateLimit ? ( + (from {managingProfile?.name}) + ) : null} +

+ + {displayRateLimit ? (
{/* Token Limits */} - {virtualKey.rate_limit.token_max_limit && ( -
-
- Token Limits -
- -
-
- Usage -
-
- - {virtualKey.rate_limit.token_current_usage} / {virtualKey.rate_limit.token_max_limit} - - - {calculateUsagePercentage(virtualKey.rate_limit.token_current_usage, virtualKey.rate_limit.token_max_limit)}% - -
-
-
- -
- Reset Period -
{parseResetPeriod(virtualKey.rate_limit.token_reset_duration || "")}
-
- -
- Last Reset -
- {formatDistanceToNow(new Date(virtualKey.rate_limit.token_last_reset), { addSuffix: true })} -
-
+ {displayRateLimit.token_max_limit != null ? ( +
+ Token Limits + n.toLocaleString()} + /> +
+ Resets {parseResetPeriod(displayRateLimit.token_reset_duration || "")} + {displayRateLimit.token_last_reset ? ( + Last reset {formatDistanceToNow(new Date(displayRateLimit.token_last_reset), { addSuffix: true })} + ) : null}
- )} + ) : null} {/* Request Limits */} - {virtualKey.rate_limit.request_max_limit && ( -
-
- Request Limits -
- -
-
- Usage -
-
- - {virtualKey.rate_limit.request_current_usage} / {virtualKey.rate_limit.request_max_limit} - - - {calculateUsagePercentage( - virtualKey.rate_limit.request_current_usage, - virtualKey.rate_limit.request_max_limit, - )} - % - -
-
-
- -
- Reset Period -
{parseResetPeriod(virtualKey.rate_limit.request_reset_duration || "")}
-
- -
- Last Reset -
- {formatDistanceToNow(new Date(virtualKey.rate_limit.request_last_reset), { addSuffix: true })} -
-
+ {displayRateLimit.request_max_limit != null ? ( +
+ Request Limits + n.toLocaleString()} + /> +
+ Resets {parseResetPeriod(displayRateLimit.request_reset_duration || "")} + {displayRateLimit.request_last_reset ? ( + Last reset {formatDistanceToNow(new Date(displayRateLimit.request_last_reset), { addSuffix: true })} + ) : null}
- )} + ) : null} - {!virtualKey.rate_limit.token_max_limit && !virtualKey.rate_limit.request_max_limit && ( + {displayRateLimit.token_max_limit == null && displayRateLimit.request_max_limit == null && (

No rate limits configured

)}
diff --git a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx index d3038fced6..d9bb8cb08e 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeySheet.tsx @@ -11,6 +11,7 @@ import { } from "@/components/ui/alertDialog"; import { AsyncMultiSelect } from "@/components/ui/asyncMultiselect"; import { Button } from "@/components/ui/button"; +import { Alert, AlertDescription } from "@/components/ui/alert"; import { ConfigSyncAlert } from "@/components/ui/configSyncAlert"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; @@ -43,9 +44,10 @@ import { import { KnownProvider } from "@/lib/types/config"; import { CreateVirtualKeyRequest, Customer, Team, UpdateVirtualKeyRequest, VirtualKey } from "@/lib/types/governance"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { useVirtualKeyUsage } from "@/app/workspace/virtual-keys/hooks/useVirtualKeyUsage"; import { zodResolver } from "@hookform/resolvers/zod"; import { useNavigate } from "@tanstack/react-router"; -import { Building, Info, RotateCcw, Trash2, Users, X } from "lucide-react"; +import { Building, Info, Lock, RotateCcw, Trash2, Users, X } from "lucide-react"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { components, MultiValueProps, OptionProps } from "react-select"; @@ -157,6 +159,11 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, const hasUpdateAccess = useRbac(RbacResource.VirtualKeys, RbacOperation.Update); const canSubmit = isEditing ? hasUpdateAccess : hasCreateAccess; + // Detect AP-managed status via the managing profile's virtual_key_ids, not just by the presence + // of assignees — directly-attached users don't imply an access-profile relation. + const { assignedUsers, isManagedByProfile: isManagedByProfileHook } = useVirtualKeyUsage(virtualKey); + const isManagedByProfile = isEditing && isManagedByProfileHook; + const handleClose = () => { setIsOpen(false); setTimeout(() => { @@ -399,6 +406,20 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, return; } try { + // Managed VKs only allow name + description updates; all other fields are owned by the access profile. + if (isManagedByProfile && virtualKey) { + await updateVirtualKey({ + vkId: virtualKey.id, + data: { + name: data.name, + description: data.description, + }, + }).unwrap(); + toast.success("Virtual key updated"); + onSave(); + return; + } + // Normalize provider configs to ensure weights are numbers and handle budget/rate limits const normalizedProviderConfigs = data.providerConfigs ? normalizeProviderConfigs(data.providerConfigs, virtualKey?.provider_configs) @@ -406,8 +427,8 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave, if (isEditing && virtualKey) { // Update existing virtual key const updateData: UpdateVirtualKeyRequest = { - name: data.name || undefined, - description: data.description || undefined, + name: data.name, + description: data.description, provider_configs: normalizedProviderConfigs, mcp_configs: data.mcpConfigs, team_id: data.entityType === "team" && data.teamId && data.teamId.trim() !== "" ? data.teamId : undefined, @@ -509,6 +530,27 @@ export default function VirtualKeySheet({ virtualKey, teams, customers, onSave,
+ {isManagedByProfile && ( + + + + This virtual key is managed by an access profile. Only the name and description can be modified — providers, budgets, rate limits, and + MCP access are controlled by the profile. + + + )} + + {/* Assigned User */} + {assignedUsers.length > 0 && ( +
+ +
+ + {assignedUsers.map((u) => u.name || u.email).join(", ")} +
+
+ )} + {/* Basic Information */}
+
+
+
)} +
{isEditing && virtualKey?.config_hash && } {/* Form Footer */} diff --git a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx index 42de64907e..c230704741 100644 --- a/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx +++ b/ui/app/workspace/virtual-keys/views/virtualKeysTable.tsx @@ -14,13 +14,16 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"; import { resetDurationLabels } from "@/lib/constants/governance"; import { getErrorMessage, useDeleteVirtualKeyMutation, useLazyGetVirtualKeysQuery } from "@/lib/store"; import { Customer, Team, VirtualKey } from "@/lib/types/governance"; import { cn } from "@/lib/utils"; +import { RateLimitDisplay } from "@/components/rateLimitDisplay"; import { formatCurrency } from "@/lib/utils/governance"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { useVirtualKeyUsage } from "../hooks/useVirtualKeyUsage"; import { ArrowUpDown, ChevronLeft, @@ -79,6 +82,128 @@ function downloadCSV(content: string) { URL.revokeObjectURL(url); } +function VKBudgetCell({ vk }: { vk: VirtualKey }) { + const { displayBudgets } = useVirtualKeyUsage(vk); + + if (!displayBudgets || displayBudgets.length === 0) { + return -; + } + + return ( +
+ {displayBudgets.map((b, idx) => ( +
+ = b.max_limit && "text-red-400")}> + {formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)} + + + Resets {formatResetDuration(b.reset_duration)} + {vk.calendar_aligned && " (calendar)"} + +
+ ))} +
+ ); +} + +function VKRateLimitCell({ vk }: { vk: VirtualKey }) { + const { displayRateLimit } = useVirtualKeyUsage(vk); + return ; +} + +// Status badge derives exhaustion from the same AP-backed source as the budget/rate-limit cells +// so managed keys don't show "Active" next to an exhausted-looking bar. +function VKStatusBadge({ vk }: { vk: VirtualKey }) { + const { isExhausted } = useVirtualKeyUsage(vk); + return ( + + {vk.is_active ? (isExhausted ? "Exhausted" : "Active") : "Inactive"} + + ); +} + +// Per-row delete button. Calls useVirtualKeyUsage (same cached query as the budget/ +// rate-limit cells — RTK dedupes) to detect managed-by-AP VKs and swap the normal +// delete AlertDialog for a disabled button + tooltip so users aren't lured into a +// confirm-then-403 loop. +function VKDeleteButton({ + vk, + hasDeleteAccess, + isDeleting, + onDelete, +}: { + vk: VirtualKey; + hasDeleteAccess: boolean; + isDeleting: boolean; + onDelete: (vkId: string) => void; +}) { + const { isManagedByProfile } = useVirtualKeyUsage(vk); + + if (isManagedByProfile) { + return ( + + + + + + + + +

+ This virtual key is managed by an access profile and can't be deleted here. Detach the profile from the user or delete it + from the access profile settings. +

+
+
+
+ ); + } + + return ( + + + + + + + Delete Virtual Key + + Are you sure you want to delete "{vk.name.length > 20 ? `${vk.name.slice(0, 20)}...` : vk.name}"? This action cannot be undone. + + + + Cancel + onDelete(vk.id)} + disabled={isDeleting} + className="bg-destructive hover:bg-destructive/90" + data-testid={`vk-delete-confirm-${vk.name}`} + > + {isDeleting ? "Deleting..." : "Delete"} + + + + + ); +} + interface VirtualKeysTableProps { virtualKeys: VirtualKey[]; totalCount: number; @@ -452,6 +577,7 @@ export default function VirtualKeysTable({ + Rate Limits @@ -461,21 +587,13 @@ export default function VirtualKeysTable({ {virtualKeys.length === 0 ? ( - + No matching virtual keys found. ) : ( virtualKeys.map((vk) => { const isRevealed = revealedKeys.has(vk.id); - const isExhausted = - vk.budgets?.some((b) => b.current_usage >= b.max_limit) || - (vk.rate_limit?.token_current_usage && - vk.rate_limit?.token_max_limit && - vk.rate_limit.token_current_usage >= vk.rate_limit.token_max_limit) || - (vk.rate_limit?.request_current_usage && - vk.rate_limit?.request_max_limit && - vk.rate_limit.request_current_usage >= vk.rate_limit.request_max_limit); return ( - {vk.budgets && vk.budgets.length > 0 ? ( -
- {vk.budgets.map((b, idx) => ( -
- = b.max_limit && "text-red-400")}> - {formatCurrency(b.current_usage)} / {formatCurrency(b.max_limit)} - - - Resets {formatResetDuration(b.reset_duration)} - {b.calendar_aligned && " (calendar)"} - -
- ))} -
- ) : ( - - - )} + +
+ + - - {vk.is_active ? (isExhausted ? "Exhausted" : "Active") : "Inactive"} - + e.stopPropagation()}>
@@ -554,39 +657,7 @@ export default function VirtualKeysTable({ > - - - - - - - Delete Virtual Key - - Are you sure you want to delete "{vk.name.length > 20 ? `${vk.name.slice(0, 20)}...` : vk.name} - "? This action cannot be undone. - - - - Cancel - handleDelete(vk.id)} - disabled={isDeleting} - className="bg-destructive hover:bg-destructive/90" - > - {isDeleting ? "Deleting..." : "Delete"} - - - - +
diff --git a/ui/components/filters/logsFilterSidebar.tsx b/ui/components/filters/logsFilterSidebar.tsx index 84784a59d9..b305d2bffb 100644 --- a/ui/components/filters/logsFilterSidebar.tsx +++ b/ui/components/filters/logsFilterSidebar.tsx @@ -115,6 +115,7 @@ export function LogsFilterSidebar({ filters, onFiltersChange }: LogsSidebarProps + @@ -669,6 +670,27 @@ function SessionFilter({ filters, onFiltersChange, defaultOpen }: FilterComponen ); } +// --------------------------------------------------------------------------- +// UserFilter +// --------------------------------------------------------------------------- + +function UserFilter({ filters, onFiltersChange, defaultOpen }: FilterComponentProps) { + const hasActive = !!filters.user_ids?.length; + return ( + +
+ onFiltersChange({ ...filters, user_ids: e.target.value ? [e.target.value] : [] })} + placeholder="User ID" + className="h-8 text-sm" + data-testid="user-id-filter-input" + /> +
+
+ ); +} + // --------------------------------------------------------------------------- // CostFilter // --------------------------------------------------------------------------- diff --git a/ui/components/rateLimitDisplay.tsx b/ui/components/rateLimitDisplay.tsx new file mode 100644 index 0000000000..62780cf1c6 --- /dev/null +++ b/ui/components/rateLimitDisplay.tsx @@ -0,0 +1,122 @@ +import { Progress } from "@/components/ui/progress"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { resetDurationLabels } from "@/lib/constants/governance"; +import { cn } from "@/lib/utils"; +import { formatCompactNumber } from "@/lib/utils/governance"; + +interface RateLimitShape { + token_max_limit?: number | null; + token_reset_duration?: string | null; + token_current_usage?: number | null; + request_max_limit?: number | null; + request_reset_duration?: string | null; + request_current_usage?: number | null; +} + +interface RateLimitDisplayProps { + rateLimits: RateLimitShape | null | undefined; + /** Compact mode for narrow cells — still renders bars, just tighter */ + compact?: boolean; + /** Render limit + reset period only (no usage bar). Use for template entities like access profiles. */ + limitOnly?: boolean; +} + +const formatResetDuration = (duration?: string | null) => { + if (!duration) return ""; + return resetDurationLabels[duration] || duration; +}; + +function LimitText({ label, max, resetDuration }: { label: string; max: number; resetDuration?: string | null }) { + return ( +
+ + {formatCompactNumber(max)} {label} + + {formatResetDuration(resetDuration)} +
+ ); +} + +function Bar({ label, current, max, resetDuration, compact }: { + label: string; + current: number; + max: number; + resetDuration?: string | null; + compact?: boolean; +}) { + const pct = max > 0 ? Math.min((current / max) * 100, 100) : 0; + const isExhausted = max > 0 && current >= max; + const barClass = isExhausted + ? "[&>div]:bg-red-500/70" + : pct > 80 + ? "[&>div]:bg-amber-500/70" + : "[&>div]:bg-emerald-500/70"; + + return ( + + +
+
+ + {formatCompactNumber(max)} {label} + + {formatResetDuration(resetDuration)} +
+ +
+
+ +

+ {current.toLocaleString()} / {max.toLocaleString()} {label} +

+ {resetDuration ? ( +

Resets {formatResetDuration(resetDuration)}

+ ) : null} +
+
+ ); +} + +export function RateLimitDisplay({ rateLimits, compact, limitOnly }: RateLimitDisplayProps) { + if (!rateLimits) { + return -; + } + + const hasTokens = rateLimits.token_max_limit != null && rateLimits.token_max_limit > 0; + const hasRequests = rateLimits.request_max_limit != null && rateLimits.request_max_limit > 0; + + if (!hasTokens && !hasRequests) { + return -; + } + + return ( +
+ {hasTokens ? ( + limitOnly ? ( + + ) : ( + + ) + ) : null} + {hasRequests ? ( + limitOnly ? ( + + ) : ( + + ) + ) : null} +
+ ); +} diff --git a/ui/lib/types/logs.ts b/ui/lib/types/logs.ts index be59d83ab1..57613f2f1b 100644 --- a/ui/lib/types/logs.ts +++ b/ui/lib/types/logs.ts @@ -470,6 +470,7 @@ export interface LogEntry { business_unit_id?: string; business_unit_name?: string; user_id?: string; + user_name?: string; virtual_key_id?: string; routing_engines_used?: string[]; routing_rule_id?: string; diff --git a/ui/lib/utils/governance.ts b/ui/lib/utils/governance.ts index b85145f73a..5c3ad946ca 100644 --- a/ui/lib/utils/governance.ts +++ b/ui/lib/utils/governance.ts @@ -28,6 +28,58 @@ export function formatCurrency(dollars: number) { return `$${dollars.toFixed(2)}`; } +/** + * Formats a number compactly (e.g. 10000 → "10K", 1500000 → "1.5M"). + * Uses Intl.NumberFormat so boundary values promote correctly (999,950 → "1M", not "1000K") + * and trailing zeros are dropped (10,000 → "10K", not "10.0K"). + */ +const compactNumberFormatter = new Intl.NumberFormat(undefined, { + notation: "compact", + maximumFractionDigits: 1, +}); + +export function formatCompactNumber(n: number): string { + if (Math.abs(n) >= 1_000) return compactNumberFormatter.format(n); + return n.toLocaleString(); +} + +const shortDurationLabels: Record = { + "1m": "/min", + "5m": "/5min", + "15m": "/15min", + "30m": "/30min", + "1h": "/hr", + "6h": "/6hr", + "1d": "/day", + "1w": "/wk", + "1M": "/mo", +}; + +/** + * Formats rate limit into compact display lines. + * e.g. ["10K tokens/hr", "100 req/hr"] + */ +export function formatRateLimitLines(rateLimits: { + token_max_limit?: number | null; + token_reset_duration?: string | null; + request_max_limit?: number | null; + request_reset_duration?: string | null; +} | null | undefined): string[] { + if (!rateLimits) return []; + const lines: string[] = []; + if (rateLimits.token_max_limit != null) { + const duration = rateLimits.token_reset_duration ?? ""; + const suffix = shortDurationLabels[duration] ?? (duration ? `/${duration}` : ""); + lines.push(`${formatCompactNumber(rateLimits.token_max_limit)} tokens${suffix}`); + } + if (rateLimits.request_max_limit != null) { + const duration = rateLimits.request_reset_duration ?? ""; + const suffix = shortDurationLabels[duration] ?? (duration ? `/${duration}` : ""); + lines.push(`${formatCompactNumber(rateLimits.request_max_limit)} req${suffix}`); + } + return lines; +} + /** * Calculates usage percentage for rate limits */