fix(monitor): allow adding missing platforms in v2 settings

This commit is contained in:
r266-tech
2026-09-30 06:56:15 +08:00
parent a60a29549f
commit 69d6d106fd
4 changed files with 189 additions and 0 deletions
@@ -89,6 +89,24 @@
<p class="mt-0.5 text-xs text-gray-500 dark:text-dark-400">
{{ t('channelMonitorV2.settings.platformsHint') }}
</p>
<div v-if="availablePlatforms.length" class="mt-3 flex flex-wrap items-center gap-2">
<Select
v-model="selectedPlatform"
class="min-w-48"
:options="availablePlatforms"
:aria-label="t('channelMonitorV2.settings.selectPlatform')"
:placeholder="t('channelMonitorV2.settings.selectPlatform')"
:disabled="saving"
/>
<button
type="button"
class="btn btn-secondary btn-sm"
:disabled="saving || !selectedPlatform"
@click="addPlatform"
>
{{ t('channelMonitorV2.settings.addPlatform') }}
</button>
</div>
</div>
<div class="divide-y divide-gray-100 dark:divide-dark-700">
<div
@@ -263,6 +281,8 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Select from '@/components/common/Select.vue'
import { CONCRETE_PLATFORM_OPTIONS } from '@/constants/platforms'
import Toggle from '@/components/common/Toggle.vue'
import Icon from '@/components/icons/Icon.vue'
import { useAppStore } from '@/stores/app'
@@ -284,6 +304,18 @@ const saving = ref(false)
const draft = ref<MonitorConfig | null>(null)
const original = ref('')
const groups = ref<AdminGroup[]>([])
const selectedPlatform = ref('')
const availablePlatforms = computed(() =>
CONCRETE_PLATFORM_OPTIONS.filter(
(option) => !draft.value?.platforms.some((platform) => platform.platform === option.value)
)
)
function addPlatform() {
if (!draft.value || !availablePlatforms.value.some((option) => option.value === selectedPlatform.value)) return
draft.value.platforms.push({ platform: selectedPlatform.value, enabled: true, models: [] })
selectedPlatform.value = ''
}
const dirty = computed(() => (draft.value ? JSON.stringify(draft.value) !== original.value : false))
const namedModelCount = computed(
@@ -0,0 +1,153 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import Select from '@/components/common/Select.vue'
import MonitorSettingsPanel from '../MonitorSettingsPanel.vue'
import { CONCRETE_PLATFORM_OPTIONS } from '@/constants/platforms'
import type { MonitorConfig } from '@/api/channelMonitorV2'
const mocks = vi.hoisted(() => ({
getConfig: vi.fn(),
updateConfig: vi.fn(),
groups: vi.fn(),
showError: vi.fn(),
showSuccess: vi.fn(),
}))
vi.mock('@/api/channelMonitorV2', () => ({
getConfig: mocks.getConfig,
updateConfig: mocks.updateConfig,
MONITOR_ERROR_CATEGORIES: ['authentication', 'network'],
}))
vi.mock('@/api/admin', () => ({ adminAPI: { groups: { getAllIncludingInactive: mocks.groups } } }))
vi.mock('@/stores/app', () => ({ useAppStore: () => ({
cachedPublicSettings: { channel_monitor_enabled: true },
showError: mocks.showError,
showSuccess: mocks.showSuccess,
}) }))
vi.mock('@/utils/featureFlags', () => ({
getChannelMonitorMode: () => 'v2',
isChannelMonitorV2Mode: () => true,
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key, te: () => false }),
}))
function config(): MonitorConfig {
return {
version: 2,
enabled: true,
refresh_interval_seconds: 60,
platforms: [
{ platform: 'openai', enabled: false, models: ['gpt-4'] },
{ platform: 'custom-provider', enabled: true, models: ['custom-model'] },
],
group_ids: [42],
ignored_error_categories: [],
health_thresholds: {
minimum_sample: 70, warning_error_rate: 0.1, critical_error_rate: 0.3,
target_ttft_ms: 1500, warning_ttft_ms: 2000, critical_ttft_ms: 5000,
warning_cache_rate: 0.7, critical_cache_rate: 0.4,
error_weight: 0.5, ttft_weight: 0.3, cache_weight: 0.2,
},
}
}
async function panel(value = config()) {
mocks.getConfig.mockResolvedValue(value)
const wrapper = mount(MonitorSettingsPanel, { global: { stubs: { RouterLink: true, teleport: true } } })
await flushPromises()
return wrapper
}
function button(wrapper: Awaited<ReturnType<typeof panel>>, key: string) {
return wrapper.findAll('button').find((item) => item.text() === 'channelMonitorV2.settings.' + key)!
}
async function add(wrapper: Awaited<ReturnType<typeof panel>>, platform: string) {
wrapper.findComponent(Select).vm.$emit('update:modelValue', platform)
await flushPromises()
await button(wrapper, 'addPlatform').trigger('click')
}
beforeEach(() => {
vi.clearAllMocks()
mocks.groups.mockResolvedValue([])
mocks.updateConfig.mockImplementation(async (value) => JSON.parse(JSON.stringify(value)))
})
describe('MonitorSettingsPanel platform configuration', () => {
it('adds a missing provider and saves its models without changing existing settings', async () => {
const original = config()
const wrapper = await panel(original)
expect(button(wrapper, 'save').attributes('disabled')).toBeDefined()
expect(wrapper.findComponent(Select).props('options')).not.toContainEqual(expect.objectContaining({ value: 'openai' }))
await wrapper.findComponent(Select).find('button').trigger('click')
const option = wrapper.findAll('[role="option"]').find((item) => item.text() === 'DeepSeek')!
await option.trigger('click')
await button(wrapper, 'addPlatform').trigger('click')
expect(button(wrapper, 'addPlatform').attributes('disabled')).toBeDefined()
expect(wrapper.findComponent(Select).props('options')).not.toContainEqual(
expect.objectContaining({ value: 'deepseek' })
)
const models = wrapper.findAll('input[type="text"]').at(-1)!
await models.setValue(' deepseek-chat, deepseek-reasoner, deepseek-chat ')
await button(wrapper, 'save').trigger('click')
await flushPromises()
expect(mocks.updateConfig).toHaveBeenCalledWith({
...original,
platforms: [...original.platforms, {
platform: 'deepseek', enabled: true, models: ['deepseek-chat', 'deepseek-reasoner'],
}],
})
expect(button(wrapper, 'save').attributes('disabled')).toBeDefined()
expect(original.platforms).toHaveLength(2)
const persisted = mocks.updateConfig.mock.results[0].value
wrapper.unmount()
const reloaded = await panel(await persisted)
expect((reloaded.findAll('input[type="text"]').at(-1)!.element as HTMLInputElement).value).toBe('deepseek-chat, deepseek-reasoner')
expect(reloaded.findComponent(Select).props('options')).not.toContainEqual(
expect.objectContaining({ value: 'deepseek' })
)
reloaded.unmount()
})
it('can start with no configured platforms and rejects duplicate additions', async () => {
const wrapper = await panel({ ...config(), platforms: [] })
expect(button(wrapper, 'addPlatform').attributes('disabled')).toBeDefined()
await add(wrapper, 'kimi')
await add(wrapper, 'kimi')
await button(wrapper, 'save').trigger('click')
await flushPromises()
expect(mocks.updateConfig.mock.calls[0][0].platforms).toEqual([
{ platform: 'kimi', enabled: true, models: [] },
])
wrapper.unmount()
})
it('restores the server config and available options when saving fails', async () => {
const wrapper = await panel()
mocks.updateConfig.mockRejectedValueOnce(new Error('save failed'))
await add(wrapper, 'minimax')
await button(wrapper, 'save').trigger('click')
await flushPromises()
expect(mocks.showError).toHaveBeenCalledOnce()
expect(wrapper.findComponent(Select).props('options')).toContainEqual(
expect.objectContaining({ value: 'minimax' })
)
expect(wrapper.findAll('input[type="text"]')).toHaveLength(2)
expect(button(wrapper, 'save').attributes('disabled')).toBeDefined()
wrapper.unmount()
})
it('hides the add control after every supported platform is configured', async () => {
const wrapper = await panel({
...config(),
platforms: CONCRETE_PLATFORM_OPTIONS.map(({ value }) => ({ platform: value, enabled: true, models: [] })),
})
expect(wrapper.findComponent(Select).exists()).toBe(false)
expect(button(wrapper, 'addPlatform')).toBeUndefined()
wrapper.unmount()
})
})
@@ -96,6 +96,8 @@ export default {
refreshTitle: 'Aggregation interval',
refreshHint: 'Affects matrix time granularity and refresh cadence',
refreshAria: 'Aggregation interval',
selectPlatform: 'Select a platform',
addPlatform: 'Add platform',
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”',
@@ -95,6 +95,8 @@ export default {
refreshTitle: '汇总频率',
refreshHint: '影响矩阵时间粒度与刷新节奏',
refreshAria: '汇总频率',
selectPlatform: '选择平台',
addPlatform: '添加平台',
platformsTitle: '平台与模型',
platformsHint: '留空 = 展示全部真实模型名;填写后仅名单内单独成行,其余归入「其他」',
modelsPlaceholder: '留空=全部真实模型;或填写主流模型名单(其余归其他)',