mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 02:15:57 +00:00
feat(providers): add provider-level Codex pool activity monitor (#539)
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
This commit is contained in:
1 parent
789c9613be
commit
a524b457ba
13 files changed
+596
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "池拥有者",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ProviderCodexPoolActivityResponse>(
|
||||
`/v1/providers/${providerId}/codex-pool-activity`,
|
||||
{ limit: String(limit) },
|
||||
),
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
data: query.data ?? EMPTY,
|
||||
};
|
||||
}
|
||||
@@ -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 ? (
|
||||
<ProviderPoolActivitySection
|
||||
provider={provider}
|
||||
providerCounts={poolActivity.provider_counts}
|
||||
recentRequests={poolActivity.recent_requests}
|
||||
topAgents={poolActivity.top_agents}
|
||||
statsSampleSize={poolActivity.stats_sample_size}
|
||||
fetching={poolActivityFetching}
|
||||
onRefresh={() => void refreshPoolActivity()}
|
||||
providerByName={providerByName}
|
||||
statusByName={statusByName}
|
||||
quotaByName={quotaByName}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showApiKey ? (
|
||||
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<h3 className="text-sm font-medium">{t("detail.apiKeySection")}</h3>
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-md border bg-background/70 px-2 py-1">
|
||||
<p className="text-[9px] font-medium uppercase tracking-wide text-muted-foreground xl:text-[10px]">
|
||||
{label}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[13px] font-semibold leading-tight tabular-nums xl:text-sm">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProviderPoolActivitySectionProps {
|
||||
provider: ProviderData;
|
||||
providerCounts: CodexPoolProviderCount[];
|
||||
recentRequests: CodexPoolRecentRequest[];
|
||||
topAgents: ProviderCodexPoolAgentCount[];
|
||||
statsSampleSize: number;
|
||||
fetching: boolean;
|
||||
onRefresh: () => void;
|
||||
providerByName: Map<string, ProviderData>;
|
||||
statusByName: Map<string, { availability: ChatGPTOAuthAvailability }>;
|
||||
quotaByName: Map<string, ChatGPTOAuthProviderQuota | null>;
|
||||
}
|
||||
|
||||
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<CodexPoolEntry[]>(() => {
|
||||
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 (
|
||||
<section className="space-y-3 rounded-lg border p-3 sm:p-4 overflow-hidden">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-medium">{t("detail.poolActivityTitle")}</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1.5 px-2"
|
||||
onClick={onRefresh}
|
||||
disabled={fetching}
|
||||
aria-busy={fetching}
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", fetching && "animate-spin")} />
|
||||
{t("detail.poolActivityRefresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detail.poolActivityDescription")}
|
||||
</p>
|
||||
|
||||
{/* Summary stats */}
|
||||
<div className="grid gap-1.5 grid-cols-2 sm:grid-cols-4">
|
||||
<MonitorStat
|
||||
label={ta("chatgptOAuthRouting.metrics.poolSize")}
|
||||
value={String(entries.length)}
|
||||
/>
|
||||
<MonitorStat
|
||||
label={ta("chatgptOAuthRouting.metrics.observedSample")}
|
||||
value={String(statsSampleSize)}
|
||||
/>
|
||||
<MonitorStat
|
||||
label={t("detail.poolActivityTopAgents")}
|
||||
value={String(topAgents.length)}
|
||||
/>
|
||||
<MonitorStat
|
||||
label={ta("chatgptOAuthRouting.metrics.failovers")}
|
||||
value={String(failoverCount)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Pool member cards */}
|
||||
{entries.length > 0 ? (
|
||||
<div className="grid auto-rows-min gap-2 [grid-template-columns:repeat(auto-fit,minmax(min(100%,12.25rem),1fr))]">
|
||||
{entries.map((entry) => (
|
||||
<CodexPoolMemberCard key={entry.name} entry={entry} showProviderLinks />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
title={t("detail.poolActivityEmpty")}
|
||||
description={t("detail.poolActivityEmptyDesc")}
|
||||
className="py-6"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recent requests */}
|
||||
{recentRequests.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{ta("chatgptOAuthRouting.sequenceTitle")}
|
||||
</h4>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{ta("chatgptOAuthRouting.recentRequestsCount", {
|
||||
count: recentRequests.length,
|
||||
})}
|
||||
</Badge>
|
||||
</div>
|
||||
<CodexPoolRecentRequestsList
|
||||
recentRequests={recentRequests}
|
||||
loading={fetching && recentRequests.length === 0}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Top agents */}
|
||||
{topAgents.length > 0 ? (
|
||||
<div className="space-y-1.5">
|
||||
<h4 className="text-xs font-medium text-muted-foreground">
|
||||
{t("detail.poolActivityTopAgentsTitle")}
|
||||
</h4>
|
||||
<ul className="space-y-1 list-none p-0 m-0" role="list">
|
||||
{topAgents.map((agent) => (
|
||||
<li
|
||||
key={agent.agent_id}
|
||||
className="flex items-center justify-between rounded-md border bg-muted/30 px-2.5 py-1.5 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{agent.agent_key ? (
|
||||
<Link
|
||||
to={`/agents/${agent.agent_id}/codex-pool`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{agent.agent_key}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{agent.agent_id.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
<Link
|
||||
to={`/agents/${agent.agent_id}/codex-pool`}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
aria-label={`View pool details for ${agent.agent_key || agent.agent_id}`}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
<span className="tabular-nums text-muted-foreground">
|
||||
{agent.request_count} {t("detail.poolActivityRequests")}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user