feat(channel-monitor-v2): 补齐管理端配置、隐藏吞吐开关与测试

管理端 V2 配置面板与系统设置中的模式/隐藏 RPM·TPM 开关,纳入关键
vitest 与 Makefile 前端关键路径,覆盖门控与展示行为。
This commit is contained in:
IanShaw027
2026-08-07 11:05:32 +08:00
parent d8c5024cee
commit d2d91ff9a8
10 changed files with 244 additions and 27 deletions
+6 -1
View File
@@ -3,12 +3,17 @@
FRONTEND_CRITICAL_VITEST := \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/channelMonitorV2.spec.ts \
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
src/views/auth/__tests__/WechatCallbackView.spec.ts \
src/views/user/__tests__/PaymentView.spec.ts \
src/views/user/__tests__/PaymentResultView.spec.ts \
src/views/user/__tests__/ChannelStatusView.mode.spec.ts \
src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \
src/views/admin/__tests__/SettingsView.spec.ts
src/views/admin/__tests__/SettingsView.spec.ts \
src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \
src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \
src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts
# 一键编译前后端
build: build-backend build-frontend
+4
View File
@@ -670,7 +670,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;
@@ -961,7 +963,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;
+12 -3
View File
@@ -16,12 +16,21 @@ export default {
features: {
channelMonitor: {
title: 'Channel Monitor',
description: 'Periodically probe configured channels and surface availability / latency to users. Turning it off stops the scheduler and returns an empty list on the user page.',
description: 'Choose either V1 active probes or V2 passive usage monitoring. When disabled, both background jobs stop and the user entry is hidden.',
configureLink: 'Configure monitors in Channel Management > Channel Monitor',
enabled: 'Enable Channel Monitor',
enabledHint: 'Disabling stops background checks; existing history is preserved.',
enabledHint: 'Disabling stops both the V1 scheduler and V2 aggregation; existing config and history are kept.',
mode: 'Monitor mode',
modeHint: 'Only one implementation can be active: V2 never sends upstream probes; V1 probes configured monitors on a schedule.',
modeV2: 'V2 passive monitoring',
modeV1: 'V1 active probes',
modeV2Hint: 'Aggregates health metrics from real gateway traffic without upstream probe traffic.',
modeV1Hint: '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',
+12 -3
View File
@@ -16,12 +16,21 @@ export default {
features: {
channelMonitor: {
title: '渠道监控',
description: '定期对配置的渠道发起健康检查,向用户展示可用性与延迟。关闭后调度器停止扫描,用户端列表为空。',
description: '启用后在 V1 主动探测与 V2 被动用量监控中二选一。关闭后两种模式的后台任务均停止,用户端入口隐藏。',
configureLink: '前往 渠道管理 > 渠道监控 配置监控项',
enabled: '启用渠道监控',
enabledHint: '关闭后后台不再执行定时检测,已有数据保留。',
enabledHint: '关闭后 V1 调度器与 V2 聚合均停止;已有配置与历史保留。',
mode: '监控模式',
modeHint: '同一时间只能启用一种实现:V2 不产生上游探测流量;V1 会按配置的监控项主动探活。',
modeV2: 'V2 被动监控',
modeV1: 'V1 主动探测',
modeV2Hint: '基于真实网关流量聚合健康指标,不向上游发送探活请求。',
modeV1Hint: '按配置的渠道监控项定时发起上游健康检查(产生探测流量)。',
defaultInterval: '默认检测间隔(秒)',
defaultIntervalHint: '新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。',
defaultIntervalHint: '仅 V1 模式使用:新建渠道监控时表单的默认值,可被单个渠道覆盖。范围 15 – 3600 秒。',
hideThroughput: '对用户隐藏吞吐速率(RPM / TPM)',
hideThroughputHint:
'开启后,用户端渠道监控页面与用户 API 不返回 RPM/TPM,避免用「速率 × 时间窗」反推集群规模。管理员仍可见完整指标;错误率、延迟、缓存率照常展示。',
},
availableChannels: {
title: '可用渠道',
+4
View File
@@ -262,7 +262,11 @@ export interface PublicSettings {
account_quota_notify_enabled: boolean
balance_low_notify_threshold: number
channel_monitor_enabled: boolean
/** Exclusive mode: v1 active probes or v2 passive aggregation. Default v2. */
channel_monitor_mode?: 'v1' | 'v2'
channel_monitor_default_interval_seconds: number
/** When true, user monitor hides RPM/TPM so scale cannot be reverse-estimated. */
channel_monitor_hide_throughput?: boolean
available_channels_enabled: boolean
model_plaza_enabled: boolean
model_plaza_require_auth: boolean
+35 -1
View File
@@ -68,9 +68,9 @@
* `makeSidebarFlag(flag)` returns a `() => boolean | undefined` compatible with
* `AppSidebar.NavItem.featureFlag`, where `false` hides the menu entry.
*/
import { useAppStore } from '@/stores/app'
import type { PublicSettings } from '@/types'
import { DEFAULT_INTERVAL_SECONDS } from '@/constants/channelMonitor'
export type FeatureFlagMode = 'opt-in' | 'opt-out'
@@ -152,3 +152,37 @@ export function isFeatureFlagEnabled(flag: FeatureFlagDefinition): boolean {
export function makeSidebarFlag(flag: FeatureFlagDefinition): () => boolean {
return () => isFeatureFlagEnabled(flag)
}
/** True when channel monitor feature flag is enabled. */
export function isChannelMonitorRouteEnabled(): boolean {
return isFeatureFlagEnabled(FeatureFlags.channelMonitor)
}
export type ChannelMonitorMode = 'v1' | 'v2'
/** Exclusive channel-monitor implementation. Invalid/missing → v2. */
export function getChannelMonitorMode(): ChannelMonitorMode {
const appStore = useAppStore()
const mode = appStore.cachedPublicSettings?.channel_monitor_mode
return mode === 'v1' ? 'v1' : 'v2'
}
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"
@@ -81,6 +130,7 @@
/>
</template>
</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()
+68 -1
View File
@@ -6816,7 +6816,50 @@
<Toggle v-model="form.channel_monitor_enabled" />
</div>
<div v-if="form.channel_monitor_enabled">
<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>
@@ -6832,6 +6875,19 @@
{{ 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>
@@ -9157,6 +9213,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;
@@ -9458,7 +9516,9 @@ const form = reactive<SettingsForm>({
account_quota_notify_emails: [] as NotifyEmailEntry[],
// Channel Monitor feature switch
channel_monitor_enabled: true,
channel_monitor_mode: 'v2' 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
@@ -10455,6 +10515,11 @@ async function loadSettings() {
: defaultFingerprintSignalRows();
form.login_agreement_mode =
settings.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
form.channel_monitor_mode =
settings.channel_monitor_mode === "v1" ? "v1" : "v2";
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 =
@@ -11092,8 +11157,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: {