Merge pull request #5356 from IanShaw027/feat/channel-monitor-v2-ops-ui

feat(channel-monitor-v2): 新增被动渠道监控 V2
This commit is contained in:
Wesley Liddick authored and GitHub committed 2026-08-09 12:25:37 +08:00
commit 909cbb9b12
87 files changed
+10453 -231

No files matched your search

+6 -1
View File
@@ -3,12 +3,17 @@
FRONTEND_CRITICAL_VITEST := \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/channelMonitorV2.spec.ts \
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
src/views/auth/__tests__/WechatCallbackView.spec.ts \
src/views/user/__tests__/PaymentView.spec.ts \
src/views/user/__tests__/PaymentResultView.spec.ts \
src/views/user/__tests__/ChannelStatusView.mode.spec.ts \
src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \
src/views/admin/__tests__/SettingsView.spec.ts
src/views/admin/__tests__/SettingsView.spec.ts \
src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \
src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \
src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts
# 一键编译前后端
build: build-backend build-frontend
+8 -1
View File
@@ -110,6 +110,7 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
@@ -319,7 +320,13 @@ func provideCleanup(
}
return nil
}},
{"ChannelMonitorRunner", func() error {
{"ChannelMonitorV2Aggregator", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorRunner != nil {
channelMonitorRunner.Stop()
}
+14 -3
View File
@@ -174,8 +174,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository)
announcementHandler := handler.NewAnnouncementHandler(announcementService)
channelMonitorRepository := repository.NewChannelMonitorRepository(client, db)
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor, settingService)
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
channelMonitorV2Repository := repository.NewChannelMonitorV2Repository(db)
channelMonitorV2Service := service.ProvideChannelMonitorV2Service(channelMonitorV2Repository, settingService)
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service)
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
@@ -308,7 +311,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
@@ -331,8 +334,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
@@ -398,6 +402,7 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
@@ -606,6 +611,12 @@ func provideCleanup(
}
return nil
}},
{"ChannelMonitorV2Aggregator", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorRunner != nil {
channelMonitorRunner.Stop()
+1
View File
@@ -88,6 +88,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // backupSvc
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
nil, // channelMonitorV2Aggregator
nil, // quotaFlusher
nil, // upstreamBillingProbe
nil, // ollamaCloudUsage
@@ -370,7 +370,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentAlipayMobilePrecreateDeepLink: paymentCfg.AlipayMobilePrecreateDeepLink,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
GrokDefaultTextModel: settings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
@@ -327,8 +327,10 @@ type UpdateSettingsRequest struct {
PaymentAlipayMobilePrecreateDeepLink *bool `json:"payment_alipay_mobile_precreate_deep_link"`
// Channel Monitor feature switch
ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"`
ChannelMonitorMode *string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
// Grok model mapping policy
GrokDefaultTextModel *string `json:"grok_default_text_model"`
@@ -1863,12 +1865,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.ChannelMonitorEnabled
}(),
ChannelMonitorMode: func() string {
if req.ChannelMonitorMode != nil {
return *req.ChannelMonitorMode
}
return previousSettings.ChannelMonitorMode
}(),
ChannelMonitorDefaultIntervalSeconds: func() int {
if req.ChannelMonitorDefaultIntervalSeconds != nil {
return *req.ChannelMonitorDefaultIntervalSeconds
}
return previousSettings.ChannelMonitorDefaultIntervalSeconds
}(),
ChannelMonitorHideThroughput: func() bool {
if req.ChannelMonitorHideThroughput != nil {
return *req.ChannelMonitorHideThroughput
}
return previousSettings.ChannelMonitorHideThroughput
}(),
GrokDefaultTextModel: func() string {
if req.GrokDefaultTextModel != nil {
return *req.GrokDefaultTextModel
@@ -2318,7 +2332,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentAlipayMobilePrecreateDeepLink: updatedPaymentCfg.AlipayMobilePrecreateDeepLink,
ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled,
ChannelMonitorMode: updatedSettings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
@@ -35,7 +35,8 @@ func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool {
if h.settingService == nil {
return true
}
return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled
runtime := h.settingService.GetChannelMonitorRuntime(c.Request.Context())
return runtime.Enabled && runtime.Mode == service.ChannelMonitorModeV1
}
// --- Response ---
@@ -0,0 +1,204 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type ChannelMonitorV2Handler struct {
service *service.ChannelMonitorV2Service
}
func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service) *ChannelMonitorV2Handler {
return &ChannelMonitorV2Handler{service: svc}
}
// channelMonitorV2IsAdmin is true when the request already passed admin auth
// (shared Dimensions/Errors handlers serve both user and admin route groups).
func channelMonitorV2IsAdmin(c *gin.Context) bool {
role, ok := middleware.GetUserRoleFromContext(c)
return ok && role == service.RoleAdmin
}
func (h *ChannelMonitorV2Handler) GetConfig(c *gin.Context) {
cfg, err := h.service.GetConfig(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, cfg)
}
func (h *ChannelMonitorV2Handler) UpdateConfig(c *gin.Context) {
var input service.ChannelMonitorV2Config
if err := c.ShouldBindJSON(&input); err != nil {
response.BadRequest(c, "invalid channel monitor v2 config")
return
}
subject, ok := middleware.GetAuthSubjectFromContext(c)
if !ok || subject.UserID <= 0 {
response.Unauthorized(c, "user not found in context")
return
}
updated, err := h.service.UpdateConfig(c.Request.Context(), input, input.Version, subject.UserID)
if err != nil {
if errors.Is(err, service.ErrChannelMonitorV2ConfigConflict) {
response.Error(c, http.StatusConflict, err.Error())
return
}
if errors.Is(err, service.ErrChannelMonitorV2InvalidConfig) {
response.BadRequest(c, err.Error())
return
}
response.ErrorFrom(c, err)
return
}
response.Success(c, updated)
}
func (h *ChannelMonitorV2Handler) Dimensions(c *gin.Context) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
result, err := h.service.Dimensions(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
// Admin and user share this handler; only non-admin responses strip volume.
if !channelMonitorV2IsAdmin(c) {
service.RedactChannelMonitorV2Dimensions(result)
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) Snapshot(c *gin.Context) { h.snapshot(c, false) }
func (h *ChannelMonitorV2Handler) AdminSnapshot(c *gin.Context) { h.snapshot(c, true) }
func (h *ChannelMonitorV2Handler) Models(c *gin.Context) { h.models(c, false) }
func (h *ChannelMonitorV2Handler) AdminModels(c *gin.Context) { h.models(c, true) }
func (h *ChannelMonitorV2Handler) Matrix(c *gin.Context) { h.matrix(c, false) }
func (h *ChannelMonitorV2Handler) AdminMatrix(c *gin.Context) { h.matrix(c, true) }
func (h *ChannelMonitorV2Handler) Users(c *gin.Context) { h.users(c, false) }
func (h *ChannelMonitorV2Handler) AdminUsers(c *gin.Context) { h.users(c, true) }
func (h *ChannelMonitorV2Handler) snapshot(c *gin.Context, admin bool) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
result, err := h.service.Snapshot(c.Request.Context(), filter, admin)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) models(c *gin.Context, admin bool) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
result, err := h.service.Models(c.Request.Context(), filter, admin)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) matrix(c *gin.Context, admin bool) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
groupBy, err := service.ParseChannelMonitorV2GroupBy(c.Query("group_by"))
if err != nil {
response.BadRequest(c, err.Error())
return
}
result, err := h.service.Matrix(c.Request.Context(), filter, groupBy, admin)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) Errors(c *gin.Context) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, channelMonitorV2IsAdmin(c))
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
subject, exists := middleware.GetAuthSubjectFromContext(c)
if !exists {
response.Error(c, http.StatusUnauthorized, "user not found in context")
return
}
result, err := h.service.Users(c.Request.Context(), filter, subject.UserID, admin)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) parseFilter(c *gin.Context) (service.ChannelMonitorV2Filter, bool) {
groups, err := parseChannelMonitorV2GroupIDs(queryList(c, "group_id"))
if err != nil {
response.BadRequest(c, "invalid group_id")
return service.ChannelMonitorV2Filter{}, false
}
filter, err := h.service.ParseFilter(c.Query("range"), queryList(c, "platform"), queryList(c, "model"), groups)
if err != nil {
response.BadRequest(c, err.Error())
return service.ChannelMonitorV2Filter{}, false
}
return filter, true
}
func queryList(c *gin.Context, key string) []string {
values := c.QueryArray(key)
result := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if part = strings.TrimSpace(part); part != "" {
result = append(result, part)
}
}
}
return result
}
func parseChannelMonitorV2GroupIDs(values []string) ([]int64, error) {
result := make([]int64, 0, len(values))
for _, value := range values {
id, err := strconv.ParseInt(value, 10, 64)
if err != nil || id <= 0 {
return nil, errors.New("invalid group id")
}
result = append(result, id)
}
return result, nil
}
@@ -0,0 +1,35 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestChannelMonitorV2QueryListSupportsRepeatedAndCommaValues(t *testing.T) {
c, _ := gin.CreateTestContext(nil)
c.Request = httptest.NewRequest("GET", "/?platform=openai,grok&platform=anthropic", nil)
require.Equal(t, []string{"openai", "grok", "anthropic"}, queryList(c, "platform"))
}
func TestChannelMonitorV2GroupByQueryDefaultsAndRejectsInvalid(t *testing.T) {
groupBy, err := service.ParseChannelMonitorV2GroupBy("")
require.NoError(t, err)
require.Equal(t, service.ChannelMonitorV2GroupByPlatformGroup, groupBy)
_, err = service.ParseChannelMonitorV2GroupBy("invalid")
require.Error(t, err)
}
func TestChannelMonitorV2MatrixHandlerRejectsInvalidGroupBy(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/matrix?group_by=invalid", nil)
h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil))
h.Matrix(c)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
+8 -4
View File
@@ -300,8 +300,10 @@ type SystemSettings struct {
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
// Channel Monitor feature switch
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
GrokDefaultTextModel string `json:"grok_default_text_model"`
@@ -406,8 +408,10 @@ type PublicSettings struct {
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+1
View File
@@ -53,6 +53,7 @@ type Handlers struct {
Subscription *SubscriptionHandler
Announcement *AnnouncementHandler
ChannelMonitor *ChannelMonitorUserHandler
ChannelMonitorV2 *ChannelMonitorV2Handler
Admin *AdminHandlers
Gateway *GatewayHandler
OpenAIGateway *OpenAIGatewayHandler
@@ -103,7 +103,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
+3
View File
@@ -175,6 +175,7 @@ func ProvideHandlers(
subscriptionHandler *SubscriptionHandler,
announcementHandler *AnnouncementHandler,
channelMonitorUserHandler *ChannelMonitorUserHandler,
channelMonitorV2Handler *ChannelMonitorV2Handler,
adminHandlers *AdminHandlers,
gatewayHandler *GatewayHandler,
openaiGatewayHandler *OpenAIGatewayHandler,
@@ -199,6 +200,7 @@ func ProvideHandlers(
Subscription: subscriptionHandler,
Announcement: announcementHandler,
ChannelMonitor: channelMonitorUserHandler,
ChannelMonitorV2: channelMonitorV2Handler,
Admin: adminHandlers,
Gateway: gatewayHandler,
OpenAIGateway: openaiGatewayHandler,
@@ -225,6 +227,7 @@ var ProviderSet = wire.NewSet(
NewSubscriptionHandler,
NewAnnouncementHandler,
NewChannelMonitorUserHandler,
NewChannelMonitorV2Handler,
ProvideGatewayHandler,
ProvideOpenAIGatewayHandler,
NewTotpHandler,
@@ -0,0 +1,454 @@
package repository
import (
"context"
"database/sql"
"fmt"
"time"
)
// Platform is derived from group/account (usage_logs has no provider column on upstream schema).
const channelMonitorV2PlatformSQL = `lower(` + usageLogEffectivePlatformExpr + `)`
const channelMonitorV2ModelSQL = `COALESCE(NULLIF(TRIM(ul.requested_model), ''), NULLIF(TRIM(ul.model), ''), 'unknown')`
// Tiered retention balances UI windows against storage:
//
// 1m facts → short (late writes + rebuild rollups)
// 5m/1h/12h/1d rollups → longer, aligned to 90m / 24h / 7d / 30d(+audit)
//
// Backfill may still write short-lived 1m rows for old windows so rollups can be
// built; prune at end of each recompute drops them past their TTL while rollups remain.
const (
channelMonitorV2RetentionUser1m = 3 * 24 * time.Hour
channelMonitorV2RetentionMetrics1m = 7 * 24 * time.Hour
channelMonitorV2RetentionError1m = 7 * 24 * time.Hour
channelMonitorV2RetentionHistogram1m = 7 * 24 * time.Hour
channelMonitorV2RetentionRollup5m = 7 * 24 * time.Hour // bucket_seconds=300
channelMonitorV2RetentionRollup1h = 30 * 24 * time.Hour // 3600
channelMonitorV2RetentionRollup12h = 45 * 24 * time.Hour // 43200
channelMonitorV2RetentionRollup1d = 90 * 24 * time.Hour // 86400
channelMonitorV2RetentionMax = channelMonitorV2RetentionRollup1d
)
// channelMonitorV2MaxRetention is the longest stored window (1d rollup). Used to
// clamp recompute/backfill so we never scan older than product history needs.
func channelMonitorV2MaxRetention() time.Duration {
return channelMonitorV2RetentionMax
}
func channelMonitorV2RetentionCutoff(now time.Time, retention time.Duration) time.Time {
return now.UTC().Truncate(time.Minute).Add(-retention)
}
type channelMonitorV2RetentionRule struct {
table string
retention time.Duration
bucketSeconds int // 0 = fact table (no bucket_seconds column)
}
// channelMonitorV2RetentionRules is ordered coarse→fine for predictable prune plans.
var channelMonitorV2RetentionRules = []channelMonitorV2RetentionRule{
{table: "channel_monitor_v2_user_metrics_1m", retention: channelMonitorV2RetentionUser1m},
{table: "channel_monitor_v2_metrics_1m", retention: channelMonitorV2RetentionMetrics1m},
{table: "channel_monitor_v2_error_metrics_1m", retention: channelMonitorV2RetentionError1m},
{table: "channel_monitor_v2_latency_histograms_1m", retention: channelMonitorV2RetentionHistogram1m},
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
}
func (r *channelMonitorV2Repository) pruneChannelMonitorV2Retention(ctx context.Context, tx *sql.Tx, now time.Time) error {
// During historical bootstrap, retain all 1m facts until the cursor reaches
// the oldest rollup boundary. Otherwise adjacent chunks would rebuild the
// same daily bucket from source rows already pruned by the prior chunk.
var backfillCursor time.Time
if err := tx.QueryRowContext(ctx, `SELECT backfill_cursor FROM channel_monitor_v2_watermarks WHERE id = 1`).Scan(&backfillCursor); err == nil && backfillCursor.After(channelMonitorV2RetentionCutoff(now, channelMonitorV2RetentionMax)) {
return nil
}
for _, rule := range channelMonitorV2RetentionRules {
cutoff := channelMonitorV2RetentionCutoff(now, rule.retention)
var err error
if rule.bucketSeconds == 0 {
_, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE bucket_start < $1`, rule.table), cutoff)
} else {
_, err = tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE bucket_seconds = $1 AND bucket_start < $2`, rule.table),
rule.bucketSeconds, cutoff,
)
}
if err != nil {
return fmt.Errorf("prune %s (bucket_seconds=%d): %w", rule.table, rule.bucketSeconds, err)
}
}
return nil
}
func (r *channelMonitorV2Repository) RecomputeRange(ctx context.Context, start, end time.Time) (err error) {
start = start.UTC().Truncate(time.Minute)
end = end.UTC().Truncate(time.Minute)
now := time.Now().UTC().Truncate(time.Minute)
// Clamp to longest rollup TTL so backfill does not scan beyond product history.
maxCutoff := channelMonitorV2RetentionCutoff(now, channelMonitorV2MaxRetention())
if start.Before(maxCutoff) {
start = maxCutoff
}
if !start.Before(end) {
return nil
}
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
// Idempotent window rewrite: drop existing facts/rollups in [start,end) then re-insert.
for _, table := range []string{
"channel_monitor_v2_latency_histograms_rollup",
"channel_monitor_v2_error_metrics_rollup",
"channel_monitor_v2_user_metrics_rollup",
"channel_monitor_v2_metrics_rollup",
"channel_monitor_v2_latency_histograms_1m",
"channel_monitor_v2_error_metrics_1m",
"channel_monitor_v2_user_metrics_1m",
"channel_monitor_v2_metrics_1m",
} {
if _, err = tx.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE bucket_start >= $1 AND bucket_start < $2", table), start, end); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2UsageMetricsSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL), start, end); err != nil {
return fmt.Errorf("aggregate channel monitor v2 usage: %w", err)
}
if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2UserMetricsSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL), start, end); err != nil {
return fmt.Errorf("aggregate channel monitor v2 users: %w", err)
}
if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2HistogramSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL, channelMonitorV2HistogramBoundSQL("latency.value_ms")), start, end); err != nil {
return fmt.Errorf("aggregate channel monitor v2 histograms: %w", err)
}
if _, err = tx.ExecContext(ctx, channelMonitorV2ErrorAggregationSQL, start, end); err != nil {
return fmt.Errorf("aggregate channel monitor v2 errors: %w", err)
}
if err = r.recomputeFixedRollups(ctx, tx, start, end); err != nil {
return err
}
// Drop rows past per-tier TTL (1m short, coarse rollups long). Safe after rollup
// so a backfill chunk can build 1d rollups from temporary 1m rows then discard 1m.
if err = r.pruneChannelMonitorV2Retention(ctx, tx, now); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, channelMonitorV2WatermarkSQL, start, end); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
const channelMonitorV2UsageMetricsSQL = `
INSERT INTO channel_monitor_v2_metrics_1m (
bucket_start, platform, group_id, model, success_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
)
SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s,
COUNT(DISTINCT COALESCE(NULLIF(ul.request_id, ''), 'usage:' || ul.id::text))
FILTER (WHERE COALESCE(ul.request_type, 0) NOT IN (4, 6) AND ` + usageLogSuccessFilterUL + `),
COALESCE(SUM(ul.input_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.output_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.cache_creation_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.cache_read_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0),
COUNT(ul.first_token_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `),
COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0),
COUNT(ul.duration_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), NOW()
FROM usage_logs ul
LEFT JOIN groups g ON g.id = ul.group_id
LEFT JOIN accounts a ON a.id = ul.account_id
WHERE ul.created_at >= $1 AND ul.created_at < $2
GROUP BY 1, 2, 3, 4`
const channelMonitorV2UserMetricsSQL = `
INSERT INTO channel_monitor_v2_user_metrics_1m (
bucket_start, platform, group_id, model, user_id, success_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
)
SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s, ul.user_id,
COUNT(DISTINCT COALESCE(NULLIF(ul.request_id, ''), 'usage:' || ul.id::text))
FILTER (WHERE COALESCE(ul.request_type, 0) NOT IN (4, 6) AND ` + usageLogSuccessFilterUL + `),
COALESCE(SUM(ul.input_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.output_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.cache_creation_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.cache_read_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0),
COUNT(ul.first_token_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `),
COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0),
COUNT(ul.duration_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), NOW()
FROM usage_logs ul
LEFT JOIN groups g ON g.id = ul.group_id
LEFT JOIN accounts a ON a.id = ul.account_id
WHERE ul.created_at >= $1 AND ul.created_at < $2 AND ul.user_id IS NOT NULL
GROUP BY 1, 2, 3, 4, 5`
const channelMonitorV2HistogramSQL = `
INSERT INTO channel_monitor_v2_latency_histograms_1m (
bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms, sample_count
)
SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s,
audience.user_id, latency.metric, %s, COUNT(*)
FROM usage_logs ul
LEFT JOIN groups g ON g.id = ul.group_id
LEFT JOIN accounts a ON a.id = ul.account_id
CROSS JOIN LATERAL (VALUES (0::bigint), (ul.user_id)) audience(user_id)
CROSS JOIN LATERAL (VALUES ('ttft'::text, ul.first_token_ms), ('duration'::text, ul.duration_ms)) latency(metric, value_ms)
WHERE ul.created_at >= $1 AND ul.created_at < $2
AND audience.user_id IS NOT NULL AND latency.value_ms IS NOT NULL AND latency.value_ms >= 0
AND ` + usageLogSuccessFilterUL + `
GROUP BY 1, 2, 3, 4, 5, 6, 7`
func channelMonitorV2HistogramBoundSQL(column string) string {
return `CASE
WHEN ` + column + ` <= 50 THEN 50 WHEN ` + column + ` <= 100 THEN 100
WHEN ` + column + ` <= 250 THEN 250 WHEN ` + column + ` <= 500 THEN 500
WHEN ` + column + ` <= 1000 THEN 1000 WHEN ` + column + ` <= 2000 THEN 2000
WHEN ` + column + ` <= 3000 THEN 3000 WHEN ` + column + ` <= 5000 THEN 5000
WHEN ` + column + ` <= 8000 THEN 8000 WHEN ` + column + ` <= 10000 THEN 10000
WHEN ` + column + ` <= 15000 THEN 15000 WHEN ` + column + ` <= 30000 THEN 30000
WHEN ` + column + ` <= 60000 THEN 60000 WHEN ` + column + ` <= 120000 THEN 120000
WHEN ` + column + ` <= 300000 THEN 300000 WHEN ` + column + ` <= 600000 THEN 600000
ELSE 2147483647 END`
}
// Error dedup lookback: request_id branch is bounded by chunk start minus 90
// minutes so candidate_ids never forces a full-history scan of ops_error_logs.
const channelMonitorV2ErrorAggregationSQL = `
WITH dedup AS (
WITH candidate_ids AS MATERIALIZED (
SELECT DISTINCT request_id
FROM ops_error_logs
WHERE created_at >= $1 AND created_at < $2 AND NULLIF(request_id, '') IS NOT NULL
)
SELECT DISTINCT ON (COALESCE(NULLIF(request_id, ''), 'error:' || id::text))
date_trunc('minute', created_at) AS bucket_start,
lower(COALESCE(NULLIF(TRIM(platform), ''), 'unknown')) AS platform,
COALESCE(group_id, 0) AS group_id,
COALESCE(NULLIF(TRIM(requested_model), ''), NULLIF(TRIM(model), ''), 'unknown') AS model,
user_id, error_type, error_owner, COALESCE(status_code, 0) AS status_code,
COALESCE(upstream_status_code, 0) AS upstream_status_code,
lower(CONCAT_WS(' ', error_type, error_source, error_message, upstream_error_message, upstream_error_detail, error_body)) AS text,
(CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) > 0 ELSE FALSE END
OR error_owner = 'provider' OR upstream_status_code IS NOT NULL) AS upstream_affected,
CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) ELSE 0 END AS upstream_attempts
FROM ops_error_logs current_error
WHERE (
(NULLIF(current_error.request_id, '') IS NULL AND current_error.created_at >= $1 AND current_error.created_at < $2)
OR (
current_error.request_id IN (SELECT request_id FROM candidate_ids)
AND current_error.created_at >= $1 - INTERVAL '90 minutes'
AND current_error.created_at < $2
)
)
AND NOT current_error.is_count_tokens
AND (COALESCE(current_error.status_code, 0) >= 400 OR current_error.error_type = 'cyber_policy')
ORDER BY COALESCE(NULLIF(request_id, ''), 'error:' || id::text), created_at DESC, id DESC
), classified AS (
SELECT *, CASE
-- Keep in lockstep with service.ClassifyChannelMonitorV2Error needles.
WHEN error_type = 'cyber_policy' OR text LIKE ANY(ARRAY['%content policy%','%content_policy%','%safety policy%','%moderation%','%blocked keyword%']) THEN 'content_policy'
WHEN status_code = 401 OR upstream_status_code = 401 OR text LIKE ANY(ARRAY['%unauthorized%','%invalid api key%','%invalid_api_key%','%authentication%','%api_key_disabled%']) THEN 'authentication'
WHEN text LIKE ANY(ARRAY['%context window%','%context length%','%maximum prompt length%','%too many tokens%','%max_tokens%']) THEN 'context_limit'
WHEN text LIKE ANY(ARRAY['%failed to deserialize%','%missing required parameter%','%invalid request%','%invalid_request%','%tool_choice%']) THEN 'invalid_request'
WHEN text LIKE ANY(ARRAY['%does not support the requested model%','%not supported by any configured account%','%model not supported%','%unsupported model%']) THEN 'model_unsupported'
WHEN text LIKE ANY(ARRAY['%group not allowed%','%group_not_allowed%','%group access%']) THEN 'group_access'
WHEN text LIKE ANY(ARRAY['%run out of credits%','%insufficient balance%','%insufficient quota%','%subscription%','%quota exceeded%','%billing hard limit%']) THEN 'quota_or_balance'
WHEN text LIKE ANY(ARRAY['%no available accounts%','%no healthy account%','%no healthy upstream account%','%failover budget exhausted%','%account pool%']) THEN 'account_pool_unavailable'
WHEN status_code = 429 OR upstream_status_code = 429 OR text LIKE ANY(ARRAY['%rate limit%','%rate_limit%','%high demand%','%overloaded%','%concurrency limit%','%capacity%']) THEN 'rate_or_capacity'
WHEN status_code IN (408,504) OR text LIKE ANY(ARRAY['%timeout%','%deadline exceeded%','%error code: 524%','%gateway time-out%','%gateway timeout%']) THEN 'timeout'
WHEN text LIKE ANY(ARRAY['%transport%','%stream_read_error%','%connection reset%','%connection refused%','%tls%','%http2%','%missing terminal event%','%unexpected eof%']) THEN 'transport_or_stream'
WHEN status_code = 403 OR upstream_status_code = 403 THEN 'upstream_forbidden'
WHEN status_code = 404 OR upstream_status_code = 404 THEN 'not_found'
WHEN status_code = 499 OR text LIKE ANY(ARRAY['%client cancelled%','%client canceled%','%context canceled%']) THEN 'client_cancelled'
WHEN upstream_status_code >= 500 OR (error_owner = 'provider' AND status_code >= 500) THEN 'upstream_5xx'
WHEN status_code >= 500 OR error_type = 'internal' OR error_owner = 'system' THEN 'internal'
ELSE 'other' END AS category
FROM dedup
WHERE bucket_start >= $1 AND bucket_start < $2
), metric_rows AS (
INSERT INTO channel_monitor_v2_metrics_1m (bucket_start, platform, group_id, model, error_requests, upstream_affected_requests, upstream_attempt_count, computed_at)
SELECT bucket_start, platform, group_id, model, COUNT(*), COUNT(*) FILTER (WHERE upstream_affected), SUM(upstream_attempts), NOW()
FROM classified GROUP BY 1,2,3,4
ON CONFLICT (bucket_start, platform, group_id, model) DO UPDATE SET
error_requests = EXCLUDED.error_requests, upstream_affected_requests = EXCLUDED.upstream_affected_requests,
upstream_attempt_count = EXCLUDED.upstream_attempt_count, computed_at = NOW()
), user_rows AS (
INSERT INTO channel_monitor_v2_user_metrics_1m (bucket_start, platform, group_id, model, user_id, error_requests, computed_at)
SELECT bucket_start, platform, group_id, model, user_id, COUNT(*), NOW()
FROM classified WHERE user_id IS NOT NULL GROUP BY 1,2,3,4,5
ON CONFLICT (bucket_start, platform, group_id, model, user_id) DO UPDATE SET error_requests = EXCLUDED.error_requests, computed_at = NOW()
)
INSERT INTO channel_monitor_v2_error_metrics_1m (bucket_start, platform, group_id, model, error_category, taxonomy_version, error_requests)
SELECT bucket_start, platform, group_id, model, category, 1, COUNT(*) FROM classified GROUP BY 1,2,3,4,5
ON CONFLICT (bucket_start, platform, group_id, model, error_category, taxonomy_version)
DO UPDATE SET error_requests = EXCLUDED.error_requests`
// Floor matches channelMonitorV2RetentionMax (90d). Keep the INTERVAL literal in
// sync when changing channelMonitorV2RetentionRollup1d.
//
// Coverage starts track how far back recompute has walked ($1 = chunk start), not
// "min(source_log.created_at)". Using global min(ops_error_logs) pins
// error_coverage_start to the first real error forever and collapses UI windows
// when errors only exist in a recent slice (common on first upgrade).
const channelMonitorV2WatermarkSQL = `
INSERT INTO channel_monitor_v2_watermarks (id, usage_coverage_start, error_coverage_start, data_through, last_successful_at, backfill_cursor, updated_at)
VALUES (
1,
$1,
$1,
$2, NOW(), $1, NOW()
)
ON CONFLICT (id) DO UPDATE SET
usage_coverage_start = GREATEST(
date_trunc('minute', NOW()) - INTERVAL '90 days',
LEAST(COALESCE(channel_monitor_v2_watermarks.usage_coverage_start, EXCLUDED.usage_coverage_start), EXCLUDED.usage_coverage_start)
),
error_coverage_start = GREATEST(
date_trunc('minute', NOW()) - INTERVAL '90 days',
LEAST(COALESCE(channel_monitor_v2_watermarks.error_coverage_start, EXCLUDED.error_coverage_start), EXCLUDED.error_coverage_start)
),
data_through = GREATEST(COALESCE(channel_monitor_v2_watermarks.data_through, EXCLUDED.data_through), EXCLUDED.data_through),
last_successful_at = NOW(),
backfill_cursor = LEAST(COALESCE(channel_monitor_v2_watermarks.backfill_cursor, EXCLUDED.backfill_cursor), EXCLUDED.backfill_cursor),
updated_at = NOW()`
var channelMonitorV2FixedRollupSeconds = []int{300, 3600, 43200, 86400}
func (r *channelMonitorV2Repository) recomputeFixedRollups(ctx context.Context, tx *sql.Tx, start, end time.Time) error {
for _, seconds := range channelMonitorV2FixedRollupSeconds {
// Coarse buckets are immutable between boundaries during the normal
// trailing refresh. Historical backfills and boundary-crossing windows
// still rebuild them; this avoids repeatedly regrouping the full current
// day/user table every few minutes.
if seconds >= 43200 && sameFixedRollupBucket(start, end, seconds) {
continue
}
interval := fmt.Sprintf("%d seconds", seconds)
for _, table := range []string{
"channel_monitor_v2_latency_histograms_rollup",
"channel_monitor_v2_error_metrics_rollup",
"channel_monitor_v2_user_metrics_rollup",
"channel_monitor_v2_metrics_rollup",
} {
if _, err := tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2FixedRollupDeleteSQL, table), interval, seconds, start, end); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, channelMonitorV2MetricsRollupSQL, interval, seconds, start, end); err != nil {
return fmt.Errorf("roll up channel monitor v2 metrics %ds: %w", seconds, err)
}
if _, err := tx.ExecContext(ctx, channelMonitorV2UserMetricsRollupSQL, interval, seconds, start, end); err != nil {
return fmt.Errorf("roll up channel monitor v2 user metrics %ds: %w", seconds, err)
}
if _, err := tx.ExecContext(ctx, channelMonitorV2HistogramRollupSQL, interval, seconds, start, end); err != nil {
return fmt.Errorf("roll up channel monitor v2 histograms %ds: %w", seconds, err)
}
if _, err := tx.ExecContext(ctx, channelMonitorV2ErrorRollupSQL, interval, seconds, start, end); err != nil {
return fmt.Errorf("roll up channel monitor v2 errors %ds: %w", seconds, err)
}
}
return nil
}
func sameFixedRollupBucket(start, end time.Time, seconds int) bool {
if !end.After(start) {
return true
}
interval := time.Duration(seconds) * time.Second
return start.Truncate(interval).Equal(end.Add(-time.Nanosecond).Truncate(interval))
}
const channelMonitorV2FixedRollupBoundsSQL = `
WITH bounds AS (
SELECT
date_bin($1::interval, $3::timestamptz, TIMESTAMPTZ '1970-01-01') AS start_at,
date_bin($1::interval, $4::timestamptz - INTERVAL '1 microsecond', TIMESTAMPTZ '1970-01-01') + $1::interval AS end_at
)`
const channelMonitorV2FixedRollupDeleteSQL = channelMonitorV2FixedRollupBoundsSQL + `
DELETE FROM %s
USING bounds
WHERE bucket_seconds = $2::integer
AND bucket_start >= bounds.start_at
AND bucket_start < bounds.end_at`
const channelMonitorV2MetricsRollupSQL = `
INSERT INTO channel_monitor_v2_metrics_rollup (
bucket_start, bucket_seconds, platform, group_id, model, success_requests, error_requests,
upstream_affected_requests, upstream_attempt_count, input_tokens, output_tokens,
cache_creation_tokens, cache_read_tokens, ttft_sum_ms, ttft_count, duration_sum_ms,
duration_count, computed_at
)
` + channelMonitorV2FixedRollupBoundsSQL + `
SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
platform, group_id, model, SUM(success_requests), SUM(error_requests),
SUM(upstream_affected_requests), SUM(upstream_attempt_count), SUM(input_tokens),
SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens),
SUM(ttft_sum_ms), SUM(ttft_count), SUM(duration_sum_ms), SUM(duration_count), NOW()
FROM channel_monitor_v2_metrics_1m m, bounds
WHERE m.bucket_start >= bounds.start_at AND m.bucket_start < bounds.end_at
GROUP BY 1, 2, 3, 4, 5`
const channelMonitorV2UserMetricsRollupSQL = `
INSERT INTO channel_monitor_v2_user_metrics_rollup (
bucket_start, bucket_seconds, platform, group_id, model, user_id, success_requests,
error_requests, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
)
` + channelMonitorV2FixedRollupBoundsSQL + `
SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
platform, group_id, model, user_id, SUM(success_requests), SUM(error_requests),
SUM(input_tokens), SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens),
SUM(ttft_sum_ms), SUM(ttft_count), SUM(duration_sum_ms), SUM(duration_count), NOW()
FROM channel_monitor_v2_user_metrics_1m m, bounds
WHERE m.bucket_start >= bounds.start_at AND m.bucket_start < bounds.end_at
GROUP BY 1, 2, 3, 4, 5, 6`
const channelMonitorV2HistogramRollupSQL = `
INSERT INTO channel_monitor_v2_latency_histograms_rollup (
bucket_start, bucket_seconds, platform, group_id, model, user_id, metric, upper_bound_ms, sample_count
)
` + channelMonitorV2FixedRollupBoundsSQL + `
SELECT date_bin($1::interval, h.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
platform, group_id, model, user_id, metric, upper_bound_ms, SUM(sample_count)
FROM channel_monitor_v2_latency_histograms_1m h, bounds
WHERE h.bucket_start >= bounds.start_at AND h.bucket_start < bounds.end_at
GROUP BY 1, 2, 3, 4, 5, 6, 7, 8`
const channelMonitorV2ErrorRollupSQL = `
INSERT INTO channel_monitor_v2_error_metrics_rollup (
bucket_start, bucket_seconds, platform, group_id, model, error_category, taxonomy_version, error_requests
)
` + channelMonitorV2FixedRollupBoundsSQL + `
SELECT date_bin($1::interval, e.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
platform, group_id, model, error_category, taxonomy_version, SUM(error_requests)
FROM channel_monitor_v2_error_metrics_1m e, bounds
WHERE e.bucket_start >= bounds.start_at AND e.bucket_start < bounds.end_at
GROUP BY 1, 2, 3, 4, 5, 6, 7`
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,288 @@
package repository
import (
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestChannelMonitorV2DisplayModelIsPlatformScoped(t *testing.T) {
cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{
{Platform: "openai", Enabled: true, Models: []string{"shared", "gpt-5"}},
{Platform: "grok", Enabled: true, Models: []string{"grok-4"}},
// Empty models list must NOT collapse everything into __other__.
{Platform: "anthropic", Enabled: true, Models: []string{}},
}}
require.Equal(t, "shared", channelMonitorV2DisplayModel(cfg, "openai", "shared"))
require.Equal(t, service.ChannelMonitorV2OtherModel, channelMonitorV2DisplayModel(cfg, "grok", "shared"))
require.Equal(t, "claude-sonnet-4", channelMonitorV2DisplayModel(cfg, "anthropic", "claude-sonnet-4"))
// Unconfigured platform still surfaces the real model name.
require.Equal(t, "gemini-2.5-pro", channelMonitorV2DisplayModel(cfg, "gemini", "gemini-2.5-pro"))
require.True(t, channelMonitorV2ModelSelected(service.ChannelMonitorV2Filter{Models: []string{service.ChannelMonitorV2OtherModel}}, cfg, "grok", "shared"))
}
func TestChannelMonitorV2MatrixDimensionKey(t *testing.T) {
cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}}}
key := channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformGroupModel, cfg, "openai", 7, "gpt-5")
require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", groupID: 7, model: "gpt-5"}, key)
key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformModel, cfg, "openai", 7, "unlisted")
require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", model: service.ChannelMonitorV2OtherModel}, key)
key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatform, cfg, "openai", 7, "gpt-5")
require.Equal(t, channelMonitorV2MatrixKey{platform: "openai"}, key)
}
func TestChannelMonitorV2HistogramPercentilesAreMergedFromCounts(t *testing.T) {
// 100 samples: 50@100, 40@500, 10@1000
// target = int64(total*p + 0.999999) truncates: p50→50, p90→90, p95→95
// cumulative hits: p50@100, p90@500 (50+40), p95@1000
histogram := map[int64]int64{100: 50, 500: 40, 1000: 10}
require.Equal(t, int64(100), *histPercentile(histogram, .5))
require.Equal(t, int64(500), *histPercentile(histogram, .9))
require.Equal(t, int64(1000), *histPercentile(histogram, .95))
require.Nil(t, histPercentile(nil, .95))
// latencyMetric exposes avg + p50 + p90 + p95
lat := latencyMetric(1000, 10, histogram)
require.NotNil(t, lat.AvgMs)
require.NotNil(t, lat.P50Ms)
require.NotNil(t, lat.P90Ms)
require.NotNil(t, lat.P95Ms)
require.Equal(t, int64(100), *lat.P50Ms)
require.Equal(t, int64(500), *lat.P90Ms)
require.Equal(t, int64(1000), *lat.P95Ms)
}
func TestChannelMonitorV2MetricIncludesSuccessRate(t *testing.T) {
acc := newMetricAccumulator()
acc.success, acc.errors = 80, 20
metric := acc.metric(1, false)
require.Equal(t, int64(100), metric.RequestCount)
require.InDelta(t, 0.8, metric.SuccessRate, 0.0001)
require.InDelta(t, 0.2, metric.ErrorRate, 0.0001)
require.Nil(t, metric.UpstreamAffectedRequests)
adminMetric := acc.metric(1, true)
require.NotNil(t, adminMetric.UpstreamAffectedRequests)
}
func TestChannelMonitorV2WhereUsesConfiguredScopeAndEmptyFilterMeansAllConfigured(t *testing.T) {
filter := service.ChannelMonitorV2Filter{Start: time.Unix(1, 0), End: time.Unix(2, 0)}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}, {Platform: "grok", Enabled: false}},
GroupIDs: []int64{3, 4},
}
where, args := channelMonitorV2Where(filter, cfg, "m")
require.Contains(t, where, "m.platform = ANY($3)")
require.Contains(t, where, "m.group_id = ANY($4)")
require.Len(t, args, 4)
}
func TestChannelMonitorV2WhereRejectsGroupFilterOutsideConfiguredScope(t *testing.T) {
filter := service.ChannelMonitorV2Filter{
Start: time.Unix(1, 0), End: time.Unix(2, 0), GroupIDs: []int64{9},
}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
GroupIDs: []int64{3, 4},
}
where, args := channelMonitorV2Where(filter, cfg, "m")
require.Contains(t, where, "FALSE")
require.NotContains(t, where, "m.group_id = ANY")
require.Len(t, args, 3)
}
func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T) {
query := strings.ToLower(channelMonitorV2ErrorAggregationSQL)
require.Contains(t, query, "not current_error.is_count_tokens")
require.Contains(t, query, "error_type = 'cyber_policy'")
require.Contains(t, query, "distinct on")
require.Contains(t, query, "candidate_ids")
require.Contains(t, query, "where bucket_start >= $1 and bucket_start < $2")
require.Contains(t, query, "upstream_affected_requests")
require.Contains(t, query, "jsonb_array_length(upstream_errors) > 0")
// request_id dedup must be time-bounded (no full-history scan).
require.Contains(t, query, "interval '90 minutes'")
require.Contains(t, query, "current_error.created_at >= $1 - interval '90 minutes'")
}
func TestChannelMonitorV2UsageSuccessExcludesCyberBillingRows(t *testing.T) {
for _, query := range []string{channelMonitorV2UsageMetricsSQL, channelMonitorV2UserMetricsSQL} {
require.Contains(t, query, "COALESCE(ul.request_type, 0) NOT IN (4, 6)")
require.Contains(t, query, "ul.actual_cost > 0")
}
require.Contains(t, channelMonitorV2PlatformSQL, "g.platform = 'composite'")
require.Contains(t, channelMonitorV2PlatformSQL, "a.platform")
require.Contains(t, channelMonitorV2HistogramSQL, "ul.actual_cost > 0")
}
func TestChannelMonitorV2RatesUseCoveredWindow(t *testing.T) {
start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
filter := service.ChannelMonitorV2Filter{Start: start, End: start.Add(24 * time.Hour)}
coverage := service.ChannelMonitorV2Coverage{CoverageStart: start.Add(6 * time.Hour), DataThrough: start.Add(18 * time.Hour)}
require.Equal(t, 12*60.0, channelMonitorV2CoveredMinutes(filter, coverage))
effective := channelMonitorV2CommonCoverageFilter(filter, coverage)
require.Equal(t, coverage.CoverageStart, effective.Start)
require.Equal(t, coverage.DataThrough, effective.End)
}
func TestChannelMonitorV2HistoryCoverageCompleteIgnoresTrailingLag(t *testing.T) {
start := time.Date(2026, 8, 7, 2, 20, 0, 0, time.UTC)
// History reaches the window start → complete even if data_through is behind filter.End.
require.True(t, channelMonitorV2HistoryCoverageComplete(start, start))
require.True(t, channelMonitorV2HistoryCoverageComplete(start.Add(-time.Hour), start))
// Backfill still short of the window start → incomplete.
require.False(t, channelMonitorV2HistoryCoverageComplete(start.Add(time.Hour), start))
require.False(t, channelMonitorV2HistoryCoverageComplete(time.Time{}, start))
}
func TestChannelMonitorV2TierRetentionPolicy(t *testing.T) {
require.Equal(t, 3*24*time.Hour, channelMonitorV2RetentionUser1m)
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionMetrics1m)
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionError1m)
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionHistogram1m)
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionRollup5m)
require.Equal(t, 30*24*time.Hour, channelMonitorV2RetentionRollup1h)
require.Equal(t, 45*24*time.Hour, channelMonitorV2RetentionRollup12h)
require.Equal(t, 90*24*time.Hour, channelMonitorV2RetentionRollup1d)
require.Equal(t, channelMonitorV2RetentionRollup1d, channelMonitorV2MaxRetention())
require.Contains(t, channelMonitorV2WatermarkSQL, "INTERVAL '90 days'")
// Every fixed rollup second must appear with a retention rule.
wantSeconds := map[int]time.Duration{
300: channelMonitorV2RetentionRollup5m,
3600: channelMonitorV2RetentionRollup1h,
43200: channelMonitorV2RetentionRollup12h,
86400: channelMonitorV2RetentionRollup1d,
}
seen := map[int]time.Duration{}
for _, rule := range channelMonitorV2RetentionRules {
if rule.bucketSeconds == 0 {
require.True(t, rule.retention > 0)
continue
}
if prev, ok := seen[rule.bucketSeconds]; ok {
require.Equal(t, prev, rule.retention)
}
seen[rule.bucketSeconds] = rule.retention
}
for seconds, want := range wantSeconds {
got, ok := seen[seconds]
require.Truef(t, ok, "missing retention rule for bucket_seconds=%d", seconds)
require.Equal(t, want, got)
}
now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
require.Equal(t, now.Add(-7*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2RetentionMetrics1m))
require.Equal(t, now.Add(-90*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2MaxRetention()))
}
func TestSameFixedRollupBucket(t *testing.T) {
start := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC)
require.True(t, sameFixedRollupBucket(start, start.Add(10*time.Minute), 86400))
require.False(t, sameFixedRollupBucket(start, start.Add(15*time.Hour), 43200))
require.False(t, sameFixedRollupBucket(start, start.Add(24*time.Hour), 86400))
}
// Needles present in service.ClassifyChannelMonitorV2Error must appear in the
// aggregation SQL CASE so rollup categories match drilldown classification.
func TestChannelMonitorV2SQLTaxonomyContainsGoNeedles(t *testing.T) {
sql := channelMonitorV2ErrorAggregationSQL
needles := []string{
"blocked keyword",
"invalid_api_key",
"max_tokens",
"invalid_request",
"model not supported",
"billing hard limit",
"no healthy upstream account",
"rate_limit",
"gateway timeout",
"connection refused",
"unexpected eof",
}
for _, needle := range needles {
require.Containsf(t, strings.ToLower(sql), strings.ToLower(needle), "SQL taxonomy missing Go needle %q", needle)
}
}
func TestApplyIgnoredErrorsAdjustsRatesKeepsAbsoluteVolume(t *testing.T) {
m := service.ChannelMonitorV2Metric{
RequestCount: 100,
ErrorRequests: 20,
ErrorRate: 0.20,
SuccessRate: 0.80,
}
// Success absolute still 80 → success rate stays 0.80 even after ignoring 5 errors.
m.SuccessRequests = 80
applyIgnoredErrors(&m, 5)
require.Equal(t, int64(100), m.RequestCount)
require.Equal(t, int64(20), m.ErrorRequests)
require.InDelta(t, 0.15, m.ErrorRate, 0.0001)
require.InDelta(t, 0.80, m.SuccessRate, 0.0001)
// Clamp ignored > errors: scored error_rate → 0; success stays true ratio.
m2 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8}
applyIgnoredErrors(&m2, 99)
require.InDelta(t, 0.0, m2.ErrorRate, 0.0001)
require.InDelta(t, 0.8, m2.SuccessRate, 0.0001)
// No-op when ignored is zero
m3 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8}
applyIgnoredErrors(&m3, 0)
require.InDelta(t, 0.2, m3.ErrorRate, 0.0001)
require.InDelta(t, 0.8, m3.SuccessRate, 0.0001)
}
func TestRedactChannelMonitorV2MetricZerosVolume(t *testing.T) {
// Service helper is in service package; covered there. Keep a smoke note that
// rates survive a manual zeroing of volume fields used by the UI contract.
m := service.ChannelMonitorV2Metric{
RequestCount: 100, ErrorRequests: 10, SuccessRequests: 90,
TokenCount: 1000, RPM: 5, TPM: 50, ErrorRate: 0.1, SuccessRate: 0.9, CacheRate: 0.4,
}
// Mimic redact: zero volume only
m.RequestCount, m.ErrorRequests, m.SuccessRequests, m.TokenCount = 0, 0, 0, 0
require.Equal(t, 0.1, m.ErrorRate)
require.Equal(t, 5.0, m.RPM)
}
func TestChannelMonitorV2CatalogFilterClearsMultiSelectDimensions(t *testing.T) {
start := time.Unix(1, 0)
end := time.Unix(2, 0)
filter := service.ChannelMonitorV2Filter{
Start: start, End: end, Bucket: time.Minute,
Platforms: []string{"openai"}, GroupIDs: []int64{3}, Models: []string{"gpt-5"},
}
catalog := channelMonitorV2CatalogFilter(filter)
require.Nil(t, catalog.Platforms)
require.Nil(t, catalog.GroupIDs)
require.Nil(t, catalog.Models)
// Time window / coverage-related fields remain.
require.Equal(t, start, catalog.Start)
require.Equal(t, end, catalog.End)
require.Equal(t, time.Minute, catalog.Bucket)
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{
{Platform: "openai", Enabled: true},
{Platform: "grok", Enabled: true},
},
GroupIDs: []int64{3, 4},
}
catalogWhere, catalogArgs := channelMonitorV2Where(catalog, cfg, "m")
_, metricArgs := channelMonitorV2Where(filter, cfg, "m")
// Catalog WHERE still applies config scope (enabled platforms + group allow-list).
require.Contains(t, catalogWhere, "m.platform = ANY")
require.Contains(t, catalogWhere, "m.group_id = ANY")
require.Len(t, catalogArgs, 4) // start, end, platforms, groups
require.Len(t, metricArgs, 4)
// Metrics WHERE is narrower once multi-select platforms/groups are applied.
require.NotEqual(t, catalogArgs, metricArgs)
// Group seeding without multi-select uses full config allow-list.
require.Equal(t, []int64{3, 4}, configuredChannelMonitorV2GroupIDs(catalog, cfg))
require.Equal(t, []int64{3}, configuredChannelMonitorV2GroupIDs(filter, cfg))
}
@@ -83,6 +83,9 @@ var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibil
"123_fix_legacy_auth_source_grant_on_signup_defaults.sql": newMigrationChecksumCompatibilityRule("2ce43c2cd89e9f9e1febd34a407ed9e84d177386c5544b6f02c1f58a21129f57", "6cd33422f215dcd1f486ab6f35c0ea5805d9ca69bb25906d94bc649156657145"),
"159_batch_image_foundation.sql": newMigrationChecksumCompatibilityRule("d902b70982025ec519749faf058aab7631e82c3f48167b9a4ae4db718eb72cce", "82da85b5d98e67a0507647b873a40373e84538e4adafdeed6767c0ac8b6570b2"),
"161_batch_image_pricing_snapshot.sql": newMigrationChecksumCompatibilityRule("4012af3e43636cb6af22e0176d59d1fcc70615c0f310194329461ae462c4fbd6", "96d915c9b7a6941ae99039e0ff3f1a61481eb9bddd933d11c6fadb2274554e87"),
// 195 originally seeded mode=v2; flipped to v1 (safe default / opt-in v2). Existing DBs
// that already applied the v2 seed keep their row and the historical checksum.
"195_channel_monitor_mode.sql": newMigrationChecksumCompatibilityRule("13f3792f3e3e53ee96e26415c884cf8062c77172824b54fcc9a8c0c2b1f185ec", "4c74fe33ef2274cc72e1bb49671e651274532c034b29f5b2982c2a4c88d101a6"),
// 220 originally cleared video prices for all non-grok platforms (including composite);
// composite is now preserved because it may route to Grok accounts.
"220_clear_non_grok_video_generation_config.sql": newMigrationChecksumCompatibilityRule("85e320b9ec64f2d3fcd8cf705b2b4e76a7b49f7a57140c14bff97f32691c818b", "3da48c8fdffe6390325f43d08b8e353e0a365df43d44a78dbbe655d0deb18402"),
+1
View File
@@ -98,6 +98,7 @@ var ProviderSet = wire.NewSet(
NewTLSFingerprintProfileRepository,
NewChannelRepository,
NewChannelMonitorRepository,
NewChannelMonitorV2Repository,
NewChannelMonitorRequestTemplateRepository,
NewContentModerationRepository,
NewAffiliateRepository,
@@ -984,6 +984,8 @@ func TestAPIContracts(t *testing.T) {
"balance_low_notify_recharge_url": "",
"account_quota_notify_emails": [],
"channel_monitor_enabled": true,
"channel_monitor_mode": "v1",
"channel_monitor_hide_throughput": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
@@ -1291,6 +1293,8 @@ func TestAPIContracts(t *testing.T) {
"balance_low_notify_recharge_url": "",
"account_quota_notify_emails": [],
"channel_monitor_enabled": true,
"channel_monitor_mode": "v1",
"channel_monitor_hide_throughput": true,
"channel_monitor_default_interval_seconds": 60,
"available_channels_enabled": false,
"model_plaza_enabled": false,
+68 -2
View File
@@ -3,6 +3,7 @@ package routes
import (
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -106,7 +107,8 @@ func RegisterAdminRoutes(
registerChannelRoutes(admin, h)
// 渠道监控
registerChannelMonitorRoutes(admin, h)
registerChannelMonitorRoutes(admin, h, settingService)
registerChannelMonitorV2Routes(admin, h, settingService)
// 风控中心
registerContentModerationRoutes(admin, h)
@@ -733,8 +735,10 @@ func registerChannelRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
}
}
func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers, settingService *service.SettingService) {
guard := channelMonitorAdminFeatureGuard(settingService)
monitors := admin.Group("/channel-monitors")
monitors.Use(guard)
{
monitors.GET("", h.Admin.ChannelMonitor.List)
monitors.POST("", h.Admin.ChannelMonitor.Create)
@@ -747,6 +751,7 @@ func registerChannelMonitorRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
}
templates := admin.Group("/channel-monitor-templates")
templates.Use(guard)
{
templates.GET("", h.Admin.ChannelMonitorTemplate.List)
templates.POST("", h.Admin.ChannelMonitorTemplate.Create)
@@ -777,3 +782,64 @@ func registerAffiliateRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
}
}
}
func registerChannelMonitorV2Routes(admin *gin.RouterGroup, h *handler.Handlers, settingService *service.SettingService) {
// Config GET/PUT: feature enabled only (operators can prepare V2 before flipping mode).
// Read/matrix endpoints: require mode=v2 so V1 deployments do not serve passive data.
featureGuard := channelMonitorAdminFeatureGuard(settingService)
modeV2Guard := channelMonitorModeV2Guard(settingService)
monitor := admin.Group("/channel-monitor-v2")
{
config := monitor.Group("")
config.Use(featureGuard)
{
config.GET("/config", h.ChannelMonitorV2.GetConfig)
config.PUT("/config", h.ChannelMonitorV2.UpdateConfig)
}
reads := monitor.Group("")
reads.Use(modeV2Guard)
{
reads.GET("/dimensions", h.ChannelMonitorV2.Dimensions)
reads.GET("/snapshot", h.ChannelMonitorV2.AdminSnapshot)
reads.GET("/models", h.ChannelMonitorV2.AdminModels)
reads.GET("/matrix", h.ChannelMonitorV2.AdminMatrix)
reads.GET("/errors", h.ChannelMonitorV2.Errors)
reads.GET("/users", h.ChannelMonitorV2.AdminUsers)
}
}
}
func channelMonitorAdminFeatureGuard(settingService *service.SettingService) gin.HandlerFunc {
return func(c *gin.Context) {
if settingService != nil && settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled {
c.Next()
return
}
response.ErrorFrom(c, service.ErrChannelMonitorDisabled)
c.Abort()
}
}
// channelMonitorModeV2Guard requires feature enabled and channel_monitor_mode=v2.
func channelMonitorModeV2Guard(settingService *service.SettingService) gin.HandlerFunc {
return func(c *gin.Context) {
if settingService == nil {
response.ErrorFrom(c, service.ErrChannelMonitorDisabled)
c.Abort()
return
}
rt := settingService.GetChannelMonitorRuntime(c.Request.Context())
if !rt.Enabled {
response.ErrorFrom(c, service.ErrChannelMonitorDisabled)
c.Abort()
return
}
if !rt.PassiveAggregationAllowed() {
response.ErrorFrom(c, service.ErrChannelMonitorModeMismatch)
c.Abort()
return
}
c.Next()
}
}
@@ -0,0 +1,173 @@
package routes
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// channelMonitorRouteSettingRepoStub is a minimal SettingRepository for route guards.
type channelMonitorRouteSettingRepoStub struct {
values map[string]string
}
func (s *channelMonitorRouteSettingRepoStub) Get(context.Context, string) (*service.Setting, error) {
panic("unexpected Get call")
}
func (s *channelMonitorRouteSettingRepoStub) GetValue(_ context.Context, key string) (string, error) {
return s.values[key], nil
}
func (s *channelMonitorRouteSettingRepoStub) Set(context.Context, string, string) error {
panic("unexpected Set call")
}
func (s *channelMonitorRouteSettingRepoStub) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
out := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := s.values[key]; ok {
out[key] = value
}
}
return out, nil
}
func (s *channelMonitorRouteSettingRepoStub) SetMultiple(context.Context, map[string]string) error {
panic("unexpected SetMultiple call")
}
func (s *channelMonitorRouteSettingRepoStub) GetAll(context.Context) (map[string]string, error) {
panic("unexpected GetAll call")
}
func (s *channelMonitorRouteSettingRepoStub) Delete(context.Context, string) error {
panic("unexpected Delete call")
}
func newChannelMonitorRouteSettings(enabled bool) *service.SettingService {
value := "false"
if enabled {
value = "true"
}
return service.NewSettingService(&channelMonitorRouteSettingRepoStub{
values: map[string]string{
service.SettingKeyChannelMonitorEnabled: value,
},
}, &config.Config{})
}
func newChannelMonitorModeSettings(enabled bool, mode string) *service.SettingService {
enabledVal := "false"
if enabled {
enabledVal = "true"
}
return service.NewSettingService(&channelMonitorRouteSettingRepoStub{
values: map[string]string{
service.SettingKeyChannelMonitorEnabled: enabledVal,
service.SettingKeyChannelMonitorMode: mode,
},
}, &config.Config{})
}
func TestChannelMonitorAdminFeatureGuard(t *testing.T) {
tests := []struct {
name string
svc *service.SettingService
wantStatus int
}{
{
name: "nil setting service blocks",
svc: nil,
wantStatus: http.StatusForbidden,
},
{
name: "disabled blocks",
svc: newChannelMonitorRouteSettings(false),
wantStatus: http.StatusForbidden,
},
{
name: "enabled allows",
svc: newChannelMonitorRouteSettings(true),
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(channelMonitorAdminFeatureGuard(tt.svc))
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil)
router.ServeHTTP(rec, req)
require.Equal(t, tt.wantStatus, rec.Code)
if tt.wantStatus == http.StatusForbidden {
require.Contains(t, rec.Body.String(), "CHANNEL_MONITOR_DISABLED")
}
})
}
}
func TestChannelMonitorModeV2Guard(t *testing.T) {
tests := []struct {
name string
svc *service.SettingService
wantStatus int
wantCode string
}{
{
name: "nil blocks as disabled",
svc: nil,
wantStatus: http.StatusForbidden,
wantCode: "CHANNEL_MONITOR_DISABLED",
},
{
name: "feature off blocks",
svc: newChannelMonitorModeSettings(false, service.ChannelMonitorModeV2),
wantStatus: http.StatusForbidden,
wantCode: "CHANNEL_MONITOR_DISABLED",
},
{
name: "mode v1 blocks with mode mismatch",
svc: newChannelMonitorModeSettings(true, service.ChannelMonitorModeV1),
wantStatus: http.StatusForbidden,
wantCode: "CHANNEL_MONITOR_MODE_MISMATCH",
},
{
name: "mode v2 allows",
svc: newChannelMonitorModeSettings(true, service.ChannelMonitorModeV2),
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(channelMonitorModeV2Guard(tt.svc))
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/test", nil)
router.ServeHTTP(rec, req)
require.Equal(t, tt.wantStatus, rec.Code)
if tt.wantCode != "" {
require.Contains(t, rec.Body.String(), tt.wantCode)
}
})
}
}
+13
View File
@@ -141,5 +141,18 @@ func RegisterUserRoutes(
monitors.GET("", h.ChannelMonitor.List)
monitors.GET("/:id/status", h.ChannelMonitor.GetStatus)
}
// V2 passive views require feature on + mode=v2.
monitorV2 := authenticated.Group("/channel-monitor-v2")
monitorV2.Use(panelRateLimiter.Heavy())
monitorV2.Use(channelMonitorModeV2Guard(settingService))
{
monitorV2.GET("/dimensions", h.ChannelMonitorV2.Dimensions)
monitorV2.GET("/snapshot", h.ChannelMonitorV2.Snapshot)
monitorV2.GET("/models", h.ChannelMonitorV2.Models)
monitorV2.GET("/matrix", h.ChannelMonitorV2.Matrix)
monitorV2.GET("/errors", h.ChannelMonitorV2.Errors)
monitorV2.GET("/users", h.ChannelMonitorV2.Users)
}
}
}
@@ -157,3 +157,18 @@ var (
"CHANNEL_MONITOR_KEY_DECRYPT_FAILED", "api key decryption failed; please re-edit the monitor with a fresh key",
)
)
var (
ErrChannelMonitorDisabled = infraerrors.Forbidden(
"CHANNEL_MONITOR_DISABLED",
"channel monitor feature is disabled",
)
ErrChannelMonitorActiveProbesRetired = infraerrors.Forbidden(
"CHANNEL_MONITOR_ACTIVE_PROBES_RETIRED",
"channel monitor active probes are retired in v2 mode",
)
ErrChannelMonitorModeMismatch = infraerrors.Forbidden(
"CHANNEL_MONITOR_MODE_MISMATCH",
"channel monitor mode does not allow this operation",
)
)
@@ -0,0 +1,63 @@
//go:build unit
package service
import (
"context"
"testing"
"github.com/stretchr/testify/require"
)
type channelMonitorRuntimeStub struct {
rt ChannelMonitorRuntime
}
func (s channelMonitorRuntimeStub) GetChannelMonitorRuntime(context.Context) ChannelMonitorRuntime {
return s.rt
}
func TestRunCheck_ModeV2NeverProbes(t *testing.T) {
svc := NewChannelMonitorService(nil, nil)
svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{
Enabled: true,
Mode: ChannelMonitorModeV2,
}})
results, err := svc.RunCheck(context.Background(), 1)
require.ErrorIs(t, err, ErrChannelMonitorActiveProbesRetired)
require.Nil(t, results)
}
func TestRunCheck_DisabledReturnsDisabled(t *testing.T) {
svc := NewChannelMonitorService(nil, nil)
svc.SetRuntimeReader(channelMonitorRuntimeStub{rt: ChannelMonitorRuntime{
Enabled: false,
Mode: ChannelMonitorModeV1,
}})
_, err := svc.RunCheck(context.Background(), 1)
require.ErrorIs(t, err, ErrChannelMonitorDisabled)
}
func TestRunCheck_NilRuntimeReaderFailsClosedAsV2(t *testing.T) {
svc := NewChannelMonitorService(nil, nil)
// No SetRuntimeReader → probeRuntime defaults to mode=v2 (retired).
_, err := svc.RunCheck(context.Background(), 1)
require.ErrorIs(t, err, ErrChannelMonitorActiveProbesRetired)
}
func TestNormalizeChannelMonitorMode(t *testing.T) {
require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode(""))
require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode("v1"))
require.Equal(t, ChannelMonitorModeV2, normalizeChannelMonitorMode("v2"))
require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode("invalid"))
require.Equal(t, ChannelMonitorModeV1, normalizeChannelMonitorMode(" V1 "))
}
func TestChannelMonitorRuntimeActiveProbesAllowed(t *testing.T) {
require.False(t, (ChannelMonitorRuntime{Enabled: false, Mode: ChannelMonitorModeV1}).ActiveProbesAllowed())
require.True(t, (ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV1}).ActiveProbesAllowed())
require.False(t, (ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2}).ActiveProbesAllowed())
require.True(t, (ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2}).PassiveAggregationAllowed())
}
@@ -257,8 +257,11 @@ func (r *ChannelMonitorRunner) runScheduled(ctx context.Context, task *scheduled
// fire 提交一次检测到 worker 池。功能开关关闭时跳过本次(不取消任务,
// 重新启用时立即恢复);池满或重复在飞时也跳过。
func (r *ChannelMonitorRunner) fire(ctx context.Context, task *scheduledMonitor) {
if r.settingService != nil && !r.settingService.GetChannelMonitorRuntime(ctx).Enabled {
return
if r.settingService != nil {
rt := r.settingService.GetChannelMonitorRuntime(ctx)
if !rt.ActiveProbesAllowed() {
return
}
}
if !r.tryAcquireInFlight(task.id) {
slog.Debug("channel_monitor: skip already in-flight",
@@ -61,10 +61,19 @@ type ChannelMonitorRepository interface {
UpdateAggregationWatermark(ctx context.Context, date time.Time) error
}
// channelMonitorRuntimeReader is the optional settings view used to gate V1
// active probes by channel_monitor_enabled + channel_monitor_mode.
type channelMonitorRuntimeReader interface {
GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime
}
// ChannelMonitorService 渠道监控管理服务。
type ChannelMonitorService struct {
repo ChannelMonitorRepository
encryptor SecretEncryptor
// settings is optional; when nil, RunCheck fails closed for active probes
// (mode defaults to v2 / retired) so tests without settings never hit upstream.
settings channelMonitorRuntimeReader
// scheduler 由 wire 通过 SetScheduler 注入;CRUD 后调用对应钩子即时同步任务。
// 测试或未注入场景下保持 nil,所有钩子调用变为 no-op。
scheduler MonitorScheduler
@@ -83,6 +92,22 @@ func NewChannelMonitorService(repo ChannelMonitorRepository, encryptor SecretEnc
return &ChannelMonitorService{repo: repo, encryptor: encryptor}
}
// SetRuntimeReader injects the settings reader used to gate active probes.
// Optional: when unset, active probes are treated as mode=v2 (retired).
func (s *ChannelMonitorService) SetRuntimeReader(r channelMonitorRuntimeReader) {
if s == nil {
return
}
s.settings = r
}
func (s *ChannelMonitorService) probeRuntime(ctx context.Context) ChannelMonitorRuntime {
if s == nil || s.settings == nil {
return ChannelMonitorRuntime{Enabled: true, Mode: ChannelMonitorModeV2}
}
return s.settings.GetChannelMonitorRuntime(ctx)
}
// ---------- CRUD ----------
// List 列表查询(支持 provider/enabled/search 过滤 + 分页)。
@@ -427,7 +452,16 @@ func (s *ChannelMonitorService) ListHistory(ctx context.Context, id int64, model
// RunCheck 同步触发对一个监控的检测:并发跑 primary + extra 模型,
// 写历史记录并更新 last_checked_at。返回每个模型的检测结果。
// 仅当 channel_monitor_enabled=true 且 channel_monitor_mode=v1 时真正探测;
// mode=v2 时返回 ErrChannelMonitorActiveProbesRetired,不产生上游流量。
func (s *ChannelMonitorService) RunCheck(ctx context.Context, id int64) ([]*CheckResult, error) {
rt := s.probeRuntime(ctx)
if !rt.Enabled {
return nil, ErrChannelMonitorDisabled
}
if !rt.ActiveProbesAllowed() {
return nil, ErrChannelMonitorActiveProbesRetired
}
m, err := s.Get(ctx, id) // 已解密 APIKey
if err != nil {
return nil, err
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,419 @@
package service
import (
"context"
"database/sql"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/google/uuid"
)
const (
channelMonitorV2AggregatorLockKey = "channel-monitor-v2-aggregator"
// Retention walks back to the longest stored tier (1d rollup = 90d). Per-tier
// prune in the repository drops short-lived 1m/user/hist facts earlier.
channelMonitorV2RetentionMax = 90 * 24 * time.Hour
// First tick after upgrade prioritizes the default 90m view (with small padding).
channelMonitorV2BootstrapFirst = 2 * time.Hour
// Always refresh a small trailing window so late writes land without
// re-aggregating large history every tick.
channelMonitorV2RecentOverlap = 10 * time.Minute
// Gentle backfill: small adaptive chunks, never default 24h hammering.
// Initial historical chunk after the 2h seed.
channelMonitorV2BackfillChunkInit = time.Hour
channelMonitorV2MinBackfillChunk = 15 * time.Minute
// Depth-based ceilings (product phases 90m → 1d → 7d → 30d → 90d).
channelMonitorV2MaxChunkNear1d = 2 * time.Hour
channelMonitorV2MaxChunkNear7d = 4 * time.Hour
channelMonitorV2MaxChunkFar = 6 * time.Hour
// Soft adaptive timing: grow only when a recompute is clearly cheap.
channelMonitorV2GrowChunkUnder = 15 * time.Second
channelMonitorV2MaxBackoff = 10 * time.Minute
)
// channelMonitorRuntimeSubscriber is the optional settings hook that lets the
// aggregator wake immediately when channel_monitor_enabled / mode flips.
type channelMonitorRuntimeSubscriber interface {
SubscribeChannelMonitorRuntime(listener func()) (unsubscribe func())
}
type ChannelMonitorV2Aggregator struct {
repo ChannelMonitorV2Repository
db *sql.DB
settings channelMonitorRuntimeReader
instanceID string
stopCh chan struct{}
// kickCh wakes the loop early after a settings change (buffered 1).
kickCh chan struct{}
startOnce sync.Once
stopOnce sync.Once
mu sync.Mutex
// backfillAt is the earliest minute already recomputed (mirrors DB cursor).
// Zero means "not yet loaded from durable watermark this process".
backfillAt time.Time
backfillChunk time.Duration
backfillFailures int
// nextWaitFloor is applied after runOnce when failures require backoff.
nextWaitFloor time.Duration
// cursorLoaded is true after the first successful watermark read (or init).
cursorLoaded bool
// hasAggregated is true once any recompute in this process (or durable data) exists.
hasAggregated bool
unsub func()
ctx context.Context
cancel context.CancelFunc
}
func NewChannelMonitorV2Aggregator(repo ChannelMonitorV2Repository, db *sql.DB, settings channelMonitorRuntimeReader) *ChannelMonitorV2Aggregator {
return &ChannelMonitorV2Aggregator{
repo: repo,
db: db,
settings: settings,
instanceID: uuid.NewString(),
stopCh: make(chan struct{}),
kickCh: make(chan struct{}, 1),
backfillChunk: channelMonitorV2BackfillChunkInit,
}
}
func (s *ChannelMonitorV2Aggregator) Start() {
if s == nil || s.repo == nil {
return
}
s.startOnce.Do(func() {
s.mu.Lock()
s.ctx, s.cancel = context.WithCancel(context.Background())
s.mu.Unlock()
if sub, ok := s.settings.(channelMonitorRuntimeSubscriber); ok && sub != nil {
unsub := sub.SubscribeChannelMonitorRuntime(func() {
s.kick()
})
s.mu.Lock()
stopped := s.ctx == nil
if !stopped {
select {
case <-s.ctx.Done():
stopped = true
default:
}
}
if !stopped {
s.unsub = unsub
}
s.mu.Unlock()
if stopped && unsub != nil {
unsub()
}
}
go s.loop()
})
}
func (s *ChannelMonitorV2Aggregator) Stop() {
if s == nil {
return
}
s.stopOnce.Do(func() {
s.mu.Lock()
cancel := s.cancel
unsub := s.unsub
s.cancel = nil
s.unsub = nil
s.mu.Unlock()
if cancel != nil {
cancel()
}
if unsub != nil {
unsub()
}
close(s.stopCh)
})
}
// kick wakes the aggregation loop so mode flips take effect without waiting
// for the next refresh interval.
func (s *ChannelMonitorV2Aggregator) kick() {
if s == nil {
return
}
select {
case s.kickCh <- struct{}{}:
default:
}
}
func (s *ChannelMonitorV2Aggregator) loop() {
for {
interval := time.Minute
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if !s.passiveAggregationAllowed(ctx) {
cancel()
if !s.wait(interval) {
return
}
continue
}
if cfg, err := s.repo.GetConfig(ctx); err == nil {
if !cfg.Enabled {
cancel()
if !s.wait(interval) {
return
}
continue
}
if cfg.RefreshIntervalSeconds > 0 {
interval = time.Duration(cfg.RefreshIntervalSeconds) * time.Second
}
}
cancel()
s.runOnce()
// Hard gate: never compress bootstrap to multi-Hz ticks. Soft gate: on
// repeated failures raise the wait floor (exponential backoff).
s.mu.Lock()
floor := s.nextWaitFloor
s.mu.Unlock()
if floor > interval {
interval = floor
}
if !s.wait(interval) {
return
}
}
}
func (s *ChannelMonitorV2Aggregator) passiveAggregationAllowed(ctx context.Context) bool {
if s == nil || s.settings == nil {
// Fail closed without settings: do not aggregate under ambiguous mode.
return false
}
return s.settings.GetChannelMonitorRuntime(ctx).PassiveAggregationAllowed()
}
func (s *ChannelMonitorV2Aggregator) wait(interval time.Duration) bool {
timer := time.NewTimer(interval)
defer timer.Stop()
select {
case <-timer.C:
return true
case <-s.kickCh:
// Drain any coalesced kicks so a burst of settings writes only wakes once.
for {
select {
case <-s.kickCh:
default:
return true
}
}
case <-s.stopCh:
return false
}
}
func (s *ChannelMonitorV2Aggregator) runOnce() {
s.mu.Lock()
parent := s.ctx
s.mu.Unlock()
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithTimeout(parent, 55*time.Second)
defer cancel()
release, acquired := tryAcquireSingletonLeaderLock(ctx, nil, s.db, channelMonitorV2AggregatorLockKey, s.instanceID, 2*time.Minute)
if !acquired {
return
}
if release != nil {
defer release()
}
now := time.Now().UTC().Truncate(time.Minute)
if err := s.ensureCursor(ctx, now); err != nil {
logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] load watermark failed: %v", err)
return
}
s.mu.Lock()
cursor := s.backfillAt
hasData := s.hasAggregated
s.mu.Unlock()
// Phase 1 (first upgrade / empty): seed the default 90m UI window quickly.
if !hasData || cursor.IsZero() {
start := now.Add(-channelMonitorV2BootstrapFirst)
started := time.Now()
if err := s.repo.RecomputeRange(ctx, start, now); err != nil {
logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] bootstrap recent aggregation failed: %v", err)
s.recordBackfillFailure(now, cursor)
return
}
s.recordBackfillSuccess(start, time.Since(started), now)
return
}
// Always refresh the trailing overlap so late usage/error writes land in 1m facts.
if err := s.repo.RecomputeRange(ctx, now.Add(-channelMonitorV2RecentOverlap), now); err != nil {
logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] overlap aggregation failed: %v", err)
return
}
// Phase 2: walk history backward at most one chunk per tick until retention max (90d).
// Product UI (30d) fills first; remaining 30–90d continues silently.
retentionCutoff := now.Add(-channelMonitorV2RetentionMax)
if !cursor.After(retentionCutoff) {
return
}
end := cursor
s.mu.Lock()
chunk := s.backfillChunk
s.mu.Unlock()
if chunk <= 0 {
chunk = channelMonitorV2BackfillChunkInit
}
maxChunk := channelMonitorV2MaxChunkForDepth(now, end)
if chunk > maxChunk {
chunk = maxChunk
}
if chunk < channelMonitorV2MinBackfillChunk {
chunk = channelMonitorV2MinBackfillChunk
}
start := end.Add(-chunk)
// Once bootstrap reaches historical data, keep chunks on day boundaries so
// daily rollups never depend on 1m rows from two independently pruned chunks.
if end.Before(now.Add(-7 * 24 * time.Hour)) {
aligned := end.Add(-chunk).Truncate(24 * time.Hour)
if aligned.Before(end) {
start = aligned
}
}
if start.Before(retentionCutoff) {
start = retentionCutoff
}
if !start.Before(end) {
return
}
started := time.Now()
if err := s.repo.RecomputeRange(ctx, start, end); err != nil {
logger.LegacyPrintf("service.channel_monitor_v2", "[ChannelMonitorV2] backfill failed %s..%s: %v", start, end, err)
s.recordBackfillFailure(now, end)
return
}
s.recordBackfillSuccess(start, time.Since(started), now)
}
// channelMonitorV2MaxChunkForDepth returns the hard ceiling for a historical
// chunk ending at `end` (earliest already covered / next walk end).
func channelMonitorV2MaxChunkForDepth(now, end time.Time) time.Duration {
age := now.Sub(end)
switch {
case age < 24*time.Hour:
return channelMonitorV2MaxChunkNear1d
case age < 7*24*time.Hour:
return channelMonitorV2MaxChunkNear7d
default:
return channelMonitorV2MaxChunkFar
}
}
func (s *ChannelMonitorV2Aggregator) recordBackfillSuccess(coveredFrom time.Time, elapsed time.Duration, now time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.backfillAt = coveredFrom
s.hasAggregated = true
s.backfillFailures = 0
s.nextWaitFloor = 0
maxChunk := channelMonitorV2MaxChunkForDepth(now, coveredFrom)
// Grow slowly only when the recompute was clearly cheap.
if elapsed > 0 && elapsed < channelMonitorV2GrowChunkUnder {
next := s.backfillChunk
if next <= 0 {
next = channelMonitorV2BackfillChunkInit
}
next = time.Duration(float64(next) * 1.5)
if next > maxChunk {
next = maxChunk
}
if next < channelMonitorV2MinBackfillChunk {
next = channelMonitorV2MinBackfillChunk
}
s.backfillChunk = next
return
}
// Keep a healthy chunk within the depth ceiling after success.
if s.backfillChunk <= 0 || s.backfillChunk > maxChunk {
s.backfillChunk = channelMonitorV2BackfillChunkInit
if s.backfillChunk > maxChunk {
s.backfillChunk = maxChunk
}
}
}
func (s *ChannelMonitorV2Aggregator) recordBackfillFailure(now, end time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.backfillFailures++
// Shrink chunk toward the minimum so large DBs self-throttle.
if s.backfillChunk <= 0 {
s.backfillChunk = channelMonitorV2BackfillChunkInit
}
s.backfillChunk /= 2
if s.backfillChunk < channelMonitorV2MinBackfillChunk {
s.backfillChunk = channelMonitorV2MinBackfillChunk
}
maxChunk := channelMonitorV2MaxChunkForDepth(now, end)
if s.backfillChunk > maxChunk {
s.backfillChunk = maxChunk
}
// Exponential backoff on wait floor: 1m, 2m, 4m… capped at 10m.
floor := time.Minute << uint(s.backfillFailures-1)
if floor > channelMonitorV2MaxBackoff {
floor = channelMonitorV2MaxBackoff
}
if floor < time.Minute {
floor = time.Minute
}
s.nextWaitFloor = floor
}
// ensureCursor restores durable backfill_cursor after process restart so progress
// and historical walk continue instead of re-seeding only the last 2h.
func (s *ChannelMonitorV2Aggregator) ensureCursor(ctx context.Context, now time.Time) error {
s.mu.Lock()
loaded := s.cursorLoaded
s.mu.Unlock()
if loaded {
return nil
}
wm, err := s.repo.GetAggregationWatermark(ctx)
if err != nil {
return err
}
s.mu.Lock()
defer s.mu.Unlock()
if s.cursorLoaded {
return nil
}
if wm != nil {
if !wm.BackfillCursor.IsZero() {
s.backfillAt = wm.BackfillCursor.UTC().Truncate(time.Minute)
}
if wm.HasData || !wm.DataThrough.IsZero() {
s.hasAggregated = true
// Legacy rows may have data_through but null backfill_cursor (older workers).
// Infer cursor from data_through − initial window so we do not re-bootstrap
// only 2h and claim zero progress forever.
if s.backfillAt.IsZero() && !wm.DataThrough.IsZero() {
inferred := wm.DataThrough.UTC().Truncate(time.Minute).Add(-channelMonitorV2BootstrapFirst)
if inferred.After(now) {
inferred = now.Add(-channelMonitorV2BootstrapFirst)
}
s.backfillAt = inferred
}
}
}
s.cursorLoaded = true
return nil
}
@@ -0,0 +1,49 @@
//go:build unit
package service
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestChannelMonitorV2MaxChunkForDepth(t *testing.T) {
now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
// Within last day → tightest ceiling (2h).
require.Equal(t, channelMonitorV2MaxChunkNear1d, channelMonitorV2MaxChunkForDepth(now, now.Add(-2*time.Hour)))
// Between 1d and 7d → 4h.
require.Equal(t, channelMonitorV2MaxChunkNear7d, channelMonitorV2MaxChunkForDepth(now, now.Add(-2*24*time.Hour)))
// Older than 7d → 6h (never 24h default).
require.Equal(t, channelMonitorV2MaxChunkFar, channelMonitorV2MaxChunkForDepth(now, now.Add(-10*24*time.Hour)))
require.Less(t, channelMonitorV2MaxChunkFar, 24*time.Hour)
require.Equal(t, time.Hour, channelMonitorV2BackfillChunkInit)
require.Equal(t, 15*time.Minute, channelMonitorV2MinBackfillChunk)
}
func TestChannelMonitorV2AggregatorAdaptiveChunk(t *testing.T) {
s := NewChannelMonitorV2Aggregator(nil, nil, nil)
now := time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)
cursor := now.Add(-3 * time.Hour)
// Failure shrinks chunk and sets backoff floor.
s.backfillChunk = 2 * time.Hour
s.recordBackfillFailure(now, cursor)
require.Equal(t, time.Hour, s.backfillChunk)
require.Equal(t, time.Minute, s.nextWaitFloor)
require.Equal(t, 1, s.backfillFailures)
// Repeated failure halves again and raises floor.
s.recordBackfillFailure(now, cursor)
require.Equal(t, 30*time.Minute, s.backfillChunk)
require.Equal(t, 2*time.Minute, s.nextWaitFloor)
// Fast success grows within depth ceiling and clears backoff.
s.recordBackfillSuccess(cursor.Add(-30*time.Minute), 5*time.Second, now)
require.Equal(t, 0, s.backfillFailures)
require.Equal(t, time.Duration(0), s.nextWaitFloor)
require.Greater(t, s.backfillChunk, 30*time.Minute)
require.LessOrEqual(t, s.backfillChunk, channelMonitorV2MaxChunkForDepth(now, cursor.Add(-30*time.Minute)))
}
@@ -0,0 +1,79 @@
package service
import "strings"
type ChannelMonitorV2ErrorInput struct {
ErrorType string
ErrorOwner string
ErrorSource string
StatusCode int
UpstreamStatusCode int
Message string
}
// ClassifyChannelMonitorV2Error is deliberately ordered. Once rows are stored
// with a taxonomy version, changing this order requires a new version.
func ClassifyChannelMonitorV2Error(input ChannelMonitorV2ErrorInput) string {
errorType := strings.ToLower(strings.TrimSpace(input.ErrorType))
owner := strings.ToLower(strings.TrimSpace(input.ErrorOwner))
text := strings.ToLower(strings.Join([]string{input.ErrorType, input.ErrorSource, input.Message}, " "))
if errorType == "cyber_policy" || channelMonitorV2ContainsAny(text, "content policy", "content_policy", "safety policy", "moderation", "blocked keyword") {
return "content_policy"
}
if input.StatusCode == 401 || input.UpstreamStatusCode == 401 || channelMonitorV2ContainsAny(text, "unauthorized", "invalid api key", "invalid_api_key", "authentication", "api_key_disabled") {
return "authentication"
}
if channelMonitorV2ContainsAny(text, "context window", "context length", "maximum prompt length", "too many tokens", "max_tokens") {
return "context_limit"
}
if channelMonitorV2ContainsAny(text, "failed to deserialize", "missing required parameter", "invalid request", "invalid_request", "tool_choice") {
return "invalid_request"
}
if channelMonitorV2ContainsAny(text, "does not support the requested model", "not supported by any configured account", "model not supported", "unsupported model") {
return "model_unsupported"
}
if channelMonitorV2ContainsAny(text, "group not allowed", "group_not_allowed", "group access") {
return "group_access"
}
if channelMonitorV2ContainsAny(text, "run out of credits", "insufficient balance", "insufficient quota", "subscription", "quota exceeded", "billing hard limit") {
return "quota_or_balance"
}
if channelMonitorV2ContainsAny(text, "no available accounts", "no healthy account", "no healthy upstream account", "failover budget exhausted", "account pool") {
return "account_pool_unavailable"
}
if input.StatusCode == 429 || input.UpstreamStatusCode == 429 || channelMonitorV2ContainsAny(text, "rate limit", "rate_limit", "high demand", "overloaded", "concurrency limit", "capacity") {
return "rate_or_capacity"
}
if input.StatusCode == 408 || input.StatusCode == 504 || channelMonitorV2ContainsAny(text, "timeout", "deadline exceeded", "error code: 524", "gateway time-out", "gateway timeout") {
return "timeout"
}
if channelMonitorV2ContainsAny(text, "transport", "stream_read_error", "connection reset", "connection refused", "tls", "http2", "missing terminal event", "unexpected eof") {
return "transport_or_stream"
}
if input.StatusCode == 403 || input.UpstreamStatusCode == 403 {
return "upstream_forbidden"
}
if input.StatusCode == 404 || input.UpstreamStatusCode == 404 {
return "not_found"
}
if input.StatusCode == 499 || channelMonitorV2ContainsAny(text, "client cancelled", "client canceled", "context canceled") {
return "client_cancelled"
}
if input.UpstreamStatusCode >= 500 || (owner == "provider" && input.StatusCode >= 500) {
return "upstream_5xx"
}
if input.StatusCode >= 500 || errorType == "internal" || owner == "system" {
return "internal"
}
return "other"
}
func channelMonitorV2ContainsAny(value string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
return true
}
}
return false
}
@@ -0,0 +1,469 @@
package service
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/require"
)
type channelMonitorV2RepoStub struct {
config serviceChannelMonitorV2ConfigAlias
users *ChannelMonitorV2List[ChannelMonitorV2UserRow]
matrix *ChannelMonitorV2Matrix
errors *ChannelMonitorV2List[ChannelMonitorV2ErrorRow]
snap *ChannelMonitorV2Snapshot
group ChannelMonitorV2GroupBy
admin bool
}
// Alias keeps composite literals readable without introducing another package.
type serviceChannelMonitorV2ConfigAlias = ChannelMonitorV2Config
func (s *channelMonitorV2RepoStub) GetConfig(context.Context) (*ChannelMonitorV2Config, error) {
cfg := ChannelMonitorV2Config(s.config)
return &cfg, nil
}
func (s *channelMonitorV2RepoStub) UpdateConfig(context.Context, ChannelMonitorV2Config, int) (*ChannelMonitorV2Config, error) {
return nil, nil
}
func (s *channelMonitorV2RepoStub) GetDimensions(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config) (*ChannelMonitorV2Dimensions, error) {
return nil, nil
}
func (s *channelMonitorV2RepoStub) GetSnapshot(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, admin bool) (*ChannelMonitorV2Snapshot, error) {
s.admin = admin
if s.snap == nil {
return nil, nil
}
// Shallow copy so public redaction does not mutate fixture.
cfg := s.snap.Config
cfg.Platforms = append([]ChannelMonitorV2PlatformConfig(nil), s.snap.Config.Platforms...)
for i := range cfg.Platforms {
cfg.Platforms[i].Models = append([]string(nil), s.snap.Config.Platforms[i].Models...)
}
cfg.GroupIDs = append([]int64(nil), s.snap.Config.GroupIDs...)
cfg.IgnoredErrorCategories = append([]string(nil), s.snap.Config.IgnoredErrorCategories...)
out := *s.snap
out.Config = cfg
return &out, nil
}
func (s *channelMonitorV2RepoStub) GetModels(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config, bool) (*ChannelMonitorV2List[ChannelMonitorV2ModelRow], error) {
return nil, nil
}
func (s *channelMonitorV2RepoStub) GetMatrix(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, groupBy ChannelMonitorV2GroupBy, admin bool) (*ChannelMonitorV2Matrix, error) {
s.group, s.admin = groupBy, admin
return s.matrix, nil
}
func (s *channelMonitorV2RepoStub) GetErrors(_ context.Context, _ ChannelMonitorV2Filter, _ ChannelMonitorV2Config, admin bool) (*ChannelMonitorV2List[ChannelMonitorV2ErrorRow], error) {
s.admin = admin
if s.errors != nil {
// Return a shallow copy so service redaction does not mutate the fixture.
out := &ChannelMonitorV2List[ChannelMonitorV2ErrorRow]{
Coverage: s.errors.Coverage,
Items: append([]ChannelMonitorV2ErrorRow(nil), s.errors.Items...),
}
for i := range out.Items {
if len(out.Items[i].Details) > 0 {
out.Items[i].Details = append([]ChannelMonitorV2ErrorDetail(nil), out.Items[i].Details...)
}
}
return out, nil
}
return nil, nil
}
func (s *channelMonitorV2RepoStub) GetUsers(context.Context, ChannelMonitorV2Filter, ChannelMonitorV2Config, bool) (*ChannelMonitorV2List[ChannelMonitorV2UserRow], error) {
return s.users, nil
}
func (s *channelMonitorV2RepoStub) RecomputeRange(context.Context, time.Time, time.Time) error {
return nil
}
func (s *channelMonitorV2RepoStub) GetAggregationWatermark(context.Context) (*ChannelMonitorV2AggregationWatermark, error) {
return &ChannelMonitorV2AggregationWatermark{}, nil
}
func TestChannelMonitorV2BootstrapProgress(t *testing.T) {
now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
t.Run("no data shows active zero progress", func(t *testing.T) {
b := ChannelMonitorV2BootstrapProgress(now, time.Time{}, false)
require.NotNil(t, b)
require.True(t, b.Active)
require.Equal(t, 0, b.ProgressPercent)
require.Equal(t, now.Add(-ChannelMonitorV2BootstrapProductWindow), b.TargetStart)
})
t.Run("2h seed is small percent of 30d", func(t *testing.T) {
covered := now.Add(-2 * time.Hour)
b := ChannelMonitorV2BootstrapProgress(now, covered, true)
require.NotNil(t, b)
require.True(t, b.Active)
require.Greater(t, b.ProgressPercent, 0)
require.Less(t, b.ProgressPercent, 5)
})
t.Run("30d covered hides bootstrap", func(t *testing.T) {
covered := now.Add(-ChannelMonitorV2BootstrapProductWindow)
b := ChannelMonitorV2BootstrapProgress(now, covered, true)
require.Nil(t, b)
})
t.Run("beyond 30d also hides", func(t *testing.T) {
covered := now.Add(-60 * 24 * time.Hour)
b := ChannelMonitorV2BootstrapProgress(now, covered, true)
require.Nil(t, b)
})
}
func TestChannelMonitorV2ParseFilterDefaultsAndBuckets(t *testing.T) {
svc := &ChannelMonitorV2Service{now: func() time.Time { return time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC) }}
filter, err := svc.ParseFilter("", []string{"openai", "openai", ""}, []string{"gpt-5"}, []int64{2, 1, 2, 0})
require.NoError(t, err)
require.Equal(t, "90m", filter.Range)
require.Equal(t, 5*time.Minute, filter.Bucket)
require.Equal(t, []string{"openai"}, filter.Platforms)
require.Equal(t, []int64{1, 2}, filter.GroupIDs)
require.Equal(t, 90*time.Minute, filter.End.Sub(filter.Start))
filter, err = svc.ParseFilter("30d", nil, nil, nil)
require.NoError(t, err)
require.Equal(t, 24*time.Hour, filter.Bucket)
_, err = svc.ParseFilter("15d", nil, nil, nil)
require.ErrorIs(t, err, ErrChannelMonitorV2InvalidRange)
}
func TestParseChannelMonitorV2GroupBy(t *testing.T) {
groupBy, err := ParseChannelMonitorV2GroupBy("")
require.NoError(t, err)
require.Equal(t, ChannelMonitorV2GroupByPlatformGroup, groupBy)
for _, value := range []ChannelMonitorV2GroupBy{ChannelMonitorV2GroupByPlatform, ChannelMonitorV2GroupByPlatformGroup, ChannelMonitorV2GroupByPlatformModel, ChannelMonitorV2GroupByPlatformGroupModel} {
parsed, parseErr := ParseChannelMonitorV2GroupBy(string(value))
require.NoError(t, parseErr)
require.Equal(t, value, parsed)
}
_, err = ParseChannelMonitorV2GroupBy("group")
require.ErrorIs(t, err, ErrChannelMonitorV2InvalidGroupBy)
}
func TestChannelMonitorV2MatrixForwardsGroupingAndAdminScope(t *testing.T) {
want := &ChannelMonitorV2Matrix{GroupBy: ChannelMonitorV2GroupByPlatformModel}
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, matrix: want}
result, err := NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, ChannelMonitorV2GroupByPlatformModel, true)
require.NoError(t, err)
require.Same(t, want, result)
require.Equal(t, ChannelMonitorV2GroupByPlatformModel, repo.group)
require.True(t, repo.admin)
_, err = NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, "bad", false)
require.ErrorIs(t, err, ErrChannelMonitorV2InvalidGroupBy)
}
func TestChannelMonitorV2ConfigValidation(t *testing.T) {
cfg := ChannelMonitorV2Config{
Platforms: []ChannelMonitorV2PlatformConfig{
{Platform: " OpenAI ", Enabled: true, Models: []string{"gpt-5", "gpt-5", ""}},
{Platform: "anthropic", Enabled: true},
},
GroupIDs: []int64{3, 1, 3},
}
require.NoError(t, normalizeChannelMonitorV2Config(&cfg))
require.Equal(t, 300, cfg.RefreshIntervalSeconds)
require.Equal(t, "anthropic", cfg.Platforms[0].Platform)
require.Equal(t, []int64{1, 3}, cfg.GroupIDs)
cfg.RefreshIntervalSeconds = 120
require.ErrorIs(t, normalizeChannelMonitorV2Config(&cfg), ErrChannelMonitorV2InvalidConfig)
cfg.RefreshIntervalSeconds = 60
cfg.GroupIDs = []int64{0}
require.ErrorIs(t, normalizeChannelMonitorV2Config(&cfg), ErrChannelMonitorV2InvalidConfig)
}
func TestChannelMonitorV2ErrorTaxonomyPriority(t *testing.T) {
tests := []struct {
name string
in ChannelMonitorV2ErrorInput
want string
}{
{"cyber", ChannelMonitorV2ErrorInput{ErrorType: "cyber_policy", StatusCode: 200}, "content_policy"},
{"auth before forbidden", ChannelMonitorV2ErrorInput{StatusCode: 403, Message: "invalid API key"}, "authentication"},
{"context", ChannelMonitorV2ErrorInput{Message: "maximum prompt length exceeded"}, "context_limit"},
{"unsupported", ChannelMonitorV2ErrorInput{Message: "not supported by any configured account"}, "model_unsupported"},
{"pool", ChannelMonitorV2ErrorInput{Message: "No available accounts"}, "account_pool_unavailable"},
{"timeout", ChannelMonitorV2ErrorInput{Message: "error code: 524"}, "timeout"},
{"upstream", ChannelMonitorV2ErrorInput{ErrorOwner: "provider", StatusCode: 502, UpstreamStatusCode: 502}, "upstream_5xx"},
{"other", ChannelMonitorV2ErrorInput{StatusCode: 400, Message: "unknown"}, "other"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { require.Equal(t, tt.want, ClassifyChannelMonitorV2Error(tt.in)) })
}
}
func TestChannelMonitorV2HealthBlendsErrorTTFTAndCache(t *testing.T) {
// error 3%/5% → 40; ttft p50 2s → 100; cache 50% → 50
// overall = (0.6*40 + 0.2*100 + 0.2*50) / 1.0 = 54 → warning
p50 := int64(2000)
p95 := int64(9000)
thresholds := ChannelMonitorV2HealthThresholds{
MinimumSample: 20,
WarningErrorRate: 0.02,
CriticalErrorRate: 0.05,
TargetTTFTMs: 2500,
WarningTTFTMs: 2501,
CriticalTTFTMs: 6000,
WarningCacheRate: 0.20,
CriticalCacheRate: 0.05,
ErrorWeight: 0.60,
TTFTWeight: 0.20,
CacheWeight: 0.20,
}
metrics := ChannelMonitorV2Metric{
RequestCount: 100,
ErrorRate: 0.03,
CacheRate: 0.50,
CacheRateDenominator: 100,
TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50, P95Ms: &p95},
}
health := ChannelMonitorV2HealthForWithThresholds(metrics, thresholds)
require.Equal(t, "warning", health.ErrorRate)
require.Equal(t, "healthy", health.TTFT)
require.Equal(t, "healthy", health.Cache)
require.NotNil(t, health.Score)
require.NotNil(t, health.CacheScore)
require.InDelta(t, 50.0, *health.CacheScore, 0.01)
require.InDelta(t, 54.0, *health.Score, 0.01)
require.Equal(t, "warning", health.Overall)
// Perfect signals → 100
p50OK := int64(1000)
metrics.ErrorRate = 0
metrics.CacheRate = 1
metrics.TTFT.P50Ms = &p50OK
health = ChannelMonitorV2HealthForWithThresholds(metrics, thresholds)
require.Equal(t, "healthy", health.Overall)
require.NotNil(t, health.Score)
require.InDelta(t, 100.0, *health.Score, 0.01)
// Small samples stay unknown
health = ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{RequestCount: 2})
require.Equal(t, "unknown", health.Overall)
require.Nil(t, health.Score)
}
func TestChannelMonitorV2DefaultHealthThresholdsAreTolerant(t *testing.T) {
p50 := int64(2500)
health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{
RequestCount: 100,
ErrorRate: 0.03,
CacheRate: 0,
CacheRateDenominator: 100,
TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50},
})
require.Equal(t, "healthy", health.ErrorRate)
require.Equal(t, "healthy", health.TTFT)
require.Equal(t, "healthy", health.Cache)
require.Equal(t, "healthy", health.Overall)
require.NotNil(t, health.CacheScore)
require.InDelta(t, 100.0, *health.CacheScore, 0.01)
}
func TestErrorRateTTFTAndCacheScoreHelpers(t *testing.T) {
require.InDelta(t, 100.0, errorRateScore(0, 0.05), 0.001)
require.InDelta(t, 0.0, errorRateScore(0.05, 0.05), 0.001)
require.InDelta(t, 40.0, errorRateScore(0.03, 0.05), 0.001)
require.InDelta(t, 100.0, ttftP50Score(2500, 2500, 6000), 0.001)
require.InDelta(t, 100.0, ttftP50Score(1000, 2500, 6000), 0.001)
require.InDelta(t, 0.0, ttftP50Score(6000, 2500, 6000), 0.001)
require.InDelta(t, 50.0, ttftP50Score(4250, 2500, 6000), 0.001)
require.InDelta(t, 0.0, cacheRateScore(0), 0.001)
require.InDelta(t, 100.0, cacheRateScore(1), 0.001)
require.InDelta(t, 50.0, cacheRateScore(0.5), 0.001)
require.Equal(t, "critical", cacheRateBand(0.02, 0.20, 0.05))
require.Equal(t, "warning", cacheRateBand(0.10, 0.20, 0.05))
require.Equal(t, "healthy", cacheRateBand(0.50, 0.20, 0.05))
}
func TestChannelMonitorV2UsersRemovesOtherUserIdentity(t *testing.T) {
selfID, otherID := int64(7), int64(9)
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{
{UserID: &otherID, Email: "other@example.com", Username: "other"},
{UserID: &selfID, Email: "self@example.com", Username: "self"},
}}}
result, err := NewChannelMonitorV2Service(repo).Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false)
require.NoError(t, err)
require.Nil(t, result.Items[0].UserID)
require.Empty(t, result.Items[0].Email)
require.Equal(t, "Other user #1", result.Items[0].DisplayLabel)
require.Equal(t, selfID, *result.Items[1].UserID)
require.True(t, result.Items[1].IsSelf)
require.Equal(t, "Me", result.Items[1].DisplayLabel)
}
func TestChannelMonitorV2UsersAppendsSelfWhenMissingFromRanking(t *testing.T) {
selfID, otherID := int64(7), int64(9)
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{
{UserID: &otherID, Email: "other@example.com", Username: "other", Metrics: ChannelMonitorV2Metric{RequestCount: 10}},
}}}
result, err := NewChannelMonitorV2Service(repo).Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false)
require.NoError(t, err)
require.Len(t, result.Items, 2)
self := result.Items[1]
require.True(t, self.IsSelf)
require.Equal(t, "Me", self.DisplayLabel)
require.Equal(t, selfID, *self.UserID)
require.Equal(t, 0, self.Rank) // unranked / no traffic in window
}
func TestChannelMonitorV2TopUsersKeepsSelfOutsideLimit(t *testing.T) {
items := make([]ChannelMonitorV2UserRow, 0, 12)
for i := 1; i <= 12; i++ {
id := int64(i)
items = append(items, ChannelMonitorV2UserRow{UserID: &id, Rank: i, DisplayLabel: fmt.Sprintf("u%d", i)})
}
// self is rank 12 (index 11)
out := channelMonitorV2TopUsersWithSelf(items, 11, 10)
require.Len(t, out, 11)
require.Equal(t, int64(12), *out[10].UserID)
require.Equal(t, 12, out[10].Rank)
}
func TestChannelMonitorV2ReadAPIsRejectDisabledConfig(t *testing.T) {
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: false}}
_, err := NewChannelMonitorV2Service(repo).Matrix(context.Background(), ChannelMonitorV2Filter{}, ChannelMonitorV2GroupByPlatform, false)
require.ErrorIs(t, err, ErrChannelMonitorDisabled)
}
func TestNormalizeChannelMonitorV2IgnoredCategories(t *testing.T) {
got := normalizeChannelMonitorV2IgnoredCategories([]string{"timeout", "TIMEOUT", "not-a-real", "", "timeout", "other"})
require.Equal(t, []string{"other", "timeout"}, got)
}
func TestApplyIgnoredErrorsAdjustsRateOnly(t *testing.T) {
// applyIgnoredErrors lives in repository package; mirror the scored-rate formula.
// scored errors = total_errors - ignored; error_rate = scored / request_count
// success_rate stays SuccessRequests / RequestCount (ignored ≠ success).
m := ChannelMonitorV2Metric{RequestCount: 100, ErrorRequests: 20, SuccessRequests: 80, ErrorRate: 0.2, SuccessRate: 0.8}
ignored := int64(5)
counted := m.ErrorRequests - ignored
errorRate := float64(counted) / float64(m.RequestCount)
successRate := float64(m.SuccessRequests) / float64(m.RequestCount)
require.InDelta(t, 0.15, errorRate, 0.0001)
require.InDelta(t, 0.80, successRate, 0.0001)
require.Equal(t, int64(20), m.ErrorRequests)
require.Equal(t, int64(100), m.RequestCount)
}
func TestErrorsForViewerStripsDetailsAndCountsForNonAdmin(t *testing.T) {
fixture := &ChannelMonitorV2List[ChannelMonitorV2ErrorRow]{
Items: []ChannelMonitorV2ErrorRow{{
Category: "timeout",
Count: 42,
Rate: 0.4,
Details: []ChannelMonitorV2ErrorDetail{{
Platform: "anthropic",
Model: "claude",
ErrorType: "upstream",
StatusCode: 504,
UpstreamStatusCode: 504,
Message: "gateway timeout sk-secret",
Count: 12,
}},
}},
}
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, errors: fixture}
svc := NewChannelMonitorV2Service(repo)
userList, err := svc.ErrorsForViewer(context.Background(), ChannelMonitorV2Filter{}, false)
require.NoError(t, err)
require.False(t, repo.admin)
require.Len(t, userList.Items, 1)
require.Zero(t, userList.Items[0].Count)
require.Empty(t, userList.Items[0].Details)
require.InDelta(t, 0.4, userList.Items[0].Rate, 0.0001)
adminList, err := svc.ErrorsForViewer(context.Background(), ChannelMonitorV2Filter{}, true)
require.NoError(t, err)
require.True(t, repo.admin)
require.Equal(t, int64(42), adminList.Items[0].Count)
require.Len(t, adminList.Items[0].Details, 1)
require.Contains(t, adminList.Items[0].Details[0].Message, "gateway timeout")
}
func TestSnapshotRedactsPublicConfigPolicyFields(t *testing.T) {
updatedBy := int64(9)
repo := &channelMonitorV2RepoStub{
config: ChannelMonitorV2Config{Enabled: true},
snap: &ChannelMonitorV2Snapshot{
Config: ChannelMonitorV2Config{
Version: 3,
Enabled: true,
RefreshIntervalSeconds: 300,
Platforms: []ChannelMonitorV2PlatformConfig{
{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}},
},
GroupIDs: []int64{1, 2},
IgnoredErrorCategories: []string{"timeout"},
UpdatedBy: &updatedBy,
},
Metrics: ChannelMonitorV2Metric{RequestCount: 100, ErrorRate: 0.1, SuccessRate: 0.9, RPM: 5},
},
}
svc := NewChannelMonitorV2Service(repo)
snap, err := svc.Snapshot(context.Background(), ChannelMonitorV2Filter{}, false)
require.NoError(t, err)
require.Empty(t, snap.Config.GroupIDs)
require.Empty(t, snap.Config.IgnoredErrorCategories)
require.Nil(t, snap.Config.UpdatedBy)
require.Empty(t, snap.Config.Platforms[0].Models)
require.Equal(t, 300, snap.Config.RefreshIntervalSeconds)
require.Zero(t, snap.Metrics.RequestCount)
require.InDelta(t, 0.1, snap.Metrics.ErrorRate, 0.0001)
require.Zero(t, snap.Metrics.RPM)
}
func TestRedactChannelMonitorV2MetricKeepsRates(t *testing.T) {
p50 := int64(100)
m := ChannelMonitorV2Metric{
SuccessRequests: 90, ErrorRequests: 10, RequestCount: 100,
InputTokens: 1, OutputTokens: 2, CacheCreationTokens: 3, CacheReadTokens: 4,
TokenCount: 10, RPM: 12, TPM: 34, ErrorRate: 0.1, SuccessRate: 0.9,
CacheRate: 0.5, CacheRateNumerator: 4, CacheRateDenominator: 8,
TTFT: ChannelMonitorV2Latency{SampleCount: 50, P50Ms: &p50},
}
upstream := int64(3)
m.UpstreamAffectedRequests = &upstream
redactChannelMonitorV2Metric(&m, false)
require.Zero(t, m.RequestCount)
require.Zero(t, m.ErrorRequests)
require.Zero(t, m.TokenCount)
require.Zero(t, m.CacheRateNumerator)
require.Zero(t, m.TTFT.SampleCount)
require.Nil(t, m.UpstreamAffectedRequests)
require.InDelta(t, 0.1, m.ErrorRate, 0.0001)
require.InDelta(t, 12.0, m.RPM, 0.0001)
require.InDelta(t, 34.0, m.TPM, 0.0001)
require.NotNil(t, m.TTFT.P50Ms)
require.Equal(t, int64(100), *m.TTFT.P50Ms)
redactChannelMonitorV2Metric(&m, true)
require.Zero(t, m.RPM)
require.Zero(t, m.TPM)
require.InDelta(t, 0.1, m.ErrorRate, 0.0001)
}
func TestChannelMonitorV2HealthTTFTAtTargetIsHealthy(t *testing.T) {
p50 := int64(2500)
m := ChannelMonitorV2Metric{
RequestCount: 100, ErrorRate: 0,
CacheRate: 1, CacheRateDenominator: 100,
TTFT: ChannelMonitorV2Latency{SampleCount: 100, P50Ms: &p50},
}
h := ChannelMonitorV2HealthFor(m)
require.Equal(t, "healthy", h.TTFT)
require.NotNil(t, h.TTFTScore)
require.InDelta(t, 100.0, *h.TTFTScore, 0.01)
}
@@ -404,10 +404,24 @@ const (
// When false: runner skips scheduling and user-facing endpoints return an empty list.
SettingKeyChannelMonitorEnabled = "channel_monitor_enabled"
// SettingKeyChannelMonitorMode selects exclusive implementation:
// "v1" active probes, "v2" passive aggregation. Default "v1" (opt-in to v2).
SettingKeyChannelMonitorMode = "channel_monitor_mode"
// ChannelMonitorModeV1/V2 are the only accepted mode values.
ChannelMonitorModeV1 = "v1"
ChannelMonitorModeV2 = "v2"
// SettingKeyChannelMonitorDefaultIntervalSeconds controls the default interval (seconds)
// pre-filled when creating a new channel monitor from the admin UI. Range: [15, 3600].
SettingKeyChannelMonitorDefaultIntervalSeconds = "channel_monitor_default_interval_seconds"
// SettingKeyChannelMonitorHideThroughput hides RPM/TPM (and similar absolute
// throughput rates) from non-admin user-facing monitor APIs and UI, so users
// cannot reverse-estimate fleet volume from rates × window length.
// Default false (show rates). Admin endpoints always keep full metrics.
SettingKeyChannelMonitorHideThroughput = "channel_monitor_hide_throughput"
// SettingKeyGrokDefaultTextModel is the fallback Grok text model for empty
// request models and built-in Grok aliases (e.g. "grok" → this id). Default grok-4.5.
SettingKeyGrokDefaultTextModel = "grok_default_text_model"
@@ -2031,8 +2031,10 @@ func TestOpenAIStreamingPreambleKeepaliveUsesDownstreamIdle(t *testing.T) {
cfg := &config.Config{
Gateway: config.GatewayConfig{
StreamDataIntervalTimeout: 0,
StreamKeepaliveInterval: 1,
MaxLineSize: defaultMaxLineSize,
// Keepalive is based on *downstream* idle time (last flush to client),
// not upstream event cadence. Interval is seconds (config unit).
StreamKeepaliveInterval: 1,
MaxLineSize: defaultMaxLineSize,
},
}
svc := &OpenAIGatewayService{cfg: cfg}
@@ -2050,11 +2052,14 @@ func TestOpenAIStreamingPreambleKeepaliveUsesDownstreamIdle(t *testing.T) {
go func() {
defer func() { _ = pw.Close() }()
// Emit preamble/progress quickly so clientOutputStarted is true, then
// leave a real downstream idle gap longer than keepaliveInterval so the
// ticker can write ":\n\n". Frequent upstream ticks used to refresh
// lastDownstreamWriteAt and flake on loaded CI runners.
_, _ = pw.Write([]byte("data: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_1\"}}\n\n"))
for i := 0; i < 6; i++ {
time.Sleep(250 * time.Millisecond)
_, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_1\"}}\n\n"))
}
time.Sleep(50 * time.Millisecond)
_, _ = pw.Write([]byte("data: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_1\"}}\n\n"))
time.Sleep(1300 * time.Millisecond)
_, _ = pw.Write([]byte("data: {\"type\":\"response.completed\",\"response\":{\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}}\n\n"))
}()
@@ -186,7 +186,9 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// Channel monitor defaults (enabled, 60s)
SettingKeyChannelMonitorEnabled: "true",
SettingKeyChannelMonitorMode: ChannelMonitorModeV1,
SettingKeyChannelMonitorDefaultIntervalSeconds: "60",
SettingKeyChannelMonitorHideThroughput: "true",
// Grok: safe defaults — no cross-vendor model rewrite unless operators enable it.
SettingKeyGrokDefaultTextModel: "grok-4.5",
@@ -787,9 +789,13 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
// Channel monitor feature (default: enabled, 60s)
result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled])
result.ChannelMonitorMode = normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode])
result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval(
settings[SettingKeyChannelMonitorDefaultIntervalSeconds],
)
// 默认隐藏吞吐(迁移 206 的隐私默认):未配置时必须与 setting_public.go 的
// 公开读取路径给出同一个值,否则管理端看到“未隐藏”而用户端实际已隐藏。
result.ChannelMonitorHideThroughput = !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput])
// Grok default mapping policy
result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel])
+64 -11
View File
@@ -227,7 +227,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyBalanceLowNotifyRechargeURL,
SettingKeyAccountQuotaNotifyEnabled,
SettingKeyChannelMonitorEnabled,
SettingKeyChannelMonitorMode,
SettingKeyChannelMonitorDefaultIntervalSeconds,
SettingKeyChannelMonitorHideThroughput,
SettingKeyAvailableChannelsEnabled,
SettingKeyModelPlazaEnabled,
SettingKeyModelPlazaRequireAuth,
@@ -348,7 +350,9 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]),
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]),
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
@@ -369,8 +373,21 @@ const (
channelMonitorIntervalMin = 15
channelMonitorIntervalMax = 3600
channelMonitorIntervalFallback = 60
defaultChannelMonitorMode = ChannelMonitorModeV1
)
// normalizeChannelMonitorMode accepts only v1/v2; empty/invalid → v1 (safe default).
func normalizeChannelMonitorMode(raw string) string {
switch strings.ToLower(strings.TrimSpace(raw)) {
case ChannelMonitorModeV1, "":
return ChannelMonitorModeV1
case ChannelMonitorModeV2:
return ChannelMonitorModeV2
default:
return defaultChannelMonitorMode
}
}
// parseChannelMonitorInterval parses the stored string and clamps to [15, 3600].
// Empty / invalid input falls back to channelMonitorIntervalFallback.
func parseChannelMonitorInterval(raw string) int {
@@ -396,25 +413,55 @@ func clampChannelMonitorInterval(v int) int {
}
// ChannelMonitorRuntime is the lightweight view of the channel monitor feature
// consumed by the runner and user-facing handlers.
// consumed by the runner, V2 aggregator, and user-facing handlers.
type ChannelMonitorRuntime struct {
Enabled bool
Mode string // ChannelMonitorModeV1 or ChannelMonitorModeV2
DefaultIntervalSeconds int
// HideThroughput: when true, user-facing V2 APIs omit RPM/TPM scale signals.
HideThroughput bool
}
// ActiveProbesAllowed reports whether V1 active provider probes may run.
func (r ChannelMonitorRuntime) ActiveProbesAllowed() bool {
return r.Enabled && r.Mode == ChannelMonitorModeV1
}
// PassiveAggregationAllowed reports whether V2 passive aggregation may run.
func (r ChannelMonitorRuntime) PassiveAggregationAllowed() bool {
return r.Enabled && r.Mode == ChannelMonitorModeV2
}
// GetChannelMonitorRuntime reads the channel monitor feature flags directly from
// the settings store. Fail-open: on error returns Enabled=true with the default interval.
// the settings store. Fail-open: on error returns Enabled=true, Mode=v1, default interval.
func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime {
if s == nil || s.settingRepo == nil {
return ChannelMonitorRuntime{
Enabled: true,
Mode: defaultChannelMonitorMode,
DefaultIntervalSeconds: channelMonitorIntervalFallback,
HideThroughput: true,
}
}
vals, err := s.settingRepo.GetMultiple(ctx, []string{
SettingKeyChannelMonitorEnabled,
SettingKeyChannelMonitorMode,
SettingKeyChannelMonitorDefaultIntervalSeconds,
SettingKeyChannelMonitorHideThroughput,
})
if err != nil {
return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback}
return ChannelMonitorRuntime{
Enabled: true,
Mode: defaultChannelMonitorMode,
DefaultIntervalSeconds: channelMonitorIntervalFallback,
HideThroughput: true,
}
}
return ChannelMonitorRuntime{
Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]),
Mode: normalizeChannelMonitorMode(vals[SettingKeyChannelMonitorMode]),
DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]),
HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]),
}
}
@@ -550,14 +597,18 @@ type PublicSettingsInjectionPayload struct {
// Feature flags — MUST match the opt-in/opt-out registry in
// frontend/src/utils/featureFlags.ts. Missing a field here is the bug
// that hid the "可用渠道" menu on page refresh.
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
// ChannelMonitorHideThroughput is public so the user UI can hide RPM/TPM
// without waiting for API redaction alone (defense in depth).
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
}
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
@@ -627,7 +678,9 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
@@ -12,6 +12,7 @@ import (
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"golang.org/x/sync/singleflight"
"sync"
)
const (
@@ -148,6 +149,9 @@ type SettingService struct {
// instance owns its own cache, no shared package-level state.
openAIQuotaAutoPauseSettingsCache atomic.Value // *cachedOpenAIQuotaAutoPauseSettings
openAIQuotaAutoPauseSettingsSF singleflight.Group
channelMonitorRuntimeListenersMu sync.Mutex
channelMonitorRuntimeListeners []func()
}
// DefaultPlatformQuotaSetting 单 platform 三档限额(nil = 沿用上层;0 = 显式禁用;>0 = 上限)
@@ -372,6 +376,52 @@ func (s *SettingService) SetOnUpdateCallback(callback func()) {
s.onUpdate = callback
}
// SubscribeChannelMonitorRuntime registers a listener that is invoked after
// settings are successfully persisted (and process caches refreshed).
// Used by ChannelMonitorRunner / ChannelMonitorV2Aggregator for immediate
// mode flips without waiting for poll intervals.
func (s *SettingService) SubscribeChannelMonitorRuntime(listener func()) (unsubscribe func()) {
if s == nil || listener == nil {
return func() {}
}
s.channelMonitorRuntimeListenersMu.Lock()
s.channelMonitorRuntimeListeners = append(s.channelMonitorRuntimeListeners, listener)
idx := len(s.channelMonitorRuntimeListeners) - 1
s.channelMonitorRuntimeListenersMu.Unlock()
return func() {
s.channelMonitorRuntimeListenersMu.Lock()
defer s.channelMonitorRuntimeListenersMu.Unlock()
if idx < 0 || idx >= len(s.channelMonitorRuntimeListeners) {
return
}
s.channelMonitorRuntimeListeners[idx] = nil
}
}
func (s *SettingService) notifyChannelMonitorRuntimeListeners() {
if s == nil {
return
}
s.channelMonitorRuntimeListenersMu.Lock()
listeners := make([]func(), 0, len(s.channelMonitorRuntimeListeners))
for _, l := range s.channelMonitorRuntimeListeners {
if l != nil {
listeners = append(listeners, l)
}
}
s.channelMonitorRuntimeListenersMu.Unlock()
for _, l := range listeners {
func(fn func()) {
defer func() {
if recovered := recover(); recovered != nil {
_ = recovered // keep settings path healthy
}
}()
fn()
}(l)
}
}
// SetVersion sets the application version for injection into public settings
func (s *SettingService) SetVersion(version string) {
s.version = version
@@ -101,6 +101,21 @@ func TestSettingService_GetPublicSettings_ExposesCompactHomeEnabled(t *testing.T
require.False(t, missingSettings.CompactHomeEnabled)
}
func TestSettingService_ChannelMonitorHideThroughputDefaultsToPrivate(t *testing.T) {
missing := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
require.True(t, missing.HideThroughput)
public, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetPublicSettings(context.Background())
require.NoError(t, err)
require.True(t, public.ChannelMonitorHideThroughput)
for _, value := range []string{"false", "0", "off", "disabled"} {
runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{
SettingKeyChannelMonitorHideThroughput: value,
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
require.False(t, runtime.HideThroughput, "value=%q", value)
}
}
func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) {
repo := &settingPublicRepoStub{
values: map[string]string{
@@ -411,9 +411,11 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
// Channel monitor feature switch
updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled)
updates[SettingKeyChannelMonitorMode] = normalizeChannelMonitorMode(settings.ChannelMonitorMode)
if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 {
updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v)
}
updates[SettingKeyChannelMonitorHideThroughput] = strconv.FormatBool(settings.ChannelMonitorHideThroughput)
// Grok model mapping policy
if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" {
@@ -780,6 +782,7 @@ func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
if s.onUpdate != nil {
s.onUpdate() // Invalidate cache after settings update
}
s.notifyChannelMonitorRuntimeListeners()
}
func (s *SettingService) defaultRewriteMessageCacheControl() bool {
+8 -4
View File
@@ -196,8 +196,10 @@ type SystemSettings struct {
OpsMetricsIntervalSeconds int
// Channel Monitor feature
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
// Grok model mapping policy (admin settings; empty mapping falls back to these).
GrokDefaultTextModel string `json:"grok_default_text_model"`
@@ -370,8 +372,10 @@ type PublicSettings struct {
BalanceLowNotifyRechargeURL string
// Channel Monitor feature
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
// Grok model mapping policy (admin settings).
GrokDefaultTextModel string `json:"grok_default_text_model"`
+34 -2
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"database/sql"
"os"
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
@@ -860,6 +861,8 @@ var ProviderSet = wire.NewSet(
ProvideBalanceNotifyService,
ProvideChannelMonitorService,
ProvideChannelMonitorRunner,
ProvideChannelMonitorV2Service,
ProvideChannelMonitorV2Aggregator,
NewChannelMonitorRequestTemplateService,
ProvideUserPlatformQuotaUsageFlusher,
)
@@ -901,11 +904,15 @@ func ProvidePaymentOrderExpiryService(paymentSvc *PaymentService, lockCache Lead
// ProvideChannelMonitorService 创建渠道监控服务(CRUD + RunCheck + 用户视图聚合)。
// 加密器复用 wire 中已注入的 SecretEncryptor(AES-256-GCM)。
// settingService gates RunCheck via channel_monitor_enabled + channel_monitor_mode.
func ProvideChannelMonitorService(
repo ChannelMonitorRepository,
encryptor SecretEncryptor,
settingService *SettingService,
) *ChannelMonitorService {
return NewChannelMonitorService(repo, encryptor)
svc := NewChannelMonitorService(repo, encryptor)
svc.SetRuntimeReader(settingService)
return svc
}
// ProvideChannelMonitorRunner 创建并启动渠道监控调度器。
@@ -914,7 +921,32 @@ func ProvideChannelMonitorService(
// settingService 用于 runner 每次 fire 读取功能开关。
func ProvideChannelMonitorRunner(svc *ChannelMonitorService, settingService *SettingService) *ChannelMonitorRunner {
r := NewChannelMonitorRunner(svc, settingService)
svc.SetScheduler(r)
if svc != nil {
// Ensure runtime reader is set even if ProvideChannelMonitorService
// was constructed without settings (tests / alternate providers).
svc.SetRuntimeReader(settingService)
svc.SetScheduler(r)
}
r.Start()
return r
}
// ProvideChannelMonitorV2Service wires settings for user-facing privacy flags
// (e.g. hide RPM/TPM throughput).
func ProvideChannelMonitorV2Service(repo ChannelMonitorV2Repository, settingService *SettingService) *ChannelMonitorV2Service {
svc := NewChannelMonitorV2Service(repo)
svc.SetRuntimeReader(settingService)
return svc
}
// ProvideChannelMonitorV2Aggregator starts the passive minute-rollup worker.
// Aggregation only runs when channel_monitor_enabled=true and mode=v2 (and V2 config enabled).
// Set CHANNEL_MONITOR_V2_DISABLE_AGGREGATOR=1 to skip Start (local demo with seeded facts).
func ProvideChannelMonitorV2Aggregator(repo ChannelMonitorV2Repository, db *sql.DB, settingService *SettingService) *ChannelMonitorV2Aggregator {
aggregator := NewChannelMonitorV2Aggregator(repo, db, settingService)
if os.Getenv("CHANNEL_MONITOR_V2_DISABLE_AGGREGATOR") == "1" {
return aggregator
}
aggregator.Start()
return aggregator
}
@@ -0,0 +1,119 @@
-- Passive channel monitor V2. This migration creates empty rollup tables only;
-- backfill is intentionally handled by a bounded background worker.
CREATE TABLE IF NOT EXISTS channel_monitor_v2_config (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
version INTEGER NOT NULL DEFAULT 1,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
refresh_interval_seconds INTEGER NOT NULL DEFAULT 60
CHECK (refresh_interval_seconds IN (60, 300)),
platforms JSONB NOT NULL DEFAULT '[{"platform":"anthropic","enabled":true,"models":[]},{"platform":"openai","enabled":true,"models":[]},{"platform":"grok","enabled":true,"models":[]},{"platform":"kiro","enabled":true,"models":[]},{"platform":"gemini","enabled":true,"models":[]},{"platform":"antigravity","enabled":true,"models":[]}]'::jsonb,
group_ids BIGINT[] NOT NULL DEFAULT '{}',
updated_by BIGINT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO channel_monitor_v2_config (id) VALUES (1)
ON CONFLICT (id) DO NOTHING;
CREATE TABLE IF NOT EXISTS channel_monitor_v2_metrics_1m (
bucket_start TIMESTAMPTZ NOT NULL,
platform TEXT NOT NULL,
group_id BIGINT NOT NULL DEFAULT 0,
model TEXT NOT NULL,
success_requests BIGINT NOT NULL DEFAULT 0,
error_requests BIGINT NOT NULL DEFAULT 0,
upstream_affected_requests BIGINT NOT NULL DEFAULT 0,
upstream_attempt_count BIGINT NOT NULL DEFAULT 0,
input_tokens BIGINT NOT NULL DEFAULT 0,
output_tokens BIGINT NOT NULL DEFAULT 0,
cache_creation_tokens BIGINT NOT NULL DEFAULT 0,
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
ttft_sum_ms BIGINT NOT NULL DEFAULT 0,
ttft_count BIGINT NOT NULL DEFAULT 0,
duration_sum_ms BIGINT NOT NULL DEFAULT 0,
duration_count BIGINT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (bucket_start, platform, group_id, model)
);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_platform_time
ON channel_monitor_v2_metrics_1m (platform, bucket_start DESC);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_group_time
ON channel_monitor_v2_metrics_1m (group_id, bucket_start DESC);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_model_time
ON channel_monitor_v2_metrics_1m (model, bucket_start DESC);
CREATE TABLE IF NOT EXISTS channel_monitor_v2_user_metrics_1m (
bucket_start TIMESTAMPTZ NOT NULL,
platform TEXT NOT NULL,
group_id BIGINT NOT NULL DEFAULT 0,
model TEXT NOT NULL,
user_id BIGINT NOT NULL,
success_requests BIGINT NOT NULL DEFAULT 0,
error_requests BIGINT NOT NULL DEFAULT 0,
input_tokens BIGINT NOT NULL DEFAULT 0,
output_tokens BIGINT NOT NULL DEFAULT 0,
cache_creation_tokens BIGINT NOT NULL DEFAULT 0,
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
ttft_sum_ms BIGINT NOT NULL DEFAULT 0,
ttft_count BIGINT NOT NULL DEFAULT 0,
duration_sum_ms BIGINT NOT NULL DEFAULT 0,
duration_count BIGINT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (bucket_start, platform, group_id, model, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_metrics_user_time
ON channel_monitor_v2_user_metrics_1m (user_id, bucket_start DESC);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_metrics_time
ON channel_monitor_v2_user_metrics_1m (bucket_start DESC);
CREATE TABLE IF NOT EXISTS channel_monitor_v2_error_metrics_1m (
bucket_start TIMESTAMPTZ NOT NULL,
platform TEXT NOT NULL,
group_id BIGINT NOT NULL DEFAULT 0,
model TEXT NOT NULL,
error_category TEXT NOT NULL,
taxonomy_version SMALLINT NOT NULL,
error_requests BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (bucket_start, platform, group_id, model, error_category, taxonomy_version)
);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_time
ON channel_monitor_v2_error_metrics_1m (bucket_start DESC);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_category_time
ON channel_monitor_v2_error_metrics_1m (error_category, bucket_start DESC);
CREATE TABLE IF NOT EXISTS channel_monitor_v2_latency_histograms_1m (
bucket_start TIMESTAMPTZ NOT NULL,
platform TEXT NOT NULL,
group_id BIGINT NOT NULL DEFAULT 0,
model TEXT NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0,
metric TEXT NOT NULL CHECK (metric IN ('ttft', 'duration')),
upper_bound_ms INTEGER NOT NULL,
sample_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms)
);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_histograms_time
ON channel_monitor_v2_latency_histograms_1m (bucket_start DESC, metric);
CREATE TABLE IF NOT EXISTS channel_monitor_v2_watermarks (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
usage_coverage_start TIMESTAMPTZ,
error_coverage_start TIMESTAMPTZ,
data_through TIMESTAMPTZ,
last_successful_at TIMESTAMPTZ,
backfill_cursor TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO channel_monitor_v2_watermarks (id) VALUES (1)
ON CONFLICT (id) DO NOTHING;
COMMENT ON TABLE channel_monitor_v2_metrics_1m IS
'One-minute passive channel health facts derived from real user requests; never from active probes.';
COMMENT ON COLUMN channel_monitor_v2_error_metrics_1m.taxonomy_version IS
'Version of the ordered error classification rules used for this aggregate.';
@@ -0,0 +1,5 @@
-- Channel monitor exclusive mode: v1 (active probes) or v2 (passive aggregation).
-- Default v1 preserves existing active-probe behavior; operators opt in to v2.
INSERT INTO settings (key, value)
VALUES ('channel_monitor_mode', 'v1')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,3 @@
-- Categories excluded from error_rate / health scoring (still shown in error breakdown).
ALTER TABLE channel_monitor_v2_config
ADD COLUMN IF NOT EXISTS ignored_error_categories TEXT[] NOT NULL DEFAULT '{}';
@@ -0,0 +1,139 @@
-- Seed factory channel_monitor_v2_config.platforms with a popular-model allow-list
-- (high-traffic names from production usage). Presentation semantics:
-- - listed models → own dimension
-- - unlisted models → __other__
-- - empty models list (operator cleared) → show every real model name (no collapse)
-- Aggregation always stores the real model name; this only affects presentation.
--
-- Only rewrite rows whose platforms still look like the empty-models factory
-- default (every platform has models: []). Custom operator configs are left alone.
UPDATE channel_monitor_v2_config
SET
platforms = $platforms$
[
{
"platform": "anthropic",
"enabled": true,
"models": [
"claude-opus-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
"claude-opus-4.6",
"claude-opus-4-5",
"claude-opus-4-1",
"claude-opus-4",
"claude-fable-5",
"claude-sonnet-5",
"claude-sonnet-4-6",
"claude-sonnet-4.6",
"claude-sonnet-4-5",
"claude-sonnet-4.5",
"claude-sonnet-4",
"claude-haiku-4-5",
"claude-haiku-4.5",
"claude-3-7-sonnet",
"claude-3-5-sonnet",
"claude-3-5-haiku"
]
},
{
"platform": "openai",
"enabled": true,
"models": [
"gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.6",
"gpt-5.5",
"gpt-5.4",
"gpt-5.4-mini",
"gpt-5.3-codex-spark",
"gpt-5.2",
"gpt-5.2-pro",
"gpt-5",
"gpt-4.1",
"gpt-4.1-mini",
"gpt-4.1-nano",
"gpt-4o",
"gpt-4o-mini",
"gpt-4-turbo",
"gpt-4",
"o3",
"o3-mini",
"o4-mini",
"codex-auto-review",
"gpt-image-2",
"gpt-image-1"
]
},
{
"platform": "grok",
"enabled": true,
"models": [
"grok-4.5",
"grok-4.5-latest",
"grok-4.3",
"grok-4.20-multi-agent",
"grok-4.20-0309-reasoning",
"grok-4.20-0309-non-reasoning",
"grok-build-0.1",
"grok-build",
"grok-3-mini",
"grok-3-mini-fast",
"grok-code-fast-1",
"grok-code-fast"
]
},
{
"platform": "gemini",
"enabled": true,
"models": [
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-image",
"gemini-2.0-flash",
"gemini-2.0-flash-lite",
"gemini-3-flash",
"gemini-3-pro-image",
"gemini-3.1-pro-high",
"gemini-3.1-pro-low",
"gemini-3.1-flash-lite",
"gemini-3.5-flash",
"gemini-3.5-flash-lite"
]
},
{
"platform": "kiro",
"enabled": true,
"models": [
"claude-opus-4-8",
"claude-opus-4-6",
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-sonnet-4-6",
"claude-haiku-4-5"
]
},
{
"platform": "antigravity",
"enabled": true,
"models": [
"claude-opus-4-6",
"claude-sonnet-4-5",
"claude-sonnet-4-6",
"gemini-2.5-pro",
"gemini-2.5-flash"
]
}
]
$platforms$::jsonb,
version = version + 1,
updated_at = NOW()
WHERE id = 1
-- Only seed the exact factory default from migration 194. A row with custom
-- enabled=false, platform set/order, or any other operator tweak must be left
-- untouched even if every models list is still empty.
AND platforms = '[{"platform":"anthropic","enabled":true,"models":[]},{"platform":"openai","enabled":true,"models":[]},{"platform":"grok","enabled":true,"models":[]},{"platform":"kiro","enabled":true,"models":[]},{"platform":"gemini","enabled":true,"models":[]},{"platform":"antigravity","enabled":true,"models":[]}]'::jsonb;
@@ -0,0 +1,33 @@
-- Configurable Channel Monitor V2 health scoring thresholds.
-- Defaults are intentionally tolerant: small amounts of real traffic errors or
-- zero cache rate should not immediately paint the user page as "abnormal".
ALTER TABLE channel_monitor_v2_config
ADD COLUMN IF NOT EXISTS health_thresholds JSONB NOT NULL DEFAULT '{
"minimum_sample": 50,
"warning_error_rate": 0.05,
"critical_error_rate": 0.20,
"target_ttft_ms": 3000,
"warning_ttft_ms": 8000,
"critical_ttft_ms": 20000,
"warning_cache_rate": 0,
"critical_cache_rate": 0,
"error_weight": 0.60,
"ttft_weight": 0.20,
"cache_weight": 0.20
}'::jsonb;
UPDATE channel_monitor_v2_config
SET health_thresholds = '{
"minimum_sample": 50,
"warning_error_rate": 0.05,
"critical_error_rate": 0.20,
"target_ttft_ms": 3000,
"warning_ttft_ms": 8000,
"critical_ttft_ms": 20000,
"warning_cache_rate": 0,
"critical_cache_rate": 0,
"error_weight": 0.60,
"ttft_weight": 0.20,
"cache_weight": 0.20
}'::jsonb || health_thresholds
WHERE id = 1;
@@ -0,0 +1,95 @@
-- Fixed-size Channel Monitor V2 rollups for the 24h / 7d / 30d views.
-- Source of truth remains the 1-minute passive rollup; these tables cache the
-- UI bucket sizes so historical buckets do not need to be re-aggregated on each
-- filter change. The background aggregator overwrites affected buckets.
CREATE TABLE IF NOT EXISTS channel_monitor_v2_metrics_rollup (
bucket_start TIMESTAMPTZ NOT NULL,
bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)),
platform TEXT NOT NULL,
group_id BIGINT NOT NULL DEFAULT 0,
model TEXT NOT NULL,
success_requests BIGINT NOT NULL DEFAULT 0,
error_requests BIGINT NOT NULL DEFAULT 0,
upstream_affected_requests BIGINT NOT NULL DEFAULT 0,
upstream_attempt_count BIGINT NOT NULL DEFAULT 0,
input_tokens BIGINT NOT NULL DEFAULT 0,
output_tokens BIGINT NOT NULL DEFAULT 0,
cache_creation_tokens BIGINT NOT NULL DEFAULT 0,
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
ttft_sum_ms BIGINT NOT NULL DEFAULT 0,
ttft_count BIGINT NOT NULL DEFAULT 0,
duration_sum_ms BIGINT NOT NULL DEFAULT 0,
duration_count BIGINT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model)
);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_rollup_platform_time
ON channel_monitor_v2_metrics_rollup (bucket_seconds, platform, bucket_start DESC);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_rollup_group_time
ON channel_monitor_v2_metrics_rollup (bucket_seconds, group_id, bucket_start DESC);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_metrics_rollup_model_time
ON channel_monitor_v2_metrics_rollup (bucket_seconds, model, bucket_start DESC);
CREATE TABLE IF NOT EXISTS channel_monitor_v2_user_metrics_rollup (
bucket_start TIMESTAMPTZ NOT NULL,
bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)),
platform TEXT NOT NULL,
group_id BIGINT NOT NULL DEFAULT 0,
model TEXT NOT NULL,
user_id BIGINT NOT NULL,
success_requests BIGINT NOT NULL DEFAULT 0,
error_requests BIGINT NOT NULL DEFAULT 0,
input_tokens BIGINT NOT NULL DEFAULT 0,
output_tokens BIGINT NOT NULL DEFAULT 0,
cache_creation_tokens BIGINT NOT NULL DEFAULT 0,
cache_read_tokens BIGINT NOT NULL DEFAULT 0,
ttft_sum_ms BIGINT NOT NULL DEFAULT 0,
ttft_count BIGINT NOT NULL DEFAULT 0,
duration_sum_ms BIGINT NOT NULL DEFAULT 0,
duration_count BIGINT NOT NULL DEFAULT 0,
computed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_rollup_user_time
ON channel_monitor_v2_user_metrics_rollup (bucket_seconds, user_id, bucket_start DESC);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_user_rollup_time
ON channel_monitor_v2_user_metrics_rollup (bucket_seconds, bucket_start DESC);
CREATE TABLE IF NOT EXISTS channel_monitor_v2_error_metrics_rollup (
bucket_start TIMESTAMPTZ NOT NULL,
bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)),
platform TEXT NOT NULL,
group_id BIGINT NOT NULL DEFAULT 0,
model TEXT NOT NULL,
error_category TEXT NOT NULL,
taxonomy_version SMALLINT NOT NULL,
error_requests BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model, error_category, taxonomy_version)
);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_rollup_time
ON channel_monitor_v2_error_metrics_rollup (bucket_seconds, bucket_start DESC);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_errors_rollup_category_time
ON channel_monitor_v2_error_metrics_rollup (bucket_seconds, error_category, bucket_start DESC);
CREATE TABLE IF NOT EXISTS channel_monitor_v2_latency_histograms_rollup (
bucket_start TIMESTAMPTZ NOT NULL,
bucket_seconds INTEGER NOT NULL CHECK (bucket_seconds IN (300, 3600, 43200, 86400)),
platform TEXT NOT NULL,
group_id BIGINT NOT NULL DEFAULT 0,
model TEXT NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0,
metric TEXT NOT NULL CHECK (metric IN ('ttft', 'duration')),
upper_bound_ms INTEGER NOT NULL,
sample_count BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (bucket_seconds, bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms)
);
CREATE INDEX IF NOT EXISTS idx_channel_monitor_v2_histograms_rollup_time
ON channel_monitor_v2_latency_histograms_rollup (bucket_seconds, bucket_start DESC, metric);
COMMENT ON TABLE channel_monitor_v2_metrics_rollup IS
'Precomputed fixed UI buckets derived from channel_monitor_v2_metrics_1m for 24h/7d/30d views.';
@@ -0,0 +1,9 @@
-- Runtime aggregator reads/writes the fixed rollup caches through the role that
-- executes migrations. Use CURRENT_USER so custom DATABASE_USER values work;
-- PostgreSQL permits this form in GRANT and it remains idempotent.
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
channel_monitor_v2_metrics_rollup,
channel_monitor_v2_user_metrics_rollup,
channel_monitor_v2_error_metrics_rollup,
channel_monitor_v2_latency_histograms_rollup
TO CURRENT_USER;
@@ -0,0 +1,12 @@
-- Channel Monitor V2 is designed around fixed UI buckets; refresh passive
-- aggregation every 5 minutes and recompute the trailing bucket for each range.
-- Only migrate the untouched factory row. A row with a newer version or an
-- updater identity reflects an operator choice and must retain its 60-second
-- interval (or any other valid setting).
UPDATE channel_monitor_v2_config
SET refresh_interval_seconds = 300,
updated_at = NOW()
WHERE id = 1
AND version = 1
AND updated_by IS NULL
AND refresh_interval_seconds = 60;
@@ -0,0 +1,15 @@
-- Extend privileges for all Channel Monitor V2 tables (base + rollup + config).
-- Grant to the migration/application role rather than the historical hard-coded
-- "sub2api" role so custom DATABASE_USER deployments work as expected.
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE
channel_monitor_v2_config,
channel_monitor_v2_watermarks,
channel_monitor_v2_metrics_1m,
channel_monitor_v2_user_metrics_1m,
channel_monitor_v2_error_metrics_1m,
channel_monitor_v2_latency_histograms_1m,
channel_monitor_v2_metrics_rollup,
channel_monitor_v2_user_metrics_rollup,
channel_monitor_v2_error_metrics_rollup,
channel_monitor_v2_latency_histograms_rollup
TO CURRENT_USER;
@@ -0,0 +1,31 @@
-- Factory presets for Channel Monitor V2 config:
-- 1) ignored_error_categories: non-ops client/policy failures that should not
-- dominate health error_rate by default (still shown greyed in breakdown).
-- 2) health_thresholds cache floors: 85% watch / 60% critical (higher is better).
--
-- Only apply when the row still looks like factory empty ignore list and/or
-- zero cache thresholds (operator customizations are left alone).
UPDATE channel_monitor_v2_config
SET ignored_error_categories = ARRAY[
'authentication',
'client_cancelled',
'content_policy',
'context_limit',
'group_access',
'model_unsupported',
'not_found',
'quota_or_balance'
]::text[]
WHERE id = 1
AND COALESCE(cardinality(ignored_error_categories), 0) = 0;
UPDATE channel_monitor_v2_config
SET health_thresholds = health_thresholds
|| jsonb_build_object(
'warning_cache_rate', 0.85,
'critical_cache_rate', 0.60
)
WHERE id = 1
AND COALESCE((health_thresholds->>'warning_cache_rate')::float8, 0) = 0
AND COALESCE((health_thresholds->>'critical_cache_rate')::float8, 0) = 0;
@@ -0,0 +1,5 @@
-- Soft switch: hide RPM/TPM throughput rates on user-facing Channel Monitor V2.
-- Default false (rates visible). Admins always see full metrics.
INSERT INTO settings (key, value)
VALUES ('channel_monitor_hide_throughput', 'false')
ON CONFLICT (key) DO NOTHING;
@@ -0,0 +1,13 @@
-- Keep factory cache scoring tolerant. Migration 203 briefly wrote 0.85/0.60;
-- reset only that exact factory pair, preserving operator-customized values.
UPDATE channel_monitor_v2_config
SET health_thresholds = health_thresholds
|| jsonb_build_object(
'warning_cache_rate', 0,
'critical_cache_rate', 0
)
WHERE id = 1
AND COALESCE((health_thresholds->>'warning_cache_rate')::float8, 0) = 0.85
AND COALESCE((health_thresholds->>'critical_cache_rate')::float8, 0) = 0.60
AND version = 1
AND updated_by IS NULL;
@@ -0,0 +1,8 @@
-- Correct factory defaults introduced by migrations 203 and 204 without
-- mutating those already-applied migration files (which are checksum-locked).
-- The setting was factory-created as false by migration 204. Existing
-- installations need the same privacy-preserving default as new installs.
UPDATE settings
SET value = 'true', updated_at = NOW()
WHERE key = 'channel_monitor_hide_throughput'
AND LOWER(TRIM(value)) IN ('false', '0', 'off', 'disabled');
+57
View File
@@ -0,0 +1,57 @@
# Channel Monitor V2 Safe Defaults & Gentle Backfill
**Date:** 2026-08-08
**Status:** Approved for implementation
**Branch:** `fix/channel-monitor-v2-ops-ui-blockers` (onto channel-monitor-v2)
## Problem
1. **H2** — Migration 195 and code defaults set `channel_monitor_mode=v2`, so every upgrade without an existing key auto-opts into passive V2, stops V1 probes, and may leave users on an empty V2 view.
2. **M1** — First-enable bootstrap forces 5s ticks and ~24h `RecomputeRange` chunks until `now-30d`, hammering the primary DB.
3. Error dedup SQL matches `request_id` without a `created_at` bound, scanning `ops_error_logs` history via `candidate_ids`.
## Decisions
| Topic | Decision |
|-------|----------|
| Default mode | **v1** (keep active probes). V2 is explicit opt-in. |
| Existing `channel_monitor_mode=v2` rows | **Unchanged** (`ON CONFLICT DO NOTHING` / no force rewrite). |
| Switch to v2 | V1 runner `fire()` and `RunCheck` require `ActiveProbesAllowed()` → probes stop. |
| Backfill | Unified hard gates + adaptive soft gates (same rules, different observed load). |
| Optional profile knob | **Not in this change** (YAGNI). |
## Mode defaults
- Migration `195_channel_monitor_mode.sql`: insert `'v1'` when key missing.
- If 195 already applied with old checksum, add migration checksum compatibility (do not force rewrite applied DBs to v1).
- Code: `defaultChannelMonitorMode = v1`; empty/invalid normalize → v1.
- Frontend Settings form default and public feature flag fallback → v1 when missing/invalid.
- Nil settings on V1 `RunCheck` path remains **fail-closed** (no probes) for test safety — independent of product default.
## Gentle backfill (low-resource phases)
### Hard gates (all servers)
- Tick interval = `refresh_interval` (60 or 300s). **No 5s bootstrap override.**
- Each tick: (1) recent overlap (~10m), then (2) **at most one** historical chunk while product bootstrap incomplete / retention walk ongoing.
- Single leader lock; single short transaction budget (~55s).
- Chunk ceiling by depth (product phases 90m → 1d → 7d → 30d → silent 90d).
### Soft gates (adaptive)
- Initial historical chunk: **1h** (first seed still **2h** for 90m UI).
- Min chunk: **15m**; max by depth: ≤1d → **2h**, ≤7d → **4h**, older → **6h**.
- Success + fast: grow slowly (×1.5) up to depth max.
- Failure/timeout: halve chunk; exponential backoff on wait (cap 10m).
- Progressive UI: show whatever is covered; bootstrap banner vs 30d product window.
### Error dedup
- Bound the `request_id IN candidate_ids` branch with
`created_at >= $1 - INTERVAL '90 minutes' AND created_at < $2`.
## Out of scope
- Force existing v2 → v1.
- Separate backfill workers / read replicas.
- Admin `backfill_profile` setting (may add later).
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apiClient } from '../client'
import { getMatrix, repeatedArrayParamsSerializer } from '../channelMonitorV2'
afterEach(() => vi.restoreAllMocks())
describe('channel monitor V2 query serialization', () => {
it('uses repeated keys without bracket suffixes for array filters', () => {
const query = repeatedArrayParamsSerializer({
range: '90m',
platform: ['openai', 'grok'],
group_id: [1, 2],
model: undefined,
group_by: 'platform_group_model',
})
expect(query).toBe('range=90m&platform=openai&platform=grok&group_id=1&group_id=2&group_by=platform_group_model')
expect(query).not.toContain('%5B%5D')
})
it('sends the matrix grouping with the shared filters', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({
data: { coverage: {}, group_by: 'platform_group', items: [] },
})
await getMatrix({ range: '24h', platforms: ['openai'], groupIds: [7], models: [] }, 'platform_group', true)
expect(get).toHaveBeenCalledWith('/admin/channel-monitor-v2/matrix', expect.objectContaining({
params: {
range: '24h',
platform: ['openai'],
group_id: [7],
model: undefined,
group_by: 'platform_group',
},
}))
})
})
+4
View File
@@ -708,7 +708,9 @@ export interface SystemSettings {
// Channel Monitor feature switch
channel_monitor_enabled: boolean;
channel_monitor_mode?: 'v1' | 'v2';
channel_monitor_default_interval_seconds: number;
channel_monitor_hide_throughput?: boolean;
// Available Channels feature switch
available_channels_enabled: boolean;
@@ -1003,7 +1005,9 @@ export interface UpdateSettingsRequest {
// Channel Monitor feature switch
channel_monitor_enabled?: boolean;
channel_monitor_mode?: 'v1' | 'v2';
channel_monitor_default_interval_seconds?: number;
channel_monitor_hide_throughput?: boolean;
// Available Channels feature switch
available_channels_enabled?: boolean;
+271
View File
@@ -0,0 +1,271 @@
import { apiClient } from './client'
export type MonitorRange = '90m' | '24h' | '7d' | '30d'
export type HealthState = 'unknown' | 'healthy' | 'warning' | 'critical'
/** Fine-grained score band for multi-stop green→yellow→red gradients (score0..score10). */
export type HealthScoreBand =
| 'unknown'
| 'score0'
| 'score1'
| 'score2'
| 'score3'
| 'score4'
| 'score5'
| 'score6'
| 'score7'
| 'score8'
| 'score9'
| 'score10'
export type MonitorMatrixGroupBy = 'platform' | 'platform_group' | 'platform_model' | 'platform_group_model'
export interface MonitorFilter {
range: MonitorRange
platforms: string[]
groupIds: number[]
models: string[]
}
export interface LatencyMetric {
sample_count: number
p50_ms: number | null
p90_ms?: number | null
p95_ms: number | null
avg_ms: number | null
}
export interface MonitorMetric {
success_requests: number
error_requests: number
request_count: number
token_count: number
rpm: number
tpm: number
error_rate: number
cache_rate: number
cache_rate_numerator: number
cache_rate_denominator: number
ttft: LatencyMetric
duration: LatencyMetric
upstream_affected_requests?: number
upstream_attempt_count?: number
}
export interface MonitorHealth {
overall: HealthState
error_rate: HealthState
ttft: HealthState
cache?: HealthState
/** 0–100 blended score when samples are sufficient. */
score?: number | null
error_rate_score?: number | null
ttft_score?: number | null
cache_score?: number | null
minimum_sample: number
thresholds?: {
minimum_sample?: number
warning_error_rate: number
critical_error_rate: number
target_ttft_ms: number
warning_ttft_ms: number
critical_ttft_ms: number
warning_cache_rate?: number
critical_cache_rate?: number
error_weight: number
ttft_weight: number
cache_weight?: number
}
}
/** First-upgrade historical fill for 90m/24h/7d/30d; omitted when complete. */
export interface MonitorBootstrap {
active: boolean
progress_percent: number
covered_from?: string
target_start?: string
}
export interface MonitorCoverage {
requested_start: string
/** Exclusive upper bound of the UI-selected range (filter end). */
requested_end?: string
coverage_start: string
data_through: string
computed_at: string
aggregation_lag_seconds: number
coverage_complete: boolean
bucket_seconds: number
/** Present while initial aggregation has not covered the 30d product window. */
bootstrap?: MonitorBootstrap | null
}
export interface MonitorConfig {
version: number
enabled: boolean
refresh_interval_seconds: 60 | 300
platforms: Array<{ platform: string; enabled: boolean; models: string[] }>
group_ids: number[]
health_thresholds: {
minimum_sample: number
warning_error_rate: number
critical_error_rate: number
target_ttft_ms: number
warning_ttft_ms: number
critical_ttft_ms: number
warning_cache_rate: number
critical_cache_rate: number
error_weight: number
ttft_weight: number
cache_weight: number
}
/** Categories excluded from error_rate / health; still listed in error breakdown. */
ignored_error_categories?: string[]
}
/** Ordered taxonomy mirrored from backend ChannelMonitorV2ErrorCategories. */
export const MONITOR_ERROR_CATEGORIES = [
'content_policy',
'authentication',
'context_limit',
'invalid_request',
'model_unsupported',
'group_access',
'quota_or_balance',
'account_pool_unavailable',
'rate_or_capacity',
'timeout',
'transport_or_stream',
'upstream_forbidden',
'not_found',
'client_cancelled',
'upstream_5xx',
'internal',
'other',
] as const
export type MonitorErrorCategory = (typeof MONITOR_ERROR_CATEGORIES)[number]
export interface MonitorSnapshot {
config: MonitorConfig
coverage: MonitorCoverage
metrics: MonitorMetric
health: MonitorHealth
trend: Array<{ bucket_start: string; metrics: MonitorMetric; health: MonitorHealth }>
}
export interface MonitorMatrixBucket {
bucket_start: string
metrics: MonitorMetric
health: MonitorHealth
}
export interface MonitorMatrixRow {
platform: string
group_id?: number
group_name?: string
model?: string
metrics: MonitorMetric
health: MonitorHealth
buckets: MonitorMatrixBucket[]
}
export interface MonitorMatrixResponse {
coverage: MonitorCoverage
group_by: MonitorMatrixGroupBy
items: MonitorMatrixRow[]
}
export interface MonitorDimensions {
platforms: Array<{ value: string; label: string; request_count: number }>
groups: Array<{ id: number; name: string; platform?: string; request_count: number }>
models: Array<{ value: string; label: string; platform?: string; request_count: number }>
}
export interface MonitorModelRow { platform: string; model: string; metrics: MonitorMetric; health: MonitorHealth }
export interface MonitorErrorRow {
category: string
count: number
rate: number
details?: Array<{
platform?: string
model?: string
error_type?: string
status_code?: number
upstream_status_code?: number
message?: string
count: number
}>
/** true when category is in config.ignored_error_categories */
ignored?: boolean
}
export interface MonitorUserRow {
user_id?: number
rank: number
email?: string
username?: string
display_label: string
is_self: boolean
can_drilldown: boolean
metrics: MonitorMetric
}
function params(filter: MonitorFilter) {
return {
range: filter.range,
platform: filter.platforms.length ? filter.platforms : undefined,
group_id: filter.groupIds.length ? filter.groupIds : undefined,
model: filter.models.length ? filter.models : undefined,
}
}
export function repeatedArrayParamsSerializer(values: Record<string, unknown>): string {
const search = new URLSearchParams()
for (const [key, rawValue] of Object.entries(values)) {
if (rawValue == null || rawValue === '') continue
const entries = Array.isArray(rawValue) ? rawValue : [rawValue]
for (const value of entries) {
if (value != null && value !== '') search.append(key, String(value))
}
}
return search.toString()
}
const requestConfig = (filter: MonitorFilter, signal?: AbortSignal, extraParams: Record<string, unknown> = {}) => ({
params: { ...params(filter), ...extraParams },
paramsSerializer: { serialize: repeatedArrayParamsSerializer },
signal,
})
function base(admin: boolean) { return admin ? '/admin/channel-monitor-v2' : '/channel-monitor-v2' }
export async function getDimensions(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorDimensions>(`${base(admin)}/dimensions`, requestConfig(filter, signal))
return data
}
export async function getSnapshot(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorSnapshot>(`${base(admin)}/snapshot`, requestConfig(filter, signal))
return data
}
export async function getMatrix(filter: MonitorFilter, groupBy: MonitorMatrixGroupBy, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorMatrixResponse>(`${base(admin)}/matrix`, requestConfig(filter, signal, { group_by: groupBy }))
return data
}
export async function getModels(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorModelRow[] }>(`${base(admin)}/models`, requestConfig(filter, signal))
return data
}
export async function getErrors(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorErrorRow[] }>(`${base(admin)}/errors`, requestConfig(filter, signal))
return data
}
export async function getUsers(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorUserRow[] }>(`${base(admin)}/users`, requestConfig(filter, signal))
return data
}
export async function getConfig() {
const { data } = await apiClient.get<MonitorConfig>('/admin/channel-monitor-v2/config')
return data
}
export async function updateConfig(config: MonitorConfig) {
const { data } = await apiClient.put<MonitorConfig>('/admin/channel-monitor-v2/config', config)
return data
}
+2
View File
@@ -67,6 +67,8 @@ const icons = {
xCircle: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
exclamationCircle: 'M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z',
exclamationTriangle: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
// Awards
trophy: 'M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-4.5A3.375 3.375 0 0012.75 11.25h-1.5A3.375 3.375 0 008.25 14.25v4.5m8.25-12V6.75a.75.75 0 00-.75-.75h-1.5a.75.75 0 00-.75.75v1.5m0 0V9a.75.75 0 01-.75.75H9.75A.75.75 0 019 9V8.25m0 0V6.75a.75.75 0 00-.75-.75h-1.5a.75.75 0 00-.75.75v1.5m12 0h.008v.008h-.008V8.25zm-12 0h.008v.008H5.25V8.25z',
infoCircle: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z',
questionCircle: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
@@ -0,0 +1,271 @@
<template>
<div
ref="containerRef"
class="filter-menu relative"
:class="compact ? 'min-w-[6.5rem] sm:min-w-[7.25rem]' : 'min-w-[150px] sm:min-w-[160px]'"
>
<button
ref="triggerRef"
type="button"
class="select-trigger flex cursor-pointer list-none items-center justify-between gap-1.5 text-left"
:class="[
isOpen ? 'select-trigger-open' : '',
compact ? 'h-8 rounded-lg !px-2 !py-1 text-xs' : 'h-[42px]',
]"
:aria-expanded="isOpen"
aria-haspopup="listbox"
:aria-label="label"
@click="toggleOpen"
>
<span
class="select-value min-w-0 truncate"
:class="compact ? 'max-w-[5.5rem] sm:max-w-[6.5rem]' : 'max-w-[11rem]'"
>
{{ t('channelMonitorV2.filters.labelValue', { label, value: selectionLabel }) }}
</span>
<span class="select-icon shrink-0 text-gray-400 transition-transform" :class="isOpen ? 'rotate-180' : ''">
<Icon name="chevronDown" size="sm" />
</span>
</button>
<Teleport to="body">
<Transition name="select-dropdown">
<div
v-if="isOpen"
ref="dropdownRef"
class="select-dropdown-portal dropdown filter-dropdown"
:class="[instanceId]"
:style="dropdownStyle"
role="listbox"
aria-multiselectable="true"
@click.stop
@mousedown.stop
>
<button
type="button"
class="dropdown-item select-option select-option-group flex w-full items-center justify-between border-b border-gray-100 px-4 py-2 text-left text-sm font-semibold text-gray-700 hover:bg-gray-100 dark:border-dark-700 dark:text-gray-300 dark:hover:bg-dark-700"
@click="clear"
>
<span>{{ allLabel }}</span>
<Icon v-if="modelValue.length === 0" name="check" size="sm" class="text-primary-500" />
</button>
<button
v-for="option in options"
:key="option.value"
type="button"
role="option"
class="dropdown-item select-option flex w-full items-center justify-between gap-3 px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
:class="modelValue.includes(option.value) ? 'select-option-selected' : ''"
:aria-selected="modelValue.includes(option.value)"
@click="toggle(option.value)"
>
<span class="flex min-w-0 flex-1 items-center gap-2">
<span
class="checkbox flex h-4 w-4 items-center justify-center rounded border border-gray-300 bg-white text-primary-500 dark:border-dark-600 dark:bg-dark-900"
:class="modelValue.includes(option.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30' : ''"
>
<Icon v-if="modelValue.includes(option.value)" name="check" size="sm" class="text-primary-500" />
</span>
<span class="min-w-0 flex-1 truncate">{{ option.label }}</span>
</span>
<small v-if="option.count != null" class="text-xs text-gray-400">{{ formatCount(option.count) }}</small>
</button>
<p v-if="options.length === 0" class="px-4 py-3 text-center text-xs text-gray-400">{{ t('channelMonitorV2.filters.empty') }}</p>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import type { CSSProperties } from 'vue'
import { useI18n } from 'vue-i18n'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import Icon from '@/components/icons/Icon.vue'
import { monitorIntlLocale } from '@/features/channel-monitor-v2/monitorFormat'
interface FilterOption {
value: string
label: string
count?: number
}
const props = withDefaults(
defineProps<{
label: string
allLabel: string
modelValue: string[]
/** Options for this picker (parent may cascade by platform). */
options: FilterOption[]
/** Compact trigger for single-row toolbars. */
compact?: boolean
}>(),
{ compact: false },
)
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const { t, locale } = useI18n()
const containerRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLButtonElement | null>(null)
const dropdownRef = ref<HTMLElement | null>(null)
const isOpen = ref(false)
const instanceId = `filter-select-${Math.random().toString(36).slice(2, 9)}`
const selectionLabel = computed(() => {
if (props.modelValue.length === 0) return props.allLabel
if (props.modelValue.length === 1) {
return props.options.find((item) => item.value === props.modelValue[0])?.label || props.modelValue[0]
}
return t('channelMonitorV2.filters.selectedCount', { count: props.modelValue.length })
})
const dropdownStyle = computed<CSSProperties>(() => {
const trigger = triggerRef.value
if (!trigger) return {}
const rect = trigger.getBoundingClientRect()
const padding = 8
const viewportRight = Math.max(padding, window.innerWidth - padding)
const left = Math.min(Math.max(padding, rect.left), viewportRight)
const availableWidth = Math.max(0, viewportRight - left)
const preferredMinWidth = Math.max(200, rect.width)
const minWidth = Math.min(preferredMinWidth, availableWidth)
return {
position: 'fixed' as const,
left: `${left}px`,
top: `${rect.bottom + 4}px`,
minWidth: `${minWidth}px`,
maxWidth: `${availableWidth}px`,
zIndex: '100000020',
}
})
function clear() {
emit('update:modelValue', [])
// Stay open so users can re-select without reopening.
}
function toggle(value: string) {
const selected = new Set(props.modelValue)
if (selected.has(value)) selected.delete(value)
else selected.add(value)
emit('update:modelValue', [...selected])
// Stay open on toggle (multi-select).
}
function toggleOpen() {
isOpen.value ? close() : open()
}
function open() {
isOpen.value = true
void nextTick(() => positionDropdown())
}
function close() {
isOpen.value = false
}
function positionDropdown() {
const trigger = triggerRef.value
const dropdown = dropdownRef.value
if (!trigger || !dropdown) return
const rect = trigger.getBoundingClientRect()
const padding = 8
const viewportRight = Math.max(padding, window.innerWidth - padding)
const left = Math.min(Math.max(padding, rect.left), viewportRight)
const availableWidth = Math.max(0, viewportRight - left)
const preferredMinWidth = Math.max(200, rect.width)
const minWidth = Math.min(preferredMinWidth, availableWidth)
dropdown.style.left = `${left}px`
dropdown.style.top = `${rect.bottom + 4}px`
dropdown.style.minWidth = `${minWidth}px`
dropdown.style.maxWidth = `${availableWidth}px`
}
function formatCount(value: number) {
return Intl.NumberFormat(locale.value || monitorIntlLocale(), {
notation: value >= 10000 ? 'compact' : 'standard',
}).format(value)
}
function onDocumentMouseDown(event: MouseEvent) {
if (!isOpen.value) return
const target = event.target as Node | null
if (!target) return
if (containerRef.value?.contains(target)) return
if (dropdownRef.value?.contains(target)) return
close()
}
function onDocumentKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') close()
}
function onWindowChange() {
if (isOpen.value) positionDropdown()
}
watch(isOpen, async (open) => {
if (open) {
await nextTick()
positionDropdown()
document.addEventListener('mousedown', onDocumentMouseDown)
document.addEventListener('keydown', onDocumentKeyDown)
window.addEventListener('resize', onWindowChange)
window.addEventListener('scroll', onWindowChange, true)
} else {
document.removeEventListener('mousedown', onDocumentMouseDown)
document.removeEventListener('keydown', onDocumentKeyDown)
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
}
})
onBeforeUnmount(() => {
document.removeEventListener('mousedown', onDocumentMouseDown)
document.removeEventListener('keydown', onDocumentKeyDown)
window.removeEventListener('resize', onWindowChange)
window.removeEventListener('scroll', onWindowChange, true)
})
</script>
<style scoped>
.select-trigger {
@apply flex w-full items-center justify-between gap-2;
@apply rounded-xl px-4 py-2.5 text-sm;
@apply bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100;
@apply transition-all duration-200;
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
@apply hover:border-gray-300 dark:hover:border-dark-500;
@apply cursor-pointer;
}
.select-trigger-open {
@apply border-primary-500 ring-2 ring-primary-500/30;
}
.filter-menu summary::-webkit-details-marker {
display: none;
}
.filter-dropdown {
@apply w-max min-w-[200px] max-h-[min(50vh,360px)] overflow-y-auto rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800;
}
.dropdown-item {
@apply cursor-pointer;
}
.checkbox {
flex: none;
}
@media (max-width: 640px) {
.filter-menu {
min-width: 100%;
}
}
</style>
@@ -0,0 +1,73 @@
<template>
<div
class="stat-card !min-h-[6.5rem] !rounded-3xl !border-0 !p-4 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700"
:title="title || undefined"
>
<div
v-if="state"
class="mt-1 h-2 w-2 shrink-0 rounded-full"
:class="dotClass"
aria-hidden="true"
></div>
<div class="min-w-0 flex-1">
<span class="stat-label text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ label }}</span>
<strong
class="stat-value mt-1 block overflow-visible text-xl tabular-nums leading-tight !text-clip !whitespace-normal"
:class="stateClass"
>{{ value }}</strong>
<div
v-if="detailParts.length > 1"
class="mt-1.5 flex flex-wrap gap-x-2 gap-y-0.5 text-[11px] leading-snug text-gray-400 dark:text-dark-400"
>
<span
v-for="(part, index) in detailParts"
:key="`${index}:${part}`"
class="whitespace-nowrap tabular-nums"
>{{ part }}</span>
</div>
<small
v-else-if="detail"
class="mt-1.5 block text-[11px] leading-snug text-gray-400 dark:text-dark-400"
>{{ detail }}</small>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { HealthState } from '@/api/channelMonitorV2'
const props = defineProps<{
label: string
value: string
detail: string
state?: HealthState
/** Exact numeric tooltip (e.g. uncompacted RPM/TPM). */
title?: string
}>()
/** Split "AVG 475ms · P90 800ms" into chips so nothing is ellipsized. */
const detailParts = computed(() => {
const raw = (props.detail || '').trim()
if (!raw || raw === '-') return []
return raw
.split(/\s*[·|]\s*/)
.map((part) => part.trim())
.filter(Boolean)
})
const stateClass = computed(() => {
if (!props.state) return 'text-gray-900 dark:text-white'
if (props.state === 'healthy') return 'text-emerald-600 dark:text-emerald-400'
if (props.state === 'warning') return 'text-amber-600 dark:text-amber-400'
if (props.state === 'critical') return 'text-red-600 dark:text-red-400'
return 'text-gray-500 dark:text-dark-400'
})
const dotClass = computed(() => {
if (props.state === 'healthy') return 'bg-emerald-500'
if (props.state === 'warning') return 'bg-amber-500'
if (props.state === 'critical') return 'bg-red-500'
return 'bg-gray-300 dark:bg-dark-600'
})
</script>
@@ -0,0 +1,122 @@
<template>
<span
class="inline-flex items-center gap-1.5 tabular-nums"
:class="rankClass"
:title="titleText"
>
<svg
v-if="showTrophy && palette"
class="rank-trophy shrink-0"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
aria-hidden="true"
>
<!-- stem -->
<path
d="M506.9 750.3c1.5 0 3-0.1 4.6-0.1 4.1 0 8.2 0.1 12.3 0.2V613.8h-16.9v136.5z"
:fill="palette.mid"
/>
<!-- base plate highlight -->
<path
d="M585.3 815.7c25.5 17.4 49 44.5 56.8 84.9h98.3c-20.4-36.7-77-71.2-155.1-84.9z"
:fill="palette.light"
/>
<!-- main cup / handles / pedestal outline -->
<path
d="M922.2 64.2H757.7c-0.2 0-0.4 0.1-0.6 0.1-0.2 0-0.4-0.1-0.6-0.1h-229c-16.3 0-29.6 13.2-29.6 29.6s13.2 29.6 29.6 29.6h199.6l0.9 215.5c0 98.3-66.1 181.4-156.1 207.4-19 5.5-39 8.5-59.8 8.5-119 0-215.9-96.9-215.9-216l-0.9-215.3h48.5c16.3 0 29.6-13.2 29.6-29.6s-13.2-29.6-29.6-29.6H100.9c-16.3 0-29.6 13.2-29.6 29.6V135c0 66.3 33.3 127.3 89.1 163.2 5 3.2 10.5 4.7 16 4.7 9.7 0 19.2-4.8 24.9-13.5 8.8-13.7 4.9-32-8.8-40.9-38.8-25-62-67.4-62-113.5v-11.6H236l0.9 215.5c0 126.7 86.2 233.7 203 265.4v151.3c-11.8 1.7-23.5 3.7-34.9 6.3-15.9 3.6-25.9 19.5-22.3 35.4 3.6 15.9 19.5 25.9 35.4 22.3 29.6-6.7 61-10.1 93.3-10.1 26.3 0 50.9 2.3 73.8 6.4 78.2 13.7 134.8 48.2 155.1 84.9H282.2c10.2-19 29.8-37.1 57.1-52.1 14.3-7.9 19.5-25.9 11.6-40.2-7.9-14.3-25.9-19.5-40.2-11.6-61.7 34-95.7 81.4-95.7 133.5 0 16.3 13.2 29.6 29.6 29.6h533.5c16.3 0 29.6-13.2 29.6-29.6 0-85.9-94.3-155.9-224.9-174.9v-151C700.3 573.1 787 465.9 787 338.7l-0.9-215.3h106.6V135c-0.1-16.3-13.4-29.6-29.7-29.6zM523.8 750.4c-4.1-0.1-8.2-0.2-12.3-0.2-1.5 0-3 0.1-4.6 0.1-2.6 0-5.2 0.1-7.8 0.2V613.8h24.7v136.6z"
:fill="palette.dark"
/>
<!-- cup face highlight -->
<path
d="M727.9 338.8l-0.9-215.5h-92.3l0.9 215.5c0 84.1-19.5 168.1-63.9 207.4 90.1-26 156.2-109.1 156.2-207.4z"
:fill="palette.light"
/>
<!-- top bar accent -->
<path
d="M433.2 123.3h1.8c16.3 0 29.6-13.2 29.6-29.6S451.4 64.2 435 64.2h-1.8c-16.3 0-29.6 13.2-29.6 29.6s13.3 29.5 29.6 29.5z"
:fill="palette.dark"
/>
</svg>
<span v-if="showTrophy" class="sr-only">{{ ariaLabel }}</span>
<span
class="text-xs font-semibold"
:class="showTrophy ? rankClass : 'text-gray-500 dark:text-dark-300'"
>
{{ label }}
</span>
</span>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
/** Rank medal palettes: light / mid / dark fills for the multi-path trophy SVG. */
const RANK_PALETTES = {
1: {
// gold
light: '#FFD524',
mid: '#F5B200',
dark: '#B45309',
},
2: {
// silver
light: '#E5E7EB',
mid: '#9CA3AF',
dark: '#4B5563',
},
3: {
// bronze
light: '#E8A86A',
mid: '#CD7F32',
dark: '#8B5A2B',
},
} as const
const props = defineProps<{
rank: number | null | undefined
}>()
const rankNum = computed(() => {
const n = Number(props.rank)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : null
})
const showTrophy = computed(
() => rankNum.value != null && rankNum.value >= 1 && rankNum.value <= 3,
)
const palette = computed(() => {
if (rankNum.value === 1 || rankNum.value === 2 || rankNum.value === 3) {
return RANK_PALETTES[rankNum.value]
}
return null
})
const label = computed(() => {
// Rank 0 = present but unranked (no traffic in window / outside top list).
if (rankNum.value == null || rankNum.value <= 0) return '—'
return `#${rankNum.value}`
})
const ariaLabel = computed(() => {
if (rankNum.value == null || rankNum.value <= 0) return t('channelMonitorV2.rank.unranked')
if (rankNum.value === 1) return t('channelMonitorV2.rank.gold')
if (rankNum.value === 2) return t('channelMonitorV2.rank.silver')
if (rankNum.value === 3) return t('channelMonitorV2.rank.bronze')
return t('channelMonitorV2.rank.place', { n: rankNum.value })
})
const titleText = computed(() => ariaLabel.value)
const rankClass = computed(() => {
if (rankNum.value === 1) return 'text-amber-600 dark:text-amber-400'
if (rankNum.value === 2) return 'text-slate-500 dark:text-slate-300'
if (rankNum.value === 3) return 'text-amber-800 dark:text-amber-600'
return ''
})
</script>
@@ -0,0 +1,441 @@
<template>
<section class="mx-auto w-full max-w-6xl space-y-5 px-1 py-2 sm:px-2">
<header
class="page-header mb-0 flex flex-wrap items-center justify-between gap-3 rounded-3xl bg-white p-5 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 sm:p-6"
>
<div class="min-w-0">
<h2 class="page-title flex items-center gap-2 text-xl font-black text-gray-900 dark:text-white">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-400">
<Icon name="chart" size="sm" />
</span>
{{ t('channelMonitorV2.settings.title') }}
</h2>
<p class="page-description mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{ t('channelMonitorV2.settings.description') }}
</p>
</div>
<button
type="button"
class="btn btn-primary"
:disabled="saving || !dirty"
@click="save"
>
<Icon name="check" size="sm" />
{{ t('channelMonitorV2.settings.save') }}
</button>
</header>
<div
v-if="!systemModeV2"
class="rounded-2xl border border-amber-200 bg-amber-50/90 px-4 py-3 text-sm text-amber-900 dark:border-amber-800/50 dark:bg-amber-900/20 dark:text-amber-100"
role="status"
>
{{
t('channelMonitorV2.settings.modeBanner', {
mode: systemModeLabel,
modeV2: t('channelMonitorV2.settings.modeV2'),
})
}}
<router-link class="ml-1 font-medium underline" to="/admin/settings">{{ t('admin.settings.tabs.features') }}</router-link>
</div>
<div
v-if="loading"
class="card flex min-h-[200px] items-center justify-center !rounded-3xl !border-0 text-sm text-gray-400 shadow-sm ring-1 ring-gray-900/5 dark:ring-dark-700"
>
<span class="animate-pulse">{{ t('channelMonitorV2.settings.loading') }}</span>
</div>
<template v-else-if="draft">
<div class="card divide-y divide-gray-100 !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:divide-dark-700 dark:!bg-dark-800 dark:ring-dark-700">
<div class="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
<div>
<strong class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.enableTitle') }}</strong>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.settings.enableHint') }}
</p>
</div>
<Toggle v-model="draft.enabled" />
</div>
<div class="flex flex-wrap items-center justify-between gap-4 px-5 py-4">
<div>
<strong class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.refreshTitle') }}</strong>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">{{ t('channelMonitorV2.settings.refreshHint') }}</p>
</div>
<div class="tabs inline-flex w-auto" role="group" :aria-label="t('channelMonitorV2.settings.refreshAria')">
<button
type="button"
class="tab"
:class="draft.refresh_interval_seconds === 60 ? 'tab-active' : ''"
@click="draft.refresh_interval_seconds = 60"
>
1 min
</button>
<button
type="button"
class="tab"
:class="draft.refresh_interval_seconds === 300 ? 'tab-active' : ''"
@click="draft.refresh_interval_seconds = 300"
>
5 min
</button>
</div>
</div>
</div>
<div class="card overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="card-header !py-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.platformsTitle') }}</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.settings.platformsHint') }}
</p>
</div>
<div class="divide-y divide-gray-100 dark:divide-dark-700">
<div
v-for="platform in draft.platforms"
:key="platform.platform"
class="grid grid-cols-1 items-center gap-3 px-5 py-3 sm:grid-cols-[auto_7rem_minmax(0,1fr)_auto]"
>
<Toggle v-model="platform.enabled" />
<strong class="text-sm font-medium text-gray-900 dark:text-white">{{ platformLabel(platform.platform) }}</strong>
<input
class="input"
:value="platform.models.join(', ')"
type="text"
:placeholder="t('channelMonitorV2.settings.modelsPlaceholder')"
@change="setModels(platform, $event)"
/>
<span
class="badge justify-self-start sm:justify-self-end"
:class="platform.models.length ? 'badge-gray' : 'badge badge-primary'"
>
{{ platform.models.length ? t('channelMonitorV2.settings.badgeOther') : t('channelMonitorV2.settings.badgeAllModels') }}
</span>
</div>
</div>
</div>
<div class="card overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="card-header flex flex-wrap items-center justify-between gap-2 !py-3">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.groupsTitle') }}</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{
draft.group_ids.length
? t('channelMonitorV2.settings.groupsSelected', { count: draft.group_ids.length })
: t('channelMonitorV2.settings.groupsAll')
}}
</p>
</div>
<button
v-if="draft.group_ids.length"
type="button"
class="btn btn-ghost btn-sm"
@click="draft.group_ids = []"
>
{{ t('channelMonitorV2.settings.groupsAll') }}
</button>
</div>
<div class="max-h-[min(40vh,280px)] overflow-y-auto px-3 py-2 sm:px-4">
<div class="grid grid-cols-1 gap-1 sm:grid-cols-2">
<label
v-for="group in groups"
:key="group.id"
class="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition hover:bg-gray-50 dark:hover:bg-dark-800/60"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500/40"
:checked="draft.group_ids.includes(group.id)"
@change="toggleGroup(group.id)"
/>
<span class="min-w-0 flex-1 truncate font-medium text-gray-800 dark:text-gray-100">{{ group.name }}</span>
<small class="shrink-0 text-xs text-gray-400">{{ platformLabel(group.platform) }} · #{{ group.id }}</small>
</label>
</div>
<p v-if="groups.length === 0" class="empty-state py-8 text-sm text-gray-400">{{ t('channelMonitorV2.settings.groupsEmpty') }}</p>
</div>
</div>
<div class="card overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="card-header !py-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.errorsTitle') }}</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.settings.errorsHint') }}
</p>
</div>
<div class="max-h-[min(40vh,320px)] overflow-y-auto px-3 py-2 sm:px-4">
<div class="grid grid-cols-1 gap-1 sm:grid-cols-2">
<label
v-for="category in errorCategories"
:key="category"
class="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition hover:bg-gray-50 dark:hover:bg-dark-800/60"
>
<input
type="checkbox"
class="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500/40"
:checked="isCategoryIgnored(category)"
@change="toggleIgnoredCategory(category)"
/>
<span class="min-w-0 flex-1 truncate font-medium text-gray-800 dark:text-gray-100">
{{ categoryLabel(category) }}
</span>
<small class="shrink-0 font-mono text-[10px] text-gray-400">{{ category }}</small>
</label>
</div>
</div>
<div class="border-t border-gray-100 px-5 py-3 text-xs text-gray-500 dark:border-dark-700 dark:text-dark-400">
{{
t('channelMonitorV2.settings.ignoredSummary', {
ignored: draft.ignored_error_categories?.length || 0,
counted: countedErrorCategoryCount,
})
}}
</div>
</div>
<div class="card overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="card-header !py-3">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ t('channelMonitorV2.settings.healthTitle') }}</h3>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.settings.healthHint') }}
</p>
</div>
<div class="grid grid-cols-1 gap-4 px-5 py-4 sm:grid-cols-2 lg:grid-cols-4">
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.minimumSample') }}</span>
<input v-model.number="draft.health_thresholds.minimum_sample" class="input" type="number" min="1" max="10000" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.warningError') }}</span>
<input v-model.number="warningErrorPercent" class="input" type="number" min="0" max="100" step="0.1" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.criticalError') }}</span>
<input v-model.number="criticalErrorPercent" class="input" type="number" min="0" max="100" step="0.1" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.targetTtft') }}</span>
<input v-model.number="draft.health_thresholds.target_ttft_ms" class="input" type="number" min="1" step="100" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.warningTtft') }}</span>
<input v-model.number="draft.health_thresholds.warning_ttft_ms" class="input" type="number" min="1" step="100" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.criticalTtft') }}</span>
<input v-model.number="draft.health_thresholds.critical_ttft_ms" class="input" type="number" min="1" step="100" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.warningCache') }}</span>
<input v-model.number="warningCachePercent" class="input" type="number" min="0" max="100" step="0.1" />
</label>
<label class="block">
<span class="input-label">{{ t('channelMonitorV2.settings.fields.criticalCache') }}</span>
<input v-model.number="criticalCachePercent" class="input" type="number" min="0" max="100" step="0.1" />
</label>
</div>
</div>
<div class="space-y-2">
<div class="rounded-2xl border border-primary-200 bg-primary-50/80 px-4 py-3 text-sm text-primary-900 dark:border-primary-800/50 dark:bg-primary-900/20 dark:text-primary-100">
<template v-if="namedModelCount === 0">
{{ t('channelMonitorV2.settings.namedModelsEmpty') }}
</template>
<template v-else>
{{ t('channelMonitorV2.settings.namedModelsCount', { count: namedModelCount }) }}
</template>
</div>
<div class="rounded-2xl border border-gray-200 bg-gray-50/80 px-4 py-3 text-xs text-gray-600 dark:border-dark-600 dark:bg-dark-800/50 dark:text-gray-300">
<p class="font-medium text-gray-800 dark:text-gray-100">{{ t('channelMonitorV2.settings.userContractTitle') }}</p>
<ul class="mt-1.5 list-disc space-y-0.5 pl-4">
<li>{{ t('channelMonitorV2.settings.userContract.health') }}</li>
<li>{{ t('channelMonitorV2.settings.userContract.trend') }}</li>
<li>{{ t('channelMonitorV2.settings.userContract.latency') }}</li>
<li>{{ t('channelMonitorV2.settings.userContract.models') }}</li>
</ul>
</div>
</div>
</template>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Toggle from '@/components/common/Toggle.vue'
import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { getChannelMonitorMode, isChannelMonitorV2Mode } from '@/utils/featureFlags'
import {
getConfig,
updateConfig,
MONITOR_ERROR_CATEGORIES,
type MonitorConfig,
} from '@/api/channelMonitorV2'
import { adminAPI } from '@/api/admin'
import type { AdminGroup } from '@/types'
const { t, te } = useI18n()
const appStore = useAppStore()
const loading = ref(true)
const saving = ref(false)
const draft = ref<MonitorConfig | null>(null)
const original = ref('')
const groups = ref<AdminGroup[]>([])
const dirty = computed(() => (draft.value ? JSON.stringify(draft.value) !== original.value : false))
const namedModelCount = computed(
() => draft.value?.platforms.filter((p) => p.enabled).reduce((sum, p) => sum + p.models.length, 0) || 0
)
const errorCategories = MONITOR_ERROR_CATEGORIES
const countedErrorCategoryCount = computed(
() => errorCategories.length - (draft.value?.ignored_error_categories?.length || 0)
)
/** System settings mode must be v2 for aggregation to run; config remains editable for prep. */
const systemModeV2 = computed(() => isChannelMonitorV2Mode())
const systemModeLabel = computed(() => {
if (!appStore.cachedPublicSettings?.channel_monitor_enabled) {
return t('channelMonitorV2.settings.modeClosed')
}
return getChannelMonitorMode() === 'v1'
? t('channelMonitorV2.settings.modeV1')
: t('channelMonitorV2.settings.modeV2')
})
const defaultThresholds = {
minimum_sample: 50,
warning_error_rate: 0.05,
critical_error_rate: 0.20,
target_ttft_ms: 3000,
warning_ttft_ms: 3000,
critical_ttft_ms: 10000,
// Higher is better: below 85% watch, below 60% critical.
warning_cache_rate: 0.85,
critical_cache_rate: 0.60,
error_weight: 0.60,
ttft_weight: 0.20,
cache_weight: 0.20,
}
/** Factory ignored categories (matches backend DefaultChannelMonitorV2IgnoredErrorCategories). */
const defaultIgnoredErrorCategories = [
'authentication',
'client_cancelled',
'content_policy',
'context_limit',
'group_access',
'model_unsupported',
'not_found',
'quota_or_balance',
] as const
function percentModel(key: 'warning_error_rate' | 'critical_error_rate' | 'warning_cache_rate' | 'critical_cache_rate') {
return computed({
get: () => ((draft.value?.health_thresholds?.[key] ?? defaultThresholds[key]) * 100),
set: (value: number) => {
if (!draft.value) return
draft.value.health_thresholds[key] = Math.max(0, Math.min(100, Number(value) || 0)) / 100
},
})
}
const warningErrorPercent = percentModel('warning_error_rate')
const criticalErrorPercent = percentModel('critical_error_rate')
const warningCachePercent = percentModel('warning_cache_rate')
const criticalCachePercent = percentModel('critical_cache_rate')
function setModels(platform: MonitorConfig['platforms'][number], event: Event) {
platform.models = [
...new Set(
(event.target as HTMLInputElement).value
.split(',')
.map((v) => v.trim())
.filter(Boolean)
),
].sort()
}
function toggleGroup(id: number) {
if (!draft.value) return
draft.value.group_ids = draft.value.group_ids.includes(id)
? draft.value.group_ids.filter((value) => value !== id)
: [...draft.value.group_ids, id].sort((a, b) => a - b)
}
function isCategoryIgnored(category: string): boolean {
return Boolean(draft.value?.ignored_error_categories?.includes(category))
}
function toggleIgnoredCategory(category: string) {
if (!draft.value) return
const current = new Set(draft.value.ignored_error_categories || [])
if (current.has(category)) current.delete(category)
else current.add(category)
draft.value.ignored_error_categories = [...current].sort()
}
function categoryLabel(category: string) {
const key = `channelMonitorV2.errorCategories.${category}`
return te(key) ? t(key) : category
}
function platformLabel(value: string) {
return (
{
anthropic: 'Claude',
openai: 'OpenAI',
grok: 'Grok',
kiro: 'Kiro',
gemini: 'Gemini',
antigravity: 'Antigravity',
composite: 'Composite',
} as Record<string, string>
)[value] || value
}
function normalizeConfig(value: MonitorConfig): MonitorConfig {
const ignored = value.ignored_error_categories
return {
...value,
health_thresholds: { ...defaultThresholds, ...(value.health_thresholds || {}) },
// Preserve explicit empty arrays from the server (operator cleared all).
ignored_error_categories: [
...(ignored == null ? [...defaultIgnoredErrorCategories] : ignored),
].sort(),
}
}
async function load() {
loading.value = true
try {
const [value, groupRows] = await Promise.all([getConfig(), adminAPI.groups.getAllIncludingInactive()])
const normalized = normalizeConfig(value)
draft.value = structuredClone(normalized)
groups.value = groupRows
original.value = JSON.stringify(normalized)
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.settings.loadFailed')))
} finally {
loading.value = false
}
}
async function save() {
if (!draft.value) return
saving.value = true
try {
const payload = normalizeConfig(draft.value)
const value = await updateConfig(payload)
const normalized = normalizeConfig(value)
draft.value = structuredClone(normalized)
original.value = JSON.stringify(normalized)
appStore.showSuccess(t('channelMonitorV2.settings.saveSuccess'))
} catch (error) {
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.settings.saveFailed')))
await load()
} finally {
saving.value = false
}
}
onMounted(load)
</script>
@@ -0,0 +1,273 @@
<template>
<section
class="card flex min-h-[360px] flex-col overflow-hidden !rounded-3xl !border-0 !p-6 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700"
>
<div class="card-header mb-4 flex shrink-0 flex-wrap items-start justify-between gap-3 !border-0 !p-0">
<div class="min-w-0">
<h2 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<span class="inline-flex h-4 w-4 text-sky-500" aria-hidden="true">
<Icon name="chart" size="sm" />
</span>
{{ t('channelMonitorV2.chart.title') }}
</h2>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.chart.description') }}
</p>
</div>
<div class="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 text-xs text-gray-500 dark:text-gray-400 sm:w-auto">
<span class="flex shrink-0 items-center gap-1">
<span class="h-2 w-2 rounded-full bg-red-500"></span>{{ t('channelMonitorV2.chart.errorLegend') }}
</span>
<span class="flex shrink-0 items-center gap-1">
<span class="h-2 w-2 rounded-full bg-emerald-500"></span>{{ t('channelMonitorV2.chart.cacheLegend') }}
</span>
<span class="flex shrink-0 items-center gap-1">
<span class="h-2 w-2 rounded-full bg-sky-500"></span>{{ t('channelMonitorV2.chart.ttftLegend') }}
</span>
<span class="badge badge-gray shrink-0">{{ bucketLabel }}</span>
<button
type="button"
class="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="!zoomed"
@click="resetChartZoom"
>
{{ t('channelMonitorV2.chart.resetZoom') }}
</button>
</div>
</div>
<div class="card-body min-h-0 flex-1 !p-0">
<div v-if="loading" class="flex h-[280px] items-center justify-center sm:h-[300px]">
<div class="animate-pulse text-sm text-gray-400">{{ t('common.loading') }}</div>
</div>
<div
v-else-if="chartData"
ref="chartRef"
class="h-[280px] sm:h-[300px]"
@wheel="onChartWheel"
>
<Line :data="chartData" :options="chartOptions" />
</div>
<div v-else class="flex h-[280px] items-center justify-center sm:h-[300px]">
<EmptyState
:title="t('channelMonitorV2.chart.emptyTitle')"
:description="t('channelMonitorV2.empty.description')"
/>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, ref, watch } from 'vue'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from 'chart.js'
import { Line } from 'vue-chartjs'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import type { MonitorCoverage, MonitorMetric, MonitorHealth } from '@/api/channelMonitorV2'
import { formatMonitorMs, formatMonitorPercent } from '@/features/channel-monitor-v2/monitorFormat'
import {
applyWheelZoom,
clientXRatio,
isZoomed,
resetZoom,
sliceByZoom,
type ZoomState,
} from '@/features/channel-monitor-v2/monitorZoom'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
const { t, locale } = useI18n()
const props = defineProps<{
trend: Array<{ bucket_start: string; metrics: MonitorMetric; health: MonitorHealth }>
coverage: MonitorCoverage | null
loading?: boolean
}>()
const chartRef = ref<HTMLElement | null>(null)
const zoom = ref<ZoomState>(resetZoom())
const zoomed = computed(() => isZoomed(zoom.value))
const isDark = computed(() =>
typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
)
const bucketLabel = computed(() => {
const seconds = props.coverage?.bucket_seconds || 60
const minutes = seconds / 60
if (minutes < 60) return t('channelMonitorV2.bucket.minutes', { count: minutes })
const hours = minutes / 60
if (hours < 24) return t('channelMonitorV2.bucket.hours', { count: hours })
return t('channelMonitorV2.bucket.days', { count: hours / 24 })
})
const chartData = computed(() => {
const points = visibleTrend.value
if (!points.length) return null
const labels = points.map((p) =>
new Intl.DateTimeFormat(locale.value || undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(p.bucket_start))
)
const errorRates = smoothTrend(points.map((p) => (p.metrics.error_rate || 0) * 100))
const cacheRates = smoothTrend(points.map((p) => (p.metrics.cache_rate || 0) * 100))
const ttftP50 = smoothTrend(points.map((p) => p.metrics.ttft?.p50_ms ?? null))
return {
labels,
datasets: [
{
label: t('channelMonitorV2.chart.errorDataset'),
data: errorRates,
borderColor: '#ef4444',
backgroundColor: 'rgba(239, 67, 67, 0.10)',
yAxisID: 'yPct',
tension: 0.4,
cubicInterpolationMode: 'monotone' as const,
fill: 'origin' as const,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 10,
borderWidth: 2,
},
{
label: t('channelMonitorV2.chart.cacheDataset'),
data: cacheRates,
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.08)',
yAxisID: 'yPct',
tension: 0.4,
cubicInterpolationMode: 'monotone' as const,
fill: false,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 10,
borderWidth: 2,
},
{
label: t('channelMonitorV2.chart.ttftDataset'),
data: ttftP50,
borderColor: '#0ea5e9',
backgroundColor: 'rgba(14, 165, 233, 0.08)',
yAxisID: 'yTtft',
tension: 0.4,
cubicInterpolationMode: 'monotone' as const,
fill: false,
pointRadius: 0,
pointHoverRadius: 4,
pointHitRadius: 10,
borderWidth: 2,
spanGaps: true,
},
],
}
})
/** Window the series by zoom state around the cursor — not always the last N points. */
const visibleTrend = computed(() => sliceByZoom(props.trend || [], zoom.value))
function onChartWheel(event: WheelEvent) {
// Plain vertical wheel zooms X (narrower time range); shift/horizontal pans.
event.preventDefault()
const ratio = clientXRatio(event.clientX, chartRef.value)
zoom.value = applyWheelZoom(zoom.value, event, ratio)
}
function resetChartZoom() {
zoom.value = resetZoom()
}
watch(() => props.trend, () => {
zoom.value = resetZoom()
})
function smoothTrend(values: Array<number | null>): Array<number | null> {
if (values.length <= 2) return values
return values.map((value, index) => {
if (value == null) return null
const neighbors = values.slice(Math.max(0, index - 1), Math.min(values.length, index + 2))
.filter((item): item is number => item != null)
if (!neighbors.length) return value
return neighbors.reduce((sum, item) => sum + item, 0) / neighbors.length
})
}
const chartOptions = computed(() => {
const text = isDark.value ? '#9ca3af' : '#6b7280'
const grid = isDark.value ? '#374151' : '#f3f4f6'
const tooltipBg = isDark.value ? '#1f2937' : '#ffffff'
const tooltipTitle = isDark.value ? '#f3f4f6' : '#111827'
const tooltipBody = isDark.value ? '#d1d5db' : '#4b5563'
return {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index' as const, intersect: false },
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: tooltipBg,
titleColor: tooltipTitle,
bodyColor: tooltipBody,
borderColor: grid,
borderWidth: 1,
padding: 10,
displayColors: true,
callbacks: {
label(ctx: { dataset: { label?: string }; parsed: { y: number | null } }) {
const label = ctx.dataset.label || ''
const y = ctx.parsed.y
if (y == null) return `${label}: -`
if (label === t('channelMonitorV2.chart.errorDataset') || label === t('channelMonitorV2.chart.cacheDataset')) {
return `${label}: ${formatMonitorPercent(y / 100)}`
}
return `${label}: ${formatMonitorMs(y)}`
},
},
},
},
scales: {
x: {
ticks: { color: text, maxRotation: 0, autoSkip: true, maxTicksLimit: 8, autoSkipPadding: 10, font: { size: 10 } },
grid: { display: false },
},
yPct: {
type: 'linear' as const,
position: 'left' as const,
min: 0,
suggestedMax: 100,
ticks: {
color: text,
font: { size: 10 },
callback: (v: string | number) => `${v}%`,
},
grid: { color: grid, borderDash: [4, 4] },
title: { display: true, text: t('channelMonitorV2.chart.percentAxis'), color: text, font: { size: 11 } },
},
yTtft: {
type: 'linear' as const,
position: 'right' as const,
min: 0,
ticks: {
color: '#0ea5e9',
font: { size: 10 },
callback: (v: string | number) => formatMonitorMs(Number(v)),
},
grid: { display: false },
title: { display: true, text: t('channelMonitorV2.metrics.ttftP50'), color: '#0ea5e9', font: { size: 11 } },
},
},
}
})
</script>
@@ -0,0 +1,664 @@
<template>
<section
class="card flex min-h-[360px] flex-col overflow-visible !rounded-3xl !border-0 !p-6 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700"
>
<div class="card-header mb-4 flex shrink-0 flex-wrap items-start justify-between gap-3 !border-0 !p-0">
<div class="min-w-0">
<h2 class="flex items-center gap-2 text-sm font-bold text-gray-900 dark:text-white">
<span class="inline-flex h-4 w-4 text-emerald-500" aria-hidden="true">
<Icon name="grid" size="sm" />
</span>
{{ t('channelMonitorV2.matrix.title') }}
</h2>
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.matrix.description') }}
</p>
</div>
<div class="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 text-xs text-gray-500 dark:text-gray-400 sm:w-auto">
<span class="badge badge-gray shrink-0">{{ bucketLabel }}</span>
<span class="hidden text-[11px] text-gray-400 dark:text-dark-400 sm:inline">{{ t('channelMonitorV2.matrix.wheelZoomX') }}</span>
<button
type="button"
class="inline-flex shrink-0 items-center rounded-lg border border-gray-200 bg-white px-2 py-1 text-[11px] font-semibold text-gray-600 hover:bg-gray-50 disabled:opacity-50 dark:border-dark-700 dark:bg-dark-900 dark:text-gray-300 dark:hover:bg-dark-800"
:disabled="!zoomed"
@click="resetMatrixZoom"
>
{{ t('channelMonitorV2.matrix.resetZoom') }}
</button>
</div>
</div>
<div class="card-body min-h-0 flex-1 !p-0">
<div
v-if="rows.length"
ref="scrollRef"
class="matrix-scroll max-h-[min(42vh,420px)] max-w-full overflow-auto rounded-2xl bg-gray-50/60 p-2 dark:bg-dark-900/30"
@wheel="onMatrixWheel"
>
<div class="matrix-table w-full" :style="tableStyle">
<div
class="matrix-header matrix-row sticky top-0 z-[3] bg-gray-50 text-[10px] font-semibold uppercase tracking-wide text-gray-500 dark:bg-dark-900 dark:text-gray-400"
:class="showThroughput ? 'matrix-row--with-tps' : ''"
>
<span>{{ t('channelMonitorV2.matrix.dimension') }}</span>
<span>{{ t('channelMonitorV2.metrics.successRate') }}</span>
<span>{{ t('channelMonitorV2.metrics.ttft') }}</span>
<span v-if="showThroughput">{{ t('channelMonitorV2.metrics.tps') }}</span>
<span>{{ t('channelMonitorV2.metrics.cacheRate') }}</span>
<span class="pulse-axis flex justify-between gap-3">
<i class="not-italic">{{ axisStart }}</i>
<i class="not-italic">{{ axisEnd }}</i>
</span>
</div>
<div
v-for="entry in alignedRows"
:key="rowKey(entry.row)"
class="matrix-row border-b border-gray-100/80 dark:border-dark-700/60"
:class="showThroughput ? 'matrix-row--with-tps' : ''"
>
<div class="dimension-cell flex min-w-0 items-center gap-2 bg-white dark:bg-dark-800" :title="rowLabel(entry.row)">
<span :class="['status-dot', cellClass(entry.row.health, entry.row.metrics.request_count)]"></span>
<strong class="truncate text-xs font-semibold text-gray-800 dark:text-gray-100">{{ rowLabel(entry.row) }}</strong>
</div>
<strong class="summary-value bg-white text-xs font-medium tabular-nums text-gray-600 dark:bg-dark-800 dark:text-gray-300">
{{ successRate(entry.row.metrics) }}
</strong>
<strong
class="summary-value bg-white text-xs font-medium tabular-nums text-gray-600 dark:bg-dark-800 dark:text-gray-300"
:title="latencyPrivacy(entry.row.metrics.ttft)"
>
{{ formatMs(entry.row.metrics.ttft.p50_ms) }}
</strong>
<strong
v-if="showThroughput"
class="summary-value bg-white text-xs font-medium tabular-nums text-gray-600 dark:bg-dark-800 dark:text-gray-300"
:title="exactTps(entry.row.metrics.tpm)"
>
{{ formatTps(entry.row.metrics.tpm) }}
</strong>
<strong
class="summary-value bg-white text-xs font-medium tabular-nums text-gray-600 dark:bg-dark-800 dark:text-gray-300"
>
{{ formatPercent(entry.row.metrics.cache_rate) }}
</strong>
<div class="pulse-track grid items-stretch" :style="pulseStyle">
<span
v-for="slot in entry.slots"
:key="slot.start"
class="pulse-cell relative rounded-sm border-0 p-0 outline-offset-1"
:class="[
slot.bucket ? cellClass(slot.bucket.health, slot.bucket.metrics.request_count) : 'health-unknown',
slot.bucket ? 'has-data' : 'is-empty',
]"
tabindex="0"
role="img"
:title="slot.bucket ? bucketTooltip(slot.bucket) : t('channelMonitorV2.matrix.noTrafficAt', { time: formatBucketRange(slot.start) })"
:aria-label="slot.bucket ? bucketTooltip(slot.bucket) : t('channelMonitorV2.matrix.noTrafficAt', { time: formatBucketRange(slot.start) })"
@mouseenter="showTooltip($event, slot)"
@mousemove="moveTooltip($event)"
@mouseleave="hideTooltip"
@focus="showTooltip($event, slot)"
@blur="hideTooltip"
>
<span class="pulse-tooltip" role="tooltip">
<template v-if="slot.bucket">
<span class="pulse-tooltip-line pulse-tooltip-title">{{ formatBucketRange(slot.start) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.matrix.scoreLine', { score: formatScore(slot.bucket.health) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.successRateValue', { value: successRate(slot.bucket.metrics) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.ttftValue', { value: latencyPrivacy(slot.bucket.metrics.ttft) }) }}</span>
<span v-if="showThroughput" class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.tpsValue', { value: formatTps(slot.bucket.metrics.tpm) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.cacheRateValue', { value: formatPercent(slot.bucket.metrics.cache_rate) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.errorRateValue', { value: formatPercent(slot.bucket.metrics.error_rate) }) }}</span>
<span v-if="showThroughput" class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.rpmValue', { value: formatRate(slot.bucket.metrics.rpm) }) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.metrics.durationValue', { value: latencyPrivacy(slot.bucket.metrics.duration) }) }}</span>
</template>
<template v-else>
<span class="pulse-tooltip-line pulse-tooltip-title">{{ formatBucketRange(slot.start) }}</span>
<span class="pulse-tooltip-line">{{ t('channelMonitorV2.matrix.noTraffic') }}</span>
</template>
</span>
</span>
</div>
</div>
</div>
</div>
<div v-else class="flex min-h-[200px] items-center justify-center py-8">
<EmptyState
:title="t('channelMonitorV2.matrix.emptyTitle')"
:description="t('channelMonitorV2.empty.description')"
/>
</div>
<div class="mt-4 flex flex-col gap-2" :aria-label="t('channelMonitorV2.matrix.legendAria')">
<div class="flex items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">
<span class="shrink-0">{{ t('channelMonitorV2.matrix.bad') }}</span>
<div class="score-legend h-2.5 flex-1 overflow-hidden rounded-full"></div>
<span class="shrink-0">{{ t('channelMonitorV2.matrix.good') }}</span>
</div>
<div class="flex flex-wrap gap-4 text-[11px] text-gray-500 dark:text-gray-400">
<span class="inline-flex items-center gap-1.5"><i class="status-dot health-score10"></i>{{ t('channelMonitorV2.matrix.healthyLegend') }}</span>
<span class="inline-flex items-center gap-1.5"><i class="status-dot health-score6"></i>{{ t('channelMonitorV2.matrix.warningLegend') }}</span>
<span class="inline-flex items-center gap-1.5"><i class="status-dot health-score2"></i>{{ t('channelMonitorV2.matrix.criticalLegend') }}</span>
<span class="inline-flex items-center gap-1.5"><i class="status-dot health-unknown"></i>{{ t('channelMonitorV2.matrix.unknownLegend') }}</span>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="floatingTooltip.visible"
class="matrix-floating-tooltip"
:style="{ left: `${floatingTooltip.x}px`, top: `${floatingTooltip.y}px` }"
role="tooltip"
>
<span
v-for="(line, index) in floatingTooltip.lines"
:key="`${index}:${line}`"
class="matrix-floating-tooltip-line"
:class="index === 0 ? 'matrix-floating-tooltip-title' : ''"
>
{{ line }}
</span>
</div>
</Teleport>
</section>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, reactive, ref, watch } from 'vue'
import type {
LatencyMetric,
MonitorCoverage,
MonitorHealth,
MonitorMatrixBucket,
MonitorMatrixRow,
MonitorMetric,
} from '@/api/channelMonitorV2'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
import {
formatLatencyPrivacy,
formatMonitorMs,
formatMonitorPercent,
formatMonitorSuccessRateFromError,
formatMonitorThroughput,
formatMonitorTokensPerSecond,
tokensPerSecondFromTpm,
healthModeScore,
healthScoreClass,
} from '@/features/channel-monitor-v2/monitorFormat'
import {
applyWheelZoom,
clientXRatio,
isZoomed,
resetZoom,
sliceByZoom,
type ZoomState,
} from '@/features/channel-monitor-v2/monitorZoom'
type HealthMode = 'overall' | 'success' | 'ttft' | 'cache'
const { t, locale } = useI18n()
const props = withDefaults(
defineProps<{
rows: MonitorMatrixRow[]
coverage: MonitorCoverage
healthMode: HealthMode
/** When false, RPM/TPM are omitted from tooltips (user scale privacy). */
showThroughput?: boolean
}>(),
{ showThroughput: true },
)
type AlignedSlot = { start: string; bucket?: MonitorMatrixBucket }
const floatingTooltip = reactive({
visible: false,
x: 0,
y: 0,
lines: [] as string[],
})
const scrollRef = ref<HTMLElement | null>(null)
const zoom = ref<ZoomState>(resetZoom())
const zoomed = computed(() => isZoomed(zoom.value))
const allBucketStarts = computed(() => {
// X-axis always spans the UI-selected range [requested_start, requested_end).
// Partial backfill leaves empty cells until coverage_start/data_through fill in.
const step = Math.max(60, props.coverage.bucket_seconds) * 1000
const requestedStart = new Date(props.coverage.requested_start).getTime()
const requestedEndRaw = props.coverage.requested_end
? new Date(props.coverage.requested_end).getTime()
: NaN
// Fallback for older payloads without requested_end.
const dataThrough = new Date(props.coverage.data_through).getTime()
const end = Number.isFinite(requestedEndRaw) && requestedEndRaw > requestedStart
? requestedEndRaw
: dataThrough
if (![requestedStart, end].every(Number.isFinite) || requestedStart >= end) return []
const starts: string[] = []
for (let cursor = Math.floor(requestedStart / step) * step; cursor < end; cursor += step) {
starts.push(new Date(cursor).toISOString())
}
return starts
})
/** Visible bucket window after X zoom (cursor-centered), not always the tail. */
const bucketStarts = computed(() => sliceByZoom(allBucketStarts.value, zoom.value))
const tableStyle = computed(() => ({
'--bucket-count': String(Math.max(1, bucketStarts.value.length)),
minWidth: zoomed.value ? `calc(260px + ${pulseMinWidth.value})` : '0',
}))
const pulseMinWidth = computed(() => {
const count = Math.max(1, bucketStarts.value.length)
if (!zoomed.value) return '0px'
// Zoom in = fewer columns + wider min cell (span shrinks → intensity grows).
const intensity = Math.min(12, Math.round((1 - zoom.value.span) / 0.08))
const width = 6 + intensity * 4
const gap = intensity >= 4 ? 3 : 2
return `${count * width + Math.max(0, count - 1) * gap}px`
})
const pulseStyle = computed(() => {
const count = Math.max(1, bucketStarts.value.length)
const intensity = zoomed.value ? Math.min(12, Math.round((1 - zoom.value.span) / 0.08)) : 0
const gapPx = !zoomed.value ? (count > 24 ? 1 : 2) : intensity >= 4 ? 3 : 2
const heightPx = 16
// Unzoomed: equal flex fractions. Zoomed: enforce growing min width so blocks lengthen.
const minCell = !zoomed.value ? '0' : `${6 + intensity * 4}px`
return {
gridTemplateColumns: `repeat(${count}, minmax(${minCell}, 1fr))`,
gap: `${gapPx}px`,
height: `${heightPx}px`,
minWidth: pulseMinWidth.value,
}
})
const axisStart = computed(() =>
bucketStarts.value.length ? formatAxisTime(bucketStarts.value[0]) : '时间脉冲'
)
const axisEnd = computed(() =>
bucketStarts.value.length ? formatAxisTime(bucketStarts.value[bucketStarts.value.length - 1]) : ''
)
const bucketLabel = computed(() => {
const minutes = props.coverage.bucket_seconds / 60
if (minutes < 60) return t('channelMonitorV2.bucket.minutes', { count: minutes })
const hours = minutes / 60
if (hours < 24) return t('channelMonitorV2.bucket.hours', { count: hours })
return t('channelMonitorV2.bucket.days', { count: hours / 24 })
})
/** Shared ISO start → column index for the visible window (rebuilt when zoom/coverage changes). */
const bucketStartIndex = computed(() => {
const map = new Map<string, number>()
bucketStarts.value.forEach((start, index) => map.set(start, index))
return map
})
/** Pre-aligned sparse slots per row so wheel zoom does not rebuild Maps every paint. */
const alignedRows = computed(() => {
const starts = bucketStarts.value
const indexByStart = bucketStartIndex.value
return props.rows.map((row) => {
const slots: AlignedSlot[] = starts.map((start) => ({ start }))
for (const bucket of row.buckets || []) {
const key = new Date(bucket.bucket_start).toISOString()
const index = indexByStart.get(key)
if (index != null) slots[index] = { start: starts[index], bucket }
}
return { row, slots }
})
})
function onMatrixWheel(event: WheelEvent) {
const track = scrollRef.value
const target = event.target as HTMLElement | null
const pulse = target?.closest('.pulse-track') as HTMLElement | null
const overMatrix = Boolean(target?.closest('.matrix-scroll'))
// Plain vertical wheel over the matrix zooms X (narrower range → wider cells).
// Shift+wheel or horizontal delta pans; leave non-matrix page scroll alone.
const isPan = event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)
if (!overMatrix && !pulse) return
// When not zoomed and user scrolls vertically outside pulse, still zoom if over matrix body.
if (!overMatrix && !isPan) return
event.preventDefault()
const ratioEl = pulse || track
const ratio = clientXRatio(event.clientX, ratioEl)
zoom.value = applyWheelZoom(zoom.value, event, ratio)
}
function resetMatrixZoom() {
zoom.value = resetZoom()
}
watch(
() => [
props.coverage.requested_start,
props.coverage.requested_end,
props.coverage.coverage_start,
props.coverage.data_through,
props.coverage.bucket_seconds,
],
() => {
zoom.value = resetZoom()
},
)
function cellClass(health: MonitorHealth, requestCount: number): string {
return healthScoreClass(health, props.healthMode, requestCount)
}
function rowLabel(row: MonitorMatrixRow): string {
const parts = [row.platform]
if (row.group_name || row.group_id) parts.push(row.group_name || `#${row.group_id}`)
if (row.model) parts.push(row.model === '__other__' ? t('channelMonitorV2.otherModels') : row.model)
return parts.join(' / ')
}
function rowKey(row: MonitorMatrixRow): string {
return [row.platform, row.group_id || 0, row.model || ''].join(':')
}
function successRate(metrics: MonitorMetric): string {
// Empty traffic: no request count and no throughput signal.
// When throughput is hidden for privacy, still show success from error_rate.
const noCount = metrics.request_count <= 0
const noTP = (metrics.rpm || 0) <= 0 && (metrics.tpm || 0) <= 0
if (noCount && noTP && props.showThroughput) return '-'
return formatMonitorSuccessRateFromError(metrics.error_rate)
}
function formatScore(health: MonitorHealth): string {
const score = healthModeScore(health, props.healthMode)
if (score == null) return '—'
return `${Math.round(score)}`
}
function bucketTooltip(bucket: MonitorMatrixBucket): string {
return bucketTooltipLines(bucket).join('\n')
}
function bucketTooltipLines(bucket: MonitorMatrixBucket): string[] {
const metrics = bucket.metrics
const lines = [
formatBucketRange(bucket.bucket_start),
t('channelMonitorV2.matrix.scoreLine', { score: formatScore(bucket.health) }),
t('channelMonitorV2.metrics.successRateValue', { value: successRate(metrics) }),
t('channelMonitorV2.metrics.ttftValue', { value: latencyPrivacy(metrics.ttft) }),
]
if (props.showThroughput) {
lines.push(t('channelMonitorV2.metrics.tpsValue', { value: formatTps(metrics.tpm) }))
}
lines.push(
t('channelMonitorV2.metrics.cacheRateValue', { value: formatPercent(metrics.cache_rate) }),
t('channelMonitorV2.metrics.errorRateValue', { value: formatPercent(metrics.error_rate) }),
)
if (props.showThroughput) {
lines.push(t('channelMonitorV2.metrics.rpmValue', { value: formatRate(metrics.rpm) }))
}
lines.push(t('channelMonitorV2.metrics.durationValue', { value: latencyPrivacy(metrics.duration) }))
return lines
}
function emptyTooltipLines(start: string): string[] {
return [formatBucketRange(start), t('channelMonitorV2.matrix.noTraffic')]
}
function showTooltip(event: MouseEvent | FocusEvent, slot: AlignedSlot) {
floatingTooltip.lines = slot.bucket ? bucketTooltipLines(slot.bucket) : emptyTooltipLines(slot.start)
floatingTooltip.visible = true
positionTooltip(event)
}
function moveTooltip(event: MouseEvent) {
if (!floatingTooltip.visible) return
positionTooltip(event)
}
function hideTooltip() {
floatingTooltip.visible = false
}
function positionTooltip(event: MouseEvent | FocusEvent) {
if ('clientX' in event) {
floatingTooltip.x = Math.min(window.innerWidth - 12, Math.max(12, event.clientX))
floatingTooltip.y = Math.min(window.innerHeight - 12, Math.max(12, event.clientY)) - 12
return
}
const target = event.target as HTMLElement | null
const rect = target?.getBoundingClientRect()
if (!rect) return
floatingTooltip.x = rect.left + rect.width / 2
floatingTooltip.y = rect.top - 10
}
function latencyPrivacy(metric: LatencyMetric) {
return formatLatencyPrivacy(metric.p50_ms, metric.p90_ms, metric.avg_ms, metric.p95_ms)
}
function formatPercent(value: number) {
return formatMonitorPercent(value)
}
function formatRate(value: number) {
return formatMonitorThroughput(value)
}
function formatTps(tpm: number | null | undefined) {
return formatMonitorTokensPerSecond(tpm)
}
function exactTps(tpm: number | null | undefined) {
const tps = tokensPerSecondFromTpm(tpm)
return Intl.NumberFormat(locale.value || undefined, { maximumFractionDigits: 3 }).format(tps)
}
function formatMs(value: number | null) {
return formatMonitorMs(value)
}
function formatAxisTime(value: string) {
return new Intl.DateTimeFormat(locale.value || undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
function formatBucketRange(value: string) {
const start = new Date(value)
const end = new Date(start.getTime() + props.coverage.bucket_seconds * 1000)
return `${formatAxisTime(start.toISOString())} - ${new Intl.DateTimeFormat(locale.value || undefined, { hour: '2-digit', minute: '2-digit' }).format(end)}`
}
</script>
<style scoped>
/* dimension | success | ttft | cache | pulse */
.matrix-row {
display: grid;
grid-template-columns:
minmax(120px, 1.2fr)
minmax(52px, 0.34fr)
minmax(58px, 0.36fr)
minmax(52px, 0.34fr)
minmax(120px, 2.8fr);
align-items: center;
gap: 0.5rem clamp(0.25rem, 0.8vw, 0.625rem);
min-height: 2.25rem;
}
/* + tokens/s column when throughput visible */
.matrix-row--with-tps {
grid-template-columns:
minmax(110px, 1.15fr)
minmax(48px, 0.3fr)
minmax(54px, 0.32fr)
minmax(58px, 0.36fr)
minmax(48px, 0.3fr)
minmax(120px, 2.6fr);
}
.matrix-table,
.pulse-track {
min-width: 0;
}
.status-dot {
display: inline-block;
height: 0.5rem;
width: 0.5rem;
flex: none;
border-radius: 9999px;
}
/* Multi-stop green → yellow → red (score10 best … score0 worst) */
.health-score10 { background: #16a34a; }
.health-score9 { background: #22c55e; }
.health-score8 { background: #4ade80; }
.health-score7 { background: #a3e635; }
.health-score6 { background: #facc15; }
.health-score5 { background: #fbbf24; }
.health-score4 { background: #f59e0b; }
.health-score3 { background: #f97316; }
.health-score2 { background: #fb7185; }
.health-score1 { background: #f87171; }
.health-score0 { background: rgb(239, 67, 67); }
/* Coarse fallbacks (older payloads without score) */
.health-healthy { background: #22c55e; }
.health-warning { background: #f59e0b; }
.health-critical { background: #ef4444; }
.health-unknown { background: #9ca3af; }
.score-legend {
background: linear-gradient(
90deg,
rgb(239, 67, 67) 0%,
#f87171 15%,
#f97316 30%,
#f59e0b 45%,
#facc15 55%,
#a3e635 70%,
#22c55e 85%,
#16a34a 100%
);
}
.pulse-cell {
position: relative;
min-width: 0;
}
.pulse-cell.has-data {
cursor: help;
}
.pulse-cell.is-empty {
opacity: 0.55;
cursor: default;
}
.pulse-cell.has-data:hover,
.pulse-cell.has-data:focus-visible {
outline: 2px solid rgb(var(--color-primary-500, 99 102 241) / 0.55);
outline-offset: 1px;
z-index: 5;
}
/* CSS-only hover tooltip — no click modal, no absolute request counts.
Native title is also provided so dense/scrolling layouts can always show the
full content even when a browser clips transformed children. */
.pulse-tooltip {
pointer-events: none;
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
z-index: 40;
min-width: 11.5rem;
max-width: 16rem;
transform: translateX(-50%) translateY(4px);
border-radius: 0.75rem;
border: 1px solid rgb(229 231 235);
background: rgb(255 255 255);
padding: 0.5rem 0.625rem;
box-shadow: 0 10px 25px -5px rgb(0 0 0 / 0.15);
opacity: 0;
visibility: hidden;
transition: opacity 0.12s ease, transform 0.12s ease, visibility 0.12s;
white-space: nowrap;
}
:global(.dark) .pulse-tooltip {
border-color: rgb(55 65 81);
background: rgb(17 24 39);
color: rgb(229 231 235);
}
.pulse-tooltip-line {
display: block;
font-size: 11px;
line-height: 1.45;
color: rgb(75 85 99);
}
:global(.dark) .pulse-tooltip-line {
color: rgb(209 213 219);
}
.pulse-tooltip-title {
margin-bottom: 0.2rem;
font-weight: 600;
color: rgb(17 24 39);
}
:global(.dark) .pulse-tooltip-title {
color: rgb(243 244 246);
}
.pulse-cell:hover .pulse-tooltip,
.pulse-cell:focus-visible .pulse-tooltip {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
}
/* Keep semantic/test text in-cell, but render the visible tooltip through body
Teleport so it cannot be clipped by the matrix viewport. */
.pulse-tooltip {
display: none;
}
.matrix-floating-tooltip {
pointer-events: none;
position: fixed;
z-index: 9999;
min-width: 11.5rem;
max-width: min(18rem, calc(100vw - 1.5rem));
transform: translate(-50%, -100%);
border-radius: 0.75rem;
border: 1px solid rgb(229 231 235);
background: rgb(255 255 255);
padding: 0.5rem 0.625rem;
box-shadow: 0 18px 40px -12px rgb(0 0 0 / 0.28);
white-space: nowrap;
}
:global(.dark) .matrix-floating-tooltip {
border-color: rgb(55 65 81);
background: rgb(17 24 39);
color: rgb(229 231 235);
}
.matrix-floating-tooltip-line {
display: block;
font-size: 11px;
line-height: 1.45;
color: rgb(75 85 99);
}
:global(.dark) .matrix-floating-tooltip-line {
color: rgb(209 213 219);
}
.matrix-floating-tooltip-title {
margin-bottom: 0.2rem;
font-weight: 600;
color: rgb(17 24 39);
}
:global(.dark) .matrix-floating-tooltip-title {
color: rgb(243 244 246);
}
@media (max-width: 640px) {
.matrix-row {
grid-template-columns: minmax(88px, 1fr) minmax(48px, 0.45fr) minmax(54px, 0.5fr) minmax(96px, 2.6fr);
gap: 0.35rem;
}
.matrix-row > :nth-child(2) {
left: 0;
}
.matrix-row > :nth-child(3) {
left: 0;
}
}
</style>
@@ -0,0 +1,50 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import MetricCell from '../MetricCell.vue'
describe('MetricCell', () => {
it('renders label/value/detail through shipped template with design-system classes', () => {
const wrapper = mount(MetricCell, {
props: {
label: '请求',
value: '1,234',
detail: '12.5 RPM',
state: 'healthy',
},
})
expect(wrapper.classes().join(' ') + wrapper.html()).toContain('stat-card')
expect(wrapper.text()).toContain('请求')
expect(wrapper.text()).toContain('1,234')
expect(wrapper.text()).toContain('12.5 RPM')
expect(wrapper.find('strong').classes().join(' ')).toMatch(/emerald/)
})
it('maps warning and critical health states to distinct colors', () => {
const warning = mount(MetricCell, {
props: { label: '错误', value: '10%', detail: '1 次', state: 'warning' },
})
expect(warning.find('strong').classes().join(' ')).toMatch(/amber/)
const critical = mount(MetricCell, {
props: { label: '错误', value: '50%', detail: '5 次', state: 'critical' },
})
expect(critical.find('strong').classes().join(' ')).toMatch(/red/)
})
it('renders multi-part detail as non-truncated chips (AVG · P90 fully visible)', () => {
const wrapper = mount(MetricCell, {
props: {
label: '首 Token P50',
value: '400ms',
detail: 'AVG 475ms · P90 800ms',
state: 'healthy',
},
})
expect(wrapper.find('small.truncate').exists()).toBe(false)
expect(wrapper.find('.whitespace-nowrap').exists()).toBe(true)
expect(wrapper.text()).toContain('AVG 475ms')
expect(wrapper.text()).toContain('P90 800ms')
expect(wrapper.text()).not.toContain('…')
})
})
@@ -0,0 +1,176 @@
const i18nT = (key: string, params?: Record<string, unknown>) => {
const map: Record<string, string> = {
'channelMonitorV2.matrix.title': '色块矩阵',
'channelMonitorV2.matrix.description': '趋势色块视图',
'channelMonitorV2.bucket.minutes': '{count}分钟',
'channelMonitorV2.matrix.wheelZoomX': '滚轮缩放横向',
'channelMonitorV2.matrix.resetZoom': '重置缩放',
'channelMonitorV2.matrix.dimension': '维度',
'channelMonitorV2.metrics.successRate': '成功率',
'channelMonitorV2.metrics.ttft': '首 Token',
'channelMonitorV2.metrics.tps': '每秒 Token',
'channelMonitorV2.metrics.cacheRate': '缓存率',
'channelMonitorV2.matrix.scoreLine': '评分 {score}',
'channelMonitorV2.metrics.successRateValue': '成功率 {value}',
'channelMonitorV2.metrics.errorRateValue': '错误率 {value}',
'channelMonitorV2.metrics.rpmValue': 'RPM {value}',
'channelMonitorV2.metrics.tpmValue': 'TPM {value}',
'channelMonitorV2.metrics.tpsValue': '每秒 Token {value}',
'channelMonitorV2.metrics.ttftValue': '首 Token {value}',
'channelMonitorV2.metrics.durationValue': '时长 {value}',
'channelMonitorV2.metrics.cacheRateValue': '缓存率 {value}',
'channelMonitorV2.matrix.noTrafficAt': '{time} 无流量',
'channelMonitorV2.matrix.noTraffic': '无流量',
'channelMonitorV2.matrix.legendAria': '图例',
'channelMonitorV2.matrix.bad': '差',
'channelMonitorV2.matrix.good': '好',
'channelMonitorV2.matrix.healthyLegend': '健康',
'channelMonitorV2.matrix.warningLegend': '警告',
'channelMonitorV2.matrix.criticalLegend': '异常',
'channelMonitorV2.matrix.unknownLegend': '未知',
}
const template = map[key] || key
return template.replace(/\{(\w+)\}/g, (_, name) => String(params?.[name] ?? ''))
}
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: i18nT,
te: (key: string) => key.startsWith('channelMonitorV2.'),
locale: { value: 'zh' },
}),
}
})
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import RelayPulseMatrix from '../RelayPulseMatrix.vue'
import type { MonitorHealth, MonitorMetric } from '@/api/channelMonitorV2'
const health: MonitorHealth = {
overall: 'warning',
error_rate: 'warning',
ttft: 'healthy',
cache: 'warning',
score: 52,
error_rate_score: 40,
ttft_score: 100,
cache_score: 50,
minimum_sample: 20,
}
function metrics(requestCount: number): MonitorMetric {
return {
success_requests: requestCount ? requestCount - 1 : 0,
error_requests: requestCount ? 1 : 0,
request_count: requestCount,
token_count: 100,
rpm: 1.5,
tpm: 10.2,
error_rate: requestCount ? 1 / requestCount : 0,
cache_rate: 0.5,
cache_rate_numerator: 50,
cache_rate_denominator: 100,
ttft: { sample_count: requestCount, p50_ms: 100, p95_ms: 300, avg_ms: 150 },
duration: { sample_count: requestCount, p50_ms: 500, p95_ms: 900, avg_ms: 600 },
upstream_affected_requests: 2,
upstream_attempt_count: 3,
}
}
describe('RelayPulseMatrix', () => {
it('shows privacy-safe hover tooltips and multi-band colors without click modal', async () => {
const wrapper = mount(RelayPulseMatrix, {
props: {
rows: [{
platform: 'openai',
group_id: 7,
group_name: '默认组',
model: 'gpt-5',
metrics: metrics(10),
health,
buckets: [
{ bucket_start: '2026-08-01T00:00:00Z', metrics: metrics(10), health },
{ bucket_start: '2026-08-01T00:01:00Z', metrics: metrics(0), health },
],
}],
coverage: {
requested_start: '2026-08-01T00:00:00Z',
requested_end: '2026-08-01T00:03:00Z',
coverage_start: '2026-08-01T00:00:00Z',
data_through: '2026-08-01T00:03:00Z',
computed_at: '2026-08-01T00:03:00Z',
aggregation_lag_seconds: 0,
coverage_complete: true,
bucket_seconds: 60,
},
healthMode: 'overall',
},
})
const cells = wrapper.findAll('.pulse-cell')
expect(cells).toHaveLength(3)
// Hover tooltip content (privacy-safe: no absolute request/error counts)
const tip = cells[0].text()
expect(tip).toContain('成功率')
expect(tip).toContain('90.0%')
expect(tip).toContain('首 Token')
expect(tip).toContain('每秒 Token')
expect(tip).toContain('缓存率')
expect(tip).toContain('RPM')
expect(tip).not.toContain('请求数')
expect(tip).not.toContain('用户错误')
expect(tip).not.toContain('上游受影响')
// Summary columns: success · ttft · tokens/s · cache
const header = wrapper.find('.matrix-header').text()
expect(header).toContain('成功率')
expect(header).toContain('首 Token')
expect(header).toContain('每秒 Token')
expect(header).toContain('缓存率')
// Multi-band class from score 52 → score5
expect(cells[0].classes().some((c) => c.startsWith('health-score'))).toBe(true)
// Redacted user payloads may have request_count=0 but still include score.
expect(cells[1].classes().some((c) => c.startsWith('health-score'))).toBe(true)
expect(cells[2].classes()).toContain('health-unknown')
// No click-to-open modal
await cells[0].trigger('click')
expect(wrapper.find('[role="dialog"]').exists()).toBe(false)
})
})
describe('RelayPulseMatrix axis range', () => {
it('uses selected requested range for the X axis even with partial coverage', () => {
const wrapper = mount(RelayPulseMatrix, {
props: {
rows: [{
platform: 'openai',
group_id: 7,
group_name: '默认组',
model: 'gpt-5',
metrics: metrics(10),
health,
buckets: [
{ bucket_start: '2026-08-01T00:02:00Z', metrics: metrics(10), health },
],
}],
coverage: {
requested_start: '2026-08-01T00:00:00Z',
requested_end: '2026-08-01T00:05:00Z',
coverage_start: '2026-08-01T00:02:00Z',
data_through: '2026-08-01T00:03:00Z',
computed_at: '2026-08-01T00:03:00Z',
aggregation_lag_seconds: 0,
coverage_complete: false,
bucket_seconds: 60,
},
healthMode: 'overall',
},
})
// 5 minutes @ 60s buckets → 5 cells spanning the selected range
expect(wrapper.findAll('.pulse-cell')).toHaveLength(5)
})
})
@@ -0,0 +1,104 @@
/**
* Structure contracts: channel-monitor-v2 + studio shells must use project
* design-system utility classes rather than isolated flat RGB skins.
*/
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = resolve(__dirname, '../../..')
function read(rel: string) {
return readFileSync(resolve(root, rel), 'utf8')
}
describe('channel-monitor-v2 design system structure', () => {
it('user ChannelStatus V2 shell uses page-header, card, btn, tabs utilities', () => {
// Route wrapper may switch V1/V2; design chrome lives on the V2 implementation.
const src = read('views/user/ChannelStatusV2View.vue')
expect(src).toContain('page-header')
expect(src).toContain('page-title')
expect(src).toContain('class="card')
expect(src).toContain('btn btn-secondary')
expect(src).toContain('class="tab')
expect(src).toContain('tab-active')
expect(src).toContain('badge badge-warning')
// Compact single-row toolbar
expect(src).toContain('monitor-toolbar')
expect(src).toContain('clearFilters')
expect(src).toContain('healthModeOptions')
expect(src).toContain("'cache'")
// Ops elevation: rounded-3xl + ring surfaces
expect(src).toContain('rounded-3xl')
expect(src).toContain('ring-1 ring-gray-900/5')
// Overview-first KPI strip before primary viz
expect(src.indexOf('summaryAria')).toBeLessThan(src.indexOf('MonitorTrendChart'))
// No page-level fixed min-width that forces viewport horizontal scroll
expect(src).not.toMatch(/min-width:\s*980px/)
expect(src).not.toMatch(/min-w-\[980px\]/)
// Dense tables scroll internally
expect(src).toMatch(/max-h-\[min\(52vh/)
expect(src).toContain('overflow-auto')
// Trend view toggle (pulse matrix / line chart) + default platform/group dimension
expect(src).toContain("trendView")
expect(src).toContain("'platform_group'")
expect(src).toContain('MonitorTrendChart')
})
it('RelayPulseMatrix uses card chrome, matrix scroll, and hover tooltips (no click modal)', () => {
const src = read('features/channel-monitor-v2/RelayPulseMatrix.vue')
expect(src).toContain('class="card')
expect(src).toContain('card-header')
expect(src).toContain('card-body')
expect(src).toContain('matrix-scroll')
expect(src).toMatch(/max-h-\[min\(42vh/)
expect(src).toContain('overflow-auto')
expect(src).toContain('pulse-tooltip')
expect(src).toContain('rounded-3xl')
expect(src).toContain('ring-1 ring-gray-900/5')
expect(src).not.toContain('modal-overlay')
expect(src).not.toContain('modal-content')
})
it('MetricCell uses stat-card utility', () => {
const src = read('features/channel-monitor-v2/MetricCell.vue')
expect(src).toContain('stat-card')
expect(src).toContain('stat-label')
expect(src).toContain('stat-value')
expect(src).toContain('rounded-3xl')
})
it('MonitorTrendChart uses Ops chart shell tokens', () => {
const src = read('features/channel-monitor-v2/MonitorTrendChart.vue')
expect(src).toContain('class="card')
expect(src).toContain('rounded-3xl')
expect(src).toContain('ring-1 ring-gray-900/5')
expect(src).toContain('EmptyState')
expect(src).toContain('min-h-[360px]')
})
it('FilterMultiSelect uses rounded-xl input chrome and dropdown utility', () => {
const src = read('features/channel-monitor-v2/FilterMultiSelect.vue')
expect(src).toContain('rounded-xl')
expect(src).toContain('dropdown')
expect(src).toContain('dropdown-item')
})
it('MonitorSettingsPanel uses page-header, card, btn-primary, tabs', () => {
const src = read('features/channel-monitor-v2/MonitorSettingsPanel.vue')
expect(src).toContain('page-header')
expect(src).toContain('btn btn-primary')
expect(src).toContain('class="card')
expect(src).toContain('tab-active')
expect(src).toMatch(/max-h-\[min\(40vh/)
})
it('admin ChannelMonitorView V2 tab chrome uses project tabs', () => {
const src = read('views/admin/ChannelMonitorView.vue')
expect(src).toContain('page-header')
expect(src).toContain('page-title')
expect(src).toContain('class="tabs')
expect(src).toContain('tab-active')
expect(src).toContain('MonitorSettingsPanel')
})
})
@@ -0,0 +1,119 @@
import { describe, expect, it } from 'vitest'
import {
formatLatencyKpiSecondary,
formatLatencyPrivacy,
formatMonitorMs,
formatMonitorNumber,
formatMonitorPercent,
formatMonitorRate,
formatMonitorSuccessRate,
formatMonitorSuccessRateFromError,
formatMonitorThroughput,
formatMonitorTokensPerSecond,
healthScoreClass,
healthStateClass,
scoreToBand,
tokensPerSecondFromTpm,
} from '../monitorFormat'
import type { MonitorHealth } from '@/api/channelMonitorV2'
describe('monitorFormat accuracy', () => {
it('converts backend TPM (per minute) to tokens/sec for display', () => {
expect(tokensPerSecondFromTpm(60)).toBe(1)
expect(tokensPerSecondFromTpm(6000)).toBe(100)
expect(formatMonitorTokensPerSecond(60)).toBe('1')
expect(formatMonitorTokensPerSecond(null)).toBe('0')
})
it('formats request/token counts with compact notation above threshold', () => {
expect(formatMonitorNumber(10)).toBe('10')
expect(formatMonitorNumber(9999)).toBe('9,999')
// compact at >= 10000
expect(formatMonitorNumber(10000)).toMatch(/1/)
expect(formatMonitorNumber(0)).toBe('0')
})
it('formats rates and percents from raw API fractions without hardcoding pretty values', () => {
expect(formatMonitorRate(12.56)).toBe('12.6')
expect(formatMonitorPercent(0.1)).toBe('10.0%')
expect(formatMonitorPercent(0.005)).toBe('0.50%')
// value < 0.01 uses 2 decimal places (includes exact 0)
expect(formatMonitorPercent(0)).toBe('0.00%')
expect(formatMonitorPercent(0.5)).toBe('50.0%')
})
it('formats RPM/TPM throughput with K/M compact notation', () => {
expect(formatMonitorThroughput(12.56)).toBe('12.6')
expect(formatMonitorThroughput(999)).toMatch(/999/)
expect(formatMonitorThroughput(1000)).toBe('1.0K')
expect(formatMonitorThroughput(1500)).toBe('1.5K')
expect(formatMonitorThroughput(1_000_000)).toBe('1.0M')
expect(formatMonitorThroughput(1_500_000)).toBe('1.5M')
expect(formatMonitorThroughput(0)).toBe('0')
})
it('formats latency ms/s boundaries from API fields', () => {
expect(formatMonitorMs(null)).toBe('-')
expect(formatMonitorMs(300)).toBe('300ms')
expect(formatMonitorMs(1500)).toBe('1.5s')
expect(formatMonitorMs(999)).toBe('999ms')
})
it('formats KPI secondary latency as AVG · P90 (P50 is primary)', () => {
expect(formatLatencyKpiSecondary(150, 400)).toBe('AVG 150ms · P90 400ms')
expect(formatLatencyKpiSecondary(1500, null, 2000)).toBe('AVG 1.5s · P95 2.0s')
expect(formatLatencyKpiSecondary(null, null)).toBe('-')
})
it('computes success rate from success_requests / request_count', () => {
expect(formatMonitorSuccessRate(9, 10)).toBe('90.0%')
expect(formatMonitorSuccessRate(0, 0)).toBe('-')
expect(formatMonitorSuccessRate(1, 3)).toBe('33.3%')
})
it('derives success rate from error_rate without absolute counts', () => {
expect(formatMonitorSuccessRateFromError(0.1)).toBe('90.0%')
expect(formatMonitorSuccessRateFromError(0)).toBe('100.0%')
})
it('maps continuous scores to multi-stop bands', () => {
expect(scoreToBand(100)).toBe('score10')
expect(scoreToBand(95)).toBe('score10')
expect(scoreToBand(80)).toBe('score8')
expect(scoreToBand(50)).toBe('score5')
expect(scoreToBand(0)).toBe('score0')
expect(scoreToBand(null)).toBe('unknown')
})
it('builds score-based health classes for matrix cells', () => {
const health: MonitorHealth = {
overall: 'warning',
error_rate: 'warning',
ttft: 'healthy',
cache: 'warning',
score: 52,
error_rate_score: 40,
ttft_score: 100,
cache_score: 50,
minimum_sample: 20,
}
expect(healthScoreClass(health, 'overall', 10)).toBe('health-score5')
expect(healthScoreClass(health, 'success', 10)).toBe('health-score4')
expect(healthScoreClass(health, 'ttft', 10)).toBe('health-score10')
expect(healthScoreClass(health, 'cache', 10)).toBe('health-score5')
// Redacted user payloads have request_count=0 but keep score fields.
expect(healthScoreClass(health, 'overall', 0)).toBe('health-score5')
expect(healthScoreClass({ ...health, score: null }, 'overall', 0)).toBe('health-unknown')
})
it('maps health states for status dots', () => {
expect(healthStateClass('healthy')).toBe('health-healthy')
expect(healthStateClass(undefined)).toBe('health-unknown')
})
it('formats privacy-safe latency lines with avg/p50/p90', () => {
expect(formatLatencyPrivacy(100, 250, 120, 300)).toBe('AVG 120ms · P50 100ms · P90 250ms')
expect(formatLatencyPrivacy(100, null, null, 300)).toBe('P50 100ms · P95 300ms')
expect(formatLatencyPrivacy(null, null)).toBe('-')
})
})
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import {
DEFAULT_ZOOM,
MIN_ZOOM_SPAN,
applyWheelZoom,
clampZoom,
isZoomed,
resetZoom,
sliceByZoom,
} from '../monitorZoom'
describe('monitorZoom', () => {
it('resets to full span', () => {
expect(resetZoom()).toEqual(DEFAULT_ZOOM)
expect(isZoomed(DEFAULT_ZOOM)).toBe(false)
})
it('zooms in around cursor without always pinning to the end', () => {
const mid = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0.5)
expect(mid.span).toBeLessThan(1)
expect(mid.span).toBeGreaterThanOrEqual(MIN_ZOOM_SPAN)
// Center zoom keeps window roughly centered
expect(mid.offset).toBeGreaterThan(0)
expect(mid.offset + mid.span).toBeLessThanOrEqual(1.001)
const left = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0)
expect(left.offset).toBeLessThanOrEqual(0.001)
const right = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 1)
expect(right.offset + right.span).toBeGreaterThan(0.99)
})
it('pans with shift+wheel', () => {
const zoomed = clampZoom({ span: 0.4, offset: 0.1 })
const panned = applyWheelZoom(zoomed, { deltaY: 200, deltaX: 0, shiftKey: true }, 0.5)
expect(panned.span).toBeCloseTo(zoomed.span, 5)
expect(panned.offset).toBeGreaterThan(zoomed.offset)
})
it('clamps span to MIN_ZOOM_SPAN and keeps window in range', () => {
const tight = clampZoom({ span: 0.01, offset: 0.9 })
expect(tight.span).toBe(MIN_ZOOM_SPAN)
expect(tight.offset + tight.span).toBeLessThanOrEqual(1.001)
})
it('slices series by window, not always last N points', () => {
const items = Array.from({ length: 100 }, (_, i) => i)
const startWindow = sliceByZoom(items, { span: 0.2, offset: 0 })
expect(startWindow[0]).toBe(0)
expect(startWindow.length).toBe(20)
const midWindow = sliceByZoom(items, { span: 0.2, offset: 0.4 })
expect(midWindow[0]).toBe(40)
expect(midWindow[midWindow.length - 1]).toBe(59)
const full = sliceByZoom(items, DEFAULT_ZOOM)
expect(full).toHaveLength(100)
})
})
@@ -0,0 +1,173 @@
/**
* Shared display formatters for channel-monitor-v2.
* Kept pure so unit tests can lock metric presentation accuracy.
*
* Privacy: prefer rates (error_rate, RPM, TPM, success %) over absolute
* request/error/token counts in user-facing surfaces.
*/
import type { HealthScoreBand, MonitorHealth } from '@/api/channelMonitorV2'
import { formatCompactNumber } from '@/utils/format'
export function monitorIntlLocale(): string {
if (typeof document !== 'undefined') {
const htmlLang = document.documentElement.getAttribute('lang')?.trim()
if (htmlLang) return htmlLang
}
if (typeof navigator !== 'undefined' && navigator.language) return navigator.language
return 'zh-CN'
}
export function formatMonitorNumber(value: number, compactAt = 10000, locale = monitorIntlLocale()): string {
return Intl.NumberFormat(locale, {
notation: value >= compactAt ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value || 0)
}
export function formatMonitorRate(value: number, locale = monitorIntlLocale()): string {
return Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value || 0)
}
/**
* RPM/TPM throughput display: always K/M compact (1.0K, 1.5M) for ops-readable density.
* Prefer this over formatMonitorRate for rate KPIs and table cells.
*/
export function formatMonitorThroughput(value: number | null | undefined): string {
if (value == null || Number.isNaN(Number(value))) return '0'
const n = Number(value)
if (Math.abs(n) < 1000) {
return Intl.NumberFormat(monitorIntlLocale(), { maximumFractionDigits: 1 }).format(n)
}
return formatCompactNumber(n)
}
/**
* Backend stores tokens-per-minute as `tpm`. Convert to tokens-per-second for display.
*/
export function tokensPerSecondFromTpm(tpm: number | null | undefined): number {
if (tpm == null || Number.isNaN(Number(tpm))) return 0
return Number(tpm) / 60
}
/** Tokens/sec from backend TPM (per-minute), compact ops formatting. */
export function formatMonitorTokensPerSecond(tpm: number | null | undefined): string {
return formatMonitorThroughput(tokensPerSecondFromTpm(tpm))
}
export function formatMonitorPercent(value: number, locale = monitorIntlLocale()): string {
return `${new Intl.NumberFormat(locale, {
minimumFractionDigits: value < 0.01 ? 2 : 1,
maximumFractionDigits: value < 0.01 ? 2 : 1,
}).format((value || 0) * 100)}%`
}
export function formatMonitorMs(value: number | null | undefined): string {
if (value == null) return '-'
return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${Math.round(value)}ms`
}
export function formatMonitorSuccessRate(successRequests: number, requestCount: number): string {
if (!requestCount) return '-'
return formatMonitorPercent(successRequests / requestCount)
}
export function formatMonitorSuccessRateFromError(errorRate: number): string {
return formatMonitorPercent(1 - (errorRate || 0))
}
/**
* Map continuous 0–100 score to 11 fine bands for multi-stop green→yellow→red.
* score10 = best (green), score0 = worst (red).
*/
export function scoreToBand(score: number | null | undefined): HealthScoreBand {
if (score == null || Number.isNaN(score)) return 'unknown'
const clamped = Math.max(0, Math.min(100, score))
// 0–100 → score0..score10 (11 stops for green→yellow→red gradient)
const band = Math.round(clamped / 10)
return `score${Math.max(0, Math.min(10, band))}` as HealthScoreBand
}
export type HealthDisplayMode = 'overall' | 'success' | 'ttft' | 'cache'
/** Resolve the score used for a health mode. */
export function healthModeScore(
health: MonitorHealth,
mode: HealthDisplayMode,
): number | null {
if (mode === 'success') {
return health.error_rate_score ?? null
}
if (mode === 'ttft') {
return health.ttft_score ?? null
}
if (mode === 'cache') {
return health.cache_score ?? null
}
return health.score ?? null
}
export function healthScoreClass(
health: MonitorHealth,
mode: HealthDisplayMode,
requestCount: number,
): string {
const score = healthModeScore(health, mode)
if (score == null) {
if (requestCount <= 0) return 'health-unknown'
// Fall back to coarse state when score is absent (older payloads).
const coarse =
mode === 'success'
? health.error_rate
: mode === 'ttft'
? health.ttft
: mode === 'cache'
? health.cache
: health.overall
return healthStateClass(coarse)
}
return `health-${scoreToBand(score)}`
}
export function healthStateClass(state: string | undefined): string {
return `health-${state || 'unknown'}`
}
/** Privacy-safe latency summary: avg + p50 + p90 (no absolute sample counts). */
export function formatLatencyPrivacy(
p50: number | null | undefined,
p90: number | null | undefined,
avg?: number | null | undefined,
p95?: number | null | undefined,
): string {
const parts: string[] = []
if (avg != null) parts.push(`AVG ${formatMonitorMs(avg)}`)
if (p50 != null) parts.push(`P50 ${formatMonitorMs(p50)}`)
if (p90 != null) parts.push(`P90 ${formatMonitorMs(p90)}`)
// p95 only as fallback when p90 missing (older payloads)
if (p90 == null && p95 != null) parts.push(`P95 ${formatMonitorMs(p95)}`)
return parts.length ? parts.join(' · ') : '-'
}
/**
* KPI secondary line for latency: AVG + P90 only (P50 is the primary value).
* Falls back to P95 when P90 is absent. Delimiter is " · " so MetricCell can
* split into non-truncated chips.
*/
export function formatLatencyKpiSecondary(
avg?: number | null | undefined,
p90?: number | null | undefined,
p95?: number | null | undefined,
): string {
const parts: string[] = []
if (avg != null && Number.isFinite(avg)) parts.push(`AVG ${formatMonitorMs(avg)}`)
if (p90 != null && Number.isFinite(p90)) parts.push(`P90 ${formatMonitorMs(p90)}`)
else if (p95 != null && Number.isFinite(p95)) parts.push(`P95 ${formatMonitorMs(p95)}`)
return parts.length ? parts.join(' · ') : '-'
}
/** Fallback when i18n key is missing; prefer `channelMonitorV2.errorCategories.*`. */
export function monitorErrorCategoryLabel(category: string): string {
return category
}
@@ -0,0 +1,103 @@
/**
* Pure X-axis zoom/pan for channel-monitor pulse matrix and line chart.
* No chartjs-plugin-zoom dependency — state is span + offset fractions.
*/
export interface ZoomState {
/** Visible fraction of the full series (1 = full range). */
span: number
/** Start of the visible window as a fraction of the full domain [0, 1 - span]. */
offset: number
}
export const DEFAULT_ZOOM: ZoomState = { span: 1, offset: 0 }
export const MIN_ZOOM_SPAN = 0.12
const ZOOM_STEP = 0.12
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value))
}
export function resetZoom(): ZoomState {
return { ...DEFAULT_ZOOM }
}
export function isZoomed(state: ZoomState): boolean {
return state.span < 0.999 || state.offset > 0.001
}
/** Normalize offset so the visible window stays within [0, 1]. */
export function clampZoom(state: ZoomState): ZoomState {
const span = clamp(state.span, MIN_ZOOM_SPAN, 1)
const maxOffset = Math.max(0, 1 - span)
return {
span,
offset: clamp(state.offset, 0, maxOffset),
}
}
/**
* Wheel zoom around cursor xRatio (0–1 within the chart/matrix track).
* Shift+wheel or horizontal delta pans offset instead.
*/
export function applyWheelZoom(
state: ZoomState,
event: Pick<WheelEvent, 'deltaY' | 'deltaX' | 'shiftKey'>,
xRatio: number,
): ZoomState {
const ratio = clamp(Number.isFinite(xRatio) ? xRatio : 0.5, 0, 1)
const isPan = event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY)
if (isPan) {
if (state.span >= 1) return clampZoom(state)
const delta = event.shiftKey ? event.deltaY : event.deltaX
// Positive delta → pan right (increase offset)
const panFraction = (delta / 400) * state.span
return clampZoom({
span: state.span,
offset: state.offset + panFraction,
})
}
// Zoom in (deltaY < 0) / out (deltaY > 0) around cursor
const direction = event.deltaY < 0 ? -1 : event.deltaY > 0 ? 1 : 0
if (direction === 0) return clampZoom(state)
const nextSpan = clamp(state.span * (1 + direction * ZOOM_STEP), MIN_ZOOM_SPAN, 1)
if (Math.abs(nextSpan - state.span) < 1e-9) return clampZoom(state)
// Keep the point under the cursor fixed in data space.
// windowStart is offset (domain fraction); dataPos = windowStart + ratio * span
const windowStart = state.offset
const dataPos = windowStart + ratio * state.span
const nextStart = dataPos - ratio * nextSpan
return clampZoom({
span: nextSpan,
offset: nextStart,
})
}
/**
* Slice an array using zoom state. Cursor-centered windowing — not always "last N".
*/
export function sliceByZoom<T>(items: readonly T[], state: ZoomState): T[] {
if (!items.length) return []
const zoom = clampZoom(state)
if (zoom.span >= 0.999) return [...items]
const count = Math.max(1, Math.ceil(items.length * zoom.span))
const maxStart = Math.max(0, items.length - count)
// offset is window start in [0, 1-span] domain → map to index
const start = Math.min(maxStart, Math.max(0, Math.round(zoom.offset * items.length)))
return items.slice(start, start + count)
}
/** Map a client X position within an element to 0–1 ratio. */
export function clientXRatio(clientX: number, element: HTMLElement | null | undefined): number {
if (!element) return 0.5
const rect = element.getBoundingClientRect()
if (rect.width <= 0) return 0.5
return clamp((clientX - rect.left) / rect.width, 0, 1)
}
+12 -3
View File
@@ -16,12 +16,21 @@ export default {
features: {
channelMonitor: {
title: 'Channel Monitor',
description: 'Periodically probe configured channels and surface availability / latency to users. Turning it off stops the scheduler and returns an empty list on the user page.',
description: 'Choose either V1 active probes or V2 passive usage monitoring. When disabled, both background jobs stop and the user entry is hidden.',
configureLink: 'Configure monitors in Channel Management > Channel Monitor',
enabled: 'Enable Channel Monitor',
enabledHint: 'Disabling stops background checks; existing history is preserved.',
enabledHint: 'Disabling stops both the V1 scheduler and V2 aggregation; existing config and history are kept.',
mode: 'Monitor mode',
modeHint: 'Default is V1 (active probes). Switch to V2 only when you want passive aggregation; only one implementation can be active at a time.',
modeV2: 'V2 passive monitoring',
modeV1: 'V1 active probes',
modeV2Hint: 'Opt-in: aggregates health metrics from real gateway traffic without upstream probe traffic. V1 probes stop while V2 is active.',
modeV1Hint: 'Default: runs scheduled upstream health checks for configured channel monitors (probe traffic).',
defaultInterval: 'Default check interval (seconds)',
defaultIntervalHint: 'Pre-fills the interval when creating a new monitor; each monitor can override it. Range 15 – 3600.',
defaultIntervalHint: 'V1 only: default interval for new monitors (overridable per monitor). Range 15 – 3600 seconds.',
hideThroughput: 'Hide throughput rates from users (RPM / TPM)',
hideThroughputHint:
'When on, the user Channel Monitor page and user APIs omit RPM and TPM so fleet volume cannot be reverse-estimated from rates × window. Admins still see full metrics. Error rates, latency, and cache rates remain visible.',
},
availableChannels: {
title: 'Available Channels',
@@ -0,0 +1,147 @@
/** Channel Monitor V2 (user + admin passive monitor UI) */
export default {
channelMonitorV2: {
title: 'Channel Monitor',
updating: 'Updating data',
updatedTo: 'Updated to {time}',
partialCoverage: 'Partial historical coverage',
bootstrap: {
title: 'Building historical monitor data',
description:
'On first enable, passive aggregation silently fills the 90m, 24h, 7d, and 30d windows in the background. All ranges become complete once this finishes.',
progress: '{percent}% complete',
working: 'Aggregating in the background…',
},
timeRange: 'Time range',
clearFilters: 'Reset',
refreshingFilters: 'Filters changed; refreshing matrix, trend, and details…',
switchingData: 'Switching filtered data…',
summaryAria: 'Selected range summary',
loadFailed: 'Failed to load channel monitor',
detailLoadFailed: 'Failed to load channel monitor details',
otherModels: 'Other models',
ignored: 'Ignored',
currentUser: 'Current user',
ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' },
filters: {
platform: 'Platform', allPlatforms: 'All', group: 'Group', allGroups: 'All', model: 'Model', allModels: 'All',
empty: 'No options', selectedCount: '{count}', labelValue: '{label}: {value}'
},
groupBy: {
label: 'Group by', platform: 'Platform', platformGroup: 'Platform / Group', platformModel: 'Platform / Model', platformGroupModel: 'Platform / Group / Model'
},
trendView: { label: 'Trend view', pulse: 'Pulse matrix', line: 'Line chart' },
healthMode: { label: 'Health display', overall: 'Overall', success: 'Error rate', ttft: 'First token', cache: 'Cache rate' },
tabs: { aria: 'Detail dimension', models: 'Models', errors: 'Error reasons', users: 'User ranking' },
metrics: {
rpm: 'RPM',
tpm: 'TPM',
tps: 'Tokens/s',
rpmDetail: 'Requests per minute',
tpmDetail: 'Tokens per minute',
tpsDetail: 'Derived as TPM ÷ 60',
errorRate: 'Error rate',
ttft: 'First token',
ttftP50: 'First token P50',
durationP50: 'Duration P50',
cacheRate: 'Cache rate',
cacheDetail: 'Read cache share',
successRate: 'Success rate',
successRateValue: 'Success rate {value}',
errorRateValue: 'Error rate {value}',
rpmValue: 'RPM {value}',
tpmValue: 'TPM {value}',
tpsValue: 'Tokens/s {value}',
ttftValue: 'First token {value}',
durationValue: 'Duration {value}',
cacheRateValue: 'Cache rate {value}',
},
table: { platformModel: 'Platform / Model', rank: 'Rank', user: 'User' },
empty: { title: 'No data to display', description: 'Try changing the time range or filters' },
bucket: { minutes: '{count}-minute buckets', hours: '{count}-hour buckets', days: '{count}-day buckets' },
matrix: {
title: 'Availability trend', description: 'Each row is a channel dimension and each block is an aggregate interval; hover for details', wheelZoom: 'Scroll over blocks to zoom in (narrower range, wider blocks)', wheelZoomX: 'Scroll over blocks to zoom in (narrower range, wider blocks)', dimension: 'Channel dimension', emptyTitle: 'No matrix data for the selected window', legendAria: 'Health score legend', bad: 'Bad', good: 'Good', healthyLegend: 'Healthy (≥80)', warningLegend: 'Watch (50–79)', criticalLegend: 'Critical (<50)', unknownLegend: 'No traffic / insufficient samples', noTraffic: 'No traffic in this interval', noTrafficAt: '{time} · no traffic', scoreLine: 'Health score {score}', resetZoom: 'Reset zoom'
},
chart: {
title: 'Availability trend', description: 'Smoothed trend: error rate · first token P50 · cache rate', emptyTitle: 'No trend data for the selected window', errorLegend: 'Error rate (left axis %)', cacheLegend: 'Cache rate (left axis %)', ttftLegend: 'First token P50 (right axis)', errorDataset: 'Error rate trend %', cacheDataset: 'Cache rate trend %', ttftDataset: 'First token trend P50 (ms)', percentAxis: 'Rate %', resetZoom: 'Reset zoom'
},
errorDetail: { http: 'HTTP {code}', upstream: 'Upstream {code}', noMessage: 'No error message', empty: 'Category rates only (sample messages are admin-only)' },
errorCategories: {
content_policy: 'Content policy', authentication: 'Authentication', context_limit: 'Context limit', invalid_request: 'Invalid request', model_unsupported: 'Unsupported model', group_access: 'Group access', quota_or_balance: 'Quota or balance', account_pool_unavailable: 'Account pool unavailable', rate_or_capacity: 'Rate or capacity', timeout: 'Timeout', transport_or_stream: 'Transport or stream', upstream_forbidden: 'Upstream forbidden', not_found: 'Not found', client_cancelled: 'Client cancelled', upstream_5xx: 'Upstream 5xx', internal: 'Internal', other: 'Other'
},
rank: {
gold: 'Rank 1 gold',
silver: 'Rank 2 silver',
bronze: 'Rank 3 bronze',
place: 'Rank {n}',
unranked: 'Unranked',
},
settings: {
title: 'V2 data monitor config',
description:
'Configure passive usage aggregation dimensions (platform / model / group) and refresh cadence. Health colors and details on the user /monitor page show rates, RPM, and TPM — not absolute request volume.',
save: 'Save',
loading: 'Loading…',
loadFailed: 'Failed to load V2 config',
saveSuccess: 'V2 monitor config saved',
saveFailed: 'Failed to save V2 config',
modeBanner:
'System mode is currently {mode}. V2 minute aggregation will not run; this config can be prepared now and takes effect after switching to {modeV2}. Change mode under System Settings → Feature switches.',
modeClosed: 'Channel monitor disabled',
modeV1: 'V1 active probes',
modeV2: 'V2 passive monitoring',
enableTitle: 'Enable V2 aggregation',
enableHint:
'Applies when system mode is V2. Turning this off only stops this config’s aggregation; the system mode switch remains under Feature switches.',
refreshTitle: 'Aggregation interval',
refreshHint: 'Affects matrix time granularity and refresh cadence',
refreshAria: 'Aggregation interval',
platformsTitle: 'Platforms and models',
platformsHint:
'Leave empty = show all real model names; when filled, only listed models get their own rows and the rest roll into “Other”',
modelsPlaceholder: 'Empty = all real models; or list popular models (rest → Other)',
badgeAllModels: 'All models',
badgeOther: '+ Other',
groupsTitle: 'Monitored groups',
groupsSelected: '{count} groups selected',
groupsAll: 'All groups',
groupsEmpty: 'No groups available',
errorsTitle: 'Error categories and ignores',
errorsHint:
'Checked “ignore” categories are excluded from error rate and health score, but still appear greyed in the error breakdown. Unmatched errors roll into “Other”.',
ignoredSummary: 'Ignored {ignored} categories · counted in error rate {counted} categories',
healthTitle: 'Health thresholds',
healthHint:
'Controls user-facing color bands and overall score. Defaults are tolerant so small error rates or low cache do not immediately show as unhealthy.',
fields: {
minimumSample: 'Minimum samples',
warningError: 'Error rate watch %',
criticalError: 'Error rate critical %',
targetTtft: 'TTFT target ms',
warningTtft: 'TTFT watch ms',
criticalTtft: 'TTFT critical ms',
warningCache: 'Cache rate watch %',
criticalCache: 'Cache rate critical %',
},
namedModelsEmpty: 'Platform model lists are empty: every real model name will be shown (not folded into “Other”).',
namedModelsCount: 'Showing {count} named model dimensions; unlisted models fold into per-platform “Other”.',
userContractTitle: 'User-facing display contract',
userContract: {
health: 'Health color weights: error rate 60% + first-token P50 20% + cache rate 20% (thresholds configurable above)',
trend: 'Trend can switch between pulse matrix and line chart (error · cache · first token)',
latency: 'Latency shows AVG · P50 · P90; absolute request / error counts are not shown',
models: 'Empty model lists show real names and never dump everything into “Other”',
},
},
admin: {
descriptionV1:
'System mode is V1 active probes: manage probe monitors and run checks now; V2 aggregation does not run.',
descriptionV2:
'System mode is V2 passive monitoring: configure aggregation dimensions; V1 active probes do not run.',
tabAria: 'Monitor management',
tabV2: 'V2 data monitor config',
tabV1Active: 'V1 active probes',
tabV1History: 'V1 history (probes not active in current mode)',
},
},
}
+2
View File
@@ -1,6 +1,7 @@
import landing from './landing'
import common from './common'
import dashboard from './dashboard'
import channelMonitorV2 from './channelMonitorV2'
import batchImage from './batchImage'
import admin from './admin'
import misc from './misc'
@@ -9,6 +10,7 @@ export default {
...landing,
...common,
...dashboard,
...channelMonitorV2,
...batchImage,
admin,
...misc,
+12 -3
View File
@@ -16,12 +16,21 @@ export default {
features: {
channelMonitor: {
title: '渠道监控',
description: '定期对配置的渠道发起健康检查,向用户展示可用性与延迟。关闭后调度器停止扫描,用户端列表为空。',
description: '启用后在 V1 主动探测与 V2 被动用量监控中二选一。关闭后两种模式的后台任务均停止,用户端入口隐藏。',
configureLink: '前往 渠道管理 > 渠道监控 配置监控项',
enabled: '启用渠道监控',
enabledHint: '关闭后后台不再执行定时检测,已有数据保留。',
enabledHint: '关闭后 V1 调度器与 V2 聚合均停止;已有配置与历史保留。',
mode: '监控模式',
modeHint: '默认 V1(主动探测)。仅在需要被动聚合时切换到 V2;同一时间只能启用一种实现。',
modeV2: 'V2 被动监控',
modeV1: 'V1 主动探测',
modeV2Hint: '需主动选择:基于真实网关流量聚合健康指标,不向上游发送探活请求;启用期间 V1 探测停止。',
modeV1Hint: '默认模式:按配置的渠道监控项定时发起上游健康检查(产生探测流量)。',
defaultInterval: '默认检测间隔(秒)',
defaultIntervalHint: '新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。',
defaultIntervalHint: '仅 V1 模式使用:新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。',
hideThroughput: '对用户隐藏吞吐速率(RPM / TPM)',
hideThroughputHint:
'开启后,用户端渠道监控页面与用户 API 不返回 RPM/TPM,避免用「速率 × 时间窗」反推集群规模。管理员仍可见完整指标;错误率、延迟、缓存率照常展示。',
},
availableChannels: {
title: '可用渠道',
@@ -0,0 +1,142 @@
/** Channel Monitor V2 (user + admin passive monitor UI) */
export default {
channelMonitorV2: {
title: '渠道监控',
updating: '正在更新数据',
updatedTo: '更新至 {time}',
partialCoverage: '部分历史覆盖',
bootstrap: {
title: '正在补齐历史监控数据',
description:
'首次启用被动监控时,系统会在后台静默聚合 90 分钟、24 小时、7 天与 30 天窗口;完成后可切换全部时间范围。',
progress: '进度 {percent}%',
working: '后台聚合中…',
},
timeRange: '时间范围',
clearFilters: '重置',
refreshingFilters: '筛选条件已变化,正在刷新矩阵、趋势和明细…',
switchingData: '正在切换筛选数据…',
summaryAria: '筛选范围整体汇总',
loadFailed: '渠道监控加载失败',
detailLoadFailed: '渠道监控明细加载失败',
otherModels: '其他模型',
ignored: '忽略',
currentUser: '当前用户',
ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' },
filters: {
platform: '平台', allPlatforms: '全部', group: '分组', allGroups: '全部', model: '模型', allModels: '全部',
empty: '暂无可选项', selectedCount: '{count} 项', labelValue: '{label}:{value}'
},
groupBy: {
label: '展示维度', platform: '平台', platformGroup: '平台 / 分组', platformModel: '平台 / 模型', platformGroupModel: '平台 / 分组 / 模型'
},
trendView: { label: '趋势视图', pulse: '色块矩阵', line: '折线图' },
healthMode: { label: '健康显示', overall: '综合', success: '错误率', ttft: '首 Token', cache: '缓存率' },
tabs: { aria: '明细维度', models: '模型', errors: '错误原因', users: '用户排行' },
metrics: {
rpm: 'RPM',
tpm: 'TPM',
tps: '每秒 Token',
rpmDetail: '每分钟请求数',
tpmDetail: '每分钟 Token 数',
tpsDetail: '由 TPM ÷ 60 换算',
errorRate: '错误率',
ttft: '首 Token',
ttftP50: '首 Token P50',
durationP50: '请求时长 P50',
cacheRate: '缓存率',
cacheDetail: '读缓存占比',
successRate: '成功率',
successRateValue: '成功率 {value}',
errorRateValue: '错误率 {value}',
rpmValue: 'RPM {value}',
tpmValue: 'TPM {value}',
tpsValue: '每秒 Token {value}',
ttftValue: '首 Token {value}',
durationValue: '请求时长 {value}',
cacheRateValue: '缓存率 {value}',
},
table: { platformModel: '平台 / 模型', rank: '排名', user: '用户' },
empty: { title: '没有可展示的数据', description: '尝试调整时间范围或筛选条件' },
bucket: { minutes: '{count} 分钟粒度', hours: '{count} 小时粒度', days: '{count} 天粒度' },
matrix: {
title: '可用性趋势', description: '每行是一种渠道组合,每个色块代表一个统计区间;悬停查看明细', wheelZoom: '在色块上滚轮放大(区间变窄、色块变宽)', wheelZoomX: '在色块上滚轮放大(区间变窄、色块变宽)', dimension: '渠道维度', emptyTitle: '当前筛选窗口没有矩阵数据', legendAria: '健康分数图例', bad: '差', good: '好', healthyLegend: '健康 (≥80)', warningLegend: '需关注 (50–79)', criticalLegend: '异常 (<50)', unknownLegend: '无流量 / 样本不足', noTraffic: '该区间无流量', noTrafficAt: '{time} · 无流量', scoreLine: '健康分 {score}', resetZoom: '重置缩放'
},
chart: {
title: '可用性趋势', description: '平滑趋势:错误率 · 首 Token P50 · 缓存率', emptyTitle: '当前筛选窗口没有趋势数据', errorLegend: '错误率(左轴 %)', cacheLegend: '缓存率(左轴 %)', ttftLegend: '首 Token P50(右轴)', errorDataset: '错误率趋势 %', cacheDataset: '缓存率趋势 %', ttftDataset: '首 Token 趋势 P50 (ms)', percentAxis: '比率 %', resetZoom: '重置缩放'
},
errorDetail: { http: 'HTTP {code}', upstream: '上游 {code}', noMessage: '无错误消息', empty: '仅展示分类占比(样本消息仅管理员可见)' },
errorCategories: {
content_policy: '内容策略', authentication: '认证失败', context_limit: '上下文超限', invalid_request: '请求格式', model_unsupported: '模型不支持', group_access: '分组权限', quota_or_balance: '额度或余额', account_pool_unavailable: '账号池不可用', rate_or_capacity: '限流或容量', timeout: '超时', transport_or_stream: '传输或流', upstream_forbidden: '上游拒绝', not_found: '资源不存在', client_cancelled: '客户端取消', upstream_5xx: '上游 5xx', internal: '内部错误', other: '其他'
},
rank: {
gold: '第 1 名 金',
silver: '第 2 名 银',
bronze: '第 3 名 铜',
place: '第 {n} 名',
unranked: '未上榜',
},
settings: {
title: 'V2 数据监控配置',
description:
'配置被动用量汇总维度(平台 / 模型 / 分组)与刷新频率。健康色与明细在用户端 /monitor 以比例、RPM/TPM 展示,不暴露绝对请求量。',
save: '保存',
loading: '加载中...',
loadFailed: 'V2 配置加载失败',
saveSuccess: 'V2 监控配置已保存',
saveFailed: 'V2 配置保存失败',
modeBanner:
'当前系统设置为 {mode}。V2 分钟聚合不会运行;此处配置可预先保存,切换到 {modeV2} 后立即生效。可在系统设置 → 功能开关调整。',
modeClosed: '渠道监控已关闭',
modeV1: 'V1 主动探测',
modeV2: 'V2 被动监控',
enableTitle: '启用 V2 汇总',
enableHint: '在系统模式为 V2 时生效;关闭后仅停止本配置的汇总,系统模式开关仍在「功能开关」',
refreshTitle: '汇总频率',
refreshHint: '影响矩阵时间粒度与刷新节奏',
refreshAria: '汇总频率',
platformsTitle: '平台与模型',
platformsHint: '留空 = 展示全部真实模型名;填写后仅名单内单独成行,其余归入「其他」',
modelsPlaceholder: '留空=全部真实模型;或填写主流模型名单(其余归其他)',
badgeAllModels: '全部模型',
badgeOther: '+ 其他',
groupsTitle: '监控分组',
groupsSelected: '已选择 {count} 个分组',
groupsAll: '全部分组',
groupsEmpty: '没有可选择的分组',
errorsTitle: '错误分类与忽略',
errorsHint:
'勾选「忽略」的类别不计入错误率与健康分,仍在错误原因列表中以灰色显示并标记忽略。未匹配的错误归入「其他」。',
ignoredSummary: '已忽略 {ignored} 类 · 计入错误率 {counted} 类',
healthTitle: '健康阈值',
healthHint: '控制用户端色块和整体评分。默认阈值较宽松,避免少量错误或低缓存率立即显示异常。',
fields: {
minimumSample: '最小样本数',
warningError: '错误率关注 %',
criticalError: '错误率异常 %',
targetTtft: 'TTFT 目标 ms',
warningTtft: 'TTFT 关注 ms',
criticalTtft: 'TTFT 异常 ms',
warningCache: '缓存率关注 %',
criticalCache: '缓存率异常 %',
},
namedModelsEmpty: '各平台模型列表为空:将展示全部真实模型名(不归入「其他」)。',
namedModelsCount: '将展示 {count} 个命名模型维度;名单外模型归入各平台「其他」。',
userContractTitle: '用户端展示约定',
userContract: {
health: '健康色三指标:错误率 60% + 首 Token P50 20% + 缓存率 20%(阈值可在上方配置)',
trend: '趋势可切换色块矩阵 / 折线图(错误率 · 缓存率 · 首 Token)',
latency: '延迟展示 AVG · P50 · P90;不展示绝对请求数 / 错误数',
models: '模型列表留空时展示真实模型名,不会全部归入「其他」',
},
},
admin: {
descriptionV1: '当前系统设置为 V1 主动探测:可管理监控项并立即检测;V2 聚合不会运行。',
descriptionV2: '当前系统设置为 V2 被动监控:配置聚合维度;V1 主动探测不会运行。',
tabAria: '监控管理',
tabV2: 'V2 数据监控配置',
tabV1Active: 'V1 主动探测',
tabV1History: 'V1 历史(当前模式未启用探测)',
},
},
}
+2
View File
@@ -1,6 +1,7 @@
import landing from './landing'
import common from './common'
import dashboard from './dashboard'
import channelMonitorV2 from './channelMonitorV2'
import batchImage from './batchImage'
import admin from './admin'
import misc from './misc'
@@ -9,6 +10,7 @@ export default {
...landing,
...common,
...dashboard,
...channelMonitorV2,
...batchImage,
admin,
...misc,
+4
View File
@@ -262,7 +262,11 @@ export interface PublicSettings {
account_quota_notify_enabled: boolean
balance_low_notify_threshold: number
channel_monitor_enabled: boolean
/** Exclusive mode: v1 active probes or v2 passive aggregation. Default v2. */
channel_monitor_mode?: 'v1' | 'v2'
channel_monitor_default_interval_seconds: number
/** When true, user monitor hides RPM/TPM so scale cannot be reverse-estimated. */
channel_monitor_hide_throughput?: boolean
available_channels_enabled: boolean
model_plaza_enabled: boolean
model_plaza_require_auth: boolean
+35 -1
View File
@@ -68,9 +68,9 @@
* `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with
* `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry.
*/
import { useAppStore } from '@/stores/app'
import type { PublicSettings } from '@/types'
import { DEFAULT_INTERVAL_SECONDS } from '@/constants/channelMonitor'
export type FeatureFlagMode = 'opt-in' | 'opt-out'
@@ -152,3 +152,37 @@ export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean {
export function makeSidebarFlag(flag: FeatureFlagDefinition): () => boolean {
return () => isFeatureFlagEnabled(flag)
}
/** True when channel monitor feature flag is enabled. */
export function isChannelMonitorRouteEnabled(): boolean {
return isFeatureFlagEnabled(FeatureFlags.channelMonitor)
}
export type ChannelMonitorMode = 'v1' | 'v2'
/** Exclusive channel-monitor implementation. Invalid/missing → v1 (opt-in to v2). */
export function getChannelMonitorMode(): ChannelMonitorMode {
const appStore = useAppStore()
const mode = appStore.cachedPublicSettings?.channel_monitor_mode
return mode === 'v2' ? 'v2' : 'v1'
}
export function isChannelMonitorV1Mode(): boolean {
return isChannelMonitorRouteEnabled() && getChannelMonitorMode() === 'v1'
}
export function isChannelMonitorV2Mode(): boolean {
return isChannelMonitorRouteEnabled() && getChannelMonitorMode() === 'v2'
}
export function getChannelMonitorRefreshIntervalSeconds(): number {
const appStore = useAppStore()
const configured = appStore.cachedPublicSettings?.channel_monitor_default_interval_seconds
return configured && configured > 0 ? configured : DEFAULT_INTERVAL_SECONDS
}
/** Hide RPM/TPM on user-facing monitor (scale privacy). Admin always shows full metrics. */
export function isChannelMonitorThroughputHidden(): boolean {
const appStore = useAppStore()
return Boolean(appStore.cachedPublicSettings?.channel_monitor_hide_throughput)
}
@@ -1,6 +1,55 @@
<template>
<AppLayout>
<TablePageLayout>
<div class="w-full min-w-0 space-y-6 pb-8">
<header
class="page-header mb-0 rounded-3xl bg-white p-5 shadow-sm ring-1 ring-gray-900/5 dark:bg-dark-800 dark:ring-dark-700 sm:p-6"
>
<h1 class="page-title flex items-center gap-2 text-xl font-black text-gray-900 dark:text-white">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-400">
<Icon name="chart" size="sm" />
</span>
{{ t('admin.channelMonitor.title') }}
</h1>
<p class="page-description mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
isV1Mode
? t('channelMonitorV2.admin.descriptionV1')
: t('channelMonitorV2.admin.descriptionV2')
}}
</p>
<div class="mt-4 border-t border-gray-100 pt-4 dark:border-dark-700">
<div
class="tabs inline-flex w-full max-w-xl flex-wrap sm:w-auto"
role="tablist"
:aria-label="t('channelMonitorV2.admin.tabAria')"
>
<button
type="button"
role="tab"
class="tab flex-1 sm:flex-none"
:class="adminMonitorTab === 'v2' ? 'tab-active' : ''"
:aria-selected="adminMonitorTab === 'v2'"
@click="adminMonitorTab = 'v2'"
>
{{ t('channelMonitorV2.admin.tabV2') }}
</button>
<button
type="button"
role="tab"
class="tab flex-1 sm:flex-none"
:class="adminMonitorTab === 'legacy' ? 'tab-active' : ''"
:aria-selected="adminMonitorTab === 'legacy'"
@click="adminMonitorTab = 'legacy'"
>
{{ isV1Mode ? t('channelMonitorV2.admin.tabV1Active') : t('channelMonitorV2.admin.tabV1History') }}
</button>
</div>
</div>
</header>
<MonitorSettingsPanel v-if="adminMonitorTab === 'v2'" />
<TablePageLayout v-else>
<template #filters>
<MonitorFiltersBar
v-model:search="searchQuery"
@@ -80,7 +129,8 @@
@update:pageSize="onPageSizeChange"
/>
</template>
</TablePageLayout>
</TablePageLayout>
</div>
<MonitorFormDialog
:show="showDialog"
@@ -115,7 +165,7 @@
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
@@ -144,9 +194,13 @@ import MonitorPrimaryModelCell from '@/components/admin/monitor/MonitorPrimaryMo
import MonitorActionsCell from '@/components/admin/monitor/MonitorActionsCell.vue'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
import MonitorSettingsPanel from '@/features/channel-monitor-v2/MonitorSettingsPanel.vue'
import { isChannelMonitorV1Mode } from '@/utils/featureFlags'
const { t } = useI18n()
const appStore = useAppStore()
const isV1Mode = computed(() => isChannelMonitorV1Mode())
const adminMonitorTab = ref<'v2' | 'legacy'>(isChannelMonitorV1Mode() ? 'legacy' : 'v2')
const {
providerLabel,
providerBadgeClass,
@@ -265,6 +319,10 @@ async function toggleEnabled(row: ChannelMonitor) {
}
async function handleRunNow(row: ChannelMonitor) {
if (!isV1Mode.value) {
appStore.showError(t('admin.channelMonitor.runFailed'))
return
}
if (runningId.value != null) return
runningId.value = row.id
try {
@@ -318,7 +376,12 @@ async function confirmDelete() {
}
}
onMounted(reload)
watch(adminMonitorTab, (tab) => {
if (tab === 'legacy' && monitors.value.length === 0) void reload()
})
onMounted(() => {
if (adminMonitorTab.value === 'legacy') void reload()
})
onUnmounted(() => {
if (searchTimeout) clearTimeout(searchTimeout)
abortController?.abort()
+82 -15
View File
@@ -6953,21 +6953,77 @@
<Toggle v-model="form.channel_monitor_enabled" />
</div>
<div v-if="form.channel_monitor_enabled">
<label class="input-label">
{{ t('admin.settings.features.channelMonitor.defaultInterval') }}
<span class="text-red-500">*</span>
</label>
<input
v-model.number="form.channel_monitor_default_interval_seconds"
type="number"
min="15"
max="3600"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.channelMonitor.defaultIntervalHint') }}
</p>
<div v-if="form.channel_monitor_enabled" class="space-y-5">
<div>
<label class="input-label">
{{ t('admin.settings.features.channelMonitor.mode') }}
</label>
<div class="mt-1.5 inline-flex w-full max-w-md rounded-lg border border-gray-200 bg-gray-50 p-1 dark:border-dark-600 dark:bg-dark-900/40">
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
:class="
form.channel_monitor_mode === 'v2'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.channel_monitor_mode = 'v2'"
>
{{ t('admin.settings.features.channelMonitor.modeV2') }}
</button>
<button
type="button"
class="inline-flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition"
:class="
form.channel_monitor_mode === 'v1'
? 'bg-white text-primary-700 shadow-sm dark:bg-dark-800 dark:text-primary-300'
: 'text-gray-600 hover:text-gray-900 dark:text-dark-300 dark:hover:text-white'
"
@click="form.channel_monitor_mode = 'v1'"
>
{{ t('admin.settings.features.channelMonitor.modeV1') }}
</button>
</div>
<p class="mt-1.5 text-xs text-gray-500 dark:text-gray-400">
{{
form.channel_monitor_mode === 'v1'
? t('admin.settings.features.channelMonitor.modeV1Hint')
: t('admin.settings.features.channelMonitor.modeV2Hint')
}}
</p>
<p class="mt-1 text-xs text-gray-400 dark:text-gray-500">
{{ t('admin.settings.features.channelMonitor.modeHint') }}
</p>
</div>
<div v-if="form.channel_monitor_mode === 'v1'">
<label class="input-label">
{{ t('admin.settings.features.channelMonitor.defaultInterval') }}
<span class="text-red-500">*</span>
</label>
<input
v-model.number="form.channel_monitor_default_interval_seconds"
type="number"
min="15"
max="3600"
class="input"
/>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.settings.features.channelMonitor.defaultIntervalHint') }}
</p>
</div>
<div v-if="form.channel_monitor_mode === 'v2'" class="flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.settings.features.channelMonitor.hideThroughput') }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.settings.features.channelMonitor.hideThroughputHint') }}
</p>
</div>
<Toggle v-model="form.channel_monitor_hide_throughput" />
</div>
</div>
</div>
</div>
@@ -9297,6 +9353,8 @@ type SettingsForm = Omit<
| "wechat_connect_mp_enabled"
| "wechat_connect_mobile_enabled"
> & {
/** Form always binds a concrete boolean (SystemSettings marks this optional). */
channel_monitor_hide_throughput: boolean;
smtp_password: string;
turnstile_secret_key: string;
tencent_captcha_app_secret_key: string;
@@ -9605,7 +9663,9 @@ const form = reactive<SettingsForm>({
account_quota_notify_emails: [] as NotifyEmailEntry[],
// Channel Monitor feature switch
channel_monitor_enabled: true,
channel_monitor_mode: 'v1' as 'v1' | 'v2',
channel_monitor_default_interval_seconds: 60,
channel_monitor_hide_throughput: false,
// Available Channels feature switch
available_channels_enabled: false,
// Model Plaza feature switches + description
@@ -10602,6 +10662,11 @@ async function loadSettings() {
: defaultFingerprintSignalRows();
form.login_agreement_mode =
settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
form.channel_monitor_mode =
settings.channel_monitor_mode === "v2" ? "v2" : "v1";
form.channel_monitor_hide_throughput = Boolean(
settings.channel_monitor_hide_throughput
);
form.login_agreement_updated_at =
settings.login_agreement_updated_at || "2026-03-31";
form.login_agreement_documents =
@@ -11247,8 +11312,10 @@ async function saveSettings() {
).filter((e) => e.email.trim() !== ""),
// Channel Monitor feature switch
channel_monitor_enabled: form.channel_monitor_enabled,
channel_monitor_mode: form.channel_monitor_mode === 'v1' ? 'v1' : 'v2',
channel_monitor_default_interval_seconds:
Number(form.channel_monitor_default_interval_seconds) || 60,
channel_monitor_hide_throughput: Boolean(form.channel_monitor_hide_throughput),
// Available Channels feature switch
available_channels_enabled: form.available_channels_enabled,
// Model Plaza feature switches + description
@@ -18,6 +18,17 @@ const {
showError: vi.fn(),
}))
vi.mock('@/utils/featureFlags', () => ({
isChannelMonitorV1Mode: () => true,
isChannelMonitorV2Mode: () => false,
getChannelMonitorMode: () => 'v1' as const,
}))
vi.mock('@/features/channel-monitor-v2/MonitorSettingsPanel.vue', () => ({
default: { name: 'MonitorSettingsPanel', template: '<div data-testid="v2-settings" />' },
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
channelMonitor: {
@@ -14,6 +14,17 @@ const { listTemplates } = vi.hoisted(() => ({
listTemplates: vi.fn(),
}))
vi.mock('@/utils/featureFlags', () => ({
isChannelMonitorV1Mode: () => true,
isChannelMonitorV2Mode: () => false,
getChannelMonitorMode: () => 'v1' as const,
}))
vi.mock('@/features/channel-monitor-v2/MonitorSettingsPanel.vue', () => ({
default: { name: 'MonitorSettingsPanel', template: '<div data-testid="v2-settings" />' },
}))
vi.mock('@/api/admin', () => ({
adminAPI: {
channelMonitor: {
@@ -0,0 +1,172 @@
<template>
<AppLayout>
<MonitorHero
:overall-status="overallStatus"
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
:window="currentWindow"
:loading="loading"
:auto-refresh="autoRefresh"
@update:window="handleWindowChange"
@refresh="manualReload"
/>
<MonitorCardGrid
:items="items"
:window="currentWindow"
:countdown-seconds="countdown"
:loading="loading"
:detail-cache="detailCache"
@card-click="openDetail"
/>
<MonitorDetailDialog
:show="showDetail"
:monitor-id="detailTarget?.id ?? null"
:title="detailTitle"
@close="closeDetail"
/>
</AppLayout>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import {
list as listChannelMonitorViews,
status as fetchChannelMonitorDetail,
type UserMonitorView,
type UserMonitorDetail,
} from '@/api/channelMonitor'
import AppLayout from '@/components/layout/AppLayout.vue'
import MonitorHero, {
type MonitorWindow,
type OverallStatus,
} from '@/components/user/monitor/MonitorHero.vue'
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
const { t } = useI18n()
const appStore = useAppStore()
// ── State ──
const items = ref<UserMonitorView[]>([])
const loading = ref(false)
const currentWindow = ref<MonitorWindow>('7d')
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
const showDetail = ref(false)
const detailTarget = ref<UserMonitorView | null>(null)
let abortController: AbortController | null = null
const autoRefresh = useAutoRefresh({
storageKey: 'channel-status-auto-refresh',
intervals: [30, 60, 120] as const,
defaultInterval: DEFAULT_INTERVAL_SECONDS,
onRefresh: () => reload(true),
shouldPause: () => document.hidden || loading.value,
})
const countdown = autoRefresh.countdown
// ── Computed ──
const overallStatus = computed<OverallStatus>(() => {
if (items.value.length === 0) return 'operational'
for (const it of items.value) {
if (it.primary_status === 'failed' || it.primary_status === 'error') return 'degraded'
if (it.primary_status !== STATUS_OPERATIONAL) return 'degraded'
}
return 'operational'
})
const detailTitle = computed(() => {
return detailTarget.value?.name || t('channelStatus.detailTitle')
})
// ── Loaders ──
async function reload(silent = false) {
if (abortController) abortController.abort()
const ctrl = new AbortController()
abortController = ctrl
if (!silent) loading.value = true
try {
const res = await listChannelMonitorViews({ signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return
items.value = res.items || []
} catch (err: unknown) {
const e = err as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
} finally {
if (abortController === ctrl) {
if (!silent) loading.value = false
countdown.value = DEFAULT_INTERVAL_SECONDS
abortController = null
}
}
}
async function manualReload() {
await reload(false)
// After base reload, refresh any cached detail records so non-7d availability
// values stay in sync without forcing the user to switch tabs again.
if (currentWindow.value !== '7d') {
await Promise.all(items.value.map(it => loadDetail(it.id, true)))
}
}
async function loadDetail(id: number, force = false) {
if (!force && detailCache[id]) return
try {
detailCache[id] = await fetchChannelMonitorDetail(id)
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('channelStatus.detailLoadError')))
}
}
async function ensureDetailsForWindow() {
if (currentWindow.value === '7d') return
await Promise.all(items.value.map(it => loadDetail(it.id)))
}
// ── Handlers ──
async function handleWindowChange(value: MonitorWindow) {
currentWindow.value = value
await ensureDetailsForWindow()
}
function openDetail(row: UserMonitorView) {
detailTarget.value = row
showDetail.value = true
}
function closeDetail() {
showDetail.value = false
detailTarget.value = null
}
watch(items, () => {
void ensureDetailsForWindow()
})
watch(
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
(enabled) => {
if (enabled === false) autoRefresh.stop()
else if (autoRefresh.enabled.value) autoRefresh.start()
},
)
onMounted(() => {
void reload(false)
if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) {
autoRefresh.setEnabled(true)
}
})
onBeforeUnmount(() => {
if (abortController) abortController.abort()
})
</script>
@@ -0,0 +1,944 @@
<template>
<AppLayout>
<div class="space-y-6 pb-12">
<!-- Ops-style elevated shell: title toolbar + filters (mirrors OpsDashboardHeader) -->
<section
class="card sticky top-0 z-20 !rounded-3xl !border-0 p-0 shadow-sm ring-1 ring-gray-900/5 backdrop-blur-sm dark:!bg-dark-800 dark:ring-dark-700 supports-[backdrop-filter]:bg-white/95 dark:supports-[backdrop-filter]:bg-dark-800/95"
>
<header class="page-header mb-0 flex flex-wrap items-start justify-between gap-4 border-b border-gray-100 px-5 py-4 dark:border-dark-700 sm:px-6">
<div class="min-w-0">
<h1 class="page-title flex items-center gap-2 text-xl font-black text-gray-900 dark:text-white">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-400">
<Icon name="chart" size="sm" />
</span>
{{ t('channelMonitorV2.title') }}
</h1>
<div class="page-description mt-1.5 flex flex-wrap items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
<span class="relative flex h-2 w-2 shrink-0">
<span
class="relative inline-flex h-2 w-2 rounded-full"
:class="loading || refreshing ? 'bg-gray-400' : 'bg-green-500'"
></span>
</span>
<span v-if="refreshing" class="inline-flex items-center gap-1 text-primary-600 dark:text-primary-300">
<LoadingSpinner size="sm" />
{{ t('channelMonitorV2.updating') }}
</span>
<span v-else-if="snapshot?.coverage.data_through">
{{ t('channelMonitorV2.updatedTo', { time: formatTime(snapshot.coverage.data_through) }) }}
</span>
<span v-else class="text-gray-400">{{ t('common.loading') }}</span>
<span
v-if="snapshot && !snapshot.coverage.coverage_complete && !bootstrapActive"
class="badge badge-warning"
>
{{ t('channelMonitorV2.partialCoverage') }}
</span>
<span
v-if="bootstrapActive"
class="badge badge-primary inline-flex items-center gap-1"
>
<LoadingSpinner size="sm" />
{{ t('channelMonitorV2.bootstrap.progress', { percent: bootstrapPercent }) }}
</span>
</div>
</div>
<button
class="btn btn-secondary btn-icon flex h-8 w-8 items-center justify-center rounded-lg bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
type="button"
:title="t('common.refresh')"
:disabled="loading"
@click="reload(false)"
>
<Icon name="refresh" size="sm" :class="loading ? 'animate-spin' : ''" />
</button>
</header>
<!-- First-upgrade silent backfill: show until 30d product window is covered -->
<div
v-if="bootstrapActive"
class="border-b border-blue-100 bg-blue-50/90 px-5 py-3 dark:border-blue-900/40 dark:bg-blue-950/40 sm:px-6"
role="status"
aria-live="polite"
>
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<p class="text-sm font-semibold text-blue-900 dark:text-blue-100">
{{ t('channelMonitorV2.bootstrap.title') }}
</p>
<p class="mt-0.5 text-xs text-blue-800/80 dark:text-blue-200/80">
{{ t('channelMonitorV2.bootstrap.description') }}
</p>
</div>
<span class="shrink-0 text-xs font-medium tabular-nums text-blue-700 dark:text-blue-300">
{{ t('channelMonitorV2.bootstrap.progress', { percent: bootstrapPercent }) }}
</span>
</div>
<div
class="mt-2.5 h-1.5 overflow-hidden rounded-full bg-blue-200/80 dark:bg-blue-900/60"
role="progressbar"
:aria-valuenow="bootstrapPercent"
aria-valuemin="0"
aria-valuemax="100"
:aria-label="t('channelMonitorV2.bootstrap.working')"
>
<div
class="h-full rounded-full bg-blue-500 transition-[width] duration-500 ease-out dark:bg-blue-400"
:style="{ width: `${bootstrapPercent}%` }"
/>
</div>
</div>
<!-- Single compact toolbar row: range · filters · view controls -->
<div class="monitor-toolbar flex flex-nowrap items-center gap-1.5 overflow-x-auto px-4 py-3 sm:gap-2 sm:px-5">
<div
class="tabs inline-flex shrink-0"
role="group"
:aria-label="t('channelMonitorV2.timeRange')"
>
<button
v-for="option in ranges"
:key="option.value"
type="button"
class="tab !px-2 !py-1 text-xs sm:!px-2.5"
:class="filter.range === option.value ? 'tab-active' : ''"
@click="setRange(option.value)"
>
{{ option.label }}
</button>
</div>
<span class="mx-0.5 hidden h-5 w-px shrink-0 bg-gray-200 dark:bg-dark-700 sm:block" aria-hidden="true"></span>
<FilterMultiSelect
v-model="filter.platforms"
compact
:label="t('channelMonitorV2.filters.platform')"
:all-label="t('channelMonitorV2.filters.allPlatforms')"
:options="platformOptions"
/>
<FilterMultiSelect
v-model="selectedGroupIds"
compact
:label="t('channelMonitorV2.filters.group')"
:all-label="t('channelMonitorV2.filters.allGroups')"
:options="groupOptions"
/>
<FilterMultiSelect
v-model="filter.models"
compact
:label="t('channelMonitorV2.filters.model')"
:all-label="t('channelMonitorV2.filters.allModels')"
:options="modelOptions"
/>
<button
type="button"
class="btn btn-ghost btn-sm shrink-0 !px-2 !py-1 text-xs"
:disabled="!hasDimensionFilter"
:class="!hasDimensionFilter ? 'opacity-40' : ''"
@click="clearDimensions"
>
{{ t('channelMonitorV2.clearFilters') }}
</button>
<span class="mx-0.5 hidden h-5 w-px shrink-0 bg-gray-200 dark:bg-dark-700 md:block" aria-hidden="true"></span>
<Select
v-model="matrixGroupBy"
:options="matrixGroupOptions"
:placeholder="t('channelMonitorV2.groupBy.label')"
class="monitor-toolbar-select w-[7.5rem] shrink-0 sm:w-[8.5rem]"
/>
<div
class="tabs inline-flex shrink-0"
role="group"
:aria-label="t('channelMonitorV2.trendView.label')"
>
<button
type="button"
class="tab !px-2 !py-1 text-xs"
:class="trendView === 'pulse' ? 'tab-active' : ''"
@click="trendView = 'pulse'"
>
{{ t('channelMonitorV2.trendView.pulse') }}
</button>
<button
type="button"
class="tab !px-2 !py-1 text-xs"
:class="trendView === 'line' ? 'tab-active' : ''"
@click="trendView = 'line'"
>
{{ t('channelMonitorV2.trendView.line') }}
</button>
</div>
<div
v-if="trendView === 'pulse'"
class="tabs inline-flex shrink-0"
role="group"
:aria-label="t('channelMonitorV2.healthMode.label')"
>
<button
v-for="option in healthModeOptions"
:key="option.value"
type="button"
class="tab !px-2 !py-1 text-xs"
:class="healthMode === option.value ? 'tab-active' : ''"
@click="healthMode = option.value"
>
{{ option.label }}
</button>
</div>
</div>
</section>
<!-- Overview KPI: success · TTFT · tokens/s(optional) · cache · (+ RPM when throughput visible) -->
<section
v-if="snapshot"
class="grid grid-cols-2 gap-3 sm:grid-cols-3"
:class="showThroughput ? 'xl:grid-cols-5' : 'xl:grid-cols-4'"
:aria-label="t('channelMonitorV2.summaryAria')"
>
<MetricCell
:label="t('channelMonitorV2.metrics.successRate')"
:value="formatPercent(1 - snapshot.metrics.error_rate)"
:detail="t('channelMonitorV2.metrics.errorRateValue', { value: formatPercent(snapshot.metrics.error_rate) })"
:state="snapshot.health.error_rate"
/>
<MetricCell
:label="t('channelMonitorV2.metrics.ttftP50')"
:value="formatMs(snapshot.metrics.ttft.p50_ms)"
:detail="latencyKpiSecondary(snapshot.metrics.ttft)"
:title="latencyDetail(snapshot.metrics.ttft)"
:state="snapshot.health.ttft"
/>
<MetricCell
v-if="showThroughput"
:label="t('channelMonitorV2.metrics.tps')"
:value="formatTps(snapshot.metrics.tpm)"
:detail="t('channelMonitorV2.metrics.tpsDetail')"
:title="exactTps(snapshot.metrics.tpm)"
/>
<MetricCell
:label="t('channelMonitorV2.metrics.cacheRate')"
:value="formatPercent(snapshot.metrics.cache_rate)"
:detail="t('channelMonitorV2.metrics.cacheDetail')"
:state="snapshot.health.cache || snapshot.health.overall"
/>
<MetricCell
v-if="showThroughput"
:label="t('channelMonitorV2.metrics.rpm')"
:value="formatRate(snapshot.metrics.rpm)"
:detail="t('channelMonitorV2.metrics.rpmDetail')"
:title="exactRate(snapshot.metrics.rpm)"
/>
</section>
<section
v-else-if="loading"
class="grid grid-cols-2 gap-3 sm:grid-cols-3"
:class="showThroughput ? 'xl:grid-cols-5' : 'xl:grid-cols-4'"
aria-hidden="true"
>
<div
v-for="i in (showThroughput ? 5 : 4)"
:key="i"
class="h-24 animate-pulse rounded-2xl bg-gray-50 dark:bg-dark-900/30"
/>
</section>
<div class="relative min-h-[320px]">
<MonitorTrendChart
v-if="trendView === 'line'"
:trend="snapshot?.trend || []"
:coverage="snapshot?.coverage || null"
:loading="loading && !snapshot"
/>
<RelayPulseMatrix
v-else-if="matrix"
:rows="matrixRows"
:coverage="matrix.coverage"
:health-mode="healthMode"
:show-throughput="showThroughput"
/>
<div
v-else-if="loading"
class="card flex min-h-[320px] items-center justify-center !rounded-3xl !border-0 text-sm text-gray-400 shadow-sm ring-1 ring-gray-900/5 dark:ring-dark-700"
>
<span class="animate-pulse">{{ t('common.loading') }}</span>
</div>
</div>
<section class="card flex min-h-0 flex-col overflow-hidden !rounded-3xl !border-0 shadow-sm ring-1 ring-gray-900/5 dark:!bg-dark-800 dark:ring-dark-700">
<div class="border-b border-gray-100 px-5 pt-4 dark:border-dark-700 sm:px-6">
<nav class="tabs w-full max-w-md sm:w-auto" role="tablist" :aria-label="t('channelMonitorV2.tabs.aria')">
<button
v-for="item in tabs"
:key="item.value"
type="button"
role="tab"
class="tab flex-1 sm:flex-none"
:aria-selected="activeTab === item.value"
:class="activeTab === item.value ? 'tab-active' : ''"
@click="activeTab = item.value"
>
{{ item.label }}
</button>
</nav>
</div>
<div class="min-h-0 max-h-[min(52vh,520px)] overflow-auto p-4 sm:p-5">
<div v-if="activeTab === 'models'" class="table-container border-0">
<table class="table monitor-table min-w-[720px]">
<thead>
<tr>
<th>{{ t('channelMonitorV2.table.platformModel') }}</th>
<th>{{ t('channelMonitorV2.metrics.successRate') }}</th>
<th>{{ t('channelMonitorV2.metrics.ttftP50') }}</th>
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.tps') }}</th>
<th>{{ t('channelMonitorV2.metrics.cacheRate') }}</th>
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.rpm') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in modelRows"
:key="`${row.platform}:${row.model}`"
class="cursor-pointer"
@click="drillModel(row)"
>
<td>
<div class="flex items-center gap-2">
<span :class="statusDot(row.health)" aria-hidden="true"></span>
<div>
<span class="block text-xs text-gray-500 dark:text-dark-400">{{ row.platform }}</span>
<strong class="font-semibold text-gray-900 dark:text-white">
{{ row.model === '__other__' ? t('channelMonitorV2.otherModels') : row.model }}
</strong>
</div>
</div>
</td>
<td>
<span class="block">{{ formatPercent(1 - row.metrics.error_rate) }}</span>
<small class="text-xs text-gray-400">{{ t('channelMonitorV2.metrics.errorRateValue', { value: formatPercent(row.metrics.error_rate) }) }}</small>
</td>
<td>
<span class="block">{{ formatMs(row.metrics.ttft.p50_ms) }}</span>
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.ttft) }}</small>
</td>
<td v-if="showThroughput" :title="exactTps(row.metrics.tpm)">{{ formatTps(row.metrics.tpm) }}</td>
<td>{{ formatPercent(row.metrics.cache_rate) }}</td>
<td v-if="showThroughput">{{ formatRate(row.metrics.rpm) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="activeTab === 'errors'" class="space-y-3">
<div
v-for="row in errorRows"
:key="row.category"
class="rounded-2xl bg-gray-50 p-4 text-sm dark:bg-dark-900/30"
:class="row.ignored ? 'opacity-60' : ''"
>
<button
type="button"
class="grid w-full grid-cols-[minmax(100px,200px)_1fr_auto_auto] items-center gap-3 text-left"
@click="toggleError(row.category)"
>
<span class="flex min-w-0 items-center gap-1.5 truncate text-gray-700 dark:text-gray-200">
<span class="truncate">{{ errorLabel(row.category) }}</span>
<span v-if="row.ignored" class="badge badge-gray shrink-0 !px-1.5 !py-0 text-[10px]">{{ t('channelMonitorV2.ignored') }}</span>
</span>
<span class="h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-dark-700">
<i
class="block h-full rounded-full"
:class="row.ignored ? 'bg-gray-400 dark:bg-gray-500' : 'bg-gradient-to-r from-red-400 to-red-500'"
:style="{ width: `${Math.max(2, row.rate * 100)}%` }"
></i>
</span>
<small
class="w-14 text-right text-xs tabular-nums"
:class="row.ignored ? 'text-gray-400' : 'text-gray-500'"
>{{ formatPercent(row.rate) }}</small>
<Icon name="chevronDown" size="sm" :class="['text-gray-400 transition-transform', expandedErrors.has(row.category) ? 'rotate-180' : '']" />
</button>
<div v-if="expandedErrors.has(row.category)" class="mt-3 space-y-2 border-t border-gray-100 pt-3 dark:border-dark-700">
<template v-if="isAdmin && (row.details || []).length">
<div
v-for="(detail, index) in row.details || []"
:key="`${row.category}:${index}:${detail.message}`"
class="rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:bg-dark-900/50 dark:text-dark-300"
>
<div class="mb-1 flex flex-wrap items-center gap-2">
<span class="badge badge-gray !px-1.5 !py-0 text-[10px]">{{ detail.platform || '-' }}</span>
<span class="truncate font-medium">{{ detail.model || '-' }}</span>
<span v-if="detail.status_code" class="text-gray-400">{{ t('channelMonitorV2.errorDetail.http', { code: detail.status_code }) }}</span>
<span v-if="detail.upstream_status_code" class="text-gray-400">{{ t('channelMonitorV2.errorDetail.upstream', { code: detail.upstream_status_code }) }}</span>
<span class="ml-auto text-gray-400">×{{ detail.count }}</span>
</div>
<p class="break-words leading-relaxed">{{ detail.message || detail.error_type || t('channelMonitorV2.errorDetail.noMessage') }}</p>
</div>
</template>
<p v-else class="text-xs text-gray-400">{{ t('channelMonitorV2.errorDetail.empty') }}</p>
</div>
</div>
</div>
<div v-else class="table-container border-0">
<table class="table monitor-table min-w-[640px]">
<thead>
<tr>
<th class="w-16">{{ t('channelMonitorV2.table.rank') }}</th>
<th>{{ t('channelMonitorV2.table.user') }}</th>
<th>{{ t('channelMonitorV2.metrics.successRate') }}</th>
<th>{{ t('channelMonitorV2.metrics.ttftP50') }}</th>
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.tps') }}</th>
<th>{{ t('channelMonitorV2.metrics.cacheRate') }}</th>
<th v-if="showThroughput">{{ t('channelMonitorV2.metrics.rpm') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="row in userRows"
:key="row.user_id || row.display_label"
:class="row.is_self
? 'bg-primary-50 ring-1 ring-inset ring-primary-200/80 dark:bg-primary-900/25 dark:ring-primary-700/50'
: ''"
>
<td><MonitorRankBadge :rank="row.rank" /></td>
<td>
<strong
class="font-semibold"
:class="row.is_self ? 'text-primary-700 dark:text-primary-300' : 'text-gray-900 dark:text-white'"
>
{{ row.display_label }}
<span
v-if="row.is_self"
class="badge badge-primary ml-2 !px-1.5 !py-0 text-[10px]"
>{{ t('channelMonitorV2.currentUser') }}</span>
</strong>
</td>
<td>
<span class="block">{{ formatPercent(1 - row.metrics.error_rate) }}</span>
<small class="text-xs text-gray-400">{{ t('channelMonitorV2.metrics.errorRateValue', { value: formatPercent(row.metrics.error_rate) }) }}</small>
</td>
<td>
<span class="block">{{ formatMs(row.metrics.ttft.p50_ms) }}</span>
<small class="text-xs text-gray-400">{{ latencyDetail(row.metrics.ttft) }}</small>
</td>
<td v-if="showThroughput" :title="exactTps(row.metrics.tpm)">{{ formatTps(row.metrics.tpm) }}</td>
<td>{{ formatPercent(row.metrics.cache_rate) }}</td>
<td v-if="showThroughput">{{ formatRate(row.metrics.rpm) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="tabLoading" class="empty-state py-10 text-sm text-gray-400">{{ t('common.loading') }}</div>
<div v-else-if="activeRowsEmpty" class="empty-state py-10">
<p class="empty-state-title text-base">
{{
bootstrapActive
? t('channelMonitorV2.bootstrap.title')
: t('channelMonitorV2.empty.title')
}}
</p>
<p class="empty-state-description">
{{
bootstrapActive
? t('channelMonitorV2.bootstrap.description')
: t('channelMonitorV2.empty.description')
}}
</p>
</div>
</div>
</section>
</div>
</AppLayout>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import Select from '@/components/common/Select.vue'
import FilterMultiSelect from '@/features/channel-monitor-v2/FilterMultiSelect.vue'
import MetricCell from '@/features/channel-monitor-v2/MetricCell.vue'
import MonitorRankBadge from '@/features/channel-monitor-v2/MonitorRankBadge.vue'
import MonitorTrendChart from '@/features/channel-monitor-v2/MonitorTrendChart.vue'
import RelayPulseMatrix from '@/features/channel-monitor-v2/RelayPulseMatrix.vue'
import { useAuthStore } from '@/stores/auth'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { isChannelMonitorThroughputHidden } from '@/utils/featureFlags'
import * as api from '@/api/channelMonitorV2'
import type {
HealthState,
MonitorDimensions,
MonitorErrorRow,
MonitorFilter,
MonitorHealth,
MonitorMatrixGroupBy,
MonitorMatrixResponse,
MonitorModelRow,
MonitorRange,
MonitorSnapshot,
MonitorUserRow,
} from '@/api/channelMonitorV2'
import {
formatLatencyKpiSecondary,
formatLatencyPrivacy,
formatMonitorMs,
formatMonitorPercent,
formatMonitorThroughput,
formatMonitorTokensPerSecond,
tokensPerSecondFromTpm,
healthScoreClass,
monitorErrorCategoryLabel,
} from '@/features/channel-monitor-v2/monitorFormat'
type Tab = 'models' | 'errors' | 'users'
type HealthMode = 'overall' | 'success' | 'ttft' | 'cache'
type TrendView = 'pulse' | 'line'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const appStore = useAppStore()
const { t, te, locale } = useI18n()
const isAdmin = computed(() => authStore.isAdmin)
/** Admins always see RPM/TPM; users honor the hide-throughput system setting. */
const showThroughput = computed(() => isAdmin.value || !isChannelMonitorThroughputHidden())
const ranges = computed(() => [
{ value: '90m' as MonitorRange, label: t('channelMonitorV2.ranges.90m') },
{ value: '24h' as MonitorRange, label: t('channelMonitorV2.ranges.24h') },
{ value: '7d' as MonitorRange, label: t('channelMonitorV2.ranges.7d') },
{ value: '30d' as MonitorRange, label: t('channelMonitorV2.ranges.30d') },
])
const tabs = computed(() => [
{ value: 'models' as Tab, label: t('channelMonitorV2.tabs.models') },
{ value: 'errors' as Tab, label: t('channelMonitorV2.tabs.errors') },
{ value: 'users' as Tab, label: t('channelMonitorV2.tabs.users') },
])
const matrixGroupOptions = computed(() => [
{ value: 'platform' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platform') },
{ value: 'platform_group' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformGroup') },
{ value: 'platform_model' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformModel') },
{ value: 'platform_group_model' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformGroupModel') },
])
const healthModeOptions = computed(() => [
{ value: 'overall' as HealthMode, label: t('channelMonitorV2.healthMode.overall') },
{ value: 'success' as HealthMode, label: t('channelMonitorV2.healthMode.success') },
{ value: 'ttft' as HealthMode, label: t('channelMonitorV2.healthMode.ttft') },
{ value: 'cache' as HealthMode, label: t('channelMonitorV2.healthMode.cache') },
])
const filter = ref<MonitorFilter>({
range: parseRange(route.query.range),
platforms: csv(route.query.platform),
groupIds: csv(route.query.group).map(Number).filter(Boolean),
models: csv(route.query.model),
})
const activeTab = ref<Tab>(
(['models', 'errors', 'users'].includes(String(route.query.tab)) ? route.query.tab : 'models') as Tab
)
const matrixGroupBy = ref<MonitorMatrixGroupBy>(parseMatrixGroupBy(route.query.group_by))
const healthMode = ref<HealthMode>(parseHealthMode(route.query.health_mode))
const trendView = ref<TrendView>(parseTrendView(route.query.trend_view))
const dimensions = ref<MonitorDimensions>({ platforms: [], groups: [], models: [] })
const snapshot = ref<MonitorSnapshot | null>(null)
const matrix = ref<MonitorMatrixResponse | null>(null)
const modelRows = ref<MonitorModelRow[]>([])
const errorRows = ref<MonitorErrorRow[]>([])
const userRows = ref<MonitorUserRow[]>([])
const loading = ref(false)
const tabLoading = ref(false)
const refreshing = ref(false)
const expandedErrors = ref(new Set<string>())
let controller: AbortController | null = null
let sequence = 0
let autoRefreshTimer: number | null = null
const hasDimensionFilter = computed(
() => filter.value.platforms.length + filter.value.groupIds.length + filter.value.models.length > 0
)
// Full platform catalog (never pruned). Groups/models cascade by selected platforms
// so choosing a platform narrows the other pickers without collapsing platforms.
const platformOptions = computed(() =>
(dimensions.value.platforms || []).map((item) => ({
value: item.value,
label: item.label,
}))
)
const selectedPlatforms = computed(() => new Set(filter.value.platforms))
const groupOptions = computed(() =>
(dimensions.value.groups || [])
.filter(
(item) =>
selectedPlatforms.value.size === 0 ||
!item.platform ||
selectedPlatforms.value.has(item.platform),
)
.map((item) => ({
value: String(item.id),
label: item.platform ? `${item.platform} / ${item.name || `#${item.id}`}` : item.name || `#${item.id}`,
}))
)
const modelOptions = computed(() =>
(dimensions.value.models || [])
.filter(
(item) =>
selectedPlatforms.value.size === 0 ||
!item.platform ||
selectedPlatforms.value.has(item.platform),
)
.map((item) => ({
value: item.value,
label:
item.platform && !item.label.includes(item.platform)
? `${item.platform} / ${item.label}`
: item.label,
}))
)
const selectedGroupIds = computed({
get: () => filter.value.groupIds.map(String),
set: (value: string[]) => {
filter.value.groupIds = value.map(Number).filter((id) => Number.isInteger(id) && id > 0)
},
})
// Soft-prune group/model selections that fall outside the platform cascade.
// Do NOT wipe when options are temporarily empty (loading); only drop invalid ids.
watch(
[groupOptions, modelOptions],
() => {
if (groupOptions.value.length > 0) {
const allowed = new Set(groupOptions.value.map((item) => item.value))
const next = filter.value.groupIds.filter((id) => allowed.has(String(id)))
if (next.length !== filter.value.groupIds.length) {
filter.value.groupIds = next
}
}
if (modelOptions.value.length > 0) {
const allowed = new Set(modelOptions.value.map((item) => item.value))
const next = filter.value.models.filter((model) => allowed.has(model))
if (next.length !== filter.value.models.length) {
filter.value.models = next
}
}
},
{ flush: 'post' },
)
const activeRowsEmpty = computed(() =>
activeTab.value === 'models'
? modelRows.value.length === 0
: activeTab.value === 'errors'
? errorRows.value.length === 0
: userRows.value.length === 0
)
/** First-upgrade backfill toward 90m/24h/7d/30d; banner hides when backend omits bootstrap. */
const bootstrapActive = computed(() => Boolean(snapshot.value?.coverage?.bootstrap?.active))
const bootstrapPercent = computed(() => {
const raw = snapshot.value?.coverage?.bootstrap?.progress_percent
if (typeof raw !== 'number' || Number.isNaN(raw)) return 0
return Math.min(100, Math.max(0, Math.round(raw)))
})
const matrixRows = computed(() => {
const items = matrix.value?.items || []
// platform_group views should only show real groups, never bare platform placeholders.
if (matrixGroupBy.value === 'platform_group' || matrixGroupBy.value === 'platform_group_model') {
return items.filter((row) => row.group_id != null && Number(row.group_id) > 0)
}
return items
})
function csv(value: unknown) {
return typeof value === 'string' ? value.split(',').filter(Boolean) : []
}
function parseRange(value: unknown): MonitorRange {
return ['90m', '24h', '7d', '30d'].includes(String(value)) ? (value as MonitorRange) : '90m'
}
function parseMatrixGroupBy(value: unknown): MonitorMatrixGroupBy {
const allowed: MonitorMatrixGroupBy[] = [
'platform',
'platform_group',
'platform_model',
'platform_group_model',
]
return allowed.includes(value as MonitorMatrixGroupBy)
? (value as MonitorMatrixGroupBy)
: 'platform_group'
}
function parseHealthMode(value: unknown): HealthMode {
const allowed: HealthMode[] = ['overall', 'success', 'ttft', 'cache']
return allowed.includes(value as HealthMode) ? (value as HealthMode) : 'overall'
}
function parseTrendView(value: unknown): TrendView {
return value === 'line' ? 'line' : 'pulse'
}
function syncQuery() {
void router.replace({
query: {
range: filter.value.range,
platform: filter.value.platforms.join(',') || undefined,
group: filter.value.groupIds.join(',') || undefined,
model: filter.value.models.join(',') || undefined,
group_by: matrixGroupBy.value,
health_mode: healthMode.value,
trend_view: trendView.value === 'line' ? 'line' : undefined,
tab: activeTab.value,
},
})
}
/** Dimensions catalog: range only — never re-filtered by platform/group/model selection. */
async function loadDimensions(signal?: AbortSignal, id = sequence) {
const rangeOnly: MonitorFilter = {
range: filter.value.range,
platforms: [],
groupIds: [],
models: [],
}
const next = await api.getDimensions(rangeOnly, isAdmin.value, signal)
if (id !== sequence) return
dimensions.value = next
}
async function loadMetrics(signal?: AbortSignal, id = sequence) {
const [nextSnapshot, nextMatrix] = await Promise.all([
api.getSnapshot(filter.value, isAdmin.value, signal),
api.getMatrix(filter.value, matrixGroupBy.value, isAdmin.value, signal),
])
if (id !== sequence) return
snapshot.value = nextSnapshot
matrix.value = nextMatrix
scheduleAutoRefresh()
await loadTab(signal, id)
}
async function reload(silent = true) {
controller?.abort()
const request = new AbortController()
controller = request
const id = ++sequence
refreshing.value = true
if (!silent) loading.value = true
try {
// Catalog + metrics in parallel; catalog ignores dimension filters so options never shrink.
await Promise.all([
loadDimensions(request.signal, id),
loadMetrics(request.signal, id),
])
} catch (error) {
if ((error as { name?: string }).name !== 'CanceledError') {
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.loadFailed')))
}
} finally {
if (id === sequence) {
loading.value = false
tabLoading.value = false
refreshing.value = false
}
}
}
/** When only range changes, still refresh dimensions; dimension filters only re-load metrics. */
async function reloadMetricsOnly(silent = true) {
controller?.abort()
const request = new AbortController()
controller = request
const id = ++sequence
refreshing.value = true
if (!silent) loading.value = true
try {
await loadMetrics(request.signal, id)
} catch (error) {
if ((error as { name?: string }).name !== 'CanceledError') {
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.loadFailed')))
}
} finally {
if (id === sequence) {
loading.value = false
tabLoading.value = false
refreshing.value = false
}
}
}
async function loadTab(signal?: AbortSignal, id = sequence) {
tabLoading.value = true
try {
if (activeTab.value === 'models') {
modelRows.value = (await api.getModels(filter.value, isAdmin.value, signal)).items || []
} else if (activeTab.value === 'errors') {
errorRows.value = (await api.getErrors(filter.value, isAdmin.value, signal)).items || []
} else {
userRows.value = (await api.getUsers(filter.value, isAdmin.value, signal)).items || []
}
} catch (error) {
const e = error as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.name === 'CanceledError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(error, t('channelMonitorV2.detailLoadFailed')))
} finally {
if (id === sequence) tabLoading.value = false
}
}
function setRange(value: MonitorRange) {
filter.value.range = value
}
function clearDimensions() {
// Replace arrays so deep watch always fires and metrics reload full window.
filter.value = {
...filter.value,
platforms: [],
groupIds: [],
models: [],
}
}
function scheduleAutoRefresh() {
if (autoRefreshTimer) {
window.clearInterval(autoRefreshTimer)
autoRefreshTimer = null
}
// Poll faster while first-upgrade bootstrap is filling 90m→30d so the progress bar moves.
const seconds = bootstrapActive.value
? 10
: snapshot.value?.config?.refresh_interval_seconds || 300
autoRefreshTimer = window.setInterval(() => {
if (!loading.value && !refreshing.value) {
void reload(true)
}
}, Math.max(bootstrapActive.value ? 10 : 60, seconds) * 1000)
}
function drillModel(row: MonitorModelRow) {
filter.value.platforms = [row.platform]
filter.value.models = [row.model]
}
function formatRate(value: number) {
return formatMonitorThroughput(value)
}
function exactRate(value: number) {
return Intl.NumberFormat(locale.value || undefined, { maximumFractionDigits: 2 }).format(value || 0)
}
function formatTps(tpm: number | null | undefined) {
return formatMonitorTokensPerSecond(tpm)
}
function exactTps(tpm: number | null | undefined) {
return Intl.NumberFormat(locale.value || undefined, { maximumFractionDigits: 3 }).format(
tokensPerSecondFromTpm(tpm),
)
}
function formatPercent(value: number) {
return formatMonitorPercent(value)
}
function formatMs(value: number | null) {
return formatMonitorMs(value)
}
function latencyDetail(metric: {
p50_ms: number | null
p90_ms?: number | null
p95_ms: number | null
avg_ms?: number | null
}) {
return formatLatencyPrivacy(metric.p50_ms, metric.p90_ms, metric.avg_ms, metric.p95_ms)
}
/** KPI secondary: AVG · P90 under the P50 primary value. */
function latencyKpiSecondary(metric: {
p90_ms?: number | null
p95_ms: number | null
avg_ms?: number | null
}) {
return formatLatencyKpiSecondary(metric.avg_ms, metric.p90_ms, metric.p95_ms)
}
function formatTime(value: string) {
return new Intl.DateTimeFormat(locale.value || undefined, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
function statusDot(health?: MonitorHealth | HealthState) {
if (!health || typeof health === 'string') {
return `status-dot health-${health || 'unknown'}`
}
// Prefer multi-band score when available; otherwise fall back to the coarse
// overall state for mixed-version/older payloads.
const klass =
health.score != null
? healthScoreClass(health, 'overall', 0)
: `health-${health.overall || 'unknown'}`
return `status-dot ${klass}`
}
function errorLabel(value: string) {
const key = `channelMonitorV2.errorCategories.${value}`
return te(key) ? t(key) : monitorErrorCategoryLabel(value)
}
function toggleError(category: string) {
const next = new Set(expandedErrors.value)
if (next.has(category)) next.delete(category)
else next.add(category)
expandedErrors.value = next
}
let lastRange: MonitorRange = filter.value.range
watch(
filter,
() => {
syncQuery()
const rangeChanged = filter.value.range !== lastRange
lastRange = filter.value.range
if (rangeChanged) void reload(true)
else void reloadMetricsOnly(true)
},
{ deep: true }
)
watch(matrixGroupBy, () => {
syncQuery()
void reloadMetricsOnly(true)
})
watch(healthMode, syncQuery)
watch(trendView, syncQuery)
watch(activeTab, () => {
syncQuery()
void loadTab()
})
onMounted(() => void reload(false))
onBeforeUnmount(() => {
controller?.abort()
if (autoRefreshTimer) window.clearInterval(autoRefreshTimer)
})
</script>
<style scoped>
.status-dot {
display: inline-block;
height: 0.5rem;
width: 0.5rem;
flex: none;
border-radius: 9999px;
}
/* Multi-stop green → yellow → red score bands */
.health-score10 { background: #16a34a; }
.health-score9 { background: #22c55e; }
.health-score8 { background: #4ade80; }
.health-score7 { background: #a3e635; }
.health-score6 { background: #facc15; }
.health-score5 { background: #fbbf24; }
.health-score4 { background: #f59e0b; }
.health-score3 { background: #f97316; }
.health-score2 { background: #fb7185; }
.health-score1 { background: #f87171; }
.health-score0 { background: rgb(239, 67, 67); }
.health-healthy { background: #22c55e; }
.health-warning { background: #f59e0b; }
.health-critical { background: #ef4444; }
.health-unknown { background: #9ca3af; }
.matrix-select {
min-width: 10rem;
}
details > summary::-webkit-details-marker {
display: none;
}
</style>
+7 -166
View File
@@ -1,172 +1,13 @@
<template>
<AppLayout>
<MonitorHero
:overall-status="overallStatus"
:interval-seconds="DEFAULT_INTERVAL_SECONDS"
:window="currentWindow"
:loading="loading"
:auto-refresh="autoRefresh"
@update:window="handleWindowChange"
@refresh="manualReload"
/>
<MonitorCardGrid
:items="items"
:window="currentWindow"
:countdown-seconds="countdown"
:loading="loading"
:detail-cache="detailCache"
@card-click="openDetail"
/>
<MonitorDetailDialog
:show="showDetail"
:monitor-id="detailTarget?.id ?? null"
:title="detailTitle"
@close="closeDetail"
/>
</AppLayout>
<ChannelStatusV1View v-if="isV1" />
<ChannelStatusV2View v-else />
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import {
list as listChannelMonitorViews,
status as fetchChannelMonitorDetail,
type UserMonitorView,
type UserMonitorDetail,
} from '@/api/channelMonitor'
import AppLayout from '@/components/layout/AppLayout.vue'
import MonitorHero, {
type MonitorWindow,
type OverallStatus,
} from '@/components/user/monitor/MonitorHero.vue'
import MonitorCardGrid from '@/components/user/monitor/MonitorCardGrid.vue'
import MonitorDetailDialog from '@/components/user/MonitorDetailDialog.vue'
import { DEFAULT_INTERVAL_SECONDS, STATUS_OPERATIONAL } from '@/constants/channelMonitor'
import { useAutoRefresh } from '@/composables/useAutoRefresh'
import { computed } from 'vue'
import { isChannelMonitorV1Mode } from '@/utils/featureFlags'
import ChannelStatusV1View from './ChannelStatusV1View.vue'
import ChannelStatusV2View from './ChannelStatusV2View.vue'
const { t } = useI18n()
const appStore = useAppStore()
// ── State ──
const items = ref<UserMonitorView[]>([])
const loading = ref(false)
const currentWindow = ref<MonitorWindow>('7d')
const detailCache = reactive<Record<number, UserMonitorDetail>>({})
const showDetail = ref(false)
const detailTarget = ref<UserMonitorView | null>(null)
let abortController: AbortController | null = null
const autoRefresh = useAutoRefresh({
storageKey: 'channel-status-auto-refresh',
intervals: [30, 60, 120] as const,
defaultInterval: DEFAULT_INTERVAL_SECONDS,
onRefresh: () => reload(true),
shouldPause: () => document.hidden || loading.value,
})
const countdown = autoRefresh.countdown
// ── Computed ──
const overallStatus = computed<OverallStatus>(() => {
if (items.value.length === 0) return 'operational'
for (const it of items.value) {
if (it.primary_status === 'failed' || it.primary_status === 'error') return 'degraded'
if (it.primary_status !== STATUS_OPERATIONAL) return 'degraded'
}
return 'operational'
})
const detailTitle = computed(() => {
return detailTarget.value?.name || t('channelStatus.detailTitle')
})
// ── Loaders ──
async function reload(silent = false) {
if (abortController) abortController.abort()
const ctrl = new AbortController()
abortController = ctrl
if (!silent) loading.value = true
try {
const res = await listChannelMonitorViews({ signal: ctrl.signal })
if (ctrl.signal.aborted || abortController !== ctrl) return
items.value = res.items || []
} catch (err: unknown) {
const e = err as { name?: string; code?: string }
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
appStore.showError(extractApiErrorMessage(err, t('channelStatus.loadError')))
} finally {
if (abortController === ctrl) {
if (!silent) loading.value = false
countdown.value = DEFAULT_INTERVAL_SECONDS
abortController = null
}
}
}
async function manualReload() {
await reload(false)
// After base reload, refresh any cached detail records so non-7d availability
// values stay in sync without forcing the user to switch tabs again.
if (currentWindow.value !== '7d') {
await Promise.all(items.value.map(it => loadDetail(it.id, true)))
}
}
async function loadDetail(id: number, force = false) {
if (!force && detailCache[id]) return
try {
detailCache[id] = await fetchChannelMonitorDetail(id)
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('channelStatus.detailLoadError')))
}
}
async function ensureDetailsForWindow() {
if (currentWindow.value === '7d') return
await Promise.all(items.value.map(it => loadDetail(it.id)))
}
// ── Handlers ──
async function handleWindowChange(value: MonitorWindow) {
currentWindow.value = value
await ensureDetailsForWindow()
}
function openDetail(row: UserMonitorView) {
detailTarget.value = row
showDetail.value = true
}
function closeDetail() {
showDetail.value = false
detailTarget.value = null
}
watch(items, () => {
void ensureDetailsForWindow()
})
watch(
() => appStore.cachedPublicSettings?.channel_monitor_enabled,
(enabled) => {
if (enabled === false) autoRefresh.stop()
else if (autoRefresh.enabled.value) autoRefresh.start()
},
)
onMounted(() => {
void reload(false)
if (appStore.cachedPublicSettings?.channel_monitor_enabled !== false) {
autoRefresh.setEnabled(true)
}
})
onBeforeUnmount(() => {
if (abortController) abortController.abort()
})
const isV1 = computed(() => isChannelMonitorV1Mode())
</script>
@@ -0,0 +1,38 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { defineComponent, h } from 'vue'
import { mount } from '@vue/test-utils'
const isV1 = vi.fn(() => false)
vi.mock('@/utils/featureFlags', () => ({
isChannelMonitorV1Mode: () => isV1(),
}))
vi.mock('../ChannelStatusV1View.vue', () => ({
default: defineComponent({ name: 'ChannelStatusV1View', setup: () => () => h('div', { 'data-testid': 'v1' }) }),
}))
vi.mock('../ChannelStatusV2View.vue', () => ({
default: defineComponent({ name: 'ChannelStatusV2View', setup: () => () => h('div', { 'data-testid': 'v2' }) }),
}))
import ChannelStatusView from '../ChannelStatusView.vue'
describe('ChannelStatusView mode switch', () => {
beforeEach(() => {
isV1.mockReset()
})
it('renders V2 when not in v1 mode', () => {
isV1.mockReturnValue(false)
const wrapper = mount(ChannelStatusView)
expect(wrapper.find('[data-testid="v2"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="v1"]').exists()).toBe(false)
})
it('renders V1 when in v1 mode', () => {
isV1.mockReturnValue(true)
const wrapper = mount(ChannelStatusView)
expect(wrapper.find('[data-testid="v1"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="v2"]').exists()).toBe(false)
})
})