mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-06 14:33:10 +08:00
feat(channel-monitor-v2): 补齐管理端配置、隐藏吞吐开关与测试
管理端 V2 配置面板与系统设置中的模式/隐藏 RPM·TPM 开关,纳入关键 vitest 与 Makefile 前端关键路径,覆盖门控与展示行为。
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '可用渠道',
|
||||
|
||||
@@ -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 → 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"
|
||||
@@ -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()
|
||||
|
||||
@@ -6816,21 +6816,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>
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user