From 111e511e8a52c824025afb67664869103c672f50 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 13 May 2026 18:31:55 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E2=9C=A8=20feat(usage-service):=20persist?= =?UTF-8?q?=20CPA-Manager=20runtime=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a persisted CPA-Manager configuration model with SQLite storage and a management API for reading and saving runtime settings. Use the saved configuration to control collector startup, request monitoring enablement, poll interval, batch size, transport settings, and legacy setup fallback. Validate collector polling against CPA queue retention and enable CPA usage publishing only when request monitoring is active, reducing data loss risk during setup. Refs #78, #79, #80. --- usage-service/cmd/cpa-manager/main.go | 45 ++ usage-service/internal/collector/collector.go | 23 +- usage-service/internal/httpapi/server.go | 533 +++++++++++++++++- usage-service/internal/httpapi/server_test.go | 131 +++++ usage-service/internal/store/store.go | 64 +++ 5 files changed, 777 insertions(+), 19 deletions(-) diff --git a/usage-service/cmd/cpa-manager/main.go b/usage-service/cmd/cpa-manager/main.go index d83c4dc0c..da3388724 100644 --- a/usage-service/cmd/cpa-manager/main.go +++ b/usage-service/cmd/cpa-manager/main.go @@ -34,15 +34,28 @@ func main() { manager.Start(ctx, collector.RuntimeConfig{ CPAUpstreamURL: cfg.CPAUpstreamURL, ManagementKey: cfg.ManagementKey, + CollectorMode: cfg.CollectorMode, Queue: cfg.Queue, PopSide: cfg.PopSide, + BatchSize: cfg.BatchSize, + PollInterval: cfg.PollInterval, + TLSSkipVerify: cfg.TLSSkipVerify, }) + } else if managerCfg, ok, err := db.LoadManagerConfig(ctx); err == nil && ok && + managerCfg.CPAConnection.CPABaseURL != "" && managerCfg.CPAConnection.ManagementKey != "" { + if managerCollectorEnabled(managerCfg) { + manager.Start(ctx, runtimeConfigFromManagerConfig(managerCfg, cfg)) + } } else if setup, ok, err := db.LoadSetup(ctx); err == nil && ok { manager.Start(ctx, collector.RuntimeConfig{ CPAUpstreamURL: setup.CPAUpstreamURL, ManagementKey: setup.ManagementKey, + CollectorMode: cfg.CollectorMode, Queue: setup.Queue, PopSide: setup.PopSide, + BatchSize: cfg.BatchSize, + PollInterval: cfg.PollInterval, + TLSSkipVerify: cfg.TLSSkipVerify, }) } else if err != nil { log.Printf("load setup: %v", err) @@ -69,3 +82,35 @@ func main() { log.Printf("shutdown: %v", err) } } + +func runtimeConfigFromManagerConfig(managerCfg store.ManagerConfig, base config.Config) collector.RuntimeConfig { + pollInterval := time.Duration(managerCfg.Collector.PollIntervalMS) * time.Millisecond + if pollInterval <= 0 { + pollInterval = base.PollInterval + } + batchSize := managerCfg.Collector.BatchSize + if batchSize <= 0 { + batchSize = base.BatchSize + } + return collector.RuntimeConfig{ + CPAUpstreamURL: managerCfg.CPAConnection.CPABaseURL, + ManagementKey: managerCfg.CPAConnection.ManagementKey, + CollectorMode: valueOr(managerCfg.Collector.CollectorMode, base.CollectorMode), + Queue: valueOr(managerCfg.Collector.Queue, base.Queue), + PopSide: valueOr(managerCfg.Collector.PopSide, base.PopSide), + BatchSize: batchSize, + PollInterval: pollInterval, + TLSSkipVerify: managerCfg.Collector.TLSSkipVerify, + } +} + +func valueOr(value string, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func managerCollectorEnabled(managerCfg store.ManagerConfig) bool { + return managerCfg.Collector.Enabled == nil || *managerCfg.Collector.Enabled +} diff --git a/usage-service/internal/collector/collector.go b/usage-service/internal/collector/collector.go index d52e278b3..912c5c69f 100644 --- a/usage-service/internal/collector/collector.go +++ b/usage-service/internal/collector/collector.go @@ -34,6 +34,9 @@ type RuntimeConfig struct { CollectorMode string Queue string PopSide string + BatchSize int + PollInterval time.Duration + TLSSkipVerify bool } type Manager struct { @@ -151,7 +154,7 @@ func (m *Manager) runRESP(ctx context.Context, cfg RuntimeConfig) { if ctx.Err() != nil { return } - client, err := resp.Dial(cfg.CPAUpstreamURL, m.base.TLSSkipVerify) + client, err := resp.Dial(cfg.CPAUpstreamURL, cfg.TLSSkipVerify) if err != nil { m.markError("connect", err) sleep(ctx, backoff) @@ -186,7 +189,7 @@ func (m *Manager) runRESP(ctx context.Context, cfg RuntimeConfig) { } func (m *Manager) consumeHTTP(ctx context.Context, cfg RuntimeConfig, client *httpqueue.Client) error { - ticker := time.NewTicker(m.pollInterval()) + ticker := time.NewTicker(m.pollInterval(cfg)) defer ticker.Stop() for { @@ -198,7 +201,7 @@ func (m *Manager) consumeHTTP(ctx context.Context, cfg RuntimeConfig, client *ht status.Transport = "http" status.LastError = "" }) - items, err := client.Pop(ctx, m.batchSize()) + items, err := client.Pop(ctx, m.batchSize(cfg)) if err != nil { return err } @@ -217,14 +220,14 @@ func (m *Manager) consumeHTTP(ctx context.Context, cfg RuntimeConfig, client *ht } func (m *Manager) consumeRESP(ctx context.Context, cfg RuntimeConfig, client *resp.Client, queue string, popSide string) error { - ticker := time.NewTicker(m.pollInterval()) + ticker := time.NewTicker(m.pollInterval(cfg)) defer ticker.Stop() for { if ctx.Err() != nil { return ctx.Err() } - items, err := client.Pop(queue, popSide, m.batchSize()) + items, err := client.Pop(queue, popSide, m.batchSize(cfg)) if err != nil { return err } @@ -350,14 +353,20 @@ func collectorMode(value string) string { } } -func (m *Manager) batchSize() int { +func (m *Manager) batchSize(cfg RuntimeConfig) int { + if cfg.BatchSize > 0 { + return cfg.BatchSize + } if m.base.BatchSize <= 0 { return 100 } return m.base.BatchSize } -func (m *Manager) pollInterval() time.Duration { +func (m *Manager) pollInterval(cfg RuntimeConfig) time.Duration { + if cfg.PollInterval > 0 { + return cfg.PollInterval + } if m.base.PollInterval <= 0 { return 500 * time.Millisecond } diff --git a/usage-service/internal/httpapi/server.go b/usage-service/internal/httpapi/server.go index 78fb0b989..6f022032b 100644 --- a/usage-service/internal/httpapi/server.go +++ b/usage-service/internal/httpapi/server.go @@ -5,6 +5,7 @@ import ( "embed" "encoding/json" "errors" + "fmt" "io" "mime" "net/http" @@ -48,10 +49,29 @@ const modelPriceSyncSource = "litellm" var modelPriceSyncURL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json" type setupRequest struct { - CPAUpstreamURL string `json:"cpaBaseUrl"` - ManagementKey string `json:"managementKey"` - Queue string `json:"queue"` - PopSide string `json:"popSide"` + CPAUpstreamURL string `json:"cpaBaseUrl"` + ManagementKey string `json:"managementKey"` + CollectorMode string `json:"collectorMode"` + Queue string `json:"queue"` + PopSide string `json:"popSide"` + BatchSize int `json:"batchSize"` + PollIntervalMS int `json:"pollIntervalMs"` + QueryLimit int `json:"queryLimit"` + TLSSkipVerify bool `json:"tlsSkipVerify"` + EnsureUsageStatisticsEnabled *bool `json:"ensureUsageStatisticsEnabled"` + RequestMonitoringEnabled *bool `json:"requestMonitoringEnabled"` +} + +type managerConfigResponse struct { + Config store.ManagerConfig `json:"config"` + Source string `json:"source"` + CPAUsage *cpaUsageConfig `json:"cpaUsage,omitempty"` +} + +type cpaUsageConfig struct { + UsageStatisticsEnabled bool `json:"usageStatisticsEnabled"` + RedisUsageQueueRetentionSeconds int `json:"redisUsageQueueRetentionSeconds"` + RetentionSourceDefault bool `json:"retentionSourceDefault"` } type modelPricesRequest struct { @@ -76,6 +96,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/health", s.withCORS(s.handleHealth)) mux.HandleFunc("/status", s.withCORS(s.handleStatus)) mux.HandleFunc("/usage-service/info", s.withCORS(s.handleInfo)) + mux.HandleFunc("/usage-service/config", s.withCORS(s.handleManagerConfig)) mux.HandleFunc("/setup", s.withCORS(s.handleSetup)) mux.HandleFunc("/management.html", s.handlePanel) mux.HandleFunc("/", s.handleRoot) @@ -92,7 +113,8 @@ func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { s.withCORS(s.handleModelPrices)(w, r) return } - if strings.HasPrefix(r.URL.Path, "/v0/management/usage") { + cleanUsagePath := strings.TrimRight(r.URL.Path, "/") + if cleanUsagePath == "/v0/management/usage" || strings.HasPrefix(cleanUsagePath, "/v0/management/usage/") { s.withCORS(s.handleUsage)(w, r) return } @@ -155,6 +177,118 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleManagerConfig(w http.ResponseWriter, r *http.Request) { + if !s.authorizeIfConfigured(w, r) { + return + } + + switch r.Method { + case http.MethodGet: + cfg, source, _, err := s.resolveManagerConfigWithSource(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + var cpaUsage *cpaUsageConfig + if cfg.CPAConnection.CPABaseURL != "" && cfg.CPAConnection.ManagementKey != "" { + if usageCfg, err := fetchCPAUsageConfig( + r.Context(), + cfg.CPAConnection.CPABaseURL, + cfg.CPAConnection.ManagementKey, + ); err == nil { + cpaUsage = &usageCfg + } + } + writeJSON(w, http.StatusOK, managerConfigResponse{ + Config: cfg, + Source: string(source), + CPAUsage: cpaUsage, + }) + case http.MethodPut: + var req struct { + Config store.ManagerConfig `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + current, source, _, err := s.resolveManagerConfigWithSource(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + next := s.mergeSubmittedManagerConfig(current, req.Config) + if source == setupSourceEnv && managerConfigConnectionDiffers(current, next) { + writeError(w, http.StatusConflict, errors.New("connection setup is managed by environment variables")) + return + } + if next.CPAConnection.CPABaseURL != "" || next.CPAConnection.ManagementKey != "" { + if next.CPAConnection.CPABaseURL == "" || next.CPAConnection.ManagementKey == "" { + writeError(w, http.StatusBadRequest, errors.New("cpaBaseUrl and managementKey are required")) + return + } + if err := validateManagementAPI( + r.Context(), + next.CPAConnection.CPABaseURL, + next.CPAConnection.ManagementKey, + ); err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + if managerCollectorEnabled(next) { + if err := validateCollectorAgainstCPA(r.Context(), next); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + if err := setCPAUsageStatisticsEnabled( + r.Context(), + next.CPAConnection.CPABaseURL, + next.CPAConnection.ManagementKey, + true, + ); err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + } + } else if managerCollectorEnabled(next) { + writeError(w, http.StatusBadRequest, errors.New("cpaBaseUrl and managementKey are required when request monitoring is enabled")) + return + } + if next.CPAConnection.CPABaseURL == "" || next.CPAConnection.ManagementKey == "" { + if err := s.store.SaveManagerConfig(r.Context(), next); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + s.collector.Stop() + writeJSON(w, http.StatusOK, managerConfigResponse{ + Config: next, + Source: string(setupSourceDB), + }) + return + } + if err := s.store.SaveManagerConfig(r.Context(), next); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + setup := setupFromManagerConfig(next) + if err := s.store.SaveSetup(r.Context(), setup); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if managerCollectorEnabled(next) { + s.collector.Start(context.Background(), runtimeConfigFromManagerConfig(next)) + } else { + s.collector.Stop() + } + writeJSON(w, http.StatusOK, managerConfigResponse{ + Config: next, + Source: string(setupSourceDB), + }) + default: + methodNotAllowed(w) + } +} + func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { methodNotAllowed(w) @@ -167,12 +301,18 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { } req.CPAUpstreamURL = normalizeBaseURL(req.CPAUpstreamURL) req.ManagementKey = strings.TrimSpace(req.ManagementKey) + req.CollectorMode = collectorMode(req.CollectorMode) if req.Queue == "" { req.Queue = s.cfg.Queue } if req.PopSide == "" { req.PopSide = s.cfg.PopSide } + req.PopSide = normalizePopSide(req.PopSide, s.cfg.PopSide) + req.BatchSize = positiveOrDefault(req.BatchSize, s.cfg.BatchSize, 100) + req.PollIntervalMS = positiveOrDefault(req.PollIntervalMS, int(s.cfg.PollInterval/time.Millisecond), 500) + req.QueryLimit = positiveOrDefault(req.QueryLimit, s.cfg.QueryLimit, 50000) + requestMonitoringEnabled := setupRequestMonitoringEnabled(req) if req.CPAUpstreamURL == "" || req.ManagementKey == "" { writeError(w, http.StatusBadRequest, errors.New("cpaBaseUrl and managementKey are required")) return @@ -203,6 +343,39 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { return } } + managerCfg := s.defaultManagerConfig() + if existingManagerCfg, _, ok, err := s.resolveManagerConfigWithSource(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } else if ok { + managerCfg = existingManagerCfg + } + managerCfg.CPAConnection.CPABaseURL = req.CPAUpstreamURL + managerCfg.CPAConnection.ManagementKey = req.ManagementKey + managerCfg.Collector.Enabled = boolPtr(requestMonitoringEnabled) + managerCfg.Collector.CollectorMode = req.CollectorMode + managerCfg.Collector.Queue = req.Queue + managerCfg.Collector.PopSide = req.PopSide + managerCfg.Collector.BatchSize = req.BatchSize + managerCfg.Collector.PollIntervalMS = req.PollIntervalMS + managerCfg.Collector.QueryLimit = req.QueryLimit + managerCfg.Collector.TLSSkipVerify = req.TLSSkipVerify + if requestMonitoringEnabled { + if err := validateCollectorAgainstCPA(r.Context(), managerCfg); err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + } + ensureUsageStatisticsEnabled := requestMonitoringEnabled + if req.EnsureUsageStatisticsEnabled != nil { + ensureUsageStatisticsEnabled = requestMonitoringEnabled && *req.EnsureUsageStatisticsEnabled + } + if ensureUsageStatisticsEnabled { + if err := setCPAUsageStatisticsEnabled(r.Context(), req.CPAUpstreamURL, req.ManagementKey, true); err != nil { + writeError(w, http.StatusBadGateway, err) + return + } + } setup := store.Setup{ CPAUpstreamURL: req.CPAUpstreamURL, ManagementKey: req.ManagementKey, @@ -213,12 +386,15 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, err) return } - s.collector.Start(context.Background(), collector.RuntimeConfig{ - CPAUpstreamURL: setup.CPAUpstreamURL, - ManagementKey: setup.ManagementKey, - Queue: setup.Queue, - PopSide: setup.PopSide, - }) + if err := s.store.SaveManagerConfig(r.Context(), managerCfg); err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + if requestMonitoringEnabled { + s.collector.Start(context.Background(), runtimeConfigFromManagerConfig(managerCfg)) + } else { + s.collector.Stop() + } writeJSON(w, http.StatusOK, map[string]any{"ok": true, "upstream": setup.CPAUpstreamURL}) } @@ -600,6 +776,11 @@ func (s *Server) resolveSetupWithSource(ctx context.Context) (store.Setup, setup PopSide: s.cfg.PopSide, }, setupSourceEnv, true, nil } + if managerCfg, _, ok, err := s.resolveManagerConfigWithSource(ctx); err != nil { + return store.Setup{}, setupSourceNone, false, err + } else if ok && managerCfg.CPAConnection.CPABaseURL != "" && managerCfg.CPAConnection.ManagementKey != "" { + return setupFromManagerConfig(managerCfg), setupSourceDB, true, nil + } setup, ok, err := s.store.LoadSetup(ctx) if !ok || err != nil { return setup, setupSourceNone, ok, err @@ -607,6 +788,47 @@ func (s *Server) resolveSetupWithSource(ctx context.Context) (store.Setup, setup return setup, setupSourceDB, true, nil } +func (s *Server) resolveManagerConfigWithSource(ctx context.Context) (store.ManagerConfig, setupSource, bool, error) { + cfg := s.defaultManagerConfig() + source := setupSourceNone + found := false + + if saved, ok, err := s.store.LoadManagerConfig(ctx); err != nil { + return cfg, source, false, err + } else if ok { + cfg = s.mergeSubmittedManagerConfig(cfg, saved) + source = setupSourceDB + found = true + } + + if setup, ok, err := s.store.LoadSetup(ctx); err != nil { + return cfg, source, false, err + } else if ok && cfg.CPAConnection.CPABaseURL == "" && cfg.CPAConnection.ManagementKey == "" { + cfg.CPAConnection.CPABaseURL = normalizeBaseURL(setup.CPAUpstreamURL) + cfg.CPAConnection.ManagementKey = setup.ManagementKey + cfg.Collector.Queue = valueOr(setup.Queue, cfg.Collector.Queue) + cfg.Collector.PopSide = normalizePopSide(setup.PopSide, cfg.Collector.PopSide) + source = setupSourceDB + found = true + } + + if s.cfg.CPAUpstreamURL != "" && s.cfg.ManagementKey != "" { + cfg.CPAConnection.CPABaseURL = normalizeBaseURL(s.cfg.CPAUpstreamURL) + cfg.CPAConnection.ManagementKey = s.cfg.ManagementKey + cfg.Collector.CollectorMode = collectorMode(s.cfg.CollectorMode) + cfg.Collector.Queue = valueOr(s.cfg.Queue, cfg.Collector.Queue) + cfg.Collector.PopSide = normalizePopSide(s.cfg.PopSide, cfg.Collector.PopSide) + cfg.Collector.BatchSize = positiveOrDefault(s.cfg.BatchSize, cfg.Collector.BatchSize, 100) + cfg.Collector.PollIntervalMS = positiveOrDefault(int(s.cfg.PollInterval/time.Millisecond), cfg.Collector.PollIntervalMS, 500) + cfg.Collector.QueryLimit = positiveOrDefault(s.cfg.QueryLimit, cfg.Collector.QueryLimit, 50000) + cfg.Collector.TLSSkipVerify = s.cfg.TLSSkipVerify + source = setupSourceEnv + found = true + } + + return cfg, source, found, nil +} + func setupDiffers(existing store.Setup, req setupRequest) bool { return normalizeBaseURL(existing.CPAUpstreamURL) != req.CPAUpstreamURL || existing.ManagementKey != req.ManagementKey || @@ -614,6 +836,137 @@ func setupDiffers(existing store.Setup, req setupRequest) bool { existing.PopSide != req.PopSide } +func setupFromManagerConfig(cfg store.ManagerConfig) store.Setup { + return store.Setup{ + CPAUpstreamURL: cfg.CPAConnection.CPABaseURL, + ManagementKey: cfg.CPAConnection.ManagementKey, + Queue: cfg.Collector.Queue, + PopSide: cfg.Collector.PopSide, + } +} + +func runtimeConfigFromManagerConfig(cfg store.ManagerConfig) collector.RuntimeConfig { + return collector.RuntimeConfig{ + CPAUpstreamURL: cfg.CPAConnection.CPABaseURL, + ManagementKey: cfg.CPAConnection.ManagementKey, + CollectorMode: cfg.Collector.CollectorMode, + Queue: cfg.Collector.Queue, + PopSide: cfg.Collector.PopSide, + BatchSize: cfg.Collector.BatchSize, + PollInterval: time.Duration(cfg.Collector.PollIntervalMS) * time.Millisecond, + TLSSkipVerify: cfg.Collector.TLSSkipVerify, + } +} + +func (s *Server) defaultManagerConfig() store.ManagerConfig { + pollIntervalMS := int(s.cfg.PollInterval / time.Millisecond) + return store.ManagerConfig{ + Collector: store.ManagerCollectorConfig{ + Enabled: boolPtr(true), + CollectorMode: collectorMode(s.cfg.CollectorMode), + Queue: valueOr(s.cfg.Queue, "usage"), + PopSide: normalizePopSide(s.cfg.PopSide, "right"), + BatchSize: positiveOrDefault(s.cfg.BatchSize, 100, 100), + PollIntervalMS: positiveOrDefault(pollIntervalMS, 500, 500), + QueryLimit: positiveOrDefault(s.cfg.QueryLimit, 50000, 50000), + TLSSkipVerify: s.cfg.TLSSkipVerify, + }, + } +} + +func (s *Server) mergeSubmittedManagerConfig(base store.ManagerConfig, submitted store.ManagerConfig) store.ManagerConfig { + next := base + + if submitted.CPAConnection.CPABaseURL != "" || submitted.CPAConnection.ManagementKey != "" { + next.CPAConnection.CPABaseURL = normalizeBaseURL(submitted.CPAConnection.CPABaseURL) + next.CPAConnection.ManagementKey = strings.TrimSpace(submitted.CPAConnection.ManagementKey) + } + + if submitted.Collector.Enabled != nil { + next.Collector.Enabled = boolPtr(*submitted.Collector.Enabled) + } + next.Collector.CollectorMode = collectorMode(valueOr(submitted.Collector.CollectorMode, next.Collector.CollectorMode)) + next.Collector.Queue = valueOr(strings.TrimSpace(submitted.Collector.Queue), next.Collector.Queue) + next.Collector.PopSide = normalizePopSide(submitted.Collector.PopSide, next.Collector.PopSide) + next.Collector.BatchSize = positiveOrDefault(submitted.Collector.BatchSize, next.Collector.BatchSize, 100) + next.Collector.PollIntervalMS = positiveOrDefault(submitted.Collector.PollIntervalMS, next.Collector.PollIntervalMS, 500) + next.Collector.QueryLimit = positiveOrDefault(submitted.Collector.QueryLimit, next.Collector.QueryLimit, 50000) + next.Collector.TLSSkipVerify = submitted.Collector.TLSSkipVerify + + next.ExternalUsageService.Enabled = submitted.ExternalUsageService.Enabled + next.ExternalUsageService.ServiceBase = normalizeBaseURL(submitted.ExternalUsageService.ServiceBase) + if !next.ExternalUsageService.Enabled { + next.ExternalUsageService.ServiceBase = "" + } + + return next +} + +func managerConfigConnectionDiffers(left store.ManagerConfig, right store.ManagerConfig) bool { + return normalizeBaseURL(left.CPAConnection.CPABaseURL) != normalizeBaseURL(right.CPAConnection.CPABaseURL) || + left.CPAConnection.ManagementKey != right.CPAConnection.ManagementKey || + managerCollectorEnabled(left) != managerCollectorEnabled(right) || + left.Collector.CollectorMode != right.Collector.CollectorMode || + left.Collector.Queue != right.Collector.Queue || + left.Collector.PopSide != right.Collector.PopSide || + left.Collector.BatchSize != right.Collector.BatchSize || + left.Collector.PollIntervalMS != right.Collector.PollIntervalMS || + left.Collector.TLSSkipVerify != right.Collector.TLSSkipVerify +} + +func positiveOrDefault(value int, fallback int, hardDefault int) int { + if value > 0 { + return value + } + if fallback > 0 { + return fallback + } + return hardDefault +} + +func valueOr(value string, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func normalizePopSide(value string, fallback string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "left", "right": + return strings.ToLower(strings.TrimSpace(value)) + default: + if strings.ToLower(strings.TrimSpace(fallback)) == "left" { + return "left" + } + return "right" + } +} + +func collectorMode(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "http", "resp": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "auto" + } +} + +func boolPtr(value bool) *bool { + return &value +} + +func managerCollectorEnabled(cfg store.ManagerConfig) bool { + return cfg.Collector.Enabled == nil || *cfg.Collector.Enabled +} + +func setupRequestMonitoringEnabled(req setupRequest) bool { + if req.RequestMonitoringEnabled == nil { + return true + } + return *req.RequestMonitoringEnabled +} + func (s *Server) authorizeIfConfigured(w http.ResponseWriter, r *http.Request) bool { setup, ok, err := s.resolveSetup(r.Context()) if err != nil { @@ -675,6 +1028,24 @@ func (s *Server) writeCORS(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") } +func validateCollectorAgainstCPA(ctx context.Context, cfg store.ManagerConfig) error { + usageCfg, err := fetchCPAUsageConfig(ctx, cfg.CPAConnection.CPABaseURL, cfg.CPAConnection.ManagementKey) + if err != nil { + return err + } + retentionMS := usageCfg.RedisUsageQueueRetentionSeconds * 1000 + if retentionMS <= 0 { + return errors.New("CPA redis-usage-queue-retention-seconds must be greater than 0") + } + if cfg.Collector.PollIntervalMS > retentionMS { + return fmt.Errorf( + "pollIntervalMs must be less than or equal to CPA redis-usage-queue-retention-seconds (%d seconds)", + usageCfg.RedisUsageQueueRetentionSeconds, + ) + } + return nil +} + func validateManagementAPI(ctx context.Context, baseURL string, key string) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+"/v0/management/config", nil) if err != nil { @@ -693,6 +1064,106 @@ func validateManagementAPI(ctx context.Context, baseURL string, key string) erro return errors.New("management API validation failed: " + res.Status) } +func fetchCPAUsageConfig(ctx context.Context, baseURL string, key string) (cpaUsageConfig, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, normalizeBaseURL(baseURL)+"/v0/management/config", nil) + if err != nil { + return cpaUsageConfig{}, err + } + req.Header.Set("Authorization", "Bearer "+key) + client := &http.Client{Timeout: 15 * time.Second} + res, err := client.Do(req) + if err != nil { + return cpaUsageConfig{}, err + } + defer res.Body.Close() + if res.StatusCode < 200 || res.StatusCode >= 300 { + return cpaUsageConfig{}, errors.New("management API config request failed: " + res.Status) + } + + var raw map[string]any + if err := json.NewDecoder(res.Body).Decode(&raw); err != nil { + return cpaUsageConfig{}, err + } + usageEnabled := readBoolField(raw, "usage-statistics-enabled", "usageStatisticsEnabled") + retention, hasRetention := readIntField(raw, "redis-usage-queue-retention-seconds", "redisUsageQueueRetentionSeconds") + if !hasRetention { + retention = 60 + } + return cpaUsageConfig{ + UsageStatisticsEnabled: usageEnabled, + RedisUsageQueueRetentionSeconds: retention, + RetentionSourceDefault: !hasRetention, + }, nil +} + +func setCPAUsageStatisticsEnabled(ctx context.Context, baseURL string, key string, enabled bool) error { + payload := map[string]bool{"value": enabled} + data, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext( + ctx, + http.MethodPut, + normalizeBaseURL(baseURL)+"/v0/management/usage-statistics-enabled", + strings.NewReader(string(data)), + ) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+key) + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 15 * time.Second} + res, err := client.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= 200 && res.StatusCode < 300 { + return nil + } + return errors.New("enable CPA usage statistics failed: " + res.Status) +} + +func readBoolField(raw map[string]any, keys ...string) bool { + for _, key := range keys { + value, ok := raw[key] + if !ok { + continue + } + switch typed := value.(type) { + case bool: + return typed + case string: + normalized := strings.ToLower(strings.TrimSpace(typed)) + return normalized == "1" || normalized == "true" || normalized == "yes" || normalized == "on" + } + } + return false +} + +func readIntField(raw map[string]any, keys ...string) (int, bool) { + for _, key := range keys { + value, ok := raw[key] + if !ok || value == nil { + continue + } + switch typed := value.(type) { + case float64: + return int(typed), true + case int: + return typed, true + case json.Number: + parsed, err := strconv.Atoi(typed.String()) + return parsed, err == nil + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(typed)) + return parsed, err == nil + } + } + return 0, false +} + func normalizeBaseURL(raw string) string { value := strings.TrimSpace(raw) if value == "" { @@ -714,9 +1185,47 @@ func writeJSON(w http.ResponseWriter, status int, value any) { } func writeError(w http.ResponseWriter, status int, err error) { - writeJSON(w, status, map[string]any{"error": err.Error()}) + writeJSON(w, status, map[string]any{"error": err.Error(), "code": usageServiceErrorCode(err)}) } func methodNotAllowed(w http.ResponseWriter) { writeError(w, http.StatusMethodNotAllowed, errors.New("method not allowed")) } + +func usageServiceErrorCode(err error) string { + message := err.Error() + switch { + case strings.Contains(message, "connection setup is managed by environment variables"): + return "connection_env_managed" + case strings.Contains(message, "cpaBaseUrl and managementKey are required when request monitoring is enabled"): + return "cpa_connection_required_for_monitoring" + case strings.Contains(message, "cpaBaseUrl and managementKey are required"): + return "cpa_connection_required" + case strings.Contains(message, "setup is managed by environment variables"): + return "setup_env_managed" + case strings.Contains(message, "invalid management key for existing setup"): + return "invalid_existing_management_key" + case strings.Contains(message, "invalid management key"): + return "invalid_management_key" + case strings.Contains(message, "usage service is not configured"): + return "usage_service_not_configured" + case strings.Contains(message, "CPA redis-usage-queue-retention-seconds must be greater than 0"): + return "cpa_usage_retention_invalid" + case strings.Contains(message, "pollIntervalMs must be less than or equal"): + return "poll_interval_exceeds_retention" + case strings.Contains(message, "management API validation failed"): + return "management_api_validation_failed" + case strings.Contains(message, "management API config request failed"): + return "management_api_config_failed" + case strings.Contains(message, "enable CPA usage statistics failed"): + return "enable_cpa_usage_statistics_failed" + case strings.Contains(message, "prices are required"): + return "prices_required" + case strings.Contains(message, "model price sync failed"): + return "model_price_sync_failed" + case strings.Contains(message, "method not allowed"): + return "method_not_allowed" + default: + return "request_failed" + } +} diff --git a/usage-service/internal/httpapi/server_test.go b/usage-service/internal/httpapi/server_test.go index 2c8078887..517a52ecf 100644 --- a/usage-service/internal/httpapi/server_test.go +++ b/usage-service/internal/httpapi/server_test.go @@ -267,6 +267,13 @@ func TestSetupAllowsKeyRotationForSameUpstreamWithValidNewKey(t *testing.T) { _, _ = w.Write([]byte(`{}`)) return } + if r.URL.Path == "/v0/management/usage-statistics-enabled" && + r.Method == http.MethodPut && + r.Header.Get("Authorization") == "Bearer rotated-key" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + return + } http.Error(w, "forbidden", http.StatusForbidden) })) t.Cleanup(upstream.Close) @@ -336,6 +343,130 @@ func TestSetupRejectsKeyRotationWhenSetupIsEnvironmentManaged(t *testing.T) { } } +func TestManagerConfigRejectsPollIntervalAboveRetention(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v0/management/config" && r.Header.Get("Authorization") == "Bearer management-key" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"usage-statistics-enabled":true,"redis-usage-queue-retention-seconds":1}`)) + return + } + http.Error(w, "forbidden", http.StatusForbidden) + })) + t.Cleanup(upstream.Close) + + handler := newTestHandler(t, upstream.URL, true) + body := bytes.NewBufferString(`{"config":{"cpaConnection":{"cpaBaseUrl":"` + upstream.URL + `","managementKey":"management-key"},"collector":{"collectorMode":"auto","queue":"usage","popSide":"right","batchSize":100,"pollIntervalMs":2000,"queryLimit":50000},"externalUsageService":{"enabled":true,"serviceBase":"http://usage.test"}}}`) + req := httptest.NewRequest(http.MethodPut, "/usage-service/config", body) + req.Header.Set("Authorization", "Bearer management-key") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Fatalf("save status = %d, body = %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), "pollIntervalMs") { + t.Fatalf("response body = %s", rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `"code":"poll_interval_exceeds_retention"`) { + t.Fatalf("response body = %s", rr.Body.String()) + } +} + +func TestManagerConfigReadsLegacySetup(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v0/management/config" && r.Header.Get("Authorization") == "Bearer management-key" { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"usage-statistics-enabled":true}`)) + return + } + http.Error(w, "forbidden", http.StatusForbidden) + })) + t.Cleanup(upstream.Close) + + handler := newTestHandler(t, upstream.URL, true) + req := httptest.NewRequest(http.MethodGet, "/usage-service/config", nil) + req.Header.Set("Authorization", "Bearer management-key") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("config status = %d, body = %s", rr.Code, rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `"source":"db"`) { + t.Fatalf("response body = %s", rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), upstream.URL) { + t.Fatalf("response body = %s", rr.Body.String()) + } + if !strings.Contains(rr.Body.String(), `"enabled":true`) { + t.Fatalf("response body = %s", rr.Body.String()) + } +} + +func TestSetupCanDisableRequestMonitoring(t *testing.T) { + configCalls := 0 + enableCalls := 0 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v0/management/config" && r.Header.Get("Authorization") == "Bearer management-key" { + configCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"usage-statistics-enabled":false,"redis-usage-queue-retention-seconds":1}`)) + return + } + if r.URL.Path == "/v0/management/usage-statistics-enabled" { + enableCalls++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + return + } + http.Error(w, "forbidden", http.StatusForbidden) + })) + t.Cleanup(upstream.Close) + + handler := newTestHandler(t, upstream.URL, false) + body := bytes.NewBufferString(`{"cpaBaseUrl":"` + upstream.URL + `","managementKey":"management-key","requestMonitoringEnabled":false,"ensureUsageStatisticsEnabled":false}`) + req := httptest.NewRequest(http.MethodPost, "/setup", body) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("setup status = %d, body = %s", rr.Code, rr.Body.String()) + } + if configCalls != 1 { + t.Fatalf("config calls = %d, want 1", configCalls) + } + if enableCalls != 0 { + t.Fatalf("enable calls = %d, want 0", enableCalls) + } + + statusReq := httptest.NewRequest(http.MethodGet, "/status", nil) + statusReq.Header.Set("Authorization", "Bearer management-key") + statusRR := httptest.NewRecorder() + handler.ServeHTTP(statusRR, statusReq) + + if statusRR.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", statusRR.Code, statusRR.Body.String()) + } + if !strings.Contains(statusRR.Body.String(), `"collector":"stopped"`) { + t.Fatalf("status body = %s", statusRR.Body.String()) + } + + configReq := httptest.NewRequest(http.MethodGet, "/usage-service/config", nil) + configReq.Header.Set("Authorization", "Bearer management-key") + configRR := httptest.NewRecorder() + handler.ServeHTTP(configRR, configReq) + + if configRR.Code != http.StatusOK { + t.Fatalf("config status = %d, body = %s", configRR.Code, configRR.Body.String()) + } + if !strings.Contains(configRR.Body.String(), `"enabled":false`) { + t.Fatalf("config body = %s", configRR.Body.String()) + } +} + func TestModelPricesSaveAndLoad(t *testing.T) { handler := newTestHandler(t, "http://example.test", true) body := bytes.NewBufferString(`{"prices":{"gpt-test":{"prompt":1.25,"completion":2.5,"cache":0.1}}}`) diff --git a/usage-service/internal/store/store.go b/usage-service/internal/store/store.go index 8e87a8bf2..1cea3275e 100644 --- a/usage-service/internal/store/store.go +++ b/usage-service/internal/store/store.go @@ -23,6 +23,34 @@ type Setup struct { PopSide string `json:"popSide,omitempty"` } +type ManagerConfig struct { + CPAConnection ManagerCPAConnectionConfig `json:"cpaConnection"` + Collector ManagerCollectorConfig `json:"collector"` + ExternalUsageService ManagerExternalUsageServiceConfig `json:"externalUsageService"` + UpdatedAtMS int64 `json:"updatedAtMs,omitempty"` +} + +type ManagerCPAConnectionConfig struct { + CPABaseURL string `json:"cpaBaseUrl"` + ManagementKey string `json:"managementKey,omitempty"` +} + +type ManagerCollectorConfig struct { + Enabled *bool `json:"enabled,omitempty"` + CollectorMode string `json:"collectorMode,omitempty"` + Queue string `json:"queue,omitempty"` + PopSide string `json:"popSide,omitempty"` + BatchSize int `json:"batchSize,omitempty"` + PollIntervalMS int `json:"pollIntervalMs,omitempty"` + QueryLimit int `json:"queryLimit,omitempty"` + TLSSkipVerify bool `json:"tlsSkipVerify,omitempty"` +} + +type ManagerExternalUsageServiceConfig struct { + Enabled bool `json:"enabled"` + ServiceBase string `json:"serviceBase,omitempty"` +} + type InsertResult struct { Inserted int `json:"inserted"` Skipped int `json:"skipped"` @@ -48,6 +76,8 @@ type Store struct { db *sql.DB } +const managerConfigKey = "manager_config_v1" + func Open(path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err @@ -232,6 +262,40 @@ func (s *Store) LoadSetup(ctx context.Context) (Setup, bool, error) { return setup, true, nil } +func (s *Store) SaveManagerConfig(ctx context.Context, cfg ManagerConfig) error { + cfg.UpdatedAtMS = time.Now().UnixMilli() + data, err := json.Marshal(cfg) + if err != nil { + return err + } + _, err = s.db.ExecContext( + ctx, + `insert into settings(key, value, updated_at_ms) + values(?, ?, ?) + on conflict(key) do update set value = excluded.value, updated_at_ms = excluded.updated_at_ms`, + managerConfigKey, + string(data), + cfg.UpdatedAtMS, + ) + return err +} + +func (s *Store) LoadManagerConfig(ctx context.Context) (ManagerConfig, bool, error) { + var raw string + err := s.db.QueryRowContext(ctx, `select value from settings where key = ?`, managerConfigKey).Scan(&raw) + if errors.Is(err, sql.ErrNoRows) { + return ManagerConfig{}, false, nil + } + if err != nil { + return ManagerConfig{}, false, err + } + var cfg ManagerConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + return ManagerConfig{}, false, err + } + return cfg, true, nil +} + func (s *Store) LoadModelPrices(ctx context.Context) (map[string]ModelPrice, error) { rows, err := s.db.QueryContext(ctx, `select model, prompt_per_1m, completion_per_1m, cache_per_1m, source, source_model_id, raw_json, From 5a6eef9e860ba29869b0a2fe7249ae70ed215a90 Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 13 May 2026 18:32:23 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E2=9C=A8=20feat(config):=20manage=20reques?= =?UTF-8?q?t=20monitoring=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CPA-Manager configuration controls for Usage Service runtime settings, request monitoring enablement, and collector tuning from the configuration page. Move external Usage Service setup out of System Info, add the hosted setup wizard, and expose monitoring availability so unavailable request monitoring has a clear path back to configuration. Localize the new setup, monitoring, and Usage Service error states across supported languages while keeping legacy Usage Service bootstrap data compatible. Refs #78, #79, #80. --- src/components/layout/MainLayout.tsx | 6 +- src/features/monitoring/hooks/useUsageData.ts | 19 +- src/hooks/useRequestMonitoringAvailability.ts | 113 ++++ src/i18n/locales/en.json | 114 +++- src/i18n/locales/ru.json | 119 +++- src/i18n/locales/zh-CN.json | 114 +++- src/i18n/locales/zh-TW.json | 119 +++- src/pages/ConfigPage.module.scss | 222 +++++++ src/pages/ConfigPage.tsx | 595 +++++++++++++++++- src/pages/LoginPage.module.scss | 394 ++++++++++++ src/pages/LoginPage.tsx | 533 ++++++++++++---- src/pages/MonitoringCenterPage.module.scss | 10 + src/pages/MonitoringCenterPage.tsx | 34 +- src/pages/SystemPage.module.scss | 48 -- src/pages/SystemPage.tsx | 197 ------ src/services/api/usageService.ts | 310 +++++++-- src/stores/useUsageServiceStore.ts | 10 +- 17 files changed, 2406 insertions(+), 551 deletions(-) create mode 100644 src/hooks/useRequestMonitoringAvailability.ts diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index eb99a2b02..b47f13ba7 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -32,6 +32,7 @@ import { useThemeStore, } from '@/stores'; import { triggerHeaderRefresh } from '@/hooks/useHeaderRefresh'; +import { useRequestMonitoringAvailability } from '@/hooks/useRequestMonitoringAvailability'; import { LANGUAGE_LABEL_KEYS, LANGUAGE_ORDER } from '@/utils/constants'; import { isSupportedLanguage } from '@/utils/language'; import type { Theme } from '@/types'; @@ -218,6 +219,7 @@ export function MainLayout() { const config = useConfigStore((state) => state.config); const fetchConfig = useConfigStore((state) => state.fetchConfig); const clearCache = useConfigStore((state) => state.clearCache); + const requestMonitoringAvailability = useRequestMonitoringAvailability(); const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); @@ -393,7 +395,9 @@ export function MainLayout() { { path: '/auth-files', label: t('nav.auth_files'), icon: sidebarIcons.authFiles }, { path: '/oauth', label: t('nav.oauth', { defaultValue: 'OAuth' }), icon: sidebarIcons.oauth }, { path: '/quota', label: t('nav.quota_management'), icon: sidebarIcons.quota }, - { path: '/monitoring', label: t('nav.monitoring_center'), icon: sidebarIcons.monitoring }, + ...(requestMonitoringAvailability.available + ? [{ path: '/monitoring', label: t('nav.monitoring_center'), icon: sidebarIcons.monitoring }] + : []), ...(config?.loggingToFile ? [{ path: '/logs', label: t('nav.logs'), icon: sidebarIcons.logs }] : []), diff --git a/src/features/monitoring/hooks/useUsageData.ts b/src/features/monitoring/hooks/useUsageData.ts index 652dd3d3a..799ddb0b7 100644 --- a/src/features/monitoring/hooks/useUsageData.ts +++ b/src/features/monitoring/hooks/useUsageData.ts @@ -1,5 +1,4 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { apiClient } from '@/services/api/client'; import { isUsageServiceId, normalizeUsageServiceBase, @@ -46,6 +45,7 @@ export function useUsageData(): UseUsageDataReturn { const [error, setError] = useState(''); const [lastRefreshedAt, setLastRefreshedAt] = useState(null); const [modelPrices, setModelPricesState] = useState>({}); + const [usageServiceAvailable, setUsageServiceAvailable] = useState(false); const requestIdRef = useRef(0); const resolveUsageServiceBase = useCallback(async (): Promise => { @@ -147,10 +147,15 @@ export function useUsageData(): UseUsageDataReturn { setError(''); try { - const payload = - usageServiceEnabled && usageServiceBase - ? await usageServiceApi.getUsage(usageServiceBase, managementKey) - : await apiClient.get('/usage'); + const serviceBase = await resolveUsageServiceBase(); + if (!serviceBase) { + setUsageServiceAvailable(false); + setUsage(null); + setLastRefreshedAt(null); + return; + } + setUsageServiceAvailable(true); + const payload = await usageServiceApi.getUsage(serviceBase, managementKey); if (requestIdRef.current !== requestId) return; setUsage(payload ?? null); setLastRefreshedAt(new Date()); @@ -162,7 +167,7 @@ export function useUsageData(): UseUsageDataReturn { setLoading(false); } } - }, [managementKey, usageServiceBase, usageServiceEnabled]); + }, [managementKey, resolveUsageServiceBase]); useEffect(() => { void loadModelPricesFromStorage(); @@ -193,7 +198,7 @@ export function useUsageData(): UseUsageDataReturn { error, lastRefreshedAt, modelPrices, - usageServiceAvailable: Boolean(usageServiceEnabled && usageServiceBase), + usageServiceAvailable, setModelPrices, syncModelPrices, exportUsage: exportUsageFromApi, diff --git a/src/hooks/useRequestMonitoringAvailability.ts b/src/hooks/useRequestMonitoringAvailability.ts new file mode 100644 index 000000000..6754b2950 --- /dev/null +++ b/src/hooks/useRequestMonitoringAvailability.ts @@ -0,0 +1,113 @@ +import { useEffect, useMemo, useState } from 'react'; +import { + isUsageServiceId, + normalizeUsageServiceBase, + usageServiceApi, +} from '@/services/api/usageService'; +import { useAuthStore, useUsageServiceStore } from '@/stores'; +import { detectApiBaseFromLocation } from '@/utils/connection'; + +export type RequestMonitoringUnavailableReason = + | 'checking' + | 'service_not_configured' + | 'service_unavailable' + | 'monitoring_disabled'; + +export interface RequestMonitoringAvailability { + checking: boolean; + available: boolean; + serviceBase: string; + reason: RequestMonitoringUnavailableReason | ''; +} + +export function useRequestMonitoringAvailability(): RequestMonitoringAvailability { + const apiBase = useAuthStore((state) => state.apiBase); + const managementKey = useAuthStore((state) => state.managementKey); + const usageServiceEnabled = useUsageServiceStore((state) => state.enabled); + const usageServiceBase = useUsageServiceStore((state) => state.serviceBase); + const usageServiceRevision = useUsageServiceStore((state) => state.revision); + const [state, setState] = useState({ + checking: true, + available: false, + serviceBase: '', + reason: 'checking', + }); + + const candidates = useMemo(() => { + return Array.from( + new Set( + [ + usageServiceEnabled && usageServiceBase ? usageServiceBase : '', + apiBase, + detectApiBaseFromLocation(), + ] + .map((value) => normalizeUsageServiceBase(value || '')) + .filter(Boolean) + ) + ); + }, [apiBase, usageServiceBase, usageServiceEnabled]); + + useEffect(() => { + let cancelled = false; + + const detect = async () => { + if (!managementKey || candidates.length === 0) { + setState({ + checking: false, + available: false, + serviceBase: '', + reason: 'service_not_configured', + }); + return; + } + + setState((current) => ({ ...current, checking: true, reason: 'checking' })); + const hasConfiguredUsageService = Boolean(usageServiceEnabled && usageServiceBase); + + for (const candidate of candidates) { + try { + const info = await usageServiceApi.getInfo(candidate); + if (!isUsageServiceId(info.service)) { + continue; + } + const response = await usageServiceApi.getManagerConfig(candidate, managementKey); + const collectorEnabled = response.config.collector?.enabled !== false; + const hasCPAConnection = Boolean( + response.config.cpaConnection?.cpaBaseUrl && + response.config.cpaConnection?.managementKey + ); + if (cancelled) return; + setState({ + checking: false, + available: collectorEnabled && hasCPAConnection, + serviceBase: candidate, + reason: !collectorEnabled + ? 'monitoring_disabled' + : hasCPAConnection + ? '' + : 'service_not_configured', + }); + return; + } catch { + // A regular CPA panel or an unreachable external Usage Service is handled below. + } + } + + if (cancelled) return; + setState({ + checking: false, + available: false, + serviceBase: '', + reason: hasConfiguredUsageService ? 'service_unavailable' : 'service_not_configured', + }); + }; + + void detect(); + + return () => { + cancelled = true; + }; + }, [candidates, managementKey, usageServiceBase, usageServiceEnabled, usageServiceRevision]); + + return state; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 373a1b9d4..3953bbabc 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -23,6 +23,10 @@ "connecting": "Connecting...", "connected": "Connected", "disconnected": "Disconnected", + "enabled": "Enabled", + "disabled": "Disabled", + "previous": "Previous", + "next": "Next", "connecting_status": "Connecting", "connected_status": "Connected", "disconnected_status": "Disconnected", @@ -83,6 +87,23 @@ "cpa_connection_hint": "Usage Service uses this URL to reach the CPA Management API and RESP usage queue.", "cpa_address_required": "Enter the CPA URL", "usage_service_mode_hint": "This panel is hosted by Usage Service. Enter the CPA API URL.", + "usage_service_address": "Usage Service URL", + "setup_title": "Setup Wizard", + "setup_steps": "Setup steps", + "step_count": "Step {{current}} / {{total}}", + "step_connection": "CPA Connection", + "step_auth": "Management Key", + "step_monitoring": "Request Monitoring", + "step_polling": "Polling Interval", + "step_review": "Review", + "request_monitoring_enabled": "Enable Request Monitoring", + "request_monitoring_enabled_hint": "Saving will automatically enable CPA usage statistics and start the Usage Service collector.", + "request_monitoring_disabled_hint": "The CPA connection will still be saved, but CPA usage statistics and the collector will not be enabled. If CPA usage statistics were enabled elsewhere, re-enabling the collector within the queue retention window may still collect retained events.", + "poll_interval_label": "Collection Polling Interval (ms)", + "poll_interval_hint": "Must be less than or equal to the CPA usage queue retention window. Saving will automatically enable CPA usage statistics.", + "poll_interval_invalid": "Enter a valid collection interval", + "show_key": "Show key", + "hide_key": "Hide key", "use_current_address": "Use Current URL", "remember_password_label": "Remember password", "management_key_label": "Management Key:", @@ -981,7 +1002,7 @@ "import_confirm_title": "Import Usage Data", "import_confirm_body": "{{name}} will be written into the Usage Service SQLite usage database. Duplicate events will be skipped automatically.", "import_legacy_warning": "Legacy export files are missing some request metadata, so account matching and detail accuracy may be lower than newly collected data.", - "import_export_requires_usage_service": "Import and export require Usage Service. Open the Docker-hosted panel or configure a Usage Service URL on the System page.", + "import_export_requires_usage_service": "Import and export require Usage Service. Open the Docker-hosted panel or configure a Usage Service URL under Configuration -> CPA-Manager Configuration.", "chart_line_label_1": "Line 1", "chart_line_label_2": "Line 2", "chart_line_label_3": "Line 3", @@ -1022,7 +1043,7 @@ "model_price_sync_success": "Synced {{count}} model prices from {{source}}", "model_price_sync_failed": "Failed to sync model prices", "model_price_sync_no_models": "No models available to sync", - "model_price_sync_requires_usage_service": "Price sync requires Usage Service. Open the Docker-hosted panel or configure a Usage Service URL on the System page.", + "model_price_sync_requires_usage_service": "Price sync requires Usage Service. Open the Docker-hosted panel or configure a Usage Service URL under Configuration -> CPA-Manager Configuration.", "model_price_model_required": "Please choose a model to set pricing", "cost_trend": "Cost Overview", "cost_axis_label": "Cost ($)", @@ -1120,6 +1141,7 @@ "open_usage": "Open Usage Stats", "open_logs": "Open Logs", "open_system": "Open System Info", + "open_manager_config": "Open CPA-Manager Configuration", "active_credentials": "Active Credentials", "unavailable_auth_meta": "{{count}} credentials are currently unavailable", "auth_health_meta": "Credential metadata is mapped back to usage details", @@ -1133,11 +1155,17 @@ "top_channel": "Top Channel", "usage_disabled_title": "Usage statistics are disabled", "usage_disabled_body": "Request Monitoring depends on Usage Service or compatible usage data. Enable usage statistics and verify the usage service is running to populate runtime metrics here.", + "request_monitoring_disabled_title": "Request monitoring is disabled", + "request_monitoring_unavailable_title": "Request monitoring service unavailable", + "request_monitoring_disabled_body": "CPA-Manager is not currently running the request collector. Enable request monitoring in CPA-Manager Configuration when you need this page.", + "request_monitoring_service_unavailable_body": "The configured Usage Service cannot be reached. Check the service URL, network, and deployment status.", + "request_monitoring_not_configured_body": "No available Usage Service was detected. The CPA-hosted panel mode requires a deployed and configured Usage Service.", "empty_diagnostics_body": "If CPA has produced requests but this view is still empty, confirm usage publishing is enabled, check Usage Service /status, and make sure only one Usage Service consumes the same CPA usage queue.", "zero_token_notice_title": "Zero-token model calls detected", "zero_token_notice_body": "There are {{count}} zero-token model calls in the current range ({{models}}). These models are usually not billed purely by tokens, so cost remains a partial estimate.", "model_calls": "Model Calls", "total_calls": "Total Calls", + "success_rate": "Success Rate", "success_calls": "Successful Calls", "failure_calls": "Failed Calls", "total_tokens": "Total Tokens", @@ -1529,10 +1557,50 @@ }, "tabs": { "visual": "Visual Editor", - "source": "Source File Editor" + "source": "Source File Editor", + "manager": "CPA-Manager Configuration" + }, + "manager": { + "title": "CPA-Manager Configuration", + "description": "Manage CPA-Manager runtime settings separately from CPA configuration.", + "boundary_hint": "These settings are saved for CPAM itself. CPA usage statistics and queue retention still belong to CPA configuration.", + "load_failed": "Failed to load CPA-Manager configuration", + "save_success": "CPA-Manager configuration saved", + "number_invalid": "{{label}} must be a positive integer", + "service_base_required": "Enter the standalone Usage Service URL first", + "poll_interval_retention_error": "The collection polling interval must be less than or equal to the CPA usage queue retention window", + "runtime_title": "Runtime", + "runtime_embedded": "Same-origin embedded Usage Service", + "runtime_external": "Standalone Usage Service", + "runtime_embedded_hint": "This panel is hosted by Usage Service on the same origin. No external service URL is required.", + "runtime_external_hint": "This panel is hosted by CPA. Enter the separately deployed Usage Service URL when request monitoring is needed.", + "service_base": "Usage Service URL", + "external_service_base": "Standalone Usage Service URL", + "external_service_hint": "The full Docker setup usually does not need this. CPA-hosted panel mode needs the standalone deployment URL.", + "request_monitoring_title": "Request Monitoring", + "request_monitoring_hint": "When enabled, CPA usage statistics are enabled and the collector starts. When disabled, the CPAM collector stops but CPA usage statistics and the CPA queue are not cleared.", + "request_monitoring_enabled": "Enable request monitoring", + "request_monitoring_dependency": "Configure a reachable Usage Service before enabling request monitoring.", + "request_monitoring_queue_note": "If CPA usage statistics remain enabled, re-enabling request monitoring within the CPA queue retention window may collect data retained while the CPAM collector was stopped.", + "collector_mode": "Collection Mode", + "collector_mode_auto": "Auto", + "collector_mode_http": "HTTP", + "collector_mode_resp": "RESP", + "poll_interval_ms": "Collection Polling Interval (ms)", + "poll_interval_hint": "Must be less than or equal to the CPA usage queue retention window. Current validation uses {{seconds}} seconds.", + "batch_size": "Max Items per Batch", + "query_limit": "Usage Query Limit", + "config_source": "Config Source", + "config_source_env": "Environment variables", + "config_source_db": "SQLite", + "config_source_none": "Not saved", + "cpa_usage_enabled": "CPA Usage Statistics", + "cpa_retention": "CPA Queue Retention", + "cpa_retention_value": "{{seconds}}s" }, "visual": { "notice": "Visual mode covers common fields. Review or edit unsupported config.yaml entries in source mode.", + "validation_blocked_short": "Fix errors", "quick_jump": "Quick Jump", "sections": { "server": { @@ -1703,6 +1771,24 @@ } } }, + "usage_service_errors": { + "request_failed": "Usage Service request failed", + "connection_env_managed": "Connection setup is managed by environment variables and cannot be changed from the panel.", + "cpa_connection_required": "CPA URL and Management Key are required.", + "cpa_connection_required_for_monitoring": "CPA URL and Management Key are required when request monitoring is enabled.", + "management_api_validation_failed": "CPA Management API validation failed. Check the CPA URL and Management Key.", + "management_api_config_failed": "Failed to read CPA configuration from the Management API.", + "cpa_usage_retention_invalid": "CPA usage queue retention must be greater than 0.", + "poll_interval_exceeds_retention": "The collection polling interval must be less than or equal to the CPA usage queue retention window.", + "enable_cpa_usage_statistics_failed": "Failed to enable CPA usage statistics.", + "setup_env_managed": "Setup is managed by environment variables.", + "invalid_existing_management_key": "The existing setup rejected this Management Key.", + "invalid_management_key": "Invalid Management Key.", + "usage_service_not_configured": "Usage Service is not configured.", + "prices_required": "Model prices are required.", + "model_price_sync_failed": "Model price sync failed.", + "method_not_allowed": "This operation is not supported by the current endpoint." + }, "quota_management": { "title": "Quota Management", "description": "Monitor OAuth quota status for Antigravity, Codex, and Gemini CLI credentials.", @@ -1720,27 +1806,6 @@ "sort_plan_desc": "Plan high to low", "sort_plan_asc": "Plan low to high" }, - "usage_service": { - "title": "External Usage Service", - "description": "When the panel is loaded by CPA, configure a separately deployed Usage Service to read persisted SQLite usage data. Docker embedded mode usually does not need this.", - "enable": "Enable external Usage Service", - "url_label": "Usage Service URL", - "url_placeholder": "Eg: http://127.0.0.1:18317", - "url_required": "Enter the Usage Service URL first", - "check_status": "Check status", - "save_and_connect": "Save and connect", - "saved": "Usage Service connected and saved", - "save_failed": "Failed to save Usage Service", - "status_failed": "Failed to check Usage Service status", - "disabled_saved": "External Usage Service disabled", - "collector": "Collector", - "mode": "Collection mode", - "transport": "Transport", - "queue": "Queue", - "events": "Events", - "last_consumed": "Last consumed", - "last_error": "Last error" - }, "system_info": { "title": "Management Center Info", "about_title": "CLI Proxy API Management Center", @@ -1837,6 +1902,7 @@ "data_refreshed": "Data refreshed successfully", "connection_required": "Please establish connection first", "refresh_failed": "Refresh failed", + "save_failed": "Save failed", "update_failed": "Update failed", "add_failed": "Add failed", "delete_failed": "Delete failed", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index 19694bc56..a67d9a0f2 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -22,6 +22,10 @@ "connecting": "Подключение...", "connected": "Подключено", "disconnected": "Отключено", + "enabled": "Включено", + "disabled": "Отключено", + "previous": "Назад", + "next": "Далее", "connecting_status": "Подключение", "connected_status": "Подключено", "disconnected_status": "Отключено", @@ -77,6 +81,28 @@ "custom_connection_label": "Пользовательский URL подключения:", "custom_connection_placeholder": "Напр.: https://example.com:8317", "custom_connection_hint": "По умолчанию используется текущий URL. При необходимости замените его.", + "cpa_connection_label": "URL подключения CPA", + "cpa_connection_placeholder": "Напр.: http://127.0.0.1:8317", + "cpa_connection_hint": "Usage Service использует этот URL для доступа к CPA Management API и очереди RESP usage.", + "cpa_address_required": "Введите URL CPA", + "usage_service_mode_hint": "Эта панель размещена через Usage Service. Введите URL CPA API.", + "usage_service_address": "URL Usage Service", + "setup_title": "Мастер настройки", + "setup_steps": "Шаги настройки", + "step_count": "Шаг {{current}} / {{total}}", + "step_connection": "Подключение CPA", + "step_auth": "Ключ управления", + "step_monitoring": "Мониторинг запросов", + "step_polling": "Интервал опроса", + "step_review": "Проверка", + "request_monitoring_enabled": "Включить мониторинг запросов", + "request_monitoring_enabled_hint": "При сохранении CPA usage statistics будут включены автоматически, а сборщик Usage Service будет запущен.", + "request_monitoring_disabled_hint": "Подключение CPA будет сохранено, но CPA usage statistics и сборщик не будут включены. Если CPA usage statistics включены в другом месте, повторное включение сборщика в пределах окна хранения очереди может собрать сохранённые события.", + "poll_interval_label": "Интервал опроса сборщика (мс)", + "poll_interval_hint": "Должен быть не больше окна хранения очереди CPA usage. При сохранении CPA usage statistics будут включены автоматически.", + "poll_interval_invalid": "Введите корректный интервал сбора", + "show_key": "Показать ключ", + "hide_key": "Скрыть ключ", "use_current_address": "Использовать текущий URL", "remember_password_label": "Запомнить пароль", "management_key_label": "Ключ управления:", @@ -972,7 +998,7 @@ "import_confirm_title": "Импорт данных использования", "import_confirm_body": "{{name}} будет записан в базу SQLite Usage Service. Дублирующиеся события будут пропущены автоматически.", "import_legacy_warning": "В старых файлах экспорта отсутствует часть метаданных запросов, поэтому сопоставление аккаунтов и точность деталей могут быть ниже, чем у новых данных.", - "import_export_requires_usage_service": "Для импорта и экспорта нужен Usage Service. Откройте Docker-панель или укажите URL Usage Service на странице системы.", + "import_export_requires_usage_service": "Для импорта и экспорта нужен Usage Service. Откройте Docker-панель или укажите URL Usage Service в Configuration -> CPA-Manager Configuration.", "chart_line_label_1": "Линия 1", "chart_line_label_2": "Линия 2", "chart_line_label_3": "Линия 3", @@ -1013,7 +1039,7 @@ "model_price_sync_success": "Синхронизировано {{count}} цен моделей из {{source}}", "model_price_sync_failed": "Не удалось синхронизировать цены моделей", "model_price_sync_no_models": "Нет моделей для синхронизации", - "model_price_sync_requires_usage_service": "Для синхронизации цен нужен Usage Service. Откройте Docker-панель или укажите URL Usage Service на странице системы.", + "model_price_sync_requires_usage_service": "Для синхронизации цен нужен Usage Service. Откройте Docker-панель или укажите URL Usage Service в Configuration -> CPA-Manager Configuration.", "model_price_model_required": "Выберите модель для задания цены", "cost_trend": "Обзор стоимости", "cost_axis_label": "Стоимость ($)", @@ -1111,6 +1137,7 @@ "open_usage": "Открыть статистику", "open_logs": "Открыть логи", "open_system": "Открыть сведения о системе", + "open_manager_config": "Открыть конфигурацию CPA-Manager", "active_credentials": "Активные credentials", "unavailable_auth_meta": "{{count}} credentials сейчас недоступны", "auth_health_meta": "Метаданные credential сопоставлены с usage details", @@ -1124,11 +1151,17 @@ "top_channel": "Топ-канал", "usage_disabled_title": "Статистика использования отключена", "usage_disabled_body": "Мониторинг запросов зависит от Usage Service или совместимых данных использования. Включите статистику и убедитесь, что служба использования запущена.", + "request_monitoring_disabled_title": "Мониторинг запросов отключён", + "request_monitoring_unavailable_title": "Сервис мониторинга запросов недоступен", + "request_monitoring_disabled_body": "Сборщик запросов CPA-Manager сейчас не запущен. Включите мониторинг запросов в конфигурации CPA-Manager, если нужна эта страница.", + "request_monitoring_service_unavailable_body": "Настроенный Usage Service сейчас недоступен. Проверьте URL сервиса, сеть и состояние развёртывания.", + "request_monitoring_not_configured_body": "Доступный Usage Service не обнаружен. Для панели, размещённой CPA, сначала нужно развернуть и настроить Usage Service.", "empty_diagnostics_body": "Если CPA уже обрабатывал запросы, а этот экран всё ещё пуст, включите публикацию usage, проверьте Usage Service /status и убедитесь, что одну очередь CPA usage потребляет только один Usage Service.", "zero_token_notice_title": "Обнаружены модели с нулевыми токенами", "zero_token_notice_body": "В текущем диапазоне есть {{count}} вызовов моделей с нулевыми токенами ({{models}}). Такие модели обычно не тарифицируются только по токенам, поэтому стоимость остаётся частичной оценкой.", "model_calls": "Вызовы моделей", "total_calls": "Всего вызовов", + "success_rate": "Успешность", "success_calls": "Успешных вызовов", "failure_calls": "Ошибочных вызовов", "total_tokens": "Всего токенов", @@ -1520,10 +1553,50 @@ }, "tabs": { "visual": "Визуальный редактор", - "source": "Редактор файла" + "source": "Редактор файла", + "manager": "Конфигурация CPA-Manager" + }, + "manager": { + "title": "Конфигурация CPA-Manager", + "description": "Управляйте настройками CPA-Manager отдельно от конфигурации CPA.", + "boundary_hint": "Эти настройки сохраняются для самого CPAM. CPA usage statistics и хранение очереди остаются частью конфигурации CPA.", + "load_failed": "Не удалось загрузить конфигурацию CPA-Manager", + "save_success": "Конфигурация CPA-Manager сохранена", + "number_invalid": "{{label}} должен быть положительным целым числом", + "service_base_required": "Сначала укажите URL отдельного Usage Service", + "poll_interval_retention_error": "Интервал опроса сборщика должен быть не больше окна хранения очереди CPA usage", + "runtime_title": "Среда выполнения", + "runtime_embedded": "Встроенный Usage Service на том же origin", + "runtime_external": "Отдельный Usage Service", + "runtime_embedded_hint": "Эта панель размещена Usage Service на том же origin. URL внешнего сервиса не требуется.", + "runtime_external_hint": "Эта панель размещена CPA. Укажите URL отдельно развёрнутого Usage Service, если нужен мониторинг запросов.", + "service_base": "URL Usage Service", + "external_service_base": "URL отдельного Usage Service", + "external_service_hint": "Полная Docker-схема обычно не требует этого поля. Режиму панели, размещённой CPA, нужен URL отдельного развёртывания.", + "request_monitoring_title": "Мониторинг запросов", + "request_monitoring_hint": "При включении CPA usage statistics включаются, а сборщик запускается. При отключении сборщик CPAM останавливается, но CPA usage statistics и очередь CPA не очищаются.", + "request_monitoring_enabled": "Включить мониторинг запросов", + "request_monitoring_dependency": "Настройте доступный Usage Service перед включением мониторинга запросов.", + "request_monitoring_queue_note": "Если CPA usage statistics остаются включёнными, повторное включение мониторинга запросов в пределах окна хранения очереди CPA может собрать данные, сохранённые во время остановки сборщика CPAM.", + "collector_mode": "Режим сбора", + "collector_mode_auto": "Авто", + "collector_mode_http": "HTTP", + "collector_mode_resp": "RESP", + "poll_interval_ms": "Интервал опроса сборщика (мс)", + "poll_interval_hint": "Должен быть не больше окна хранения очереди CPA usage. Текущая проверка использует {{seconds}} сек.", + "batch_size": "Максимум элементов в пакете", + "query_limit": "Лимит запроса usage", + "config_source": "Источник конфигурации", + "config_source_env": "Переменные окружения", + "config_source_db": "SQLite", + "config_source_none": "Не сохранено", + "cpa_usage_enabled": "CPA Usage Statistics", + "cpa_retention": "Хранение очереди CPA", + "cpa_retention_value": "{{seconds}} с" }, "visual": { "notice": "Визуальный режим охватывает основные поля. Остальные параметры config.yaml по-прежнему нужно проверять или редактировать в режиме исходника.", + "validation_blocked_short": "Есть ошибки", "quick_jump": "Быстрый переход", "sections": { "server": { @@ -1694,6 +1767,24 @@ } } }, + "usage_service_errors": { + "request_failed": "Запрос к Usage Service не выполнен", + "connection_env_managed": "Подключение управляется переменными окружения и не может быть изменено из панели.", + "cpa_connection_required": "Требуются URL CPA и ключ управления.", + "cpa_connection_required_for_monitoring": "URL CPA и ключ управления требуются, когда включён мониторинг запросов.", + "management_api_validation_failed": "Проверка CPA Management API не удалась. Проверьте URL CPA и ключ управления.", + "management_api_config_failed": "Не удалось прочитать конфигурацию CPA через Management API.", + "cpa_usage_retention_invalid": "Время хранения очереди CPA usage должно быть больше 0.", + "poll_interval_exceeds_retention": "Интервал опроса сборщика должен быть не больше окна хранения очереди CPA usage.", + "enable_cpa_usage_statistics_failed": "Не удалось включить CPA usage statistics.", + "setup_env_managed": "Настройка управляется переменными окружения.", + "invalid_existing_management_key": "Существующая настройка отклонила этот ключ управления.", + "invalid_management_key": "Неверный ключ управления.", + "usage_service_not_configured": "Usage Service не настроен.", + "prices_required": "Требуются цены моделей.", + "model_price_sync_failed": "Синхронизация цен моделей не удалась.", + "method_not_allowed": "Эта операция не поддерживается текущим endpoint." + }, "quota_management": { "title": "Управление квотами", "description": "Следите за статусом квот OAuth для учётных данных Antigravity, Codex и Gemini CLI.", @@ -1711,27 +1802,6 @@ "sort_plan_desc": "План от высокого", "sort_plan_asc": "План от низкого" }, - "usage_service": { - "title": "Внешний сервис статистики", - "description": "Когда панель загружается через CPA, настройте отдельный Usage Service для чтения сохранённой статистики SQLite. Во встроенном Docker-режиме это обычно не требуется.", - "enable": "Включить внешний Usage Service", - "url_label": "Адрес Usage Service", - "url_placeholder": "Например http://127.0.0.1:18317", - "url_required": "Сначала укажите адрес Usage Service", - "check_status": "Проверить статус", - "save_and_connect": "Сохранить и подключить", - "saved": "Usage Service подключён и сохранён", - "save_failed": "Не удалось сохранить Usage Service", - "status_failed": "Не удалось проверить статус Usage Service", - "disabled_saved": "Внешний Usage Service отключён", - "collector": "Сборщик", - "mode": "Режим сбора", - "transport": "Транспорт", - "queue": "Очередь", - "events": "События", - "last_consumed": "Последнее чтение", - "last_error": "Последняя ошибка" - }, "system_info": { "title": "Информация о центре управления", "about_title": "CLI Proxy API Management Center", @@ -1828,6 +1898,7 @@ "data_refreshed": "Данные успешно обновлены", "connection_required": "Сначала установите подключение", "refresh_failed": "Не удалось обновить", + "save_failed": "Не удалось сохранить", "update_failed": "Не удалось обновить", "add_failed": "Не удалось добавить", "delete_failed": "Не удалось удалить", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b5de774d7..a256d83ff 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -23,6 +23,10 @@ "connecting": "连接中...", "connected": "已连接", "disconnected": "未连接", + "enabled": "已启用", + "disabled": "已停用", + "previous": "上一步", + "next": "下一步", "connecting_status": "连接中", "connected_status": "已连接", "disconnected_status": "未连接", @@ -83,6 +87,23 @@ "cpa_connection_hint": "Usage Service 会用该地址连接 CPA Management API 和 RESP 用量队列。", "cpa_address_required": "请输入 CPA 地址", "usage_service_mode_hint": "当前面板由 Usage Service 托管,请填写 CPA API 地址。", + "usage_service_address": "Usage Service 地址", + "setup_title": "初始化向导", + "setup_steps": "初始化步骤", + "step_count": "步骤 {{current}} / {{total}}", + "step_connection": "CPA 连接", + "step_auth": "管理密钥", + "step_monitoring": "请求监控", + "step_polling": "采集间隔", + "step_review": "确认提交", + "request_monitoring_enabled": "启用请求监控", + "request_monitoring_enabled_hint": "保存时会自动启用 CPA 用量统计,并启动 Usage Service 采集器。", + "request_monitoring_disabled_hint": "关闭后仍会保存 CPA 连接,但不会启用 CPA 用量统计或启动采集器。若 CPA 已在其他位置开启用量统计,队列保留时间内再次启用采集器可能仍会采集到期间数据。", + "poll_interval_label": "采集轮询间隔 (毫秒)", + "poll_interval_hint": "必须小于等于 CPA 用量队列保留时间;保存时会自动启用 CPA 用量统计。", + "poll_interval_invalid": "请输入有效的采集间隔", + "show_key": "显示密钥", + "hide_key": "隐藏密钥", "use_current_address": "使用当前地址", "remember_password_label": "记住密码", "management_key_label": "管理密钥:", @@ -981,7 +1002,7 @@ "import_confirm_title": "导入使用统计", "import_confirm_body": "将把 {{name}} 写入 Usage Service 的 SQLite 用量库,重复事件会自动跳过。", "import_legacy_warning": "旧版导出文件缺少部分请求元数据,账号匹配和明细精度可能低于新采集数据。", - "import_export_requires_usage_service": "导入导出需要连接 Usage Service,请从 Docker 面板访问或在系统页配置 Usage Service 地址。", + "import_export_requires_usage_service": "导入导出需要连接 Usage Service,请从 Docker 面板访问或在「配置面板 -> CPA-Manager 配置」中配置 Usage Service 地址。", "chart_line_label_1": "曲线 1", "chart_line_label_2": "曲线 2", "chart_line_label_3": "曲线 3", @@ -1022,7 +1043,7 @@ "model_price_sync_success": "已从 {{source}} 同步 {{count}} 条模型价格", "model_price_sync_failed": "模型价格同步失败", "model_price_sync_no_models": "当前没有可同步的模型", - "model_price_sync_requires_usage_service": "价格同步需要连接 Usage Service,请从 Docker 面板访问或在系统页配置 Usage Service 地址。", + "model_price_sync_requires_usage_service": "价格同步需要连接 Usage Service,请从 Docker 面板访问或在「配置面板 -> CPA-Manager 配置」中配置 Usage Service 地址。", "model_price_model_required": "请选择要设置价格的模型", "cost_trend": "花费统计", "cost_axis_label": "花费 ($)", @@ -1120,6 +1141,7 @@ "open_usage": "打开使用统计", "open_logs": "打开日志查看", "open_system": "打开中心信息", + "open_manager_config": "打开 CPA-Manager 配置", "active_credentials": "活跃凭证", "unavailable_auth_meta": "{{count}} 个凭证当前不可用", "auth_health_meta": "认证状态正常,可直接映射到调用明细", @@ -1133,11 +1155,17 @@ "top_channel": "热点渠道", "usage_disabled_title": "使用统计尚未启用", "usage_disabled_body": "请求监控依赖 Usage Service 或兼容用量数据。请先开启“使用统计”并确认用量服务正在运行,否则这里只能显示连接与配置状态。", + "request_monitoring_disabled_title": "请求监控未启用", + "request_monitoring_unavailable_title": "请求监控服务不可用", + "request_monitoring_disabled_body": "CPA-Manager 当前没有启动请求采集器。需要使用监控页时,请到 CPA-Manager 配置中启用请求监控。", + "request_monitoring_service_unavailable_body": "已配置的 Usage Service 暂时无法访问。请检查服务地址、网络和部署状态。", + "request_monitoring_not_configured_body": "当前没有检测到可用的 Usage Service。CPA 控制面板方案需要先部署并配置 Usage Service。", "empty_diagnostics_body": "如果 CPA 已产生请求但这里仍为空,请确认已启用用量发布,检查 Usage Service /status,并确保同一个 CPA 实例只有一个 Usage Service 消费用量队列。", "zero_token_notice_title": "检测到零 Token 模型调用", "zero_token_notice_body": "当前范围内有 {{count}} 次零 Token 模型调用({{models}})。这类模型通常不是纯 Token 计费,当前花费只能做部分估算。", "model_calls": "模型调用数", "total_calls": "总调用", + "success_rate": "成功率", "success_calls": "成功总数", "failure_calls": "失败总数", "total_tokens": "总 Tokens", @@ -1529,10 +1557,50 @@ }, "tabs": { "visual": "可视化编辑", - "source": "源文件编辑" + "source": "源文件编辑", + "manager": "CPA-Manager 配置" + }, + "manager": { + "title": "CPA-Manager 配置", + "description": "管理 CPA-Manager 自身配置,和 CPA 配置分开保存。", + "boundary_hint": "这里保存 CPAM 自身运行配置;CPA 用量统计和队列保留时间仍属于 CPA 配置。", + "load_failed": "CPA-Manager 配置加载失败", + "save_success": "CPA-Manager 配置已保存", + "number_invalid": "{{label}} 必须是正整数", + "service_base_required": "请先填写独立 Usage Service 地址", + "poll_interval_retention_error": "采集轮询间隔必须小于等于 CPA 用量队列保留时间", + "runtime_title": "运行环境", + "runtime_embedded": "同源内置 Usage Service", + "runtime_external": "独立 Usage Service", + "runtime_embedded_hint": "当前面板由 Usage Service 同源托管,不需要配置外部服务地址。", + "runtime_external_hint": "当前面板由 CPA 托管;需要请求监控时,请填写独立部署的 Usage Service 地址。", + "service_base": "Usage Service 地址", + "external_service_base": "独立 Usage Service 地址", + "external_service_hint": "完整 Docker 方案不需要填写;CPA 控制面板方案需要填写独立部署地址。", + "request_monitoring_title": "请求监控", + "request_monitoring_hint": "开启后会启用 CPA 用量统计并启动采集器;关闭后会停止 CPAM 采集器,但不会关闭 CPA 用量统计或清空 CPA 队列。", + "request_monitoring_enabled": "启用请求监控", + "request_monitoring_dependency": "请先配置可访问的 Usage Service 后再启用请求监控。", + "request_monitoring_queue_note": "如果 CPA 用量统计仍开启,在 CPA 队列保留时间内再次启用请求监控,可能会采集到关闭 CPAM 采集器期间保留的数据。", + "collector_mode": "采集模式", + "collector_mode_auto": "自动", + "collector_mode_http": "HTTP", + "collector_mode_resp": "RESP", + "poll_interval_ms": "采集轮询间隔 (毫秒)", + "poll_interval_hint": "必须小于等于 CPA 用量队列保留时间,当前按 {{seconds}} 秒校验。", + "batch_size": "每批最多拉取条数", + "query_limit": "用量查询返回上限", + "config_source": "配置来源", + "config_source_env": "环境变量", + "config_source_db": "SQLite", + "config_source_none": "未保存", + "cpa_usage_enabled": "CPA 用量统计", + "cpa_retention": "CPA 队列保留时间", + "cpa_retention_value": "{{seconds}} 秒" }, "visual": { "notice": "可视化模式覆盖常用字段,未覆盖的配置仍需在源文件模式中查看或编辑。", + "validation_blocked_short": "待修复", "quick_jump": "快速跳转", "sections": { "server": { @@ -1703,6 +1771,24 @@ } } }, + "usage_service_errors": { + "request_failed": "Usage Service 请求失败", + "connection_env_managed": "连接配置由环境变量管理,无法在面板中修改。", + "cpa_connection_required": "CPA 地址和管理密钥不能为空。", + "cpa_connection_required_for_monitoring": "启用请求监控时必须填写 CPA 地址和管理密钥。", + "management_api_validation_failed": "CPA Management API 校验失败,请检查 CPA 地址和管理密钥。", + "management_api_config_failed": "读取 CPA 配置失败,请检查 Management API。", + "cpa_usage_retention_invalid": "CPA 用量队列保留时间必须大于 0。", + "poll_interval_exceeds_retention": "采集轮询间隔必须小于等于 CPA 用量队列保留时间。", + "enable_cpa_usage_statistics_failed": "启用 CPA 用量统计失败。", + "setup_env_managed": "初始化配置由环境变量管理。", + "invalid_existing_management_key": "现有配置拒绝了该管理密钥。", + "invalid_management_key": "管理密钥无效。", + "usage_service_not_configured": "Usage Service 尚未配置。", + "prices_required": "请先提供模型价格。", + "model_price_sync_failed": "模型价格同步失败。", + "method_not_allowed": "当前接口不支持该操作。" + }, "quota_management": { "title": "配额管理", "description": "集中查看 OAuth 额度与剩余情况", @@ -1720,27 +1806,6 @@ "sort_plan_desc": "套餐高到低", "sort_plan_asc": "套餐低到高" }, - "usage_service": { - "title": "外部用量统计服务", - "description": "当面板由 CPA 自动载入时,可配置独立部署的 Usage Service 读取 SQLite 持久化用量;Docker 内置模式通常不需要配置这里。", - "enable": "启用外部 Usage Service", - "url_label": "Usage Service 地址", - "url_placeholder": "例如 http://127.0.0.1:18317", - "url_required": "请先填写 Usage Service 地址", - "check_status": "检查状态", - "save_and_connect": "保存并连接", - "saved": "Usage Service 已连接并保存", - "save_failed": "Usage Service 保存失败", - "status_failed": "Usage Service 状态检查失败", - "disabled_saved": "外部 Usage Service 已停用", - "collector": "采集器", - "mode": "采集模式", - "transport": "传输协议", - "queue": "队列", - "events": "事件数", - "last_consumed": "最后消费", - "last_error": "最后错误" - }, "system_info": { "title": "管理中心信息", "about_title": "CLI Proxy API Management Center", @@ -1837,6 +1902,7 @@ "data_refreshed": "数据刷新成功", "connection_required": "请先建立连接", "refresh_failed": "刷新失败", + "save_failed": "保存失败", "update_failed": "更新失败", "add_failed": "添加失败", "delete_failed": "删除失败", diff --git a/src/i18n/locales/zh-TW.json b/src/i18n/locales/zh-TW.json index e21f8c1c6..7d7bbd4b6 100644 --- a/src/i18n/locales/zh-TW.json +++ b/src/i18n/locales/zh-TW.json @@ -22,6 +22,10 @@ "connecting": "連線中...", "connected": "已連線", "disconnected": "未連線", + "enabled": "已啟用", + "disabled": "已停用", + "previous": "上一步", + "next": "下一步", "connecting_status": "連線中", "connected_status": "已連線", "disconnected_status": "未連線", @@ -77,6 +81,28 @@ "custom_connection_label": "自訂連線位址:", "custom_connection_placeholder": "例如: https://example.com:8317", "custom_connection_hint": "預設使用目前存取位址,若需要可手動輸入其他位址。", + "cpa_connection_label": "CPA 連線位址", + "cpa_connection_placeholder": "例如: http://127.0.0.1:8317", + "cpa_connection_hint": "Usage Service 會用該位址連線 CPA Management API 和 RESP 用量佇列。", + "cpa_address_required": "請輸入 CPA 位址", + "usage_service_mode_hint": "目前面板由 Usage Service 託管,請填寫 CPA API 位址。", + "usage_service_address": "Usage Service 位址", + "setup_title": "初始化精靈", + "setup_steps": "初始化步驟", + "step_count": "步驟 {{current}} / {{total}}", + "step_connection": "CPA 連線", + "step_auth": "管理金鑰", + "step_monitoring": "請求監控", + "step_polling": "採集間隔", + "step_review": "確認提交", + "request_monitoring_enabled": "啟用請求監控", + "request_monitoring_enabled_hint": "儲存時會自動啟用 CPA 用量統計,並啟動 Usage Service 採集器。", + "request_monitoring_disabled_hint": "關閉後仍會儲存 CPA 連線,但不會啟用 CPA 用量統計或啟動採集器。若 CPA 已在其他位置開啟用量統計,佇列保留時間內再次啟用採集器可能仍會採集到期間資料。", + "poll_interval_label": "採集輪詢間隔(毫秒)", + "poll_interval_hint": "必須小於等於 CPA 用量佇列保留時間;儲存時會自動啟用 CPA 用量統計。", + "poll_interval_invalid": "請輸入有效的採集間隔", + "show_key": "顯示金鑰", + "hide_key": "隱藏金鑰", "use_current_address": "使用目前位址", "remember_password_label": "記住密碼", "management_key_label": "管理金鑰:", @@ -1001,7 +1027,7 @@ "import_confirm_title": "匯入使用統計", "import_confirm_body": "將把 {{name}} 寫入 Usage Service 的 SQLite 用量庫,重複事件會自動跳過。", "import_legacy_warning": "舊版匯出檔缺少部分請求中繼資料,帳號匹配與明細精度可能低於新採集資料。", - "import_export_requires_usage_service": "匯入匯出需要連接 Usage Service,請從 Docker 面板訪問或在系統頁設定 Usage Service 地址。", + "import_export_requires_usage_service": "匯入匯出需要連接 Usage Service,請從 Docker 面板訪問或在「設定面板 -> CPA-Manager 設定」中設定 Usage Service 位址。", "chart_line_label_1": "曲線 1", "chart_line_label_2": "曲線 2", "chart_line_label_3": "曲線 3", @@ -1042,7 +1068,7 @@ "model_price_sync_success": "已從 {{source}} 同步 {{count}} 筆模型定價", "model_price_sync_failed": "模型定價同步失敗", "model_price_sync_no_models": "目前沒有可同步的模型", - "model_price_sync_requires_usage_service": "定價同步需要連接 Usage Service,請從 Docker 面板存取或在系統頁設定 Usage Service 位址。", + "model_price_sync_requires_usage_service": "定價同步需要連接 Usage Service,請從 Docker 面板存取或在「設定面板 -> CPA-Manager 設定」中設定 Usage Service 位址。", "model_price_model_required": "請選擇要設定定價的模型", "cost_trend": "花費統計", "cost_axis_label": "花費($)", @@ -1140,6 +1166,7 @@ "open_usage": "開啟使用統計", "open_logs": "開啟記錄檢視", "open_system": "開啟中心資訊", + "open_manager_config": "開啟 CPA-Manager 設定", "active_credentials": "活躍憑證", "unavailable_auth_meta": "{{count}} 個憑證目前不可用", "auth_health_meta": "認證資訊已映射回使用明細", @@ -1153,11 +1180,17 @@ "top_channel": "熱點渠道", "usage_disabled_title": "尚未啟用使用統計", "usage_disabled_body": "請求監控依賴 Usage Service 或相容用量資料。請先開啟「使用統計」並確認用量服務正在執行,否則此處只能顯示連線與設定狀態。", + "request_monitoring_disabled_title": "請求監控未啟用", + "request_monitoring_unavailable_title": "請求監控服務不可用", + "request_monitoring_disabled_body": "CPA-Manager 目前沒有啟動請求採集器。需要使用監控頁時,請到 CPA-Manager 設定中啟用請求監控。", + "request_monitoring_service_unavailable_body": "已設定的 Usage Service 暫時無法存取。請檢查服務位址、網路和部署狀態。", + "request_monitoring_not_configured_body": "目前沒有偵測到可用的 Usage Service。CPA 控制面板方案需要先部署並設定 Usage Service。", "empty_diagnostics_body": "如果 CPA 已產生請求但這裡仍為空,請確認已啟用用量發布,檢查 Usage Service /status,並確保同一個 CPA 執行個體只有一個 Usage Service 消費用量佇列。", "zero_token_notice_title": "偵測到零 Token 模型呼叫", "zero_token_notice_body": "目前範圍內有 {{count}} 次零 Token 模型呼叫({{models}})。這類模型通常不是純 Token 計費,當前花費只能部分估算。", "model_calls": "模型呼叫數", "total_calls": "總呼叫", + "success_rate": "成功率", "success_calls": "成功總數", "failure_calls": "失敗總數", "total_tokens": "總 Tokens", @@ -1549,10 +1582,50 @@ }, "tabs": { "visual": "視覺化編輯", - "source": "原始檔編輯" + "source": "原始檔編輯", + "manager": "CPA-Manager 設定" + }, + "manager": { + "title": "CPA-Manager 設定", + "description": "管理 CPA-Manager 自身設定,和 CPA 設定分開儲存。", + "boundary_hint": "這裡儲存 CPAM 自身執行設定;CPA 用量統計和佇列保留時間仍屬於 CPA 設定。", + "load_failed": "CPA-Manager 設定載入失敗", + "save_success": "CPA-Manager 設定已儲存", + "number_invalid": "{{label}} 必須是正整數", + "service_base_required": "請先填寫獨立 Usage Service 位址", + "poll_interval_retention_error": "採集輪詢間隔必須小於等於 CPA 用量佇列保留時間", + "runtime_title": "執行環境", + "runtime_embedded": "同源內建 Usage Service", + "runtime_external": "獨立 Usage Service", + "runtime_embedded_hint": "目前面板由 Usage Service 同源託管,不需要設定外部服務位址。", + "runtime_external_hint": "目前面板由 CPA 託管;需要請求監控時,請填寫獨立部署的 Usage Service 位址。", + "service_base": "Usage Service 位址", + "external_service_base": "獨立 Usage Service 位址", + "external_service_hint": "完整 Docker 方案不需要填寫;CPA 控制面板方案需要填寫獨立部署位址。", + "request_monitoring_title": "請求監控", + "request_monitoring_hint": "開啟後會啟用 CPA 用量統計並啟動採集器;關閉後會停止 CPAM 採集器,但不會關閉 CPA 用量統計或清空 CPA 佇列。", + "request_monitoring_enabled": "啟用請求監控", + "request_monitoring_dependency": "請先設定可存取的 Usage Service 後再啟用請求監控。", + "request_monitoring_queue_note": "如果 CPA 用量統計仍開啟,在 CPA 佇列保留時間內再次啟用請求監控,可能會採集到關閉 CPAM 採集器期間保留的資料。", + "collector_mode": "採集模式", + "collector_mode_auto": "自動", + "collector_mode_http": "HTTP", + "collector_mode_resp": "RESP", + "poll_interval_ms": "採集輪詢間隔(毫秒)", + "poll_interval_hint": "必須小於等於 CPA 用量佇列保留時間,目前按 {{seconds}} 秒驗證。", + "batch_size": "每批最多拉取筆數", + "query_limit": "用量查詢返回上限", + "config_source": "設定來源", + "config_source_env": "環境變數", + "config_source_db": "SQLite", + "config_source_none": "未儲存", + "cpa_usage_enabled": "CPA 用量統計", + "cpa_retention": "CPA 佇列保留時間", + "cpa_retention_value": "{{seconds}} 秒" }, "visual": { "notice": "視覺化模式涵蓋常用欄位,未涵蓋的設定仍需在原始檔模式中查看或編輯。", + "validation_blocked_short": "待修復", "quick_jump": "快速跳轉", "sections": { "server": { @@ -1723,6 +1796,24 @@ } } }, + "usage_service_errors": { + "request_failed": "Usage Service 請求失敗", + "connection_env_managed": "連線設定由環境變數管理,無法在面板中修改。", + "cpa_connection_required": "CPA 位址和管理金鑰不能為空。", + "cpa_connection_required_for_monitoring": "啟用請求監控時必須填寫 CPA 位址和管理金鑰。", + "management_api_validation_failed": "CPA Management API 驗證失敗,請檢查 CPA 位址和管理金鑰。", + "management_api_config_failed": "讀取 CPA 設定失敗,請檢查 Management API。", + "cpa_usage_retention_invalid": "CPA 用量佇列保留時間必須大於 0。", + "poll_interval_exceeds_retention": "採集輪詢間隔必須小於等於 CPA 用量佇列保留時間。", + "enable_cpa_usage_statistics_failed": "啟用 CPA 用量統計失敗。", + "setup_env_managed": "初始化設定由環境變數管理。", + "invalid_existing_management_key": "現有設定拒絕了該管理金鑰。", + "invalid_management_key": "管理金鑰無效。", + "usage_service_not_configured": "Usage Service 尚未設定。", + "prices_required": "請先提供模型價格。", + "model_price_sync_failed": "模型價格同步失敗。", + "method_not_allowed": "目前介面不支援該操作。" + }, "quota_management": { "title": "配額管理", "description": "集中查看 OAuth 配額與剩餘情況", @@ -1740,27 +1831,6 @@ "sort_plan_desc": "方案高到低", "sort_plan_asc": "方案低到高" }, - "usage_service": { - "title": "外部用量統計服務", - "description": "當面板由 CPA 自動載入時,可設定獨立部署的 Usage Service 讀取 SQLite 持久化用量;Docker 內建模式通常不需要設定這裡。", - "enable": "啟用外部 Usage Service", - "url_label": "Usage Service 位址", - "url_placeholder": "例如 http://127.0.0.1:18317", - "url_required": "請先填寫 Usage Service 位址", - "check_status": "檢查狀態", - "save_and_connect": "儲存並連線", - "saved": "Usage Service 已連線並儲存", - "save_failed": "Usage Service 儲存失敗", - "status_failed": "Usage Service 狀態檢查失敗", - "disabled_saved": "外部 Usage Service 已停用", - "collector": "採集器", - "mode": "採集模式", - "transport": "傳輸協議", - "queue": "佇列", - "events": "事件數", - "last_consumed": "最後消費", - "last_error": "最後錯誤" - }, "system_info": { "title": "管理中心資訊", "about_title": "CLI Proxy API Management Center", @@ -1857,6 +1927,7 @@ "data_refreshed": "資料重新整理成功", "connection_required": "請先建立連線", "refresh_failed": "重新整理失敗", + "save_failed": "儲存失敗", "update_failed": "更新失敗", "add_failed": "新增失敗", "delete_failed": "刪除失敗", diff --git a/src/pages/ConfigPage.module.scss b/src/pages/ConfigPage.module.scss index 8dd202b70..702e5f0ad 100644 --- a/src/pages/ConfigPage.module.scss +++ b/src/pages/ConfigPage.module.scss @@ -162,6 +162,228 @@ max-width: 100%; } +.managerConfigPanel { + display: flex; + flex-direction: column; + gap: $spacing-lg; + padding: 20px; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--bg-primary) 88%, transparent); +} + +.managerConfigHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $spacing-md; + + h2 { + margin: 0 0 6px; + color: var(--text-primary); + font-size: 18px; + line-height: 1.3; + } + + p { + margin: 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; + } + + @include mobile { + flex-direction: column; + } +} + +.managerSection { + display: flex; + flex-direction: column; + gap: $spacing-md; + min-width: 0; + padding: $spacing-md; + border: 1px solid color-mix(in srgb, var(--border-color) 84%, transparent); + border-radius: 8px; + background: var(--bg-secondary); + + :global(.form-group) { + margin-bottom: 0; + } +} + +.managerSectionHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $spacing-md; + min-width: 0; + + > div { + min-width: 0; + } + + h3 { + margin: 0 0 4px; + color: var(--text-primary); + font-size: 15px; + line-height: 1.35; + } + + p { + margin: 0; + color: var(--text-secondary); + font-size: 13px; + line-height: 1.6; + } + + @include mobile { + flex-direction: column; + align-items: stretch; + } +} + +.managerRuntimeBadge { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + min-height: 30px; + max-width: 100%; + padding: 0 10px; + border: 1px solid color-mix(in srgb, var(--primary-color) 26%, var(--border-color)); + border-radius: 999px; + background: color-mix(in srgb, var(--primary-color) 9%, var(--bg-primary)); + color: var(--text-primary); + font-size: 12px; + font-weight: 700; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.managerReadonlyGrid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: $spacing-sm; + + > div { + min-width: 0; + padding: $spacing-sm $spacing-md; + border: 1px solid color-mix(in srgb, var(--border-color) 80%, transparent); + border-radius: 8px; + background: var(--bg-primary); + } + + span, + strong { + display: block; + overflow-wrap: anywhere; + } + + span { + margin-bottom: 4px; + color: var(--text-secondary); + font-size: 12px; + } + + strong { + color: var(--text-primary); + font-size: 14px; + } +} + +.managerDependencyNote { + padding: 10px 12px; + border: 1px solid var(--warning-border); + border-radius: 8px; + background: var(--warning-bg); + color: var(--warning-text); + font-size: 13px; + line-height: 1.5; +} + +.managerQueueNote { + padding: 10px 12px; + border: 1px solid color-mix(in srgb, var(--warning-border) 70%, var(--border-color)); + border-radius: 8px; + background: color-mix(in srgb, var(--warning-bg) 74%, var(--bg-primary)); + color: var(--warning-text); + font-size: 13px; + line-height: 1.5; +} + +.managerConfigGrid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $spacing-md; + + :global(.form-group) { + margin-bottom: 0; + } + + @include mobile { + grid-template-columns: 1fr; + } +} + +.managerField { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.managerFieldLabel { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; +} + +.managerSelectTrigger { + height: 46px !important; + padding: 10px 12px !important; + background-color: var(--bg-secondary) !important; + box-shadow: none !important; + font-size: inherit !important; + font-weight: 400 !important; +} + +.managerMetaGrid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: $spacing-sm; + + > div { + min-width: 0; + padding: $spacing-sm $spacing-md; + border-radius: 8px; + background: var(--bg-secondary); + } + + span, + strong { + display: block; + overflow-wrap: anywhere; + } + + span { + margin-bottom: 4px; + color: var(--text-secondary); + font-size: 12px; + } + + strong { + color: var(--text-primary); + font-size: 14px; + } + + @include mobile { + grid-template-columns: 1fr; + } +} + .sourceWorkspace { display: flex; flex-direction: column; diff --git a/src/pages/ConfigPage.tsx b/src/pages/ConfigPage.tsx index 033f17820..eca1ea53f 100644 --- a/src/pages/ConfigPage.tsx +++ b/src/pages/ConfigPage.tsx @@ -1,4 +1,4 @@ -import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { Suspense, lazy, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { createPortal } from 'react-dom'; import type { ReactCodeMirrorRef } from '@uiw/react-codemirror'; @@ -6,6 +6,8 @@ import { parse as parseYaml, parseDocument } from 'yaml'; import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import { ToggleSwitch } from '@/components/ui/ToggleSwitch'; import { IconCheck, IconChevronDown, @@ -17,11 +19,38 @@ import { VisualConfigEditor } from '@/components/config/VisualConfigEditor'; import { DiffModal } from '@/components/config/DiffModal'; import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useVisualConfig } from '@/hooks/useVisualConfig'; -import { useNotificationStore, useAuthStore, useThemeStore, useConfigStore } from '@/stores'; +import { + useNotificationStore, + useAuthStore, + useThemeStore, + useConfigStore, + useUsageServiceStore, +} from '@/stores'; import { configFileApi } from '@/services/api/configFile'; +import { + getUsageServiceErrorCode, + isUsageServiceId, + normalizeUsageServiceBase, + usageServiceApi, + type CPAUsageConfig, + type ManagerConfig, + type ManagerConfigResponse, +} from '@/services/api/usageService'; +import { detectApiBaseFromLocation } from '@/utils/connection'; import styles from './ConfigPage.module.scss'; -type ConfigEditorTab = 'visual' | 'source'; +type ConfigEditorTab = 'visual' | 'source' | 'manager'; + +const MANAGER_COLLECTOR_DEFAULT = { + enabled: true, + collectorMode: 'auto', + queue: 'usage', + popSide: 'right', + batchSize: 100, + pollIntervalMs: 500, + queryLimit: 50000, + tlsSkipVerify: false, +}; const LazyConfigSourceEditor = lazy(() => import('@/components/config/ConfigSourceEditor')); @@ -42,7 +71,12 @@ export function ConfigPage() { const showNotification = useNotificationStore((state) => state.showNotification); const showConfirmation = useNotificationStore((state) => state.showConfirmation); const connectionStatus = useAuthStore((state) => state.connectionStatus); + const apiBase = useAuthStore((state) => state.apiBase); + const managementKey = useAuthStore((state) => state.managementKey); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); + const usageServiceEnabled = useUsageServiceStore((state) => state.enabled); + const usageServiceBase = useUsageServiceStore((state) => state.serviceBase); + const setUsageServiceConfig = useUsageServiceStore((state) => state.setUsageServiceConfig); const isMobile = useMediaQuery('(max-width: 768px)'); const { @@ -58,7 +92,7 @@ export function ConfigPage() { const [activeTab, setActiveTab] = useState(() => { const saved = localStorage.getItem('config-management:tab'); - if (saved === 'visual' || saved === 'source') return saved; + if (saved === 'visual' || saved === 'source' || saved === 'manager') return saved; return 'visual'; }); @@ -70,6 +104,28 @@ export function ConfigPage() { const [diffModalOpen, setDiffModalOpen] = useState(false); const [serverYaml, setServerYaml] = useState(''); const [mergedYaml, setMergedYaml] = useState(''); + const [managerConfig, setManagerConfig] = useState(null); + const [managerConfigSource, setManagerConfigSource] = useState(''); + const [managerCPAUsage, setManagerCPAUsage] = useState(null); + const [managerLoading, setManagerLoading] = useState(false); + const [managerSaving, setManagerSaving] = useState(false); + const [managerError, setManagerError] = useState(''); + const [managerDirty, setManagerDirty] = useState(false); + const [managerServiceBase, setManagerServiceBase] = useState(''); + const [managerRequestMonitoringEnabled, setManagerRequestMonitoringEnabled] = useState(true); + const [panelHostedByUsageService, setPanelHostedByUsageService] = useState(null); + const [managerCollectorMode, setManagerCollectorMode] = useState( + MANAGER_COLLECTOR_DEFAULT.collectorMode + ); + const [managerPollIntervalMs, setManagerPollIntervalMs] = useState( + String(MANAGER_COLLECTOR_DEFAULT.pollIntervalMs) + ); + const [managerBatchSize, setManagerBatchSize] = useState( + String(MANAGER_COLLECTOR_DEFAULT.batchSize) + ); + const [managerQueryLimit, setManagerQueryLimit] = useState( + String(MANAGER_COLLECTOR_DEFAULT.queryLimit) + ); // Search state const [searchQuery, setSearchQuery] = useState(''); @@ -82,12 +138,51 @@ export function ConfigPage() { const floatingActionsRef = useRef(null); const disableControls = connectionStatus !== 'connected'; - const isDirty = dirty || visualDirty; + const isManagerTab = activeTab === 'manager'; + const isDirty = isManagerTab ? managerDirty : dirty || visualDirty; const shouldRenderFloatingActions = isCurrentLayer; const hasVisualModeError = !!visualParseError; const hasVisualValidationErrors = activeTab === 'visual' && (Object.values(visualValidationErrors).some(Boolean) || visualHasPayloadValidationErrors); + const managerRetentionSeconds = + managerCPAUsage?.redisUsageQueueRetentionSeconds || + Number(visualValues.redisUsageQueueRetentionSeconds) || + 60; + const detectedPanelBase = useMemo(() => detectApiBaseFromLocation(), []); + const managerCollectorModeOptions = useMemo( + () => [ + { value: 'auto', label: t('config_management.manager.collector_mode_auto') }, + { value: 'http', label: t('config_management.manager.collector_mode_http') }, + { value: 'resp', label: t('config_management.manager.collector_mode_resp') }, + ], + [t] + ); + const getUsageServiceDisplayError = useCallback( + (error: unknown, fallbackKey: string) => { + const code = getUsageServiceErrorCode(error); + if (code) { + return t(`usage_service_errors.${code}`, { + defaultValue: t('usage_service_errors.request_failed'), + }); + } + if (error instanceof Error && error.name !== 'UsageServiceApiError' && error.message) { + return error.message; + } + return t(fallbackKey); + }, + [t] + ); + const managerConfigSourceLabel = useMemo(() => { + switch (managerConfigSource) { + case 'env': + return t('config_management.manager.config_source_env'); + case 'db': + return t('config_management.manager.config_source_db'); + default: + return t('config_management.manager.config_source_none'); + } + }, [managerConfigSource, t]); const loadConfig = useCallback(async () => { setLoading(true); @@ -112,6 +207,109 @@ export function ConfigPage() { loadConfig(); }, [loadConfig]); + useEffect(() => { + let cancelled = false; + const detectUsageServiceHost = async () => { + try { + const info = await usageServiceApi.getInfo(detectedPanelBase); + if (!cancelled) { + setPanelHostedByUsageService(isUsageServiceId(info.service)); + } + } catch { + if (!cancelled) { + setPanelHostedByUsageService(false); + } + } + }; + void detectUsageServiceHost(); + return () => { + cancelled = true; + }; + }, [detectedPanelBase]); + + const resolveManagerServiceBase = useCallback(() => { + if (panelHostedByUsageService) { + return normalizeUsageServiceBase(detectedPanelBase); + } + const preferred = managerServiceBase || (usageServiceEnabled && usageServiceBase ? usageServiceBase : ''); + return normalizeUsageServiceBase(preferred || ''); + }, [detectedPanelBase, managerServiceBase, panelHostedByUsageService, usageServiceBase, usageServiceEnabled]); + + const applyManagerConfigResponse = useCallback( + (response: ManagerConfigResponse, fallbackBase: string) => { + const nextConfig = response.config; + const collector = nextConfig.collector ?? MANAGER_COLLECTOR_DEFAULT; + const serviceBase = + nextConfig.externalUsageService?.serviceBase || fallbackBase || managerServiceBase; + + setManagerConfig(nextConfig); + setManagerConfigSource(response.source || ''); + setManagerCPAUsage(response.cpaUsage ?? null); + setManagerServiceBase(serviceBase); + setManagerRequestMonitoringEnabled(collector.enabled !== false); + setManagerCollectorMode(collector.collectorMode || MANAGER_COLLECTOR_DEFAULT.collectorMode); + setManagerPollIntervalMs(String(collector.pollIntervalMs || MANAGER_COLLECTOR_DEFAULT.pollIntervalMs)); + setManagerBatchSize(String(collector.batchSize || MANAGER_COLLECTOR_DEFAULT.batchSize)); + setManagerQueryLimit(String(collector.queryLimit || MANAGER_COLLECTOR_DEFAULT.queryLimit)); + setManagerDirty(false); + }, + [managerServiceBase] + ); + + const loadManagerConfig = useCallback(async () => { + const serviceBase = resolveManagerServiceBase(); + if (!managementKey) return; + if (!serviceBase) { + setManagerError(''); + setManagerConfig(null); + setManagerCPAUsage(null); + setManagerConfigSource(''); + return; + } + setManagerLoading(true); + setManagerError(''); + try { + const response = await usageServiceApi.getManagerConfig(serviceBase, managementKey); + applyManagerConfigResponse(response, serviceBase); + } catch (error: unknown) { + setManagerError(getUsageServiceDisplayError(error, 'config_management.manager.load_failed')); + } finally { + setManagerLoading(false); + } + }, [ + applyManagerConfigResponse, + getUsageServiceDisplayError, + managementKey, + resolveManagerServiceBase, + ]); + + const setManagerFieldDirty = useCallback(() => { + setManagerDirty(true); + }, []); + + const readManagerPositiveInteger = useCallback( + (value: string, label: string) => { + const trimmed = value.trim(); + if (!/^\d+$/.test(trimmed)) { + throw new Error( + t('config_management.manager.number_invalid', { + label, + }) + ); + } + const parsed = Number(trimmed); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error( + t('config_management.manager.number_invalid', { + label, + }) + ); + } + return Math.floor(parsed); + }, + [t] + ); + useEffect(() => { if (activeTab !== 'visual' || !visualParseError) return; @@ -123,6 +321,11 @@ export function ConfigPage() { ); }, [activeTab, showNotification, t, visualParseError]); + useEffect(() => { + if (activeTab !== 'manager') return; + void loadManagerConfig(); + }, [activeTab, loadManagerConfig]); + const handleConfirmSave = async () => { setSaving(true); try { @@ -168,7 +371,86 @@ export function ConfigPage() { } }; + const handleManagerSave = async () => { + if (disableControls) return; + const serviceBase = resolveManagerServiceBase(); + if (!serviceBase) { + showNotification(t('config_management.manager.service_base_required'), 'warning'); + return; + } + const isEmbeddedUsageService = panelHostedByUsageService === true; + setManagerSaving(true); + try { + const pollIntervalMs = managerRequestMonitoringEnabled + ? readManagerPositiveInteger( + managerPollIntervalMs, + t('config_management.manager.poll_interval_ms') + ) + : MANAGER_COLLECTOR_DEFAULT.pollIntervalMs; + const batchSize = managerRequestMonitoringEnabled + ? readManagerPositiveInteger( + managerBatchSize, + t('config_management.manager.batch_size') + ) + : MANAGER_COLLECTOR_DEFAULT.batchSize; + const queryLimit = managerRequestMonitoringEnabled + ? readManagerPositiveInteger( + managerQueryLimit, + t('config_management.manager.query_limit') + ) + : MANAGER_COLLECTOR_DEFAULT.queryLimit; + if (managerRequestMonitoringEnabled && pollIntervalMs > managerRetentionSeconds * 1000) { + showNotification(t('config_management.manager.poll_interval_retention_error'), 'error'); + return; + } + const nextConfig: ManagerConfig = { + ...(managerConfig ?? { + cpaConnection: { cpaBaseUrl: apiBase, managementKey }, + collector: MANAGER_COLLECTOR_DEFAULT, + externalUsageService: { enabled: !isEmbeddedUsageService, serviceBase: !isEmbeddedUsageService ? serviceBase : '' }, + }), + cpaConnection: { + ...(managerConfig?.cpaConnection ?? {}), + cpaBaseUrl: managerConfig?.cpaConnection?.cpaBaseUrl || apiBase, + managementKey: managerConfig?.cpaConnection?.managementKey || managementKey, + }, + collector: { + ...(managerConfig?.collector ?? MANAGER_COLLECTOR_DEFAULT), + enabled: managerRequestMonitoringEnabled, + collectorMode: managerCollectorMode, + pollIntervalMs, + batchSize, + queryLimit, + }, + externalUsageService: { + enabled: !isEmbeddedUsageService, + serviceBase: !isEmbeddedUsageService ? serviceBase : '', + }, + }; + const response = await usageServiceApi.saveManagerConfig(serviceBase, nextConfig, managementKey); + applyManagerConfigResponse(response, serviceBase); + setUsageServiceConfig({ + enabled: true, + serviceBase, + }); + showNotification(t('config_management.manager.save_success'), 'success'); + } catch (error: unknown) { + const message = getUsageServiceDisplayError(error, 'usage_service_errors.request_failed'); + showNotification( + `${t('notification.save_failed')}${message ? `: ${message}` : ''}`, + 'error' + ); + } finally { + setManagerSaving(false); + } + }; + const handleSave = async () => { + if (activeTab === 'manager') { + await handleManagerSave(); + return; + } + if (activeTab === 'visual' && visualParseError) { showNotification(t('config_management.visual_mode_save_blocked'), 'error'); return; @@ -240,6 +522,12 @@ export function ConfigPage() { (tab: ConfigEditorTab) => { if (tab === activeTab) return; + if (tab === 'manager') { + setActiveTab(tab); + localStorage.setItem('config-management:tab', tab); + return; + } + if (tab === 'source') { // Only rewrite YAML when there are pending visual changes; otherwise preserve raw YAML + comments. if (visualDirty) { @@ -406,6 +694,14 @@ export function ConfigPage() { // Status text const getStatusText = () => { + if (isManagerTab) { + if (disableControls) return t('config_management.status_disconnected'); + if (managerLoading) return t('config_management.status_loading'); + if (managerError) return t('config_management.status_load_failed'); + if (managerSaving) return t('config_management.status_saving'); + if (managerDirty) return t('config_management.status_dirty'); + return t('config_management.status_loaded'); + } if (disableControls) return t('config_management.status_disconnected'); if (loading) return t('config_management.status_loading'); if (error) return t('config_management.status_load_failed'); @@ -418,6 +714,12 @@ export function ConfigPage() { }; const getStatusClass = () => { + if (isManagerTab) { + if (managerError) return styles.error; + if (managerDirty) return styles.modified; + if (!managerLoading && !managerSaving) return styles.saved; + return ''; + } if (error || hasVisualModeError || hasVisualValidationErrors) return styles.error; if (isDirty) return styles.modified; if (!loading && !saving) return styles.saved; @@ -425,6 +727,20 @@ export function ConfigPage() { }; const getFloatingStatusText = () => { + if (isManagerTab) { + if (!isMobile) return getStatusText(); + if (disableControls) + return t('config_management.status_disconnected_short', { defaultValue: 'Disconnected' }); + if (managerLoading) + return t('config_management.status_loading_short', { defaultValue: 'Loading' }); + if (managerError) + return t('config_management.status_load_failed_short', { defaultValue: 'Failed' }); + if (managerSaving) + return t('config_management.status_saving_short', { defaultValue: 'Saving' }); + if (managerDirty) + return t('config_management.status_dirty_short', { defaultValue: 'Unsaved' }); + return t('config_management.status_loaded_short', { defaultValue: 'Loaded' }); + } if (!isMobile) return getStatusText(); if (disableControls) return t('config_management.status_disconnected_short', { defaultValue: 'Disconnected' }); @@ -433,13 +749,31 @@ export function ConfigPage() { if (hasVisualModeError) return t('config_management.visual_mode_unavailable_short', { defaultValue: 'YAML issue' }); if (hasVisualValidationErrors) - return t('config_management.visual.validation_blocked_short', { defaultValue: 'Fix errors' }); + return t('config_management.visual.validation_blocked_short'); if (saving) return t('config_management.status_saving_short', { defaultValue: 'Saving' }); if (isDirty) return t('config_management.status_dirty_short', { defaultValue: 'Unsaved' }); return t('config_management.status_loaded_short', { defaultValue: 'Loaded' }); }; const handleReload = useCallback(() => { + if (activeTab === 'manager') { + if (!managerDirty) { + void loadManagerConfig(); + return; + } + showConfirmation({ + title: t('common.unsaved_changes_title'), + message: t('config_management.reload_confirm_message'), + confirmText: t('config_management.reload'), + cancelText: t('common.cancel'), + variant: 'danger', + onConfirm: async () => { + await loadManagerConfig(); + }, + }); + return; + } + if (!isDirty) { void loadConfig(); return; @@ -455,7 +789,7 @@ export function ConfigPage() { await loadConfig(); }, }); - }, [isDirty, loadConfig, showConfirmation, t]); + }, [activeTab, isDirty, loadConfig, loadManagerConfig, managerDirty, showConfirmation, t]); const floatingActions = (
@@ -482,13 +816,15 @@ export function ConfigPage() { className={styles.floatingActionButton} onClick={handleSave} disabled={ - disableControls || - loading || - saving || - !isDirty || - diffModalOpen || - hasVisualModeError || - hasVisualValidationErrors + isManagerTab + ? disableControls || managerLoading || managerSaving || !managerDirty + : disableControls || + loading || + saving || + !isDirty || + diffModalOpen || + hasVisualModeError || + hasVisualValidationErrors } title={t('config_management.save')} aria-label={t('config_management.save')} @@ -501,9 +837,17 @@ export function ConfigPage() { ); const pageDescription = - activeTab === 'visual' + activeTab === 'manager' + ? t('config_management.manager.description') + : activeTab === 'visual' ? t('config_management.visual.notice') : t('config_management.description'); + const managerServiceTarget = resolveManagerServiceBase(); + const canConfigureRequestMonitoring = Boolean(managerServiceTarget); + const managerRuntimeModeLabel = + panelHostedByUsageService === true + ? t('config_management.manager.runtime_embedded') + : t('config_management.manager.runtime_external'); return (
@@ -521,7 +865,7 @@ export function ConfigPage() { onClick={() => handleTabChange('visual')} disabled={saving || loading} > - {t('config_management.tabs.visual', { defaultValue: '可视化编辑' })} + {t('config_management.tabs.visual')} +
{getStatusText()}
- {error &&
{error}
} - {!error && visualParseError && ( + {activeTab !== 'manager' && error &&
{error}
} + {activeTab === 'manager' && managerError && ( +
{managerError}
+ )} + {activeTab !== 'manager' && !error && visualParseError && (
{t('config_management.visual_mode_unavailable_detail', { message: visualParseError })}
)} - {activeTab === 'visual' ? ( + {activeTab === 'manager' ? ( +
+
+
+

{t('config_management.manager.title')}

+

+ {t('config_management.manager.boundary_hint')} +

+
+ +
+ +
+
+
+

{t('config_management.manager.runtime_title')}

+

+ {panelHostedByUsageService === true + ? t('config_management.manager.runtime_embedded_hint') + : t('config_management.manager.runtime_external_hint')} +

+
+ {managerRuntimeModeLabel} +
+ + {panelHostedByUsageService === true ? ( +
+
+ {t('config_management.manager.service_base')} + {detectedPanelBase} +
+
+ ) : ( + { + setManagerServiceBase(event.target.value); + setManagerFieldDirty(); + }} + disabled={disableControls || managerLoading} + hint={t('config_management.manager.external_service_hint')} + /> + )} +
+ +
+
+
+

{t('config_management.manager.request_monitoring_title')}

+

+ {t('config_management.manager.request_monitoring_hint')} +

+
+ { + setManagerRequestMonitoringEnabled(value); + setManagerFieldDirty(); + }} + disabled={disableControls || managerLoading || !canConfigureRequestMonitoring} + /> +
+ + {!canConfigureRequestMonitoring ? ( +
+ {t('config_management.manager.request_monitoring_dependency')} +
+ ) : null} + +
+ {t('config_management.manager.request_monitoring_queue_note')} +
+ +
+
+ + {t('config_management.manager.collector_mode')} + + { + setManagerPollIntervalMs(event.target.value); + setManagerFieldDirty(); + }} + disabled={ + disableControls || + managerLoading || + !managerRequestMonitoringEnabled || + !canConfigureRequestMonitoring + } + hint={t('config_management.manager.poll_interval_hint', { + seconds: managerRetentionSeconds, + })} + /> + { + setManagerBatchSize(event.target.value); + setManagerFieldDirty(); + }} + disabled={ + disableControls || + managerLoading || + !managerRequestMonitoringEnabled || + !canConfigureRequestMonitoring + } + /> + { + setManagerQueryLimit(event.target.value); + setManagerFieldDirty(); + }} + disabled={ + disableControls || + managerLoading || + !managerRequestMonitoringEnabled || + !canConfigureRequestMonitoring + } + /> +
+
+ +
+
+ {t('config_management.manager.config_source')} + {managerConfigSourceLabel} +
+
+ {t('config_management.manager.cpa_usage_enabled')} + + {managerCPAUsage?.usageStatisticsEnabled + ? t('common.enabled') + : t('common.disabled')} + +
+
+ {t('config_management.manager.cpa_retention')} + + {t('config_management.manager.cpa_retention_value', { + seconds: managerRetentionSeconds, + })} + +
+
+
+ ) : activeTab === 'visual' ? ( handleSearchChange(e.target.value)} onKeyDown={handleSearchKeyDown} - placeholder={t('config_management.search_placeholder', { - defaultValue: '搜索配置内容...', - })} + placeholder={t('config_management.search_placeholder')} disabled={disableControls || loading} className={styles.searchInput} rightElement={ @@ -570,9 +1109,7 @@ export function ConfigPage() { {searchResults.total > 0 ? `${searchResults.current} / ${searchResults.total}` - : t('config_management.search_no_results', { - defaultValue: '无结果', - })} + : t('config_management.search_no_results')} )} @@ -597,7 +1134,7 @@ export function ConfigPage() { disabled={ !searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0 } - title={t('config_management.search_prev', { defaultValue: '上一个' })} + title={t('config_management.search_prev')} > @@ -608,7 +1145,7 @@ export function ConfigPage() { disabled={ !searchQuery || lastSearchedQuery !== searchQuery || searchResults.total === 0 } - title={t('config_management.search_next', { defaultValue: '下一个' })} + title={t('config_management.search_next')} > diff --git a/src/pages/LoginPage.module.scss b/src/pages/LoginPage.module.scss index 3f4bccb75..0176880b7 100644 --- a/src/pages/LoginPage.module.scss +++ b/src/pages/LoginPage.module.scss @@ -107,6 +107,11 @@ gap: $spacing-xl; } +.setupFormContent { + max-width: min(480px, 100%); + gap: 0; +} + // Logo .logo { width: 80px; @@ -137,6 +142,83 @@ } } +.setupCard { + position: relative; + gap: 18px; + padding: 26px 28px 28px; + border-radius: 20px; + background: color-mix(in srgb, var(--bg-primary) 96%, transparent); + box-shadow: + 0 28px 70px -44px rgba(15, 23, 42, 0.42), + var(--shadow-lg); + + @media (max-width: $breakpoint-mobile) { + padding: $spacing-lg; + border: 1px solid var(--border-color); + border-radius: 18px; + background: var(--bg-primary); + box-shadow: var(--shadow-lg); + } +} + +.setupHeader { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-height: 114px; + padding: 10px 108px 0; + text-align: center; + + h1 { + margin: 0; + color: var(--text-primary); + font-size: 30px; + font-weight: 800; + line-height: 1.1; + } + + p { + margin: 0; + color: var(--text-secondary); + font-size: 15px; + font-weight: 600; + line-height: 1.35; + } + + @media (max-width: $breakpoint-mobile) { + min-height: 0; + padding: 54px 0 0; + + h1 { + font-size: 30px; + } + + p { + font-size: 15px; + } + } +} + +.setupLanguage { + position: absolute; + top: 0; + right: 0; + + .languageSelect { + min-width: 112px; + } +} + +.setupLogo { + width: 58px; + height: 58px; + object-fit: cover; + border-radius: 14px; + box-shadow: 0 18px 34px -28px color-mix(in srgb, var(--primary-color) 90%, transparent); +} + // 登录头部 .loginHeader { display: flex; @@ -200,6 +282,318 @@ } } +.setupCard .connectionBox { + display: grid; + grid-template-columns: 34px minmax(0, 1fr); + gap: 14px; + padding: 16px 18px; + border-style: solid; + border-color: color-mix(in srgb, var(--primary-color) 24%, var(--border-color)); + border-radius: 14px; + background: color-mix(in srgb, var(--primary-color) 4%, var(--bg-primary)); + + .value { + font-size: 18px; + line-height: 1.3; + } + + .hint { + margin-top: 4px; + font-size: 13px; + line-height: 1.6; + } +} + +.connectionIcon { + display: inline-grid; + place-items: center; + width: 30px; + height: 30px; + margin-top: 2px; + border-radius: 50%; + background: var(--primary-color); + color: var(--primary-contrast, #fff); +} + +.connectionCopy { + min-width: 0; +} + +.setupFlow { + display: flex; + flex-direction: column; + gap: 20px; + min-width: 0; +} + +.stepper { + display: flex; + align-items: flex-start; + width: min(390px, 100%); + margin: 0 auto; + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +.stepItem { + position: relative; + flex: 1 0 68px; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + min-width: 0; + color: var(--text-secondary); + + &:not(:last-child)::after { + content: ''; + position: absolute; + top: 16px; + left: calc(50% + 24px); + right: calc(-50% + 24px); + height: 2px; + border-radius: 999px; + background: color-mix(in srgb, var(--primary-color) 16%, var(--border-color)); + } +} + +.stepIndex { + display: grid; + place-items: center; + position: relative; + z-index: 1; + width: 32px; + height: 32px; + border: 2px solid color-mix(in srgb, var(--border-color) 88%, transparent); + border-radius: 50%; + background: var(--bg-primary); + color: var(--text-secondary); + font-size: 13px; + font-weight: 700; + box-shadow: 0 12px 24px -22px rgba(15, 23, 42, 0.5); +} + +.stepLabel { + width: 100%; + overflow-wrap: anywhere; + text-align: center; + color: currentColor; + font-size: 12px; + font-weight: 600; + line-height: 1.3; +} + +.stepItemActive { + color: var(--primary-color); + + .stepIndex { + border-color: var(--primary-color); + background: var(--primary-color); + color: #ffffff; + } +} + +.stepItemDone { + color: var(--primary-color); + + .stepIndex { + border-color: var(--primary-color); + background: var(--bg-primary); + color: var(--primary-color); + } + + &:not(:last-child)::after { + background: var(--primary-color); + } +} + +.stepPanel { + display: flex; + flex-direction: column; + gap: 20px; + min-height: 310px; + padding: 22px 28px; + border: 1px solid color-mix(in srgb, var(--border-color) 88%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--bg-primary) 80%, var(--bg-secondary)); + + @media (max-width: $breakpoint-mobile) { + min-height: 0; + padding: $spacing-md; + border-radius: 14px; + } +} + +.stepHeader { + display: flex; + flex-direction: column; + gap: 8px; + + h2 { + margin: 0; + color: var(--text-primary); + font-size: 25px; + font-weight: 800; + line-height: 1.2; + + @media (max-width: $breakpoint-mobile) { + font-size: 24px; + } + } +} + +.stepEyebrow { + display: inline-flex; + width: fit-content; + padding: 3px 9px; + border-radius: 9px; + background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-primary)); + color: var(--primary-color); + font-size: 13px; + font-weight: 800; +} + +.stepFields { + display: flex; + flex-direction: column; + gap: 16px; + + :global(.form-group) { + margin-bottom: 0; + } + + :global(.form-group > label) { + margin-bottom: 2px; + color: var(--text-primary); + font-size: 15px; + font-weight: 700; + } + + :global(.input) { + min-height: 48px; + border-radius: 12px; + background: var(--bg-primary); + font-size: 16px; + } + + :global(.hint) { + margin-top: 4px; + font-size: 13px; + line-height: 1.6; + } +} + +.optionBox { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px 22px; + border: 1px solid color-mix(in srgb, var(--primary-color) 28%, var(--border-color)); + border-radius: 14px; + background: var(--bg-primary); + + p { + margin: 0; + color: var(--text-secondary); + font-size: 15px; + line-height: 1.65; + } +} + +.authFieldBox { + display: grid; + gap: 16px; + padding: 20px 22px; + border: 1px solid var(--border-color); + border-radius: 14px; + background: var(--bg-primary); +} + +.reviewGrid { + display: grid; + gap: 0; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: 14px; + background: var(--bg-primary); + + > div { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) minmax(108px, auto); + align-items: center; + gap: 12px; + min-width: 0; + padding: 12px 16px; + + &:not(:last-child) { + border-bottom: 1px solid var(--border-color); + } + + @media (max-width: $breakpoint-mobile) { + grid-template-columns: 38px minmax(0, 1fr); + + strong { + grid-column: 2; + justify-self: start; + } + } + } + + span { + color: var(--text-secondary); + font-size: 14px; + } + + .reviewIcon { + display: inline-grid; + place-items: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: color-mix(in srgb, var(--primary-color) 12%, var(--bg-secondary)); + color: var(--primary-color); + } + + strong { + min-width: 0; + justify-self: end; + color: var(--text-primary); + font-size: 15px; + overflow-wrap: anywhere; + } +} + +.stepActions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; + + button { + border-radius: 12px; + font-size: 16px; + } + + @media (max-width: $breakpoint-mobile) { + grid-template-columns: 1fr; + } +} + +.setupBackButton { + color: var(--text-secondary) !important; + background: var(--bg-primary) !important; +} + +.setupNextButton { + > span { + display: inline-flex; + align-items: center; + } +} + // 复选框行 .toggleAdvanced { display: flex; diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx index 2df2d80aa..38203aea1 100644 --- a/src/pages/LoginPage.tsx +++ b/src/pages/LoginPage.tsx @@ -5,11 +5,20 @@ import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Select } from '@/components/ui/Select'; import { SelectionCheckbox } from '@/components/ui/SelectionCheckbox'; -import { IconEye, IconEyeOff } from '@/components/ui/icons'; +import { + IconCheck, + IconEye, + IconEyeOff, + IconInfo, + IconKey, + IconShield, + IconTimer, +} from '@/components/ui/icons'; import { useAuthStore, useLanguageStore, useNotificationStore, useUsageServiceStore } from '@/stores'; import { LEGACY_USAGE_SERVICE_LAST_CPA_BASE_KEY, USAGE_SERVICE_LAST_CPA_BASE_KEY, + getUsageServiceErrorCode, isUsageServiceId, usageServiceApi } from '@/services/api/usageService'; @@ -24,8 +33,19 @@ import styles from './LoginPage.module.scss'; * 将 API 错误转换为本地化的用户友好消息 */ type RedirectState = { from?: { pathname?: string } }; +type UsageSetupStep = 'connection' | 'auth' | 'monitoring' | 'polling' | 'review'; + +function getLocalizedErrorMessage( + error: unknown, + t: (key: string, options?: Record) => string +): string { + const usageServiceCode = getUsageServiceErrorCode(error); + if (usageServiceCode) { + return t(`usage_service_errors.${usageServiceCode}`, { + defaultValue: t('usage_service_errors.request_failed'), + }); + } -function getLocalizedErrorMessage(error: unknown, t: (key: string) => string): string { const apiError = error as Partial; const status = typeof apiError.status === 'number' ? apiError.status : undefined; const code = typeof apiError.code === 'string' ? apiError.code : undefined; @@ -92,13 +112,39 @@ export function LoginPage() { const [showCustomBase, setShowCustomBase] = useState(false); const [showKey, setShowKey] = useState(false); const [rememberPassword, setRememberPassword] = useState(false); + const [requestMonitoringEnabled, setRequestMonitoringEnabled] = useState(true); + const [pollIntervalMs, setPollIntervalMs] = useState('500'); const [loading, setLoading] = useState(false); const [autoLoading, setAutoLoading] = useState(true); const [autoLoginSuccess, setAutoLoginSuccess] = useState(false); const [error, setError] = useState(''); const [usageServiceMode, setUsageServiceMode] = useState(false); + const [usageSetupStep, setUsageSetupStep] = useState('connection'); const detectedBase = useMemo(() => detectApiBaseFromLocation(), []); + const usageSetupSteps = useMemo( + () => [ + 'connection', + 'auth', + 'monitoring', + ...(requestMonitoringEnabled ? (['polling'] as UsageSetupStep[]) : []), + 'review', + ], + [requestMonitoringEnabled] + ); + const usageSetupStepIndex = Math.max(0, usageSetupSteps.indexOf(usageSetupStep)); + const usageSetupIsFirstStep = usageSetupStepIndex <= 0; + const usageSetupIsLastStep = usageSetupStep === 'review'; + const usageSetupStepLabels = useMemo>( + () => ({ + connection: t('login.step_connection'), + auth: t('login.step_auth'), + monitoring: t('login.step_monitoring'), + polling: t('login.step_polling'), + review: t('login.step_review'), + }), + [t] + ); const languageOptions = useMemo( () => LANGUAGE_ORDER.map((lang) => ({ @@ -167,7 +213,59 @@ export function LoginPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + useEffect(() => { + if (!usageSetupSteps.includes(usageSetupStep)) { + setUsageSetupStep('review'); + } + }, [usageSetupStep, usageSetupSteps]); + + const validateUsageSetupStep = useCallback( + (step: UsageSetupStep) => { + if (step === 'connection' && !apiBase.trim()) { + setError(t('login.cpa_address_required')); + return false; + } + if (step === 'auth' && !managementKey.trim()) { + setError(t('login.error_required')); + return false; + } + if (step === 'polling') { + const parsedPollIntervalMs = Number(pollIntervalMs); + if ( + !/^\d+$/.test(pollIntervalMs.trim()) || + !Number.isFinite(parsedPollIntervalMs) || + parsedPollIntervalMs <= 0 + ) { + setError(t('login.poll_interval_invalid')); + return false; + } + } + setError(''); + return true; + }, + [apiBase, managementKey, pollIntervalMs, t] + ); + + const handleUsageSetupNext = useCallback(() => { + if (!validateUsageSetupStep(usageSetupStep)) return; + const currentIndex = usageSetupSteps.indexOf(usageSetupStep); + const nextStep = usageSetupSteps[Math.min(currentIndex + 1, usageSetupSteps.length - 1)]; + setUsageSetupStep(nextStep); + }, [usageSetupStep, usageSetupSteps, validateUsageSetupStep]); + + const handleUsageSetupBack = useCallback(() => { + setError(''); + const currentIndex = usageSetupSteps.indexOf(usageSetupStep); + const previousStep = usageSetupSteps[Math.max(currentIndex - 1, 0)]; + setUsageSetupStep(previousStep); + }, [usageSetupStep, usageSetupSteps]); + const handleSubmit = useCallback(async () => { + if (usageServiceMode && !usageSetupIsLastStep) { + handleUsageSetupNext(); + return; + } + if (!managementKey.trim()) { setError(t('login.error_required')); return; @@ -175,7 +273,16 @@ export function LoginPage() { const baseToUse = apiBase ? normalizeApiBase(apiBase) : detectedBase; if (usageServiceMode && !apiBase.trim()) { - setError(t('login.cpa_address_required', { defaultValue: '请输入 CPA 地址' })); + setError(t('login.cpa_address_required')); + return; + } + const parsedPollIntervalMs = Number(pollIntervalMs); + if ( + usageServiceMode && + requestMonitoringEnabled && + (!/^\d+$/.test(pollIntervalMs.trim()) || !Number.isFinite(parsedPollIntervalMs) || parsedPollIntervalMs <= 0) + ) { + setError(t('login.poll_interval_invalid')); return; } @@ -186,6 +293,9 @@ export function LoginPage() { await usageServiceApi.setup(detectedBase, { cpaBaseUrl: baseToUse, managementKey: managementKey.trim(), + pollIntervalMs: requestMonitoringEnabled ? parsedPollIntervalMs : undefined, + ensureUsageStatisticsEnabled: requestMonitoringEnabled, + requestMonitoringEnabled, }); setUsageServiceConfig({ enabled: true, serviceBase: detectedBase }); localStorage.setItem(USAGE_SERVICE_LAST_CPA_BASE_KEY, baseToUse); @@ -207,14 +317,18 @@ export function LoginPage() { }, [ apiBase, detectedBase, + handleUsageSetupNext, login, managementKey, navigate, + pollIntervalMs, + requestMonitoringEnabled, rememberPassword, showNotification, setUsageServiceConfig, t, usageServiceMode, + usageSetupIsLastStep, ]); const handleSubmitKeyDown = useCallback( @@ -260,125 +374,322 @@ export function LoginPage() {
) : ( /* 登录表单 */ -
+
{/* Logo */} - Logo + {!usageServiceMode && Logo} {/* 登录表单卡片 */} -
-
-
-
{t('title.login')}
- +
+ Logo +

CPA Manager

+

{t('login.setup_title')}

-
{apiBase || detectedBase}
-
- {usageServiceMode - ? t('login.usage_service_mode_hint', { - defaultValue: '当前面板由 Usage Service 托管,请填写 CPA API 地址。', - }) - : t('login.connection_auto_hint')} + ) : ( +
+
+
{t('title.login')}
+ setApiBase(e.target.value)} + onKeyDown={handleSubmitKeyDown} + hint={t('login.cpa_connection_hint')} + /> +
+ )} + + {usageSetupStep === 'auth' && ( +
+
+ setManagementKey(e.target.value)} + onKeyDown={handleSubmitKeyDown} + /> +
+ +
+
+
+ )} + + {usageSetupStep === 'monitoring' && ( +
+
+ +

+ {requestMonitoringEnabled + ? t('login.request_monitoring_enabled_hint') + : t('login.request_monitoring_disabled_hint')} +

+
+
+ )} + + {usageSetupStep === 'polling' && ( +
+ setPollIntervalMs(e.target.value)} + onKeyDown={handleSubmitKeyDown} + hint={t('login.poll_interval_hint')} + /> +
+ )} + + {usageSetupStep === 'review' && ( +
+
+ + + + {t('login.cpa_connection_label')} + {apiBase || '-'} +
+
+ + + + {t('login.management_key_label')} + {managementKey ? '••••••••••••' : '-'} +
+
+ + + + {t('login.request_monitoring_enabled')} + + {requestMonitoringEnabled + ? t('common.enabled') + : t('common.disabled')} + +
+ {requestMonitoringEnabled && ( +
+ + + + {t('login.poll_interval_label')} + {pollIntervalMs} +
+ )} +
+ + + + {t('login.remember_password_label')} + + {rememberPassword + ? t('common.enabled') + : t('common.disabled')} + +
+
+ )} +
+ + {error &&
{error}
} + +
+ + {usageSetupIsLastStep ? ( + + ) : ( + + )} +
)} - {(showCustomBase || usageServiceMode) && ( - setApiBase(e.target.value)} - hint={ - usageServiceMode - ? t('login.cpa_connection_hint', { - defaultValue: 'Usage Service 会用该地址连接 CPA Management API 和 RESP 用量队列。', - }) - : t('login.custom_connection_hint') - } - /> - )} + {!usageServiceMode && ( + <> +
+
{t('login.connection_current')}
+
{apiBase || detectedBase}
+
{t('login.connection_auto_hint')}
+
- setManagementKey(e.target.value)} - onKeyDown={handleSubmitKeyDown} - rightElement={ - } - > - {showKey ? : } - - } - /> - -
- -
- - - - {error &&
{error}
} + /> + +
+ +
+ + + + {error &&
{error}
} + + )}
)} diff --git a/src/pages/MonitoringCenterPage.module.scss b/src/pages/MonitoringCenterPage.module.scss index 739b9a341..fd63b8c49 100644 --- a/src/pages/MonitoringCenterPage.module.scss +++ b/src/pages/MonitoringCenterPage.module.scss @@ -880,6 +880,16 @@ background: color-mix(in srgb, var(--monitor-accent) 8%, var(--monitor-surface)); } +.configLink { + color: var(--monitor-accent); + font-weight: 700; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +} + .summarySection { display: grid; gap: 12px; diff --git a/src/pages/MonitoringCenterPage.tsx b/src/pages/MonitoringCenterPage.tsx index 66c279279..cf26ca32b 100644 --- a/src/pages/MonitoringCenterPage.tsx +++ b/src/pages/MonitoringCenterPage.tsx @@ -84,6 +84,7 @@ import { MonitoringPanel } from '@/features/monitoring/components/MonitoringPane import { useUsageData } from '@/features/monitoring/hooks/useUsageData'; import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; import { useInterval } from '@/hooks/useInterval'; +import { useRequestMonitoringAvailability } from '@/hooks/useRequestMonitoringAvailability'; import { apiCallApi, authFilesApi, getApiCallErrorMessage } from '@/services/api'; import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; import type { @@ -1901,6 +1902,7 @@ export function MonitoringCenterPage() { const connectionStatus = useAuthStore((state) => state.connectionStatus); const showNotification = useNotificationStore((state) => state.showNotification); const showConfirmation = useNotificationStore((state) => state.showConfirmation); + const requestMonitoringAvailability = useRequestMonitoringAvailability(); const [timeRange, setTimeRange] = useState('today'); const [customStartInput, setCustomStartInput] = useState(getTodayStartInputValue); const [customEndInput, setCustomEndInput] = useState(getCurrentInputValue); @@ -2055,8 +2057,22 @@ export function MonitoringCenterPage() { connectionStatus === 'connected' && Number(autoRefreshMs) > 0 ? Number(autoRefreshMs) : null ); - const overallLoading = usageLoading || monitoringLoading; - const combinedError = [usageError, monitoringError].filter(Boolean).join(';'); + const monitoringUnavailable = + !requestMonitoringAvailability.checking && !requestMonitoringAvailability.available; + const monitoringUnavailableTitle = + requestMonitoringAvailability.reason === 'monitoring_disabled' + ? t('monitoring.request_monitoring_disabled_title') + : t('monitoring.request_monitoring_unavailable_title'); + const monitoringUnavailableBody = + requestMonitoringAvailability.reason === 'monitoring_disabled' + ? t('monitoring.request_monitoring_disabled_body') + : requestMonitoringAvailability.reason === 'service_unavailable' + ? t('monitoring.request_monitoring_service_unavailable_body') + : t('monitoring.request_monitoring_not_configured_body'); + const overallLoading = usageLoading || monitoringLoading || requestMonitoringAvailability.checking; + const combinedError = monitoringUnavailable + ? monitoringError + : [usageError, monitoringError].filter(Boolean).join(';'); const hasPrices = Object.keys(modelPrices).length > 0; useEffect(() => { @@ -2967,6 +2983,20 @@ export function MonitoringCenterPage() {
+ {monitoringUnavailable ? ( +
+ {monitoringUnavailableTitle} + {monitoringUnavailableBody} + localStorage.setItem('config-management:tab', 'manager')} + > + {t('monitoring.open_manager_config')} + +
+ ) : null} +
- } - > -

- {t('usage_service.description', { - defaultValue: - '当面板由 CPA 自动载入时,可配置独立部署的 Usage Service 读取 SQLite 持久化用量;Docker 内置模式通常不需要配置这里。', - })} -

-
- - setUsageServiceDraftBase(event.target.value)} - disabled={!usageServiceDraftEnabled} - /> - {usageServiceStatus ? ( -
-
- {t('usage_service.collector', { defaultValue: '采集器' })} - {usageServiceStatus.collector?.collector || '-'} -
-
- {t('usage_service.mode', { defaultValue: '采集模式' })} - {usageServiceStatus.collector?.mode || '-'} -
-
- {t('usage_service.transport', { defaultValue: '传输协议' })} - {usageServiceStatus.collector?.transport || '-'} -
-
- {t('usage_service.queue', { defaultValue: '队列' })} - - {usageServiceStatus.collector?.queue || '-'} - -
-
- {t('usage_service.events', { defaultValue: '事件数' })} - {usageServiceStatus.events ?? '-'} -
-
- {t('usage_service.last_consumed', { defaultValue: '最后消费' })} - - {usageServiceStatus.collector?.lastConsumedAt - ? new Date(usageServiceStatus.collector.lastConsumedAt).toLocaleString(i18n.language) - : '-'} - -
-
- {t('usage_service.last_error', { defaultValue: '最后错误' })} - {usageServiceStatus.collector?.lastError || '-'} -
-
- ) : null} -
- - -
-
- -

{t('system_info.clear_login_desc')}

diff --git a/src/services/api/usageService.ts b/src/services/api/usageService.ts index 81de185f8..3c34a1c1b 100644 --- a/src/services/api/usageService.ts +++ b/src/services/api/usageService.ts @@ -3,6 +3,32 @@ import type { UsagePayload } from '@/features/monitoring/hooks/useUsageData'; import { normalizeApiBase } from '@/utils/connection'; import type { ModelPrice } from '@/utils/usage'; +const USAGE_SERVICE_ERROR_CODES = new Set([ + 'request_failed', + 'connection_env_managed', + 'cpa_connection_required', + 'cpa_connection_required_for_monitoring', + 'management_api_validation_failed', + 'management_api_config_failed', + 'cpa_usage_retention_invalid', + 'poll_interval_exceeds_retention', + 'enable_cpa_usage_statistics_failed', + 'setup_env_managed', + 'invalid_existing_management_key', + 'invalid_management_key', + 'usage_service_not_configured', + 'prices_required', + 'model_price_sync_failed', + 'method_not_allowed', +]); + +export interface UsageServiceApiError extends Error { + status?: number; + code?: string; + details?: unknown; + data?: unknown; +} + export interface UsageServiceInfo { service?: string; mode?: string; @@ -34,8 +60,55 @@ export interface UsageServiceStatus { export interface UsageServiceSetupRequest { cpaBaseUrl: string; managementKey: string; + collectorMode?: string; queue?: string; popSide?: string; + batchSize?: number; + pollIntervalMs?: number; + queryLimit?: number; + tlsSkipVerify?: boolean; + ensureUsageStatisticsEnabled?: boolean; + requestMonitoringEnabled?: boolean; +} + +export interface ManagerCPAConnectionConfig { + cpaBaseUrl: string; + managementKey?: string; +} + +export interface ManagerCollectorConfig { + enabled?: boolean; + collectorMode: string; + queue: string; + popSide: string; + batchSize: number; + pollIntervalMs: number; + queryLimit: number; + tlsSkipVerify?: boolean; +} + +export interface ManagerExternalUsageServiceConfig { + enabled: boolean; + serviceBase: string; +} + +export interface ManagerConfig { + cpaConnection: ManagerCPAConnectionConfig; + collector: ManagerCollectorConfig; + externalUsageService: ManagerExternalUsageServiceConfig; + updatedAtMs?: number; +} + +export interface CPAUsageConfig { + usageStatisticsEnabled: boolean; + redisUsageQueueRetentionSeconds: number; + retentionSourceDefault?: boolean; +} + +export interface ManagerConfigResponse { + config: ManagerConfig; + source?: 'env' | 'db' | ''; + cpaUsage?: CPAUsageConfig; } export interface ModelPricesResponse { @@ -83,6 +156,77 @@ const buildUrl = (base: string, path: string): string => { const authHeaders = (managementKey?: string) => managementKey ? { Authorization: `Bearer ${managementKey}` } : undefined; +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object'; + +const readUsageServiceErrorCode = (value: unknown): string => { + if (!isRecord(value) || typeof value.code !== 'string') return ''; + return USAGE_SERVICE_ERROR_CODES.has(value.code) ? value.code : ''; +}; + +const fallbackUsageServiceCodeByStatus = (status?: number): string => { + switch (status) { + case 401: + return 'invalid_management_key'; + case 405: + return 'method_not_allowed'; + case 412: + return 'usage_service_not_configured'; + default: + return ''; + } +}; + +export const getUsageServiceErrorCode = (error: unknown): string => { + if (axios.isAxiosError(error)) { + return ( + readUsageServiceErrorCode(error.response?.data) || + fallbackUsageServiceCodeByStatus(error.response?.status) + ); + } + + if (!isRecord(error)) return ''; + const code = typeof error.code === 'string' ? error.code : ''; + if (USAGE_SERVICE_ERROR_CODES.has(code)) return code; + return readUsageServiceErrorCode(error.data) || readUsageServiceErrorCode(error.details); +}; + +const readUsageServiceErrorMessage = (value: unknown): string => { + if (!isRecord(value)) return ''; + if (typeof value.error === 'string') return value.error; + if (typeof value.message === 'string') return value.message; + return ''; +}; + +const toUsageServiceApiError = (error: unknown): UsageServiceApiError => { + if (axios.isAxiosError(error)) { + const data = error.response?.data; + const message = readUsageServiceErrorMessage(data) || error.message || 'Usage Service request failed'; + const apiError = new Error(message) as UsageServiceApiError; + apiError.name = 'UsageServiceApiError'; + apiError.status = error.response?.status; + apiError.code = getUsageServiceErrorCode(error) || error.code; + apiError.details = data; + apiError.data = data; + return apiError; + } + + if (error instanceof Error) return error as UsageServiceApiError; + const fallback = new Error( + typeof error === 'string' ? error : 'Usage Service request failed' + ) as UsageServiceApiError; + fallback.name = 'UsageServiceApiError'; + return fallback; +}; + +const withUsageServiceError = async (operation: () => Promise): Promise => { + try { + return await operation(); + } catch (error) { + throw toUsageServiceApiError(error); + } +}; + const readHeader = (headers: unknown, name: string): string => { if (!headers || typeof headers !== 'object') return ''; const getter = (headers as { get?: (key: string) => unknown }).get; @@ -113,46 +257,90 @@ const parseContentDispositionFilename = (value: string): string => { export const usageServiceApi = { getInfo: async (base: string): Promise => { - const response = await axios.get(buildUrl(base, '/usage-service/info'), { - timeout: USAGE_SERVICE_TIMEOUT_MS, + return withUsageServiceError(async () => { + const response = await axios.get(buildUrl(base, '/usage-service/info'), { + timeout: USAGE_SERVICE_TIMEOUT_MS, + }); + return response.data; }); - return response.data; }, setup: async (base: string, payload: UsageServiceSetupRequest): Promise => { - await axios.post(buildUrl(base, '/setup'), payload, { - timeout: USAGE_SERVICE_TIMEOUT_MS, + await withUsageServiceError(async () => { + await axios.post(buildUrl(base, '/setup'), payload, { + timeout: USAGE_SERVICE_TIMEOUT_MS, + }); + }); + }, + + getManagerConfig: async ( + base: string, + managementKey?: string + ): Promise => { + return withUsageServiceError(async () => { + const response = await axios.get( + buildUrl(base, '/usage-service/config'), + { + timeout: USAGE_SERVICE_TIMEOUT_MS, + headers: authHeaders(managementKey), + } + ); + return response.data; + }); + }, + + saveManagerConfig: async ( + base: string, + config: ManagerConfig, + managementKey?: string + ): Promise => { + return withUsageServiceError(async () => { + const response = await axios.put( + buildUrl(base, '/usage-service/config'), + { config }, + { + timeout: USAGE_SERVICE_TIMEOUT_MS, + headers: authHeaders(managementKey), + } + ); + return response.data; }); }, getStatus: async (base: string, managementKey?: string): Promise => { - const response = await axios.get(buildUrl(base, '/status'), { - timeout: USAGE_SERVICE_TIMEOUT_MS, - headers: authHeaders(managementKey), + return withUsageServiceError(async () => { + const response = await axios.get(buildUrl(base, '/status'), { + timeout: USAGE_SERVICE_TIMEOUT_MS, + headers: authHeaders(managementKey), + }); + return response.data; }); - return response.data; }, getUsage: async (base: string, managementKey?: string): Promise => { - const response = await axios.get(buildUrl(base, '/v0/management/usage'), { - timeout: USAGE_SERVICE_TIMEOUT_MS, - headers: authHeaders(managementKey), + return withUsageServiceError(async () => { + const response = await axios.get(buildUrl(base, '/v0/management/usage'), { + timeout: USAGE_SERVICE_TIMEOUT_MS, + headers: authHeaders(managementKey), + }); + return response.data; }); - return response.data; }, getModelPrices: async ( base: string, managementKey?: string ): Promise => { - const response = await axios.get( - buildUrl(base, '/v0/management/model-prices'), - { - timeout: USAGE_SERVICE_TIMEOUT_MS, - headers: authHeaders(managementKey), - } - ); - return response.data; + return withUsageServiceError(async () => { + const response = await axios.get( + buildUrl(base, '/v0/management/model-prices'), + { + timeout: USAGE_SERVICE_TIMEOUT_MS, + headers: authHeaders(managementKey), + } + ); + return response.data; + }); }, saveModelPrices: async ( @@ -160,15 +348,17 @@ export const usageServiceApi = { prices: Record, managementKey?: string ): Promise => { - const response = await axios.put( - buildUrl(base, '/v0/management/model-prices'), - { prices }, - { - timeout: USAGE_SERVICE_TIMEOUT_MS, - headers: authHeaders(managementKey), - } - ); - return response.data; + return withUsageServiceError(async () => { + const response = await axios.put( + buildUrl(base, '/v0/management/model-prices'), + { prices }, + { + timeout: USAGE_SERVICE_TIMEOUT_MS, + headers: authHeaders(managementKey), + } + ); + return response.data; + }); }, syncModelPrices: async ( @@ -176,31 +366,35 @@ export const usageServiceApi = { managementKey?: string, models?: string[] ): Promise => { - const response = await axios.post( - buildUrl(base, '/v0/management/model-prices/sync'), - models ? { models } : {}, - { - timeout: 30 * 1000, - headers: authHeaders(managementKey), - } - ); - return response.data; + return withUsageServiceError(async () => { + const response = await axios.post( + buildUrl(base, '/v0/management/model-prices/sync'), + models ? { models } : {}, + { + timeout: 30 * 1000, + headers: authHeaders(managementKey), + } + ); + return response.data; + }); }, exportUsage: async ( base: string, managementKey?: string ): Promise => { - const response = await axios.get(buildUrl(base, '/v0/management/usage/export'), { - timeout: USAGE_SERVICE_TRANSFER_TIMEOUT_MS, - headers: authHeaders(managementKey), - responseType: 'blob', + return withUsageServiceError(async () => { + const response = await axios.get(buildUrl(base, '/v0/management/usage/export'), { + timeout: USAGE_SERVICE_TRANSFER_TIMEOUT_MS, + headers: authHeaders(managementKey), + responseType: 'blob', + }); + const contentDisposition = readHeader(response.headers, 'content-disposition'); + return { + blob: response.data, + filename: parseContentDispositionFilename(contentDisposition) || 'usage-events.jsonl', + }; }); - const contentDisposition = readHeader(response.headers, 'content-disposition'); - return { - blob: response.data, - filename: parseContentDispositionFilename(contentDisposition) || 'usage-events.jsonl', - }; }, importUsage: async ( @@ -208,14 +402,16 @@ export const usageServiceApi = { payload: Blob | string, managementKey?: string ): Promise => { - const response = await axios.post( - buildUrl(base, '/v0/management/usage/import'), - payload, - { - timeout: USAGE_SERVICE_TRANSFER_TIMEOUT_MS, - headers: authHeaders(managementKey), - } - ); - return response.data; + return withUsageServiceError(async () => { + const response = await axios.post( + buildUrl(base, '/v0/management/usage/import'), + payload, + { + timeout: USAGE_SERVICE_TRANSFER_TIMEOUT_MS, + headers: authHeaders(managementKey), + } + ); + return response.data; + }); }, }; diff --git a/src/stores/useUsageServiceStore.ts b/src/stores/useUsageServiceStore.ts index c3df3c795..74638e13d 100644 --- a/src/stores/useUsageServiceStore.ts +++ b/src/stores/useUsageServiceStore.ts @@ -6,6 +6,7 @@ import { normalizeUsageServiceBase } from '@/services/api/usageService'; export interface UsageServiceStoreState { enabled: boolean; serviceBase: string; + revision: number; setUsageServiceConfig: (config: { enabled: boolean; serviceBase: string }) => void; clearUsageServiceConfig: () => void; } @@ -15,13 +16,16 @@ export const useUsageServiceStore = create()( (set) => ({ enabled: false, serviceBase: '', + revision: 0, setUsageServiceConfig: ({ enabled, serviceBase }) => { - set({ + set((state) => ({ enabled, serviceBase: enabled ? normalizeUsageServiceBase(serviceBase) : '', - }); + revision: state.revision + 1, + })); }, - clearUsageServiceConfig: () => set({ enabled: false, serviceBase: '' }), + clearUsageServiceConfig: () => + set((state) => ({ enabled: false, serviceBase: '', revision: state.revision + 1 })), }), { name: 'cli-proxy-usage-service', From e4dc60053ed9692cccf8f071ce8574d4815e946e Mon Sep 17 00:00:00 2001 From: seakee Date: Wed, 13 May 2026 18:32:45 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E2=9C=A8=20docs(readme):=20document=20CPA-?= =?UTF-8?q?Manager=20config=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the English and Chinese README files to describe the CPA versus CPA-Manager configuration boundary and the new persisted manager configuration flow. Document setup behavior, queue retention constraints, request monitoring disable semantics, migration steps, and the updated Usage Service endpoints. The documentation reduces upgrade ambiguity for users moving from environment variables, config files, or legacy setup storage to SQLite-backed CPAM settings. Refs #81. --- README.md | 42 +++++++++++++++++++++++++++++++----------- README_CN.md | 42 +++++++++++++++++++++++++++++++----------- 2 files changed, 62 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b5f937abc..e1440b278 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ Since v6.10.0, CPA no longer includes built-in usage statistics. This project no | Mode | Entry URL | What the user configures | Best for | |---|---|---|---| | Full Docker mode | `http://:18317/management.html` | CPA URL + Management Key on login | New deployments, one entry point, least browser/CORS complexity | -| CPA panel mode | `http://:8317/management.html` | Usage Service URL under **Management Center Info -> External Usage Service** | Existing CPA automatic panel loading | +| CPA panel mode | `http://:8317/management.html` | Usage Service URL under **Configuration -> CPA-Manager Configuration** | Existing CPA automatic panel loading | | Frontend only | Vite dev server or `dist/index.html` | CPA URL, optionally Usage Service URL | Development | Full Docker mode does not bundle CPA itself. CPA still runs as the upstream service; the Docker image provides the Usage Service plus an embedded copy of this management panel. @@ -42,10 +42,12 @@ Full Docker mode does not bundle CPA itself. CPA still runs as the upstream serv Request statistics require the CPA usage queue: - CPA Management must be enabled because the usage queue uses the same availability and Management Key as `/v0/management`. -- Enable usage publishing in CPA with `usage-statistics-enabled: true`, or through `PUT /usage-statistics-enabled` with `{ "value": true }`. +- Request monitoring requires CPA usage publishing: set `usage-statistics-enabled: true`, or submit `{ "value": true }` to `PUT /usage-statistics-enabled`. CPA-Manager enables this automatically when request monitoring is enabled during setup or configuration save. +- Disabling CPAM request monitoring only stops the Usage Service collector. It does not automatically disable CPA usage publishing or clear the CPA usage queue. If CPA usage publishing remains enabled, re-enabling request monitoring within the queue retention window may collect events retained while the collector was stopped. - CPA `v6.10.8+` is preferred because it exposes the HTTP usage queue endpoint `/v0/management/usage-queue`, which can pass through regular HTTP reverse proxies. - Older CPA versions use the RESP queue protocol. Usage Service falls back to RESP in `auto` mode when the HTTP queue endpoint is unavailable. RESP listens on the CPA API port, usually `8317`, and cannot pass through a regular HTTP reverse proxy. - CPA keeps queue items in memory for `redis-usage-queue-retention-seconds`, default `60` seconds and maximum `3600` seconds. Keep Usage Service running continuously. +- Usage Service `pollIntervalMs` must be less than or equal to the CPA queue retention window converted to milliseconds. Saves are rejected when the collector would poll too slowly and risk expired queue items. - Exactly one Usage Service should consume the same CPA usage queue. ## Architecture @@ -62,7 +64,7 @@ Browser -> SQLite /data/usage.sqlite ``` -The login page detects that it is hosted by Usage Service. You enter the CPA URL and Management Key. Usage Service validates the CPA Management API, stores the setup in SQLite, starts the collector with the configured mode (`auto` by default: HTTP queue first, RESP fallback), and serves the panel from the same origin. +The login page detects that it is hosted by Usage Service. You enter the CPA URL, Management Key, and choose whether to enable request monitoring. When monitoring is enabled, you also set the collector polling interval; Usage Service validates the CPA Management API, enables CPA usage publishing, checks that the poll interval does not exceed the CPA queue retention window, stores CPA-Manager configuration in SQLite, starts the collector with the configured mode (`auto` by default: HTTP queue first, RESP fallback), and serves the panel from the same origin. When monitoring is disabled, the CPA connection is still saved for Management API proxying, but CPA usage publishing and the collector stay off. ### CPA Panel Mode @@ -77,7 +79,7 @@ Usage Service -> SQLite /data/usage.sqlite ``` -Use this when CPA still auto-downloads and serves the panel. Deploy Usage Service separately, then open **Management Center Info -> External Usage Service**, enable it, enter the Usage Service URL, and save. +Use this when CPA still auto-downloads and serves the panel. Request monitoring is optional; when Usage Service is not deployed, the panel hides the request monitoring entry and direct visits to the monitoring page show a setup hint. To use request monitoring, deploy Usage Service separately, then open **Configuration -> CPA-Manager Configuration**, enable it, enter the Usage Service URL, and save. ## Quick Start: Full Docker Mode @@ -209,7 +211,7 @@ Then enter `http://host.docker.internal:8317` as the CPA URL. 3. In the CPA panel, go to: ```text - Management Center Info -> External Usage Service + Configuration -> CPA-Manager Configuration ``` 4. Enable it and enter: @@ -218,7 +220,7 @@ Then enter `http://host.docker.internal:8317` as the CPA URL. http://:18317 ``` -5. Click **Save and connect**. +5. Save the CPA-Manager configuration. The panel sends the current CPA URL and Management Key to Usage Service. After that, monitoring reads usage data from Usage Service while other management calls continue to use CPA. @@ -232,7 +234,7 @@ This builds the React panel and embeds it into the Go Usage Service binary. ## Usage Service Configuration -Most users can configure CPA URL and Management Key from the panel. Environment variables are useful for automated deployments. +Most users can configure CPA URL, Management Key, request monitoring enablement, collection mode, and polling interval from **Configuration -> CPA-Manager Configuration**. CPA-Manager configuration is persisted in SQLite. Environment variables are mainly for first bootstrap and unattended deployments. | Variable | Default | Description | |---|---:|---| @@ -253,7 +255,7 @@ Most users can configure CPA URL and Management Key from the panel. Environment | `USAGE_RESP_TLS_SKIP_VERIFY` | `false` | Skip TLS verification for RESP connection | | `PANEL_PATH` | empty | Serve a custom `management.html` instead of the embedded one | -Configuration precedence is: environment variables > `config.json` > program defaults. Relative paths in the config file are resolved from the config file directory. The generated default config is: +Startup configuration precedence is: environment variables > `config.json` > program defaults. Relative paths in the config file are resolved from the config file directory. The generated default config is: ```json { @@ -262,16 +264,32 @@ Configuration precedence is: environment variables > `config.json` > program def } ``` -If `CPA_UPSTREAM_URL` and `CPA_MANAGEMENT_KEY` are set, collection starts automatically on boot. Otherwise, use the web panel setup flow. +If `CPA_UPSTREAM_URL` and `CPA_MANAGEMENT_KEY` are set, collection starts automatically on boot and the connection is shown as environment-managed in the panel. Otherwise, use the web panel setup flow; the result is saved to SQLite `settings.manager_config_v1`. The legacy `settings.setup` value is still written for compatibility and rollback. + +### CPA vs CPA-Manager Configuration Boundary + +- **CPA configuration**: `usage-statistics-enabled`, `redis-usage-queue-retention-seconds`, proxy, logging, routing, auth files, and related fields still belong to CPA and are managed by `/config` / `/config.yaml`. +- **CPA-Manager configuration**: CPA URL, Management Key, request monitoring enablement, Usage Service collection mode, `pollIntervalMs`, `batchSize`, `queryLimit`, and the CPA panel mode Usage Service bootstrap URL are persisted in Usage Service SQLite. +- The configuration panel shows CPA and CPA-Manager settings separately. Saving CPAM settings does not write to CPA `config.yaml`; enabling request monitoring calls CPA Management API to enable usage publishing, while disabling request monitoring only stops the CPAM collector. + +### Migration Guide + +1. Back up the Usage Service data directory, especially `/data/usage.sqlite`. +2. After upgrading, open **Configuration -> CPA-Manager Configuration** and verify the CPA URL, request monitoring switch, collection mode, and polling interval. Older stored configs without the switch are treated as monitoring enabled. +3. If an older version already saved CPA URL and Management Key through `/setup`, the service can read `settings.setup` as a fallback and writes the new `settings.manager_config_v1` structure on the next save. +4. If you use `CPA_UPSTREAM_URL` / `CPA_MANAGEMENT_KEY`, the connection remains environment-managed. To switch to panel persistence, remove those environment variables, restart, and save from the panel. +5. In CPA panel mode, the browser still needs the Usage Service URL before it can read that service's SQLite configuration. Once entered, the value is saved to SQLite and kept in local storage as bootstrap data. ## Data and Security Notes - SQLite data is stored under `/data`; mount it to persistent storage. - In full Docker mode, CPA URL and Management Key are stored in the SQLite `settings` table so collection can resume after restart. +- New versions prefer SQLite `settings.manager_config_v1`; legacy `settings.setup` is kept as compatibility data. - Protect the `/data` volume. It contains usage metadata and the saved Management Key. - Usage Service redacts key-like fields before storing raw JSON payload snapshots, but request metadata may still expose models, endpoints, account labels, and token usage. - RESP queue consumption is pop-based. Do not run multiple Usage Service consumers against the same CPA instance. - If Usage Service is down longer than CPA's queue retention window, that period's usage cannot be recovered without CPA-side persistence. +- If only the CPAM collector is stopped while CPA usage publishing remains enabled, restarting the collector within the retention window may consume queue items produced while collection was disabled. ## Runtime Endpoints @@ -280,6 +298,8 @@ If `CPA_UPSTREAM_URL` and `CPA_MANAGEMENT_KEY` are set, collection starts automa | `GET /health` | Basic health check | | `GET /status` | Collector, SQLite, event count, and error status | | `GET /usage-service/info` | Allows the frontend to detect full Docker mode | +| `GET /usage-service/config` | Reads persistent CPA-Manager configuration and CPA usage publishing status | +| `PUT /usage-service/config` | Saves CPA-Manager configuration and restarts the collector when needed | | `POST /setup` | Save CPA URL + Management Key and start collection | | `GET /v0/management/usage` | Compatible usage payload for the panel | | `GET /v0/management/usage/export` | Export usage events as JSONL | @@ -297,14 +317,14 @@ Usage import accepts two file families: JSONL/NDJSON event files exported by Usa ## Feature Overview - **Dashboard**: connection state, backend version, quick health summary -- **Configuration**: visual and source editing for CPA configuration +- **Configuration**: visual/source editing for CPA configuration and separate CPA-Manager configuration - **AI Providers**: Gemini, Codex, Claude, Vertex, OpenAI-compatible providers, and Ampcode - **Auth Files**: upload, download, delete, status, OAuth exclusions, model aliases - **Quota**: quota views for supported providers - **Request Monitoring**: persisted usage KPIs, model/channel/account breakdowns, model pricing, estimated token cost, failure analysis, realtime tables - **Codex Account Inspection**: batch probing and cleanup suggestions for Codex auth pools - **Logs**: incremental file log reading and filtering -- **Management Center Info**: model list, version checks, local state tools, external Usage Service configuration +- **Management Center Info**: model list, version checks, and local state tools ## Development diff --git a/README_CN.md b/README_CN.md index 3a95a9780..03a47f27d 100644 --- a/README_CN.md +++ b/README_CN.md @@ -32,7 +32,7 @@ CPA 自 v6.10.0 起不再内置用量统计。当前方案通过常驻 Usage Ser | 模式 | 入口地址 | 用户需要配置 | 适用场景 | |---|---|---|---| | 完整 Docker 方案 | `http://:18317/management.html` | 登录时填写 CPA 地址 + Management Key | 新部署、单入口、最少浏览器/CORS 问题 | -| CPA 控制面板方案 | `http://:8317/management.html` | 在「中心信息 -> 外部用量统计服务」配置 Usage Service 地址 | 保留 CPA 自动载入面板的现有习惯 | +| CPA 控制面板方案 | `http://:8317/management.html` | 在「配置面板 -> CPA-Manager 配置」配置 Usage Service 地址 | 保留 CPA 自动载入面板的现有习惯 | | 前端开发方案 | Vite dev server 或 `dist/index.html` | CPA 地址,可选 Usage Service 地址 | 本地开发 | 完整 Docker 方案不内置 CPA 本体。CPA 仍然作为上游服务独立运行;Docker 镜像提供 Usage Service 和内置管理面板。 @@ -42,10 +42,12 @@ CPA 自 v6.10.0 起不再内置用量统计。当前方案通过常驻 Usage Ser 请求统计依赖 CPA 的用量队列: - CPA 必须启用 Management,因为用量队列与 `/v0/management` 使用相同的可用性条件和 Management Key。 -- 在 CPA 中启用用量发布:配置 `usage-statistics-enabled: true`,或通过 `PUT /usage-statistics-enabled` 提交 `{ "value": true }`。 +- 使用请求监控时,CPA 必须启用用量发布:配置 `usage-statistics-enabled: true`,或通过 `PUT /usage-statistics-enabled` 提交 `{ "value": true }`。CPA-Manager 初始化或保存启用请求监控时会自动打开该开关。 +- 关闭 CPAM 请求监控只会停止 Usage Service 采集器,不会自动关闭 CPA 用量发布或清空 CPA 用量队列。如果 CPA 用量发布仍开启,在队列保留时间内再次启用请求监控,可能会采集到关闭采集器期间保留的数据。 - CPA `v6.10.8+` 推荐使用 HTTP 用量队列接口 `/v0/management/usage-queue`,可通过普通 HTTP 反代访问。 - 旧版 CPA 使用 RESP 队列协议。Usage Service 在 `auto` 模式下,如果 HTTP 队列接口不可用,会回退到 RESP。RESP 监听在 CPA API 端口,通常是 `8317`,不能通过普通 HTTP 反代转发。 - CPA 在内存中保留队列项的时间由 `redis-usage-queue-retention-seconds` 控制,默认 `60` 秒,最大 `3600` 秒。Usage Service 应保持常驻运行。 +- Usage Service 的 `pollIntervalMs` 必须小于等于 CPA 队列保留时间换算后的毫秒值;否则服务会拒绝保存,避免空闲轮询过慢导致队列项过期。 - 同一个 CPA 实例只应有一个 Usage Service 消费用量队列。 ## 架构 @@ -62,7 +64,7 @@ CPA 自 v6.10.0 起不再内置用量统计。当前方案通过常驻 Usage Ser -> SQLite /data/usage.sqlite ``` -登录页会识别当前由 Usage Service 托管。你填写 CPA 地址和 Management Key 后,Usage Service 会验证 CPA Management API,保存设置到 SQLite,按配置的采集模式启动采集器(默认 `auto`:优先 HTTP 队列,旧版回退 RESP),并从同源提供完整管理面板。 +登录页会识别当前由 Usage Service 托管。你填写 CPA 地址、Management Key,并选择是否启用请求监控。启用时还需要填写采集轮询间隔,Usage Service 会验证 CPA Management API,启用 CPA 用量统计,校验采集间隔不超过 CPA 队列保留时间,把 CPA-Manager 配置保存到 SQLite,按配置的采集模式启动采集器(默认 `auto`:优先 HTTP 队列,旧版回退 RESP),并从同源提供完整管理面板。关闭请求监控时仍会保存 CPA 连接用于反代管理接口,但不会启用 CPA 用量统计或启动采集器。 ### CPA 控制面板方案 @@ -77,7 +79,7 @@ Usage Service -> SQLite /data/usage.sqlite ``` -当你希望保留 CPA 自动下载并托管面板的机制时,使用这个方案。单独部署 Usage Service,然后在「中心信息 -> 外部用量统计服务」中启用并填写地址。 +当你希望保留 CPA 自动下载并托管面板的机制时,使用这个方案。请求监控是可选能力;如果没有部署 Usage Service,面板会自动隐藏请求监控入口,直接访问监控页时会提示先部署并配置 Usage Service。需要请求监控时,单独部署 Usage Service,然后在面板的「配置面板 -> CPA-Manager 配置」中启用并填写地址。 ## 快速开始:完整 Docker 方案 @@ -209,7 +211,7 @@ docker run -d \ 3. 在 CPA 面板进入: ```text - 中心信息 -> 外部用量统计服务 + 配置面板 -> CPA-Manager 配置 ``` 4. 启用并填写: @@ -218,7 +220,7 @@ docker run -d \ http://:18317 ``` -5. 点击「保存并连接」。 +5. 保存 CPA-Manager 配置。 面板会把当前 CPA 地址和 Management Key 发送给 Usage Service。之后监控页从 Usage Service 读取用量数据,其他管理功能仍然访问 CPA。 @@ -232,7 +234,7 @@ docker compose -f docker-compose.usage.yml up --build ## Usage Service 配置项 -大多数用户可以直接在面板中配置 CPA 地址和 Management Key。环境变量适合自动化部署。 +大多数用户可以直接在面板的「配置面板 -> CPA-Manager 配置」中配置 CPA 地址、Management Key、是否启用请求监控、采集模式和轮询间隔。CPA-Manager 配置会保存到 SQLite;环境变量更适合首次引导和无人值守部署。 | 变量 | 默认值 | 说明 | |---|---:|---| @@ -253,7 +255,7 @@ docker compose -f docker-compose.usage.yml up --build | `USAGE_RESP_TLS_SKIP_VERIFY` | `false` | RESP TLS 连接是否跳过证书校验 | | `PANEL_PATH` | 空 | 使用自定义 `management.html` 替代内置面板 | -配置优先级为:环境变量 > `config.json` > 程序默认值。配置文件中的相对路径按配置文件所在目录解析。默认生成的配置文件内容如下: +启动类配置的优先级为:环境变量 > `config.json` > 程序默认值。配置文件中的相对路径按配置文件所在目录解析。默认生成的配置文件内容如下: ```json { @@ -262,16 +264,32 @@ docker compose -f docker-compose.usage.yml up --build } ``` -如果设置了 `CPA_UPSTREAM_URL` 和 `CPA_MANAGEMENT_KEY`,服务启动后会自动开始采集。否则通过面板 setup 流程配置。 +如果设置了 `CPA_UPSTREAM_URL` 和 `CPA_MANAGEMENT_KEY`,服务启动后会自动开始采集,并作为环境变量管理的连接配置展示在面板中。否则通过面板 setup 流程配置,保存到 SQLite `settings.manager_config_v1`;旧版 `settings.setup` 会继续写入,用于兼容已有数据和回滚。 + +### CPA 与 CPA-Manager 配置边界 + +- **CPA 配置**:`usage-statistics-enabled`、`redis-usage-queue-retention-seconds`、代理、日志、路由、认证文件等仍属于 CPA,由 `/config` / `/config.yaml` 管理。 +- **CPA-Manager 配置**:CPA 连接地址、Management Key、请求监控开关、Usage Service 采集模式、`pollIntervalMs`、`batchSize`、`queryLimit`、CPA 控制面板模式下的 Usage Service 引导地址等保存到 Usage Service SQLite。 +- 配置面板会分开展示 CPA 与 CPA-Manager 配置。保存 CPAM 配置不会写入 CPA `config.yaml`;启用请求监控时会按要求调用 CPA Management API 启用用量统计,关闭请求监控时只停止 CPAM 采集器。 + +### 迁移指引 + +1. 备份 Usage Service 数据目录,尤其是 `/data/usage.sqlite`。 +2. 升级后首次打开面板,进入「配置面板 -> CPA-Manager 配置」检查 CPA 地址、请求监控开关、采集模式和轮询间隔。旧数据缺少请求监控开关时按已启用处理。 +3. 如果旧版本已经通过 `/setup` 保存过 CPA 地址和 Management Key,服务会从 `settings.setup` 自动生成新的 `settings.manager_config_v1` 视图,并在下次保存时写入新结构。 +4. 如果使用环境变量 `CPA_UPSTREAM_URL` / `CPA_MANAGEMENT_KEY`,连接配置仍由环境变量管理;要改为面板持久化,请移除环境变量后重启,再在面板保存。 +5. CPA 托管面板模式下,浏览器仍需要先知道 Usage Service 地址才能读取其数据库配置;首次填写后会同步写入 SQLite,并继续保留本地缓存作为 bootstrap。 ## 数据与安全说明 - SQLite 数据存储在 `/data`,必须挂载到持久化 volume 或宿主机目录。 - 完整 Docker 方案会把 CPA 地址和 Management Key 保存到 SQLite `settings` 表,用于容器重启后恢复采集。 +- 新版会优先读取 SQLite `settings.manager_config_v1`;旧 `settings.setup` 会保留为兼容数据。 - 请保护 `/data` volume,它包含用量元数据和保存的 Management Key。 - Usage Service 会在保存 raw JSON 快照前脱敏疑似密钥字段,但请求元数据仍可能暴露模型、接口、账号标签和 token 用量。 - RESP 队列是弹出式消费,不要让多个 Usage Service 同时消费同一个 CPA 实例。 - 如果 Usage Service 停机超过 CPA 队列保留时间,该时段用量无法在不修改 CPA 的情况下恢复。 +- 如果只关闭 CPAM 采集器而 CPA 用量发布仍开启,队列保留时间内重新开启采集器可能会消费停用期间仍保留的队列项。 ## 运行时接口 @@ -280,6 +298,8 @@ docker compose -f docker-compose.usage.yml up --build | `GET /health` | 基础健康检查 | | `GET /status` | 采集器、SQLite、事件数、错误状态 | | `GET /usage-service/info` | 让前端识别完整 Docker 方案 | +| `GET /usage-service/config` | 读取 CPA-Manager 持久化配置和 CPA 用量统计状态 | +| `PUT /usage-service/config` | 保存 CPA-Manager 配置,并按需重启采集器 | | `POST /setup` | 保存 CPA 地址和 Management Key,并启动采集 | | `GET /v0/management/usage` | 面板兼容用量数据 | | `GET /v0/management/usage/export` | JSONL 导出用量事件 | @@ -297,14 +317,14 @@ setup 后,`/status`、用量、模型价格和 `/v0/management/*` 反代接口 ## 功能概览 - **仪表盘**:连接状态、后端版本、快速健康概览 -- **配置管理**:可视化和源码模式编辑 CPA 配置 +- **配置管理**:可视化和源码模式编辑 CPA 配置,并单独管理 CPA-Manager 配置 - **AI 提供商**:Gemini、Codex、Claude、Vertex、OpenAI 兼容渠道、Ampcode - **认证文件**:上传、下载、删除、状态、OAuth 排除模型、模型别名 - **配额管理**:支持提供商的配额视图 - **请求监控**:持久化用量 KPI、模型/渠道/账号拆解、模型价格、Token 费用估算、失败分析、实时表格 - **Codex 账号巡检**:批量探测 Codex 认证池并给出清理建议 - **日志**:增量读取和筛选文件日志 -- **中心信息**:模型列表、版本检查、本地状态工具、外部 Usage Service 配置 +- **中心信息**:模型列表、版本检查、本地状态工具 ## 开发命令