mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
Merge pull request #6759 from ssharkkky/feat/cmv2-user-ranking-toggle
feat(channel-monitor-v2): add user ranking toggle and keep missing TTFT neutral
This commit is contained in:
@@ -376,6 +376,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
|
||||
|
||||
GrokDefaultTextModel: settings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
|
||||
|
||||
@@ -334,6 +334,7 @@ type UpdateSettingsRequest struct {
|
||||
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking *bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
// Grok model mapping policy
|
||||
GrokDefaultTextModel *string `json:"grok_default_text_model"`
|
||||
@@ -1906,6 +1907,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.ChannelMonitorShowQuota
|
||||
}(),
|
||||
ChannelMonitorHideUserRanking: func() bool {
|
||||
if req.ChannelMonitorHideUserRanking != nil {
|
||||
return *req.ChannelMonitorHideUserRanking
|
||||
}
|
||||
return previousSettings.ChannelMonitorHideUserRanking
|
||||
}(),
|
||||
GrokDefaultTextModel: func() string {
|
||||
if req.GrokDefaultTextModel != nil {
|
||||
return *req.GrokDefaultTextModel
|
||||
@@ -2366,6 +2373,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: updatedSettings.ChannelMonitorHideUserRanking,
|
||||
|
||||
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
|
||||
|
||||
@@ -307,6 +307,7 @@ type SystemSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
|
||||
GrokDefaultTextModel string `json:"grok_default_text_model"`
|
||||
@@ -417,6 +418,7 @@ type PublicSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
|
||||
@@ -108,6 +108,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
|
||||
@@ -992,6 +992,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"channel_monitor_mode": "v1",
|
||||
"channel_monitor_hide_throughput": true,
|
||||
"channel_monitor_show_quota": false,
|
||||
"channel_monitor_hide_user_ranking": false,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"model_plaza_enabled": false,
|
||||
@@ -1305,6 +1306,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"channel_monitor_mode": "v1",
|
||||
"channel_monitor_hide_throughput": true,
|
||||
"channel_monitor_show_quota": false,
|
||||
"channel_monitor_hide_user_ranking": false,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"model_plaza_enabled": false,
|
||||
|
||||
@@ -405,6 +405,18 @@ func (s *ChannelMonitorV2Service) hideThroughputForViewer(ctx context.Context, a
|
||||
return s.settings.GetChannelMonitorRuntime(ctx).HideThroughput
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) hideUserRankingForViewer(ctx context.Context, admin bool) bool {
|
||||
if admin {
|
||||
return false
|
||||
}
|
||||
// Missing reader keeps the current ranking tab visible. Unlike throughput,
|
||||
// ranking is already public and must stay on until an operator turns it off.
|
||||
if s == nil || s.settings == nil {
|
||||
return false
|
||||
}
|
||||
return s.settings.GetChannelMonitorRuntime(ctx).HideUserRanking
|
||||
}
|
||||
|
||||
func (s *ChannelMonitorV2Service) GetConfig(ctx context.Context) (*ChannelMonitorV2Config, error) {
|
||||
return s.repo.GetConfig(ctx)
|
||||
}
|
||||
@@ -652,6 +664,9 @@ func (s *ChannelMonitorV2Service) Users(ctx context.Context, filter ChannelMonit
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.hideUserRankingForViewer(ctx, admin) {
|
||||
return &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{}}, nil
|
||||
}
|
||||
result, err := s.repo.GetUsers(ctx, filter, *cfg, admin)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -252,6 +252,20 @@ func TestChannelMonitorV2HealthBlendsErrorTTFTAndCache(t *testing.T) {
|
||||
require.Nil(t, health.Score)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2HealthLeavesMissingTTFTUnknown(t *testing.T) {
|
||||
health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{
|
||||
RequestCount: 200,
|
||||
ErrorRate: 0,
|
||||
CacheRate: 0,
|
||||
CacheRateDenominator: 200,
|
||||
TTFT: ChannelMonitorV2Latency{SampleCount: 0},
|
||||
})
|
||||
require.Equal(t, "unknown", health.TTFT)
|
||||
require.Nil(t, health.TTFTScore)
|
||||
require.NotEqual(t, "critical", health.Overall)
|
||||
require.Equal(t, "healthy", health.Overall)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2DefaultHealthThresholdsAreTolerant(t *testing.T) {
|
||||
p50 := int64(2500)
|
||||
health := ChannelMonitorV2HealthFor(ChannelMonitorV2Metric{
|
||||
@@ -287,6 +301,32 @@ func TestErrorRateTTFTAndCacheScoreHelpers(t *testing.T) {
|
||||
require.Equal(t, "healthy", cacheRateBand(0.50, 0.20, 0.05))
|
||||
}
|
||||
|
||||
type channelMonitorV2RuntimeStub struct {
|
||||
rt ChannelMonitorRuntime
|
||||
}
|
||||
|
||||
func (s channelMonitorV2RuntimeStub) GetChannelMonitorRuntime(context.Context) ChannelMonitorRuntime {
|
||||
return s.rt
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2UsersHiddenWhenSettingEnabled(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"},
|
||||
}}}
|
||||
svc := NewChannelMonitorV2Service(repo)
|
||||
svc.SetRuntimeReader(channelMonitorV2RuntimeStub{rt: ChannelMonitorRuntime{HideUserRanking: true}})
|
||||
|
||||
hidden, err := svc.Users(context.Background(), ChannelMonitorV2Filter{}, selfID, false)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, hidden.Items)
|
||||
|
||||
admin, err := svc.Users(context.Background(), ChannelMonitorV2Filter{}, selfID, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, admin.Items, 2)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2UsersRemovesOtherUserIdentity(t *testing.T) {
|
||||
selfID, otherID := int64(7), int64(9)
|
||||
repo := &channelMonitorV2RepoStub{config: ChannelMonitorV2Config{Enabled: true}, users: &ChannelMonitorV2List[ChannelMonitorV2UserRow]{Items: []ChannelMonitorV2UserRow{
|
||||
|
||||
@@ -490,6 +490,10 @@ const (
|
||||
// fail-closed (only the literal "true" enables it). Admin endpoints always
|
||||
// keep the full snapshots regardless of this flag.
|
||||
SettingKeyChannelMonitorShowQuota = "channel_monitor_show_quota"
|
||||
// SettingKeyChannelMonitorHideUserRanking hides the user ranking tab and
|
||||
// /users payload from non-admin channel-monitor v2 viewers.
|
||||
// Default false (keep the current ranking tab). Admin endpoints always keep it.
|
||||
SettingKeyChannelMonitorHideUserRanking = "channel_monitor_hide_user_ranking"
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -191,6 +191,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
|
||||
SettingKeyChannelMonitorDefaultIntervalSeconds: "60",
|
||||
SettingKeyChannelMonitorHideThroughput: "true",
|
||||
SettingKeyChannelMonitorShowQuota: "false",
|
||||
SettingKeyChannelMonitorHideUserRanking: "false",
|
||||
|
||||
// Grok compatibility defaults: cross-client mapping stays enabled unless
|
||||
// operators explicitly disable it.
|
||||
@@ -804,6 +805,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
// 配额展示默认关闭且 fail-closed:仅字面 "true" 视为开启
|
||||
// (与 setting_public.go 公开读取路径保持一致)。
|
||||
result.ChannelMonitorShowQuota = settings[SettingKeyChannelMonitorShowQuota] == "true"
|
||||
result.ChannelMonitorHideUserRanking = isTrueSettingValue(settings[SettingKeyChannelMonitorHideUserRanking])
|
||||
|
||||
// Grok default mapping policy
|
||||
result.GrokDefaultTextModel = strings.TrimSpace(settings[SettingKeyGrokDefaultTextModel])
|
||||
@@ -1010,6 +1012,15 @@ func isFalseSettingValue(value string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func isTrueSettingValue(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "true", "1", "on", "enabled":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) {
|
||||
_ = enabled
|
||||
source = strings.TrimSpace(source)
|
||||
|
||||
@@ -232,6 +232,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||||
SettingKeyChannelMonitorHideThroughput,
|
||||
SettingKeyChannelMonitorShowQuota,
|
||||
SettingKeyChannelMonitorHideUserRanking,
|
||||
SettingKeyAvailableChannelsEnabled,
|
||||
SettingKeyModelPlazaEnabled,
|
||||
SettingKeyModelPlazaRequireAuth,
|
||||
@@ -358,6 +359,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
||||
ChannelMonitorHideThroughput: !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]),
|
||||
ChannelMonitorShowQuota: settings[SettingKeyChannelMonitorShowQuota] == "true",
|
||||
ChannelMonitorHideUserRanking: isTrueSettingValue(settings[SettingKeyChannelMonitorHideUserRanking]),
|
||||
|
||||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||||
|
||||
@@ -430,6 +432,9 @@ type ChannelMonitorRuntime struct {
|
||||
// snapshots; otherwise the user handler strips them server-side.
|
||||
// Parsed fail-closed (only literal "true" enables). Admin always sees them.
|
||||
ShowQuota bool
|
||||
// HideUserRanking: when true, user-facing V2 views hide the user ranking tab
|
||||
// and the /users payload. Parsed fail-open (only literal "true" hides it).
|
||||
HideUserRanking bool
|
||||
}
|
||||
|
||||
// ActiveProbesAllowed reports whether V1 active provider probes may run.
|
||||
@@ -459,6 +464,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo
|
||||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||||
SettingKeyChannelMonitorHideThroughput,
|
||||
SettingKeyChannelMonitorShowQuota,
|
||||
SettingKeyChannelMonitorHideUserRanking,
|
||||
})
|
||||
if err != nil {
|
||||
return ChannelMonitorRuntime{
|
||||
@@ -474,6 +480,7 @@ func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMo
|
||||
DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
||||
HideThroughput: !isFalseSettingValue(vals[SettingKeyChannelMonitorHideThroughput]),
|
||||
ShowQuota: vals[SettingKeyChannelMonitorShowQuota] == "true",
|
||||
HideUserRanking: isTrueSettingValue(vals[SettingKeyChannelMonitorHideUserRanking]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -618,14 +625,17 @@ type PublicSettingsInjectionPayload struct {
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
// ChannelMonitorShowQuota gates the user-facing quota/balance display on
|
||||
// monitors; fail-closed (absent/false = hidden). Admin UI always shows it.
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
|
||||
// ChannelMonitorHideUserRanking hides the user ranking tab and /users payload
|
||||
// from non-admin channel-monitor v2 viewers; default false (visible).
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
PluginManagementEnabled bool `json:"plugin_management_enabled"`
|
||||
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.
|
||||
@@ -700,6 +710,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
|
||||
@@ -139,6 +139,21 @@ func TestSettingService_ChannelMonitorShowQuotaFailsClosed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingService_ChannelMonitorHideUserRankingDefaultsToVisible(t *testing.T) {
|
||||
missing := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
|
||||
require.False(t, missing.HideUserRanking)
|
||||
public, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).GetPublicSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.False(t, public.ChannelMonitorHideUserRanking)
|
||||
|
||||
for _, value := range []string{"true", "1", "on", "enabled"} {
|
||||
runtime := NewSettingService(&settingPublicRepoStub{values: map[string]string{
|
||||
SettingKeyChannelMonitorHideUserRanking: value,
|
||||
}}, &config.Config{}).GetChannelMonitorRuntime(context.Background())
|
||||
require.True(t, runtime.HideUserRanking, "value=%q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) {
|
||||
repo := &settingPublicRepoStub{
|
||||
values: map[string]string{
|
||||
|
||||
@@ -418,6 +418,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
}
|
||||
updates[SettingKeyChannelMonitorHideThroughput] = strconv.FormatBool(settings.ChannelMonitorHideThroughput)
|
||||
updates[SettingKeyChannelMonitorShowQuota] = strconv.FormatBool(settings.ChannelMonitorShowQuota)
|
||||
updates[SettingKeyChannelMonitorHideUserRanking] = strconv.FormatBool(settings.ChannelMonitorHideUserRanking)
|
||||
|
||||
// Grok model mapping policy
|
||||
if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" {
|
||||
|
||||
@@ -202,6 +202,7 @@ type SystemSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
// Grok model mapping policy (admin settings; empty mapping falls back to these).
|
||||
GrokDefaultTextModel string `json:"grok_default_text_model"`
|
||||
@@ -382,6 +383,7 @@ type PublicSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
// Grok model mapping policy (admin settings).
|
||||
GrokDefaultTextModel string `json:"grok_default_text_model"`
|
||||
|
||||
@@ -722,6 +722,7 @@ export interface SystemSettings {
|
||||
channel_monitor_default_interval_seconds: number;
|
||||
channel_monitor_hide_throughput?: boolean;
|
||||
channel_monitor_show_quota?: boolean;
|
||||
channel_monitor_hide_user_ranking?: boolean;
|
||||
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled: boolean;
|
||||
@@ -1023,6 +1024,7 @@ export interface UpdateSettingsRequest {
|
||||
channel_monitor_default_interval_seconds?: number;
|
||||
channel_monitor_hide_throughput?: boolean;
|
||||
channel_monitor_show_quota?: boolean;
|
||||
channel_monitor_hide_user_ranking?: boolean;
|
||||
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled?: boolean;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
:title="title || undefined"
|
||||
>
|
||||
<div
|
||||
v-if="state"
|
||||
v-if="resolvedState"
|
||||
class="mt-1 h-2 w-2 shrink-0 rounded-full"
|
||||
:class="dotClass"
|
||||
aria-hidden="true"
|
||||
@@ -56,18 +56,25 @@ const detailParts = computed(() => {
|
||||
.filter(Boolean)
|
||||
})
|
||||
|
||||
const missingValue = computed(() => {
|
||||
const value = (props.value || '').trim()
|
||||
return value === '' || value === '-' || value === '—'
|
||||
})
|
||||
|
||||
const resolvedState = computed(() => (missingValue.value ? undefined : props.state))
|
||||
|
||||
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'
|
||||
if (!resolvedState.value) return missingValue.value ? 'text-gray-500 dark:text-dark-400' : 'text-gray-900 dark:text-white'
|
||||
if (resolvedState.value === 'healthy') return 'text-emerald-600 dark:text-emerald-400'
|
||||
if (resolvedState.value === 'warning') return 'text-amber-600 dark:text-amber-400'
|
||||
if (resolvedState.value === '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'
|
||||
if (resolvedState.value === 'healthy') return 'bg-emerald-500'
|
||||
if (resolvedState.value === 'warning') return 'bg-amber-500'
|
||||
if (resolvedState.value === 'critical') return 'bg-red-500'
|
||||
return 'bg-gray-300 dark:bg-dark-600'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -20,6 +20,19 @@ describe('MetricCell', () => {
|
||||
expect(wrapper.find('strong').classes().join(' ')).toMatch(/emerald/)
|
||||
})
|
||||
|
||||
it('does not paint missing first-token dash as critical red', () => {
|
||||
const wrapper = mount(MetricCell, {
|
||||
props: {
|
||||
label: '首 Token P50',
|
||||
value: '-',
|
||||
detail: '-',
|
||||
state: 'critical',
|
||||
},
|
||||
})
|
||||
expect(wrapper.find('strong').classes().join(' ')).not.toMatch(/red/)
|
||||
expect(wrapper.find('strong').classes().join(' ')).toMatch(/gray|dark/)
|
||||
})
|
||||
|
||||
it('maps warning and critical health states to distinct colors', () => {
|
||||
const warning = mount(MetricCell, {
|
||||
props: { label: '错误', value: '10%', detail: '1 次', state: 'warning' },
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
healthStateClass,
|
||||
scoreToBand,
|
||||
tokensPerSecondFromTpm,
|
||||
ttftDisplayState,
|
||||
} from '../monitorFormat'
|
||||
import type { MonitorHealth } from '@/api/channelMonitorV2'
|
||||
|
||||
@@ -111,6 +112,23 @@ describe('monitorFormat accuracy', () => {
|
||||
expect(healthStateClass(undefined)).toBe('health-unknown')
|
||||
})
|
||||
|
||||
it('keeps missing first-token samples neutral instead of critical', () => {
|
||||
const health: MonitorHealth = {
|
||||
overall: 'healthy',
|
||||
error_rate: 'healthy',
|
||||
ttft: 'critical',
|
||||
cache: 'healthy',
|
||||
score: 90,
|
||||
error_rate_score: 100,
|
||||
ttft_score: null,
|
||||
cache_score: 100,
|
||||
minimum_sample: 20,
|
||||
}
|
||||
expect(healthScoreClass(health, 'ttft', 200)).toBe('health-unknown')
|
||||
expect(ttftDisplayState('critical', { p50_ms: null, sample_count: 0 })).toBe('unknown')
|
||||
expect(ttftDisplayState('healthy', { p50_ms: 400, sample_count: 20 })).toBe('healthy')
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* request/error/token counts in user-facing surfaces.
|
||||
*/
|
||||
|
||||
import type { HealthScoreBand, MonitorHealth } from '@/api/channelMonitorV2'
|
||||
import type { HealthScoreBand, HealthState, MonitorHealth } from '@/api/channelMonitorV2'
|
||||
import { formatCompactNumber } from '@/utils/format'
|
||||
|
||||
export function monitorIntlLocale(): string {
|
||||
@@ -114,22 +114,36 @@ export function healthScoreClass(
|
||||
requestCount: number,
|
||||
): string {
|
||||
const score = healthModeScore(health, mode)
|
||||
// Sync-only traffic has no first-token samples. Never paint TTFT as red/empty-fail.
|
||||
if (mode === 'ttft' && score == null) return 'health-unknown'
|
||||
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
|
||||
: mode === 'cache'
|
||||
? health.cache
|
||||
: health.overall
|
||||
return healthStateClass(coarse)
|
||||
}
|
||||
return `health-${scoreToBand(score)}`
|
||||
}
|
||||
|
||||
/** Missing first-token samples are "not applicable", not a failed latency budget. */
|
||||
export function isTtftUnavailable(ttft?: { p50_ms?: number | null; sample_count?: number } | null): boolean {
|
||||
if (ttft == null) return true
|
||||
return ttft.p50_ms == null
|
||||
}
|
||||
|
||||
export function ttftDisplayState(
|
||||
state: HealthState | undefined,
|
||||
ttft?: { p50_ms?: number | null; sample_count?: number } | null,
|
||||
): HealthState | undefined {
|
||||
if (isTtftUnavailable(ttft)) return 'unknown'
|
||||
return state
|
||||
}
|
||||
|
||||
export function healthStateClass(state: string | undefined): string {
|
||||
return `health-${state || 'unknown'}`
|
||||
}
|
||||
|
||||
@@ -34,6 +34,9 @@ export default {
|
||||
showQuota: 'Show channel usage/balance to users',
|
||||
showQuotaHint:
|
||||
'When on, quota-mode channel monitors expose the linked account usage windows/balance on the user Channel Status page. Disabled by default; admins always see it.',
|
||||
hideUserRanking: 'Hide user ranking from users',
|
||||
hideUserRankingHint:
|
||||
'When on, the user Channel Monitor V2 page hides the user ranking tab and the user API returns no ranking rows. Admins still see the ranking.',
|
||||
},
|
||||
availableChannels: {
|
||||
title: 'Available Channels',
|
||||
|
||||
@@ -34,6 +34,9 @@ export default {
|
||||
showQuota: '向用户展示渠道用量/余额',
|
||||
showQuotaHint:
|
||||
'开启后,配额模式的渠道监控会在用户端渠道状态页展示关联账号的用量滚动窗口/余额。默认关闭;管理员始终可见。',
|
||||
hideUserRanking: '对用户隐藏用户排行',
|
||||
hideUserRankingHint:
|
||||
'开启后,用户端渠道监控 V2 不再显示「用户排行」页,用户 API 也不返回排行数据。管理员仍可查看。',
|
||||
},
|
||||
availableChannels: {
|
||||
title: '可用渠道',
|
||||
|
||||
@@ -273,6 +273,8 @@ export interface PublicSettings {
|
||||
channel_monitor_hide_throughput?: boolean
|
||||
/** When true, user monitor shows account quota/balance snapshots (default off). */
|
||||
channel_monitor_show_quota?: boolean
|
||||
/** When true, user monitor hides the user ranking tab and /users payload. */
|
||||
channel_monitor_hide_user_ranking?: boolean
|
||||
available_channels_enabled: boolean
|
||||
model_plaza_enabled: boolean
|
||||
model_plaza_require_auth: boolean
|
||||
|
||||
@@ -202,3 +202,9 @@ export function isChannelMonitorQuotaVisible(): boolean {
|
||||
const appStore = useAppStore()
|
||||
return appStore.cachedPublicSettings?.channel_monitor_show_quota === true
|
||||
}
|
||||
|
||||
/** Hide the user ranking tab on user-facing monitor v2. Admin always keeps it. */
|
||||
export function isChannelMonitorUserRankingHidden(): boolean {
|
||||
const appStore = useAppStore()
|
||||
return Boolean(appStore.cachedPublicSettings?.channel_monitor_hide_user_ranking)
|
||||
}
|
||||
|
||||
@@ -7104,16 +7104,29 @@
|
||||
</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 v-if="form.channel_monitor_mode === 'v2'" class="space-y-4">
|
||||
<div 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 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.hideUserRanking') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.features.channelMonitor.hideUserRankingHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.channel_monitor_hide_user_ranking" />
|
||||
</div>
|
||||
<Toggle v-model="form.channel_monitor_hide_throughput" />
|
||||
</div>
|
||||
|
||||
<div v-if="form.channel_monitor_mode === 'v1'" class="flex items-start justify-between gap-4">
|
||||
@@ -9483,6 +9496,7 @@ type SettingsForm = Omit<
|
||||
/** Form always binds a concrete boolean (SystemSettings marks this optional). */
|
||||
channel_monitor_hide_throughput: boolean;
|
||||
channel_monitor_show_quota: boolean;
|
||||
channel_monitor_hide_user_ranking: boolean;
|
||||
smtp_password: string;
|
||||
turnstile_secret_key: string;
|
||||
tencent_captcha_app_secret_key: string;
|
||||
@@ -9797,6 +9811,7 @@ const form = reactive<SettingsForm>({
|
||||
channel_monitor_default_interval_seconds: 60,
|
||||
channel_monitor_hide_throughput: false,
|
||||
channel_monitor_show_quota: false,
|
||||
channel_monitor_hide_user_ranking: false,
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled: false,
|
||||
// Model Plaza feature switches + description
|
||||
@@ -10803,6 +10818,9 @@ async function loadSettings() {
|
||||
form.channel_monitor_show_quota = Boolean(
|
||||
settings.channel_monitor_show_quota
|
||||
);
|
||||
form.channel_monitor_hide_user_ranking = Boolean(
|
||||
settings.channel_monitor_hide_user_ranking
|
||||
);
|
||||
form.login_agreement_updated_at =
|
||||
settings.login_agreement_updated_at || "2026-03-31";
|
||||
form.login_agreement_documents =
|
||||
@@ -11457,6 +11475,7 @@ async function saveSettings() {
|
||||
Number(form.channel_monitor_default_interval_seconds) || 60,
|
||||
channel_monitor_hide_throughput: Boolean(form.channel_monitor_hide_throughput),
|
||||
channel_monitor_show_quota: Boolean(form.channel_monitor_show_quota),
|
||||
channel_monitor_hide_user_ranking: Boolean(form.channel_monitor_hide_user_ranking),
|
||||
// Available Channels feature switch
|
||||
available_channels_enabled: form.available_channels_enabled,
|
||||
// Model Plaza feature switches + description
|
||||
|
||||
@@ -211,7 +211,7 @@
|
||||
:value="formatMs(snapshot.metrics.ttft.p50_ms)"
|
||||
:detail="latencyKpiSecondary(snapshot.metrics.ttft)"
|
||||
:title="latencyDetail(snapshot.metrics.ttft)"
|
||||
:state="snapshot.health.ttft"
|
||||
:state="ttftCellState(snapshot.health.ttft, snapshot.metrics.ttft)"
|
||||
/>
|
||||
<MetricCell
|
||||
v-if="showThroughput"
|
||||
@@ -473,7 +473,7 @@ 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 { isChannelMonitorThroughputHidden, isChannelMonitorUserRankingHidden } from '@/utils/featureFlags'
|
||||
import * as api from '@/api/channelMonitorV2'
|
||||
import type {
|
||||
HealthState,
|
||||
@@ -498,6 +498,7 @@ import {
|
||||
tokensPerSecondFromTpm,
|
||||
healthScoreClass,
|
||||
monitorErrorCategoryLabel,
|
||||
ttftDisplayState,
|
||||
} from '@/features/channel-monitor-v2/monitorFormat'
|
||||
|
||||
type Tab = 'models' | 'errors' | 'users'
|
||||
@@ -512,6 +513,8 @@ 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())
|
||||
/** Admins always see ranking; users honor the hide-user-ranking system setting. */
|
||||
const showUserRanking = computed(() => isAdmin.value || !isChannelMonitorUserRankingHidden())
|
||||
|
||||
const ranges = computed(() => [
|
||||
{ value: '90m' as MonitorRange, label: t('channelMonitorV2.ranges.90m') },
|
||||
@@ -519,11 +522,16 @@ const ranges = computed(() => [
|
||||
{ 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 tabs = computed(() => {
|
||||
const items: Array<{ value: Tab; label: string }> = [
|
||||
{ value: 'models', label: t('channelMonitorV2.tabs.models') },
|
||||
{ value: 'errors', label: t('channelMonitorV2.tabs.errors') },
|
||||
]
|
||||
if (showUserRanking.value) {
|
||||
items.push({ value: 'users', label: t('channelMonitorV2.tabs.users') })
|
||||
}
|
||||
return items
|
||||
})
|
||||
const matrixGroupOptions = computed(() => [
|
||||
{ value: 'platform' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platform') },
|
||||
{ value: 'platform_group' as MonitorMatrixGroupBy, label: t('channelMonitorV2.groupBy.platformGroup') },
|
||||
@@ -543,9 +551,7 @@ const filter = ref<MonitorFilter>({
|
||||
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 activeTab = ref<Tab>(parseTab(route.query.tab, showUserRanking.value))
|
||||
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))
|
||||
@@ -672,6 +678,10 @@ function parseMatrixGroupBy(value: unknown): MonitorMatrixGroupBy {
|
||||
? (value as MonitorMatrixGroupBy)
|
||||
: 'platform_group'
|
||||
}
|
||||
function parseTab(value: unknown, allowUsers: boolean): Tab {
|
||||
const allowed: Tab[] = allowUsers ? ['models', 'errors', 'users'] : ['models', 'errors']
|
||||
return allowed.includes(value as Tab) ? (value as Tab) : 'models'
|
||||
}
|
||||
function parseHealthMode(value: unknown): HealthMode {
|
||||
const allowed: HealthMode[] = ['overall', 'success', 'ttft', 'cache']
|
||||
return allowed.includes(value as HealthMode) ? (value as HealthMode) : 'overall'
|
||||
@@ -773,8 +783,10 @@ async function loadTab(signal?: AbortSignal, id = sequence) {
|
||||
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 {
|
||||
} else if (showUserRanking.value) {
|
||||
userRows.value = (await api.getUsers(filter.value, isAdmin.value, signal)).items || []
|
||||
} else {
|
||||
userRows.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
const e = error as { name?: string; code?: string }
|
||||
@@ -835,6 +847,9 @@ function formatPercent(value: number) {
|
||||
function formatMs(value: number | null) {
|
||||
return formatMonitorMs(value)
|
||||
}
|
||||
function ttftCellState(state: HealthState | undefined, metric: { p50_ms: number | null; sample_count?: number }) {
|
||||
return ttftDisplayState(state, metric)
|
||||
}
|
||||
function latencyDetail(metric: {
|
||||
p50_ms: number | null
|
||||
p90_ms?: number | null
|
||||
@@ -904,6 +919,11 @@ watch(activeTab, () => {
|
||||
syncQuery()
|
||||
void loadTab()
|
||||
})
|
||||
watch(showUserRanking, (allowed) => {
|
||||
if (!allowed && activeTab.value === 'users') {
|
||||
activeTab.value = 'models'
|
||||
}
|
||||
})
|
||||
onMounted(() => void reload(false))
|
||||
onBeforeUnmount(() => {
|
||||
controller?.abort()
|
||||
|
||||
Reference in New Issue
Block a user