From a524b457ba6b994b3d31406575bdb08cd49503cd Mon Sep 17 00:00:00 2001 From: "Kai (Tam Nhu) Tran" <61256810+kaitranntt@users.noreply.github.com> Date: Sun, 29 Mar 2026 01:46:23 -0400 Subject: [PATCH] feat(providers): add provider-level Codex pool activity monitor (#539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add provider-scoped runtime monitor for Codex pool owners: - New `GET /v1/providers/{id}/codex-pool-activity` endpoint aggregates pool health across all agents - New Pool Activity section on provider detail page (pool owners only) - Shows aggregate member health, recent requests, top agents with drill-down links - 7-day time window on provider-scoped span query for performance - Reuses `buildCodexPoolActivity()` — zero duplicated aggregation logic - i18n complete (en/vi/zh), accessible markup Closes #499 --- cmd/gateway_http_handlers.go | 6 + internal/http/providers.go | 15 ++ .../http/providers_codex_pool_activity.go | 177 +++++++++++++++ internal/store/pg/tracing_codex_pool.go | 60 +++++ internal/store/sqlitestore/tracing.go | 5 + internal/store/tracing_store.go | 10 + ui/web/src/i18n/locales/en/providers.json | 10 +- ui/web/src/i18n/locales/vi/providers.json | 10 +- ui/web/src/i18n/locales/zh/providers.json | 10 +- ui/web/src/lib/query-keys.ts | 1 + .../hooks/use-provider-codex-pool-activity.ts | 56 +++++ .../provider-detail/provider-overview.tsx | 25 ++ .../provider-pool-activity-section.tsx | 214 ++++++++++++++++++ 13 files changed, 596 insertions(+), 3 deletions(-) create mode 100644 internal/http/providers_codex_pool_activity.go create mode 100644 ui/web/src/pages/providers/hooks/use-provider-codex-pool-activity.ts create mode 100644 ui/web/src/pages/providers/provider-detail/provider-pool-activity-section.tsx diff --git a/cmd/gateway_http_handlers.go b/cmd/gateway_http_handlers.go index babe1f22..1d587f60 100644 --- a/cmd/gateway_http_handlers.go +++ b/cmd/gateway_http_handlers.go @@ -62,6 +62,12 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir if stores.MCP != nil { providersH.SetMCPServerLookup(buildMCPServerLookup(stores.MCP)) } + if stores.Tracing != nil { + providersH.SetTracingStore(stores.Tracing) + } + if stores.Agents != nil { + providersH.SetAgentStore(stores.Agents) + } } var teamEventsH *httpapi.TeamEventsHandler diff --git a/internal/http/providers.go b/internal/http/providers.go index 2b1a7dd0..df6a3fce 100644 --- a/internal/http/providers.go +++ b/internal/http/providers.go @@ -29,6 +29,8 @@ type ProvidersHandler struct { cliMu sync.Mutex // serializes Claude CLI provider create to prevent duplicates msgBus *bus.MessageBus sysConfigStore store.SystemConfigStore + tracingStore store.TracingStore // optional: for provider-scoped pool activity + agents store.AgentCRUDStore // optional: for provider pool activity agent lookup } // NewProvidersHandler creates a handler for provider management endpoints. @@ -59,6 +61,16 @@ func (h *ProvidersHandler) SetAPIBaseFallback(fn func(providerType string) strin h.apiBaseFallback = fn } +// SetTracingStore sets the tracing store for provider-scoped pool activity. +func (h *ProvidersHandler) SetTracingStore(ts store.TracingStore) { + h.tracingStore = ts +} + +// SetAgentStore sets the agent store for provider pool activity agent lookup. +func (h *ProvidersHandler) SetAgentStore(as store.AgentCRUDStore) { + h.agents = as +} + // resolveAPIBase returns the provider's api_base, falling back to config/env if empty. func (h *ProvidersHandler) resolveAPIBase(p *store.LLMProviderData) string { if p.APIBase != "" { @@ -98,6 +110,9 @@ func (h *ProvidersHandler) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /v1/providers/{id}/verify", h.auth(h.handleVerifyProvider)) mux.HandleFunc("POST /v1/providers/{id}/verify-embedding", h.auth(h.handleVerifyEmbedding)) + // Provider-scoped Codex pool activity monitor + mux.HandleFunc("GET /v1/providers/{id}/codex-pool-activity", h.auth(h.handleProviderCodexPoolActivity)) + // Embedding system status mux.HandleFunc("GET /v1/embedding/status", h.auth(h.handleEmbeddingStatus)) diff --git a/internal/http/providers_codex_pool_activity.go b/internal/http/providers_codex_pool_activity.go new file mode 100644 index 00000000..8e9c5fdc --- /dev/null +++ b/internal/http/providers_codex_pool_activity.go @@ -0,0 +1,177 @@ +package http + +import ( + "context" + "log/slog" + "net/http" + "slices" + "strconv" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/i18n" + "github.com/nextlevelbuilder/goclaw/internal/providers" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +// providerCodexPoolAgentCount tracks per-agent request counts for provider-scoped activity. +type providerCodexPoolAgentCount struct { + AgentID uuid.UUID `json:"agent_id"` + AgentKey string `json:"agent_key,omitempty"` + RequestCount int `json:"request_count"` +} + +// handleProviderCodexPoolActivity returns pool activity aggregated across all agents +// that use this provider's Codex pool. Only valid for chatgpt_oauth pool owners. +func (h *ProvidersHandler) handleProviderCodexPoolActivity(w http.ResponseWriter, r *http.Request) { + locale := extractLocale(r) + if h.tracingStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": i18n.T(locale, i18n.MsgInvalidRequest, "tracing store unavailable"), + }) + return + } + + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "provider")}) + return + } + + provider, err := h.store.GetProvider(r.Context(), id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": i18n.T(locale, i18n.MsgNotFound, "provider", id.String())}) + return + } + + if provider.ProviderType != store.ProviderChatGPTOAuth { + writeJSON(w, http.StatusOK, emptyProviderPoolActivityResponse()) + return + } + + // Resolve pool providers from provider settings (capped at 20 to bound query size) + const maxPoolCandidates = 20 + settings := store.ParseChatGPTOAuthProviderSettings(provider.Settings) + poolCandidates := []string{provider.Name} + strategy := store.ChatGPTOAuthStrategyPrimaryFirst + if settings != nil && settings.CodexPool != nil { + if settings.CodexPool.Strategy != "" { + strategy = settings.CodexPool.Strategy + } + for _, name := range settings.CodexPool.ExtraProviderNames { + if name != "" && !slices.Contains(poolCandidates, name) { + poolCandidates = append(poolCandidates, name) + if len(poolCandidates) >= maxPoolCandidates { + break + } + } + } + } + + // Filter to registered Codex providers + poolProviders := registeredCodexPoolProviders(h.providerReg, provider.TenantID, poolCandidates) + if len(poolProviders) == 0 { + writeJSON(w, http.StatusOK, emptyProviderPoolActivityResponse()) + return + } + + limit := 18 + if raw := r.URL.Query().Get("limit"); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 50 { + limit = parsed + } + } + statsLimit := maxInt(limit, codexPoolRuntimeHealthSampleSize) + + rawSpans, err := h.tracingStore.ListCodexPoolSpansByProviders(r.Context(), provider.TenantID, poolProviders, statsLimit) + if err != nil { + slog.Error("providers.codex_pool_activity", "provider", provider.Name, "error", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgFailedToList, "pool activity")}) + return + } + + // Filter spans to those with routing evidence matching pool providers + spans := make([]store.CodexPoolSpan, 0, len(rawSpans)) + agentRequestCounts := make(map[uuid.UUID]int) + for _, item := range rawSpans { + if evidence := providers.ExtractChatGPTOAuthRoutingEvidence(item.Metadata); evidence.HasData() { + if !providerInPool(poolProviders, evidence.SelectedProvider) && !providerInPool(poolProviders, evidence.ServingProvider) { + continue + } + } else if !providerInPool(poolProviders, item.Provider) { + continue + } + spans = append(spans, item.CodexPoolSpan) + agentRequestCounts[item.AgentID]++ + } + + providerCounts, recent := buildCodexPoolActivity(poolProviders, spans) + if len(recent) > limit { + recent = recent[:limit] + } + + // Build top agents list sorted by request count + topAgents := buildTopAgents(r.Context(), agentRequestCounts, h.agents) + + writeJSON(w, http.StatusOK, map[string]any{ + "strategy": strategy, + "pool_providers": poolProviders, + "stats_sample_size": len(spans), + "provider_counts": providerCounts, + "recent_requests": recent, + "top_agents": topAgents, + }) +} + +func emptyProviderPoolActivityResponse() map[string]any { + return map[string]any{ + "strategy": store.ChatGPTOAuthStrategyPrimaryFirst, + "pool_providers": []string{}, + "stats_sample_size": 0, + "provider_counts": []codexPoolProviderCount{}, + "recent_requests": []codexPoolRecentRequest{}, + "top_agents": []providerCodexPoolAgentCount{}, + } +} + +// buildTopAgents converts agent request counts into a sorted slice, resolving agent keys. +func buildTopAgents(ctx context.Context, counts map[uuid.UUID]int, agentStore store.AgentCRUDStore) []providerCodexPoolAgentCount { + if len(counts) == 0 { + return []providerCodexPoolAgentCount{} + } + + agentIDs := make([]uuid.UUID, 0, len(counts)) + for id := range counts { + agentIDs = append(agentIDs, id) + } + + keyByID := make(map[uuid.UUID]string, len(agentIDs)) + if agentStore != nil { + if agents, err := agentStore.GetByIDs(ctx, agentIDs); err != nil { + slog.Warn("providers.codex_pool_activity.resolve_agents", "error", err, "count", len(agentIDs)) + } else { + for _, ag := range agents { + keyByID[ag.ID] = ag.AgentKey + } + } + } + + result := make([]providerCodexPoolAgentCount, 0, len(counts)) + for id, count := range counts { + result = append(result, providerCodexPoolAgentCount{ + AgentID: id, + AgentKey: keyByID[id], + RequestCount: count, + }) + } + + slices.SortFunc(result, func(a, b providerCodexPoolAgentCount) int { + return b.RequestCount - a.RequestCount + }) + + if len(result) > 10 { + result = result[:10] + } + + return result +} diff --git a/internal/store/pg/tracing_codex_pool.go b/internal/store/pg/tracing_codex_pool.go index 8dd942f4..439bd7af 100644 --- a/internal/store/pg/tracing_codex_pool.go +++ b/internal/store/pg/tracing_codex_pool.go @@ -67,3 +67,63 @@ func (s *PGTracingStore) ListCodexPoolSpans(ctx context.Context, agentID, tenant } return spans, nil } + +const listCodexPoolSpansByProvidersQuery = ` +SELECT + sp.id, + sp.trace_id, + sp.start_time, + COALESCE(sp.duration_ms, 0), + sp.status, + COALESCE(sp.provider, ''), + COALESCE(sp.model, ''), + COALESCE(sp.metadata, '{}'::jsonb), + t.agent_id +FROM spans sp +JOIN traces t ON t.id = sp.trace_id +WHERE t.tenant_id = $1 + AND t.parent_trace_id IS NULL + AND sp.tenant_id = $1 + AND sp.span_type = 'llm_call' + AND sp.start_time > NOW() - INTERVAL '7 days' + AND ( + sp.provider = ANY($2) + OR COALESCE(sp.metadata->'chatgpt_oauth_routing'->>'selected_provider', '') = ANY($2) + OR COALESCE(sp.metadata->'chatgpt_oauth_routing'->>'serving_provider', '') = ANY($2) + ) +ORDER BY sp.start_time DESC +LIMIT $3` + +// ListCodexPoolSpansByProviders returns recent LLM call spans across all agents for the given pool providers. +func (s *PGTracingStore) ListCodexPoolSpansByProviders(ctx context.Context, tenantID uuid.UUID, poolProviders []string, limit int) ([]store.CodexPoolProviderSpan, error) { + rows, err := s.db.QueryContext(ctx, listCodexPoolSpansByProvidersQuery, tenantID, pq.Array(poolProviders), limit) + if err != nil { + return nil, err + } + defer rows.Close() + + spans := make([]store.CodexPoolProviderSpan, 0, limit) + for rows.Next() { + var item store.CodexPoolProviderSpan + var metadata json.RawMessage + if err := rows.Scan( + &item.SpanID, + &item.TraceID, + &item.StartedAt, + &item.DurationMS, + &item.Status, + &item.Provider, + &item.Model, + &metadata, + &item.AgentID, + ); err != nil { + return nil, err + } + item.Metadata = metadata + spans = append(spans, item) + } + if err := rows.Err(); err != nil { + return nil, err + } + return spans, nil +} diff --git a/internal/store/sqlitestore/tracing.go b/internal/store/sqlitestore/tracing.go index 399a094e..c0947893 100644 --- a/internal/store/sqlitestore/tracing.go +++ b/internal/store/sqlitestore/tracing.go @@ -255,3 +255,8 @@ func (s *SQLiteTracingStore) DeleteTracesOlderThan(ctx context.Context, cutoff t func (s *SQLiteTracingStore) ListCodexPoolSpans(_ context.Context, _, _ uuid.UUID, _ []string, _ int) ([]store.CodexPoolSpan, error) { return nil, nil } + +// ListCodexPoolSpansByProviders is not supported in SQLite (Codex pool is a standard-edition feature). +func (s *SQLiteTracingStore) ListCodexPoolSpansByProviders(_ context.Context, _ uuid.UUID, _ []string, _ int) ([]store.CodexPoolProviderSpan, error) { + return nil, nil +} diff --git a/internal/store/tracing_store.go b/internal/store/tracing_store.go index df233cb2..961028e1 100644 --- a/internal/store/tracing_store.go +++ b/internal/store/tracing_store.go @@ -162,4 +162,14 @@ type TracingStore interface { // ListCodexPoolSpans returns recent LLM call spans for agents using Codex OAuth pool providers. ListCodexPoolSpans(ctx context.Context, agentID, tenantID uuid.UUID, poolProviders []string, limit int) ([]CodexPoolSpan, error) + + // ListCodexPoolSpansByProviders returns recent LLM call spans across all agents + // that used any of the given pool providers. Used for provider-scoped activity monitoring. + ListCodexPoolSpansByProviders(ctx context.Context, tenantID uuid.UUID, poolProviders []string, limit int) ([]CodexPoolProviderSpan, error) +} + +// CodexPoolProviderSpan extends CodexPoolSpan with the agent ID for provider-scoped aggregation. +type CodexPoolProviderSpan struct { + CodexPoolSpan + AgentID uuid.UUID } diff --git a/ui/web/src/i18n/locales/en/providers.json b/ui/web/src/i18n/locales/en/providers.json index 742e5255..d0a41766 100644 --- a/ui/web/src/i18n/locales/en/providers.json +++ b/ui/web/src/i18n/locales/en/providers.json @@ -222,7 +222,15 @@ "oauthManagedByHint": "This alias is managed by provider {{provider}}. Edit pool membership there instead of on this account.", "codexPoolDefaultsTitle": "Pool Defaults", "codexPoolDefaultsDescription": "Define the reusable OpenAI Codex pool owned by this alias for agents that use it as their primary provider.", - "oauthDisplayNameRecommendation": "Add a display name if you want clearer labels when multiple OpenAI Codex OAuth accounts appear in the pool." + "oauthDisplayNameRecommendation": "Add a display name if you want clearer labels when multiple OpenAI Codex OAuth accounts appear in the pool.", + "poolActivityTitle": "Pool Activity", + "poolActivityDescription": "Aggregate runtime activity across all agents using this provider's Codex pool.", + "poolActivityRefresh": "Refresh", + "poolActivityTopAgents": "Agents", + "poolActivityTopAgentsTitle": "Top agents using this pool", + "poolActivityRequests": "req", + "poolActivityEmpty": "No activity yet", + "poolActivityEmptyDesc": "Pool activity will appear here once agents start routing requests through this pool." }, "embedding": { "enable": "Enable Embedding", diff --git a/ui/web/src/i18n/locales/vi/providers.json b/ui/web/src/i18n/locales/vi/providers.json index 0b9a901e..b12dfeb7 100644 --- a/ui/web/src/i18n/locales/vi/providers.json +++ b/ui/web/src/i18n/locales/vi/providers.json @@ -226,7 +226,15 @@ "oauthManagedByHint": "Bí danh này đang được quản lý bởi provider {{provider}}. Hãy chỉnh membership ở provider đó thay vì tại tài khoản này.", "codexPoolDefaultsTitle": "Thiết lập pool mặc định", "codexPoolDefaultsDescription": "Định nghĩa pool OpenAI Codex dùng lại do chính bí danh này sở hữu cho các agent dùng nó làm provider chính.", - "oauthDisplayNameRecommendation": "Thêm tên hiển thị nếu bạn muốn nhãn rõ ràng hơn khi nhiều tài khoản OpenAI Codex OAuth xuất hiện trong pool." + "oauthDisplayNameRecommendation": "Thêm tên hiển thị nếu bạn muốn nhãn rõ ràng hơn khi nhiều tài khoản OpenAI Codex OAuth xuất hiện trong pool.", + "poolActivityTitle": "Hoạt động pool", + "poolActivityDescription": "Hoạt động runtime tổng hợp trên tất cả agent sử dụng pool Codex của provider này.", + "poolActivityRefresh": "Làm mới", + "poolActivityTopAgents": "Agent", + "poolActivityTopAgentsTitle": "Agent sử dụng pool nhiều nhất", + "poolActivityRequests": "req", + "poolActivityEmpty": "Chưa có hoạt động", + "poolActivityEmptyDesc": "Hoạt động pool sẽ xuất hiện ở đây khi agent bắt đầu gửi request qua pool này." }, "list": { "poolOwner": "Chủ pool", diff --git a/ui/web/src/i18n/locales/zh/providers.json b/ui/web/src/i18n/locales/zh/providers.json index 51230d9e..7a78f417 100644 --- a/ui/web/src/i18n/locales/zh/providers.json +++ b/ui/web/src/i18n/locales/zh/providers.json @@ -244,7 +244,15 @@ "oauthManagedByHint": "这个别名由 provider {{provider}} 管理。请到那个 provider 上编辑池成员,而不是在这个账户上修改。", "codexPoolDefaultsTitle": "默认账户池", "codexPoolDefaultsDescription": "为把这个别名作为主 provider 的 agent 定义由该别名拥有的可复用 OpenAI Codex 账户池。", - "oauthDisplayNameRecommendation": "如果您希望多个 OpenAI Codex OAuth 账户在池里显示得更清楚,可以补充显示名称。" + "oauthDisplayNameRecommendation": "如果您希望多个 OpenAI Codex OAuth 账户在池里显示得更清楚,可以补充显示名称。", + "poolActivityTitle": "池活动", + "poolActivityDescription": "汇总所有使用该 provider Codex 池的 agent 的运行时活动。", + "poolActivityRefresh": "刷新", + "poolActivityTopAgents": "Agent", + "poolActivityTopAgentsTitle": "使用该池最多的 agent", + "poolActivityRequests": "请求", + "poolActivityEmpty": "暂无活动", + "poolActivityEmptyDesc": "当 agent 开始通过此池路由请求时,池活动将在此显示。" }, "list": { "poolOwner": "池拥有者", diff --git a/ui/web/src/lib/query-keys.ts b/ui/web/src/lib/query-keys.ts index 48c53879..8da844d4 100644 --- a/ui/web/src/lib/query-keys.ts +++ b/ui/web/src/lib/query-keys.ts @@ -7,6 +7,7 @@ export const queryKeys = { models: (providerId: string) => ["providers", providerId, "models"] as const, chatgptOAuthStatuses: (providerKeys: string[]) => ["providers", "chatgpt-oauth-statuses", ...providerKeys] as const, chatgptOAuthQuotas: (providerNames: string[]) => ["providers", "chatgpt-oauth-quotas", ...providerNames] as const, + codexPoolActivity: (providerId: string, limit: number) => ["providers", providerId, "codex-pool-activity", limit] as const, }, agents: { all: ["agents"] as const, diff --git a/ui/web/src/pages/providers/hooks/use-provider-codex-pool-activity.ts b/ui/web/src/pages/providers/hooks/use-provider-codex-pool-activity.ts new file mode 100644 index 00000000..b257c7c5 --- /dev/null +++ b/ui/web/src/pages/providers/hooks/use-provider-codex-pool-activity.ts @@ -0,0 +1,56 @@ +import { useQuery } from "@tanstack/react-query"; +import { useHttp } from "@/hooks/use-ws"; +import { queryKeys } from "@/lib/query-keys"; +import type { + CodexPoolProviderCount, + CodexPoolRecentRequest, +} from "@/pages/agents/agent-detail/hooks/use-codex-pool-activity"; +import type { EffectiveChatGPTOAuthRoutingStrategy } from "@/types/agent"; + +export interface ProviderCodexPoolAgentCount { + agent_id: string; + agent_key?: string; + request_count: number; +} + +interface ProviderCodexPoolActivityResponse { + strategy: EffectiveChatGPTOAuthRoutingStrategy; + pool_providers: string[]; + stats_sample_size: number; + provider_counts: CodexPoolProviderCount[]; + recent_requests: CodexPoolRecentRequest[]; + top_agents: ProviderCodexPoolAgentCount[]; +} + +const EMPTY: ProviderCodexPoolActivityResponse = { + strategy: "primary_first", + pool_providers: [], + stats_sample_size: 0, + provider_counts: [], + recent_requests: [], + top_agents: [], +}; + +export function useProviderCodexPoolActivity( + providerId: string, + limit = 18, + enabled = true, +) { + const http = useHttp(); + + const query = useQuery({ + queryKey: queryKeys.providers.codexPoolActivity(providerId, limit), + enabled: enabled && Boolean(providerId), + staleTime: 5_000, + queryFn: () => + http.get( + `/v1/providers/${providerId}/codex-pool-activity`, + { limit: String(limit) }, + ), + }); + + return { + ...query, + data: query.data ?? EMPTY, + }; +} diff --git a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx index 641caa6c..36e371bc 100644 --- a/ui/web/src/pages/providers/provider-detail/provider-overview.tsx +++ b/ui/web/src/pages/providers/provider-detail/provider-overview.tsx @@ -34,6 +34,8 @@ import { ChatGPTOAuthRoutingSection, } from "@/pages/agents/agent-detail/config-sections"; import type { CodexPoolEntry } from "@/pages/agents/agent-detail/codex-pool-activity-panel"; +import { useProviderCodexPoolActivity } from "../hooks/use-provider-codex-pool-activity"; +import { ProviderPoolActivitySection } from "./provider-pool-activity-section"; interface ProviderOverviewProps { provider: ProviderData; @@ -311,6 +313,14 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) }); }, [canEditPoolRouting, provider.name, providerByName, quotaByName, quotaProviderNames, statusByName]); + // Provider-scoped pool activity (only for pool owners) + const isPoolOwner = canEditPoolRouting && poolEntries.length > 0; + const { + data: poolActivity, + isFetching: poolActivityFetching, + refetch: refreshPoolActivity, + } = useProviderCodexPoolActivity(provider.id, 8, isPoolOwner); + const { verifyEmbedding, embVerifying, embResult, resetEmb } = useProviderVerify(); useEffect(() => { resetEmb(); }, [embModel, embDimensions, resetEmb]); @@ -464,6 +474,21 @@ export function ProviderOverview({ provider, onUpdate }: ProviderOverviewProps) /> ) : null} + {isPoolOwner ? ( + void refreshPoolActivity()} + providerByName={providerByName} + statusByName={statusByName} + quotaByName={quotaByName} + /> + ) : null} + {showApiKey ? (

{t("detail.apiKeySection")}

diff --git a/ui/web/src/pages/providers/provider-detail/provider-pool-activity-section.tsx b/ui/web/src/pages/providers/provider-detail/provider-pool-activity-section.tsx new file mode 100644 index 00000000..01cc9364 --- /dev/null +++ b/ui/web/src/pages/providers/provider-detail/provider-pool-activity-section.tsx @@ -0,0 +1,214 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { Activity, ExternalLink, RefreshCw } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { EmptyState } from "@/components/shared/empty-state"; +import { cn } from "@/lib/utils"; +import type { CodexPoolProviderCount } from "@/pages/agents/agent-detail/hooks/use-codex-pool-activity"; +import { CodexPoolMemberCard } from "@/pages/agents/agent-detail/codex-pool-member-card"; +import { CodexPoolRecentRequestsList } from "@/pages/agents/agent-detail/codex-pool-recent-requests-list"; +import type { CodexPoolEntry } from "@/pages/agents/agent-detail/codex-pool-entry-types"; +import type { ChatGPTOAuthAvailability } from "../hooks/use-chatgpt-oauth-provider-statuses"; +import type { ChatGPTOAuthProviderQuota } from "../hooks/use-chatgpt-oauth-provider-quotas"; +import type { ProviderData } from "../hooks/use-providers"; +import type { ProviderCodexPoolAgentCount } from "../hooks/use-provider-codex-pool-activity"; +import type { CodexPoolRecentRequest } from "@/pages/agents/agent-detail/hooks/use-codex-pool-activity"; + +function MonitorStat({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} + +interface ProviderPoolActivitySectionProps { + provider: ProviderData; + providerCounts: CodexPoolProviderCount[]; + recentRequests: CodexPoolRecentRequest[]; + topAgents: ProviderCodexPoolAgentCount[]; + statsSampleSize: number; + fetching: boolean; + onRefresh: () => void; + providerByName: Map; + statusByName: Map; + quotaByName: Map; +} + +export function ProviderPoolActivitySection({ + provider, + providerCounts, + recentRequests, + topAgents, + statsSampleSize, + fetching, + onRefresh, + providerByName, + statusByName, + quotaByName, +}: ProviderPoolActivitySectionProps) { + const { t } = useTranslation("providers"); + const { t: ta } = useTranslation("agents"); + + const entries = useMemo(() => { + return providerCounts.map((c) => { + const p = providerByName.get(c.provider_name); + const availability: ChatGPTOAuthAvailability = + statusByName.get(c.provider_name)?.availability ?? + (p?.enabled === false ? "disabled" : "needs_sign_in"); + return { + name: c.provider_name, + label: p?.display_name || c.provider_name, + availability, + role: c.provider_name === provider.name ? "preferred" as const : "extra" as const, + requestCount: c.request_count, + directSelectionCount: c.direct_selection_count, + failoverServeCount: c.failover_serve_count, + successCount: c.success_count, + failureCount: c.failure_count, + consecutiveFailures: c.consecutive_failures, + successRate: c.success_rate, + healthScore: c.health_score, + healthState: c.health_state ?? "idle", + lastSelectedAt: c.last_selected_at, + lastFailoverAt: c.last_failover_at, + lastUsedAt: c.last_used_at, + lastSuccessAt: c.last_success_at, + lastFailureAt: c.last_failure_at, + providerHref: p?.id ? `/providers/${p.id}` : undefined, + quota: quotaByName.get(c.provider_name), + }; + }); + }, [provider.name, providerByName, providerCounts, quotaByName, statusByName]); + + const failoverCount = recentRequests.filter((r) => r.used_failover).length; + + return ( +
+
+

{t("detail.poolActivityTitle")}

+ +
+

+ {t("detail.poolActivityDescription")} +

+ + {/* Summary stats */} +
+ + + + +
+ + {/* Pool member cards */} + {entries.length > 0 ? ( +
+ {entries.map((entry) => ( + + ))} +
+ ) : ( + + )} + + {/* Recent requests */} + {recentRequests.length > 0 ? ( +
+
+

+ {ta("chatgptOAuthRouting.sequenceTitle")} +

+ + {ta("chatgptOAuthRouting.recentRequestsCount", { + count: recentRequests.length, + })} + +
+ +
+ ) : null} + + {/* Top agents */} + {topAgents.length > 0 ? ( +
+

+ {t("detail.poolActivityTopAgentsTitle")} +

+
    + {topAgents.map((agent) => ( +
  • +
    + {agent.agent_key ? ( + + {agent.agent_key} + + ) : ( + + {agent.agent_id.slice(0, 8)} + + )} + + + +
    + + {agent.request_count} {t("detail.poolActivityRequests")} + +
  • + ))} +
+
+ ) : null} +
+ ); +}