Merge pull request #5780 from Randark-JMT/fix/channel-monitor-p2

fix(monitor): P2 audit follow-ups — credential/balance semantics, capability checks, single account load, quota placeholder UI
This commit is contained in:
Wesley Liddick
2026-08-19 11:21:35 +08:00
committed by GitHub
24 changed files with 1066 additions and 111 deletions
+5 -2
View File
@@ -134,7 +134,9 @@ export interface CreateParams {
api_key: string
/** 缺省 probe;antigravity 仅支持 quota */
check_mode?: CheckMode
/** 配额模式必填:数据源账号(provider 需与账号平台一致) */
/** 配额模式必填:数据源账号(provider 需与账号平台一致)。
* update 语义:>0=换绑,0=解绑(切回 probe 模式时前端发 0 清空存量关联);
* create 绝不发 0——后端会把 0 存成 &0 触发外键违约。 */
account_id?: number | null
primary_model: string
extra_models?: string[]
@@ -148,7 +150,8 @@ export interface CreateParams {
body_override?: Record<string, unknown> | null
}
// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空
// Update request: api_key 空串 = 不修改;clear_template=true 时把 template_id 置空;
// account_id=0 显式解绑关联账号(null = 不动,见 CreateParams 注释)
export type UpdateParams = Partial<CreateParams> & {
clear_template?: boolean
}
@@ -675,6 +675,14 @@ function selectProvider(provider: Provider) {
if (provider === PROVIDER_ANTIGRAVITY && form.check_mode !== CHECK_MODE_QUOTA) {
form.check_mode = CHECK_MODE_QUOTA
}
// 对称还原:从 antigravity 切走时撤掉强制 quota,否则编辑存量 antigravity
// 监控换平台后仍停留在 quota(目标平台未必支持),update 会携带残留配置。
// 同步清掉 quota 占位模型(loadFromMonitor 回填的 'quota'),否则切回
// probe 后拿 'quota' 当探活模型(与离开 grok 清 DEFAULT_GROK_MODEL 同理)。
if (previousProvider === PROVIDER_ANTIGRAVITY && form.check_mode === CHECK_MODE_QUOTA) {
form.check_mode = CHECK_MODE_PROBE
if (form.primary_model.trim() === 'quota') form.primary_model = ''
}
if (provider === PROVIDER_GROK) {
if (!form.endpoint.trim()) form.endpoint = DEFAULT_GROK_ENDPOINT
if (!form.primary_model.trim()) form.primary_model = DEFAULT_GROK_MODEL
@@ -849,6 +857,9 @@ async function handleSubmit() {
req.clear_template = true
delete req.template_id
}
// account_id 同理:probe 模式不带账号,update 发 0 显式解绑存量关联
// (后端 0=清空、null=不动)。仅 update——create 发 0 会落 &0 触发 FK 违约。
if (!usesQuotaMode.value) req.account_id = 0
await adminAPI.channelMonitor.update(target.id, req)
appStore.showSuccess(t('admin.channelMonitor.updateSuccess'))
} else {
@@ -1,7 +1,8 @@
<template>
<div class="flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900 dark:text-gray-100">{{ row.primary_model }}</span>
<!-- 纯配额模式主模型是占位符 "quota"(数据源是账号不是模型),展示层替换为本地化标签 -->
<span class="text-sm text-gray-900 dark:text-gray-100">{{ formatMonitorModel(row.primary_model) }}</span>
<HelpTooltip>
<template #trigger>
<span
@@ -13,7 +14,7 @@
</template>
<div class="space-y-2">
<div class="text-xs font-semibold text-gray-100">
{{ row.primary_model }}
{{ formatMonitorModel(row.primary_model) }}
<span
class="ml-1 inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium"
:class="statusBadgeClass(row.primary_status)"
@@ -72,5 +73,5 @@ defineProps<{
}>()
const { t } = useI18n()
const { statusLabel, statusBadgeClass, formatLatency } = useChannelMonitorFormat()
const { statusLabel, statusBadgeClass, formatLatency, formatMonitorModel } = useChannelMonitorFormat()
</script>
@@ -12,7 +12,7 @@
class="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2 text-sm dark:border-dark-600"
>
<div class="flex flex-col">
<span class="font-medium text-gray-900 dark:text-white">{{ r.model }}</span>
<span class="font-medium text-gray-900 dark:text-white">{{ formatMonitorModel(r.model) }}</span>
<span v-if="r.message" class="text-xs text-gray-500 dark:text-gray-400">{{ r.message }}</span>
<MonitorQuotaView :snapshot="r.quota" class="mt-1" />
</div>
@@ -54,5 +54,5 @@ defineEmits<{
}>()
const { t } = useI18n()
const { statusLabel, statusBadgeClass, formatLatency } = useChannelMonitorFormat()
const { statusLabel, statusBadgeClass, formatLatency, formatMonitorModel } = useChannelMonitorFormat()
</script>
@@ -0,0 +1,73 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import MonitorPrimaryModelCell from '../MonitorPrimaryModelCell.vue'
// 占位符 "quota" 是纯配额模式主模型的存储值(数据源是账号不是模型),
// 展示层必须替换为本地化标签,不得作为假模型名透出。
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
function makeRow(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
return {
id: 1,
name: 'kimi-coding',
provider: 'kimi',
api_mode: 'chat_completions',
endpoint: '',
api_key_masked: '',
primary_model: 'quota',
extra_models: [],
group_name: '',
enabled: true,
interval_seconds: 60,
jitter_seconds: 0,
last_checked_at: null,
created_by: 1,
created_at: '2026-08-18T00:00:00Z',
updated_at: '2026-08-18T00:00:00Z',
primary_status: 'operational',
primary_latency_ms: null,
availability_7d: 100,
extra_models_status: [],
template_id: null,
extra_headers: {},
body_override_mode: 'off',
body_override: null,
check_mode: 'quota',
account_id: 5,
...overrides,
}
}
function mountCell(row: ChannelMonitor) {
return mount(MonitorPrimaryModelCell, {
props: { row },
global: {
stubs: { MonitorQuotaView: true, HelpTooltip: false },
},
})
}
describe('MonitorPrimaryModelCell placeholder model display', () => {
it('replaces the quota placeholder with the localized mode label', () => {
const wrapper = mountCell(makeRow())
expect(wrapper.text()).toContain('monitorCommon.checkMode.quota')
})
it('keeps the real model for quota_probe and probe rows', () => {
const wrapper = mountCell(makeRow({
check_mode: 'quota_probe',
primary_model: 'claude-sonnet-4-5',
}))
expect(wrapper.text()).toContain('claude-sonnet-4-5')
expect(wrapper.text()).not.toContain('monitorCommon.checkMode.quota')
})
})
@@ -30,7 +30,7 @@
:key="m.model"
class="border-b border-gray-100 dark:border-dark-800"
>
<td class="py-2 pr-3 font-medium text-gray-900 dark:text-gray-100">{{ m.model }}</td>
<td class="py-2 pr-3 font-medium text-gray-900 dark:text-gray-100">{{ formatMonitorModel(m.model) }}</td>
<td class="py-2 pr-3">
<span
class="inline-flex items-center rounded-full px-2 py-0.5 text-[11px]"
@@ -83,7 +83,7 @@ defineEmits<{
const { t } = useI18n()
const appStore = useAppStore()
const { statusLabel, statusBadgeClass, formatLatency, formatPercent } = useChannelMonitorFormat()
const { statusLabel, statusBadgeClass, formatLatency, formatPercent, formatMonitorModel } = useChannelMonitorFormat()
const detail = ref<UserMonitorDetail | null>(null)
const loading = ref(false)
@@ -88,4 +88,16 @@ describe('MonitorCard quota snapshot visibility', () => {
const wrapper = mountCard(makeItem())
expect(wrapper.find('[data-testid="monitor-quota-view"]').exists()).toBe(false)
})
// 占位符 "quota" 是主模型存储值,不得作为假模型名直接透出到用户端。
it('shows the localized quota label instead of the raw placeholder model', () => {
const wrapper = mountCard(makeItem())
expect(wrapper.text()).toContain('monitorCommon.checkMode.quota')
})
it('keeps the real model name for probe monitors', () => {
const wrapper = mountCard(makeItem({ primary_model: 'claude-sonnet-4-5' }))
expect(wrapper.text()).toContain('claude-sonnet-4-5')
expect(wrapper.text()).not.toContain('monitorCommon.checkMode.quota')
})
})
@@ -23,8 +23,9 @@
>
{{ providerLabel(item.provider) }}
</span>
<!-- 纯配额模式主模型是占位符 "quota",展示层替换为本地化「配额」标签 -->
<span class="font-mono text-xs truncate text-gray-500 dark:text-gray-400">
{{ item.primary_model }}
{{ formatMonitorModel(item.primary_model) }}
</span>
<span
v-if="item.group_name"
@@ -120,6 +121,7 @@ const {
providerLabel,
providerBadgeClass,
formatLatency,
formatMonitorModel,
} = useChannelMonitorFormat()
const providerTintClass = computed(() =>
@@ -26,6 +26,9 @@ import {
STATUS_DEGRADED,
STATUS_FAILED,
STATUS_ERROR,
CHECK_MODE_PROBE,
CHECK_MODE_QUOTA,
CHECK_MODE_QUOTA_PROBE,
} from '@/constants/channelMonitor'
const NEUTRAL_BADGE = 'bg-gray-100 text-gray-800 dark:bg-dark-700 dark:text-gray-300'
@@ -76,6 +79,21 @@ export function useChannelMonitorFormat() {
return m || '-'
}
/**
* Display label for a monitor's primary model. Pure-quota monitors carry the
* literal placeholder "quota" (the probe target is an account, not a model),
* which must not leak into the UI as a fake model name — render the
* localized mode label instead. quota_probe keeps a real model name.
*/
const QUOTA_MODEL_PLACEHOLDER = 'quota'
function formatMonitorModel(model: string): string {
if (model === QUOTA_MODEL_PLACEHOLDER) {
return t('monitorCommon.checkMode.quota')
}
return model
}
function providerBadgeClass(p: Provider | string): string {
switch (p) {
case PROVIDER_OPENAI:
@@ -101,6 +119,22 @@ export function useChannelMonitorFormat() {
}
}
/**
* Tailwind class for the check-mode badge shown next to the provider badge
* in the admin monitor list. Quota-bearing modes = blue (数据源是账号配额),
* plain probe = neutral grey.
*/
function checkModeBadgeClass(m: CheckMode | string): string {
switch (m) {
case CHECK_MODE_QUOTA:
case CHECK_MODE_QUOTA_PROBE:
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300'
case CHECK_MODE_PROBE:
default:
return NEUTRAL_BADGE
}
}
/**
* Tailwind class for a provider radio-button-style picker (active/inactive state).
* Reuses the same emerald/orange/sky palette as providerBadgeClass to keep
@@ -181,7 +215,9 @@ export function useChannelMonitorFormat() {
statusBadgeClass,
providerLabel,
checkModeLabel,
formatMonitorModel,
providerBadgeClass,
checkModeBadgeClass,
providerPickerClass,
formatLatency,
formatPercent,
@@ -78,6 +78,10 @@
<span class="inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium" :class="providerBadgeClass(row.provider)">
{{ providerLabel(row.provider) }}
</span>
<!-- 三种检测模式并列展示,quota 系配额数据源与纯探活一眼可分 -->
<span class="ml-1 inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium" :class="checkModeBadgeClass(row.check_mode)">
{{ checkModeLabel(row.check_mode) }}
</span>
</template>
<template #cell-primary_model="{ row }">
@@ -204,6 +208,8 @@ const adminMonitorTab = ref<'v2' | 'legacy'>(isChannelMonitorV1Mode() ? 'legacy'
const {
providerLabel,
providerBadgeClass,
checkModeLabel,
checkModeBadgeClass,
formatLatency,
formatAvailability,
} = useChannelMonitorFormat()
@@ -0,0 +1,159 @@
import { defineComponent } from 'vue'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChannelMonitor } from '@/api/admin/channelMonitor'
import ChannelMonitorView from '@/views/admin/ChannelMonitorView.vue'
// P2-7(d):provider 徽章旁并列 check_mode 徽章,三种检测模式一眼可分
// (quota 系 = 账号配额数据源,probe = 纯探活)。
const { listMonitors } = vi.hoisted(() => ({ listMonitors: 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: {
list: listMonitors,
duplicate: vi.fn(),
update: vi.fn(),
runNow: vi.fn(),
del: vi.fn(),
},
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({ showSuccess: vi.fn(), showError: vi.fn() }),
}))
vi.mock('vue-i18n', async () => {
const actual = await vi.importActual<typeof import('vue-i18n')>('vue-i18n')
return {
...actual,
useI18n: () => ({ t: (key: string) => key }),
}
})
const AppLayoutStub = defineComponent({
template: '<main><slot /></main>',
})
const TablePageLayoutStub = defineComponent({
template: '<section><slot name="filters" /><slot name="table" /><slot name="pagination" /></section>',
})
// 渲染 cell-provider slot,徽章断言基于它。
const DataTableStub = defineComponent({
props: {
data: { type: Array, default: () => [] },
columns: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
},
template: '<div><div v-for="row in data" :key="row.id" class="provider-cell"><slot name="cell-provider" :row="row" /></div></div>',
})
function makeMonitor(overrides: Partial<ChannelMonitor> = {}): ChannelMonitor {
return {
id: 42,
name: 'primary',
provider: 'openai',
api_mode: 'chat_completions',
endpoint: 'https://api.example.com',
api_key_masked: 'sk-t***',
primary_model: 'gpt-4o-mini',
extra_models: [],
group_name: '',
enabled: true,
interval_seconds: 60,
jitter_seconds: 0,
last_checked_at: null,
created_by: 1,
created_at: '2026-07-16T00:00:00Z',
updated_at: '2026-07-16T00:00:00Z',
primary_status: '',
primary_latency_ms: null,
availability_7d: 0,
extra_models_status: [],
template_id: null,
extra_headers: {},
body_override_mode: 'off',
body_override: null,
check_mode: 'probe',
account_id: null,
...overrides,
}
}
function mountView() {
return mount(ChannelMonitorView, {
global: {
stubs: {
AppLayout: AppLayoutStub,
TablePageLayout: TablePageLayoutStub,
DataTable: DataTableStub,
MonitorFiltersBar: true,
Pagination: true,
ConfirmDialog: true,
EmptyState: true,
HelpTooltip: true,
Toggle: true,
MonitorFormDialog: true,
MonitorTemplateManagerDialog: true,
MonitorRunResultDialog: true,
MonitorPrimaryModelCell: true,
},
},
})
}
describe('ChannelMonitorView check-mode badge', () => {
beforeEach(() => {
localStorage.clear()
listMonitors.mockReset()
})
it.each([
['quota', 'monitorCommon.checkMode.quota'],
['quota_probe', 'monitorCommon.checkMode.quota_probe'],
['probe', 'monitorCommon.checkMode.probe'],
] as const)('renders the %s badge next to the provider badge', async (mode, label) => {
listMonitors.mockResolvedValue({
items: [makeMonitor({ check_mode: mode })],
total: 1,
page: 1,
page_size: 20,
pages: 1,
})
const wrapper = mountView()
await flushPromises()
const cell = wrapper.get('.provider-cell')
expect(cell.text()).toContain(label)
// provider 徽章仍在(不因并列展示被顶掉)。
expect(cell.text()).toContain('monitorCommon.providers.openai')
const badges = cell.findAll('span')
const modeBadge = badges.find(el => el.text() === label)
expect(modeBadge).toBeDefined()
// quota 系=蓝、probe=中性灰的配色区分。
const cls = modeBadge!.attributes('class')
if (mode === 'probe') {
expect(cls).toContain('bg-gray-100')
expect(cls).not.toContain('bg-blue-100')
} else {
expect(cls).toContain('bg-blue-100')
expect(cls).not.toContain('bg-gray-100')
}
wrapper.unmount()
})
})
@@ -308,4 +308,69 @@ describe('MonitorFormDialog linked account selector', () => {
expect(accountTrigger(wrapper).text()).not.toContain('alpha')
expect(accountTrigger(wrapper).text()).toContain('linkedAccountPlaceholder')
})
// P2-7(b):antigravity 强制的 quota 在切走时要对称还原,占位模型一并清掉。
it('restores probe mode and clears the quota placeholder when switching away from antigravity', async () => {
accountsGetById.mockRejectedValue(new Error('gone'))
const wrapper = mountDialog(makeMonitor({
provider: 'antigravity',
check_mode: 'quota',
account_id: 7,
endpoint: '',
primary_model: 'quota',
}))
await flushPromises()
await wrapper.get('[data-testid="monitor-provider-anthropic"]').trigger('click')
await flushPromises()
// 占位模型已被清空:probe 模式必填校验拦下提交,而不是拿 'quota' 探活。
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).not.toHaveBeenCalled()
await wrapper.get('[data-testid="monitor-primary-model"]').setValue('claude-sonnet-4-5')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).toHaveBeenCalledWith(42, expect.objectContaining({
check_mode: 'probe',
primary_model: 'claude-sonnet-4-5',
}))
})
// P2-7(c):update 切回 probe 显式发 account_id=0 解绑存量关联(null=不动)。
it('unbinds the linked account with account_id=0 when a quota monitor switches back to probe', async () => {
accountsGetById.mockRejectedValue(new Error('gone'))
const wrapper = mountDialog(makeMonitor({
provider: 'anthropic',
check_mode: 'quota',
account_id: 999,
endpoint: '',
primary_model: 'quota',
}))
await flushPromises()
await wrapper.get('[data-testid="monitor-check-mode-probe"]').trigger('click')
await wrapper.get('[data-testid="monitor-primary-model"]').setValue('claude-sonnet-4-5')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorUpdate).toHaveBeenCalledWith(42, expect.objectContaining({ account_id: 0 }))
})
// P2-7(c):create 绝不发 0(后端会把 0 存成 &0 触发外键违约),保持 null。
it('keeps account_id null when creating a probe monitor', async () => {
const wrapper = mountDialog()
await flushPromises()
await wrapper.findAll('input[type="text"]')[0].setValue('my monitor')
await wrapper.get('[data-testid="monitor-primary-model"]').setValue('claude-sonnet-4-5')
await wrapper.get('#channel-monitor-form').trigger('submit')
await flushPromises()
expect(monitorCreate).toHaveBeenCalledWith(expect.objectContaining({
check_mode: 'probe',
account_id: null,
}))
})
})