mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
fix(monitor): allow adding missing platforms in v2 settings
This commit is contained in:
@@ -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: '留空=全部真实模型;或填写主流模型名单(其余归其他)',
|
||||
|
||||
Reference in New Issue
Block a user