Merge pull request #5356 from IanShaw027/feat/channel-monitor-v2-ops-ui
feat(channel-monitor-v2): 新增被动渠道监控 V2
This commit is contained in:
87 files changed
+10453
-231
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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');
|
||||
@@ -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',
|
||||
},
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 历史(当前模式未启用探测)',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user