feat(channel-monitor): request templates with snapshot apply + headers/body override

Problem:
Upstream channels can reject monitor probes based on client fingerprint
(e.g. "only Claude Code clients allowed"). The monitor had no way to
customize the outgoing request to bypass such restrictions.

Solution:
Introduce reusable request templates that carry extra_headers plus an
optional body override; monitors reference a template and receive a
snapshot copy on apply. Template edits do NOT auto-propagate — users
must click "apply to associated monitors" to refresh snapshots, so a
bad template edit cannot instantly break all production monitors.

Data model (migration 112):
- channel_monitor_request_templates: id, name, provider, description,
  extra_headers jsonb, body_override_mode ('off'|'merge'|'replace'),
  body_override jsonb. Unique (provider, name).
- channel_monitors: +template_id (FK, ON DELETE SET NULL), +extra_headers,
  +body_override_mode, +body_override (the three runtime snapshot fields).

Checker (channel_monitor_checker.go):
- callProvider + runCheckForModel accept a CheckOptions carrying the
  snapshot fields. mergeHeaders applies user headers on top of adapter
  defaults (forbidden list: Host / Content-Length / Transfer-Encoding /
  Connection / Content-Encoding).
- buildRequestBody:
    off     -> adapter default body
    merge   -> shallow-merge over default; per-provider deny list
               (model/messages/contents) protects the challenge contract
    replace -> user body verbatim
- Replace mode skips challenge validation; instead HTTP 2xx + non-empty
  extracted response text = operational, empty = failed.
- 4 new unit tests cover all three modes + replace/empty-response case.

Admin API:
- /admin/channel-monitor-templates CRUD + /:id/apply (overwrite snapshot
  on all template_id=id monitors, returns affected count).
- channel_monitor request/response DTOs gain the 4 new fields.

Frontend:
- channelMonitorTemplate.ts API client.
- MonitorAdvancedRequestConfig.vue shared component for headers textarea
  + body mode radio + body JSON editor; used by both template and monitor
  forms.
- MonitorTemplateManagerDialog.vue: provider tabs, list/create/edit/
  delete/apply, live "associated monitors" count per row.
- MonitorFiltersBar: new 模板管理 button next to 新增监控.
- MonitorFormDialog: collapsible 高级 section with template dropdown
  (filtered by form.provider, clears on provider change) + embedded
  AdvancedRequestConfig. Picking a template copies its fields into the
  form (snapshot semantics mirrored on the client).
- i18n zh/en entries for all new copy.

chore: bump version to 0.1.114.32
This commit is contained in:
erio
2026-04-21 14:14:49 +08:00
parent 0c48f08f5c
commit a296425994
53 changed files with 8318 additions and 394 deletions

View File

@@ -7,6 +7,7 @@ import { apiClient } from '../client'
export type Provider = 'openai' | 'anthropic' | 'gemini'
export type MonitorStatus = 'operational' | 'degraded' | 'failed' | 'error'
export type BodyOverrideMode = 'off' | 'merge' | 'replace'
export interface ChannelMonitor {
id: number
@@ -37,6 +38,11 @@ export interface ChannelMonitor {
availability_7d: number
/** Latest status per extra model (used for hover tooltip) */
extra_models_status: ExtraModelStatus[]
/** 请求自定义快照字段(高级设置) */
template_id: number | null
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
}
export interface ExtraModelStatus {
@@ -71,10 +77,16 @@ export interface CreateParams {
group_name?: string
enabled?: boolean
interval_seconds: number
template_id?: number | null
extra_headers?: Record<string, string>
body_override_mode?: BodyOverrideMode
body_override?: Record<string, unknown> | null
}
// Update request: api_key empty string means "do not modify"
export type UpdateParams = Partial<CreateParams>
// Update request: api_key 空串 = 不修改clear_template=true 时把 template_id 置空
export type UpdateParams = Partial<CreateParams> & {
clear_template?: boolean
}
export interface CheckResult {
model: string

View File

@@ -0,0 +1,108 @@
/**
* Admin Channel Monitor Request Template API.
*
* 模板 = 一组可复用的 headers + 可选 body 覆盖配置。
* 应用到监控 = 拷贝快照;模板后续变动不自动同步,需手动点「应用到关联监控」刷新。
*/
import { apiClient } from '../client'
import type { BodyOverrideMode, Provider } from './channelMonitor'
export interface ChannelMonitorTemplate {
id: number
name: string
provider: Provider
description: string
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
created_at: string
updated_at: string
/** 关联的监控数量(快照来自此模板,仅 template_id 匹配即可) */
associated_monitors: number
}
export interface ListParams {
provider?: Provider
}
export interface ListResponse {
items: ChannelMonitorTemplate[]
}
export interface CreateParams {
name: string
provider: Provider
description?: string
extra_headers?: Record<string, string>
body_override_mode?: BodyOverrideMode
body_override?: Record<string, unknown> | null
}
export interface UpdateParams {
name?: string
description?: string
extra_headers?: Record<string, string>
body_override_mode?: BodyOverrideMode
body_override?: Record<string, unknown> | null
}
export interface ApplyResponse {
affected: number
}
export async function list(params: ListParams = {}): Promise<ListResponse> {
const { data } = await apiClient.get<ListResponse>('/admin/channel-monitor-templates', {
params,
})
return data
}
export async function get(id: number): Promise<ChannelMonitorTemplate> {
const { data } = await apiClient.get<ChannelMonitorTemplate>(
`/admin/channel-monitor-templates/${id}`,
)
return data
}
export async function create(params: CreateParams): Promise<ChannelMonitorTemplate> {
const { data } = await apiClient.post<ChannelMonitorTemplate>(
'/admin/channel-monitor-templates',
params,
)
return data
}
export async function update(id: number, params: UpdateParams): Promise<ChannelMonitorTemplate> {
const { data } = await apiClient.put<ChannelMonitorTemplate>(
`/admin/channel-monitor-templates/${id}`,
params,
)
return data
}
export async function del(id: number): Promise<void> {
await apiClient.delete(`/admin/channel-monitor-templates/${id}`)
}
/**
* Apply the template to all associated monitors (overwrite snapshot fields).
* Returns count of affected monitors.
*/
export async function apply(id: number): Promise<ApplyResponse> {
const { data } = await apiClient.post<ApplyResponse>(
`/admin/channel-monitor-templates/${id}/apply`,
)
return data
}
export const channelMonitorTemplateAPI = {
list,
get,
create,
update,
del,
apply,
}
export default channelMonitorTemplateAPI

View File

@@ -27,6 +27,7 @@ import backupAPI from './backup'
import tlsFingerprintProfileAPI from './tlsFingerprintProfile'
import channelsAPI from './channels'
import channelMonitorAPI from './channelMonitor'
import channelMonitorTemplateAPI from './channelMonitorTemplate'
import adminPaymentAPI from './payment'
/**
@@ -57,6 +58,7 @@ export const adminAPI = {
tlsFingerprintProfiles: tlsFingerprintProfileAPI,
channels: channelsAPI,
channelMonitor: channelMonitorAPI,
channelMonitorTemplate: channelMonitorTemplateAPI,
payment: adminPaymentAPI
}
@@ -85,6 +87,7 @@ export {
tlsFingerprintProfileAPI,
channelsAPI,
channelMonitorAPI,
channelMonitorTemplateAPI,
adminPaymentAPI
}

View File

@@ -0,0 +1,205 @@
<template>
<div class="space-y-4">
<!-- Headers textarea -->
<div>
<label class="input-label">{{ t('admin.channelMonitor.advanced.headers') }}</label>
<textarea
v-model="headersText"
rows="4"
:placeholder="t('admin.channelMonitor.advanced.headersPlaceholder')"
class="input font-mono text-xs"
@blur="commitHeaders"
/>
<p v-if="headersError" class="mt-1 text-xs text-red-500">{{ headersError }}</p>
<p v-else class="mt-1 text-xs text-gray-400">
{{ t('admin.channelMonitor.advanced.headersHint') }}
</p>
</div>
<!-- Body mode radio -->
<div>
<label class="input-label">{{ t('admin.channelMonitor.advanced.bodyMode') }}</label>
<div class="grid grid-cols-3 gap-3">
<button
v-for="opt in bodyModeOptions"
:key="opt.value"
type="button"
class="rounded-lg border-2 px-3 py-2 text-sm font-medium transition-colors"
:class="bodyModeButtonClass(opt.value)"
@click="updateBodyMode(opt.value)"
>
{{ opt.label }}
</button>
</div>
<p class="mt-1 text-xs text-gray-400">
{{ bodyModeHint }}
</p>
</div>
<!-- Body JSON (仅当 mode != off) -->
<div v-if="bodyOverrideMode !== 'off'">
<label class="input-label">{{ t('admin.channelMonitor.advanced.bodyJson') }}</label>
<textarea
v-model="bodyText"
rows="8"
:placeholder="bodyPlaceholder"
class="input font-mono text-xs"
@blur="commitBody"
/>
<p v-if="bodyError" class="mt-1 text-xs text-red-500">{{ bodyError }}</p>
<p v-else class="mt-1 text-xs text-gray-400">
{{ t('admin.channelMonitor.advanced.bodyJsonHint') }}
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { BodyOverrideMode } from '@/api/admin/channelMonitor'
const props = defineProps<{
extraHeaders: Record<string, string>
bodyOverrideMode: BodyOverrideMode
bodyOverride: Record<string, unknown> | null
}>()
const emit = defineEmits<{
(e: 'update:extraHeaders', value: Record<string, string>): void
(e: 'update:bodyOverrideMode', value: BodyOverrideMode): void
(e: 'update:bodyOverride', value: Record<string, unknown> | null): void
}>()
const { t } = useI18n()
// ---- Headers textarea (Key: Value per line) ----
const headersText = ref(serializeHeaders(props.extraHeaders))
const headersError = ref('')
watch(
() => props.extraHeaders,
(v) => {
// 外部重置时(如切换平台 / 应用模板)同步文本
headersText.value = serializeHeaders(v)
headersError.value = ''
},
)
function commitHeaders() {
const parsed = parseHeaders(headersText.value)
if (parsed.error) {
headersError.value = parsed.error
return
}
headersError.value = ''
emit('update:extraHeaders', parsed.headers)
}
function serializeHeaders(h: Record<string, string>): string {
return Object.entries(h || {})
.map(([k, v]) => `${k}: ${v}`)
.join('\n')
}
function parseHeaders(raw: string): { headers: Record<string, string>; error: string } {
const result: Record<string, string> = {}
const lines = raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)
for (const line of lines) {
const idx = line.indexOf(':')
if (idx <= 0) {
return { headers: {}, error: t('admin.channelMonitor.advanced.headersParseError', { line }) }
}
const key = line.slice(0, idx).trim()
const value = line.slice(idx + 1).trim()
if (!key) {
return { headers: {}, error: t('admin.channelMonitor.advanced.headersParseError', { line }) }
}
result[key] = value
}
return { headers: result, error: '' }
}
// ---- Body mode + JSON ----
const bodyText = ref(serializeBody(props.bodyOverride))
const bodyError = ref('')
watch(
() => props.bodyOverride,
(v) => {
bodyText.value = serializeBody(v)
bodyError.value = ''
},
)
function commitBody() {
if (props.bodyOverrideMode === 'off') {
return
}
const trimmed = bodyText.value.trim()
if (trimmed === '') {
emit('update:bodyOverride', null)
bodyError.value = ''
return
}
try {
const parsed = JSON.parse(trimmed)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
bodyError.value = t('admin.channelMonitor.advanced.bodyJsonObjectError')
return
}
emit('update:bodyOverride', parsed as Record<string, unknown>)
bodyError.value = ''
} catch (e) {
bodyError.value =
t('admin.channelMonitor.advanced.bodyJsonError') +
': ' +
(e instanceof Error ? e.message : String(e))
}
}
function serializeBody(body: Record<string, unknown> | null): string {
if (!body || Object.keys(body).length === 0) return ''
return JSON.stringify(body, null, 2)
}
function updateBodyMode(mode: BodyOverrideMode) {
emit('update:bodyOverrideMode', mode)
// 切换到 off 时清掉 body提示用户
if (mode === 'off') {
emit('update:bodyOverride', null)
}
}
const bodyModeOptions = computed<{ value: BodyOverrideMode; label: string }[]>(() => [
{ value: 'off', label: t('admin.channelMonitor.advanced.bodyModeOff') },
{ value: 'merge', label: t('admin.channelMonitor.advanced.bodyModeMerge') },
{ value: 'replace', label: t('admin.channelMonitor.advanced.bodyModeReplace') },
])
function bodyModeButtonClass(mode: BodyOverrideMode): string {
const active = props.bodyOverrideMode === mode
if (active) {
return 'border-primary-500 bg-primary-50 text-primary-700 dark:bg-primary-500/15 dark:text-primary-300 dark:border-primary-400'
}
return 'border-gray-200 bg-white text-gray-600 hover:border-primary-300 dark:border-dark-700 dark:bg-dark-800 dark:text-gray-400'
}
const bodyModeHint = computed(() => {
switch (props.bodyOverrideMode) {
case 'merge':
return t('admin.channelMonitor.advanced.bodyModeHintMerge')
case 'replace':
return t('admin.channelMonitor.advanced.bodyModeHintReplace')
default:
return t('admin.channelMonitor.advanced.bodyModeHintOff')
}
})
const bodyPlaceholder = computed(() => {
if (props.bodyOverrideMode === 'merge') {
return '{\n "system": "You are Claude Code..."\n}'
}
return '{\n "model": "claude-x",\n "messages": [{"role":"user","content":"hi"}],\n "max_tokens": 10\n}'
})
</script>

View File

@@ -44,6 +44,14 @@
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<button
@click="$emit('manage-templates')"
class="btn btn-secondary"
:title="t('admin.channelMonitor.template.manageButton')"
>
<Icon name="cog" size="md" class="mr-2" />
{{ t('admin.channelMonitor.template.manageButton') }}
</button>
<button @click="$emit('create')" class="btn btn-primary">
<Icon name="plus" size="md" class="mr-2" />
{{ t('admin.channelMonitor.createButton') }}
@@ -71,6 +79,7 @@ defineProps<{
defineEmits<{
(e: 'reload'): void
(e: 'create'): void
(e: 'manage-templates'): void
(e: 'search-input'): void
}>()

View File

@@ -95,6 +95,35 @@
<label class="input-label mb-0">{{ t('admin.channelMonitor.form.enabled') }}</label>
<Toggle v-model="form.enabled" />
</div>
<!-- 高级设置区请求模板 + 自定义 headers/body -->
<details class="rounded-lg border border-gray-200 bg-gray-50/50 p-3 dark:border-dark-700 dark:bg-dark-900/30">
<summary class="cursor-pointer text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.channelMonitor.advanced.section') }}
</summary>
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.advanced.sectionHint') }}</p>
<div class="mt-4 space-y-4">
<div>
<label class="input-label">{{ t('admin.channelMonitor.templateField.label') }}</label>
<Select
v-model="templateSelectValue"
:options="templateOptions"
:placeholder="t('admin.channelMonitor.templateField.placeholder')"
/>
<p class="mt-1 text-xs text-gray-400">{{ t('admin.channelMonitor.templateField.applyHint') }}</p>
</div>
<MonitorAdvancedRequestConfig
:extra-headers="form.extra_headers"
:body-override-mode="form.body_override_mode"
:body-override="form.body_override"
@update:extra-headers="form.extra_headers = $event"
@update:body-override-mode="form.body_override_mode = $event"
@update:body-override="form.body_override = $event"
/>
</div>
</details>
</form>
<template #footer>
@@ -136,17 +165,21 @@ import { adminAPI } from '@/api/admin'
import { keysAPI } from '@/api/keys'
import { userGroupsAPI } from '@/api/groups'
import type {
BodyOverrideMode,
ChannelMonitor,
CreateParams,
Provider,
UpdateParams,
} from '@/api/admin/channelMonitor'
import type { ChannelMonitorTemplate } from '@/api/admin/channelMonitorTemplate'
import type { ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Toggle from '@/components/common/Toggle.vue'
import Select from '@/components/common/Select.vue'
import ModelTagInput from '@/components/admin/channel/ModelTagInput.vue'
import { getPlatformTextClass } from '@/components/admin/channel/types'
import MonitorKeyPickerDialog from '@/components/admin/monitor/MonitorKeyPickerDialog.vue'
import MonitorAdvancedRequestConfig from '@/components/admin/monitor/MonitorAdvancedRequestConfig.vue'
import ProviderIcon from '@/components/user/monitor/ProviderIcon.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
import {
@@ -198,11 +231,16 @@ interface MonitorForm {
group_name: string
interval_seconds: number
enabled: boolean
// 高级设置快照
template_id: number | null
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
}
const form = reactive<MonitorForm>({
name: '',
provider: PROVIDER_OPENAI,
provider: PROVIDER_ANTHROPIC,
endpoint: '',
api_key: '',
primary_model: '',
@@ -210,6 +248,57 @@ const form = reactive<MonitorForm>({
group_name: '',
interval_seconds: systemDefaultInterval.value,
enabled: true,
template_id: null,
extra_headers: {},
body_override_mode: 'off',
body_override: null,
})
// 可用模板列表(进入 dialog 时一次性拉取 cache按 provider 过滤)。
const templatesCache = ref<ChannelMonitorTemplate[]>([])
const templatesLoading = ref(false)
const templateOptions = computed(() => {
const items = templatesCache.value.filter((t) => t.provider === form.provider)
return [
{ value: '', label: t('admin.channelMonitor.templateField.none') },
...items.map((t) => ({ value: String(t.id), label: t.name })),
]
})
async function loadTemplates() {
if (templatesCache.value.length > 0) return
templatesLoading.value = true
try {
const { items } = await adminAPI.channelMonitorTemplate.list()
templatesCache.value = items
} catch (err: unknown) {
// 模板拉取失败不阻塞监控表单,用户可以不选模板
console.warn('load monitor templates failed', err)
} finally {
templatesLoading.value = false
}
}
// 模板下拉绑定value 是 stringSelect 组件约束),需要与 number | null 互转。
const templateSelectValue = computed<string>({
get: () => (form.template_id == null ? '' : String(form.template_id)),
set: (raw: string) => {
if (raw === '') {
form.template_id = null
return
}
const id = Number(raw)
if (!Number.isFinite(id)) return
form.template_id = id
// 应用模板 = 拷贝快照
const tpl = templatesCache.value.find((t) => t.id === id)
if (tpl) {
form.extra_headers = { ...(tpl.extra_headers || {}) }
form.body_override_mode = tpl.body_override_mode
form.body_override = tpl.body_override ? { ...tpl.body_override } : null
}
},
})
interface ProviderOption {
@@ -218,8 +307,8 @@ interface ProviderOption {
}
const providerOptions = computed<ProviderOption[]>(() => [
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
])
@@ -227,13 +316,15 @@ const providerOptions = computed<ProviderOption[]>(() => [
// Editing mode loads api_key='' via loadFromMonitor and only sets it on user
// typing, so clearing on provider change is always a safe no-op until the user
// picks a new key.
// 同时清空 template_id模板有 provider 归属,跨平台不通用)。
watch(() => form.provider, () => {
form.api_key = ''
form.template_id = null
})
function resetForm() {
form.name = ''
form.provider = PROVIDER_OPENAI
form.provider = PROVIDER_ANTHROPIC
form.endpoint = ''
form.api_key = ''
form.primary_model = ''
@@ -241,6 +332,10 @@ function resetForm() {
form.group_name = ''
form.interval_seconds = systemDefaultInterval.value
form.enabled = true
form.template_id = null
form.extra_headers = {}
form.body_override_mode = 'off'
form.body_override = null
}
function loadFromMonitor(m: ChannelMonitor) {
@@ -253,13 +348,19 @@ function loadFromMonitor(m: ChannelMonitor) {
form.group_name = m.group_name || ''
form.interval_seconds = m.interval_seconds || systemDefaultInterval.value
form.enabled = m.enabled
form.template_id = m.template_id ?? null
form.extra_headers = { ...(m.extra_headers || {}) }
form.body_override_mode = m.body_override_mode || 'off'
form.body_override = m.body_override ? { ...m.body_override } : null
}
// Re-sync form whenever the dialog is opened or the target monitor changes.
// 同时拉取模板列表cache 过的话一次性返回)。
watch(
() => [props.show, props.monitor] as const,
([show, m]) => {
if (!show) return
void loadTemplates()
if (m) loadFromMonitor(m)
else resetForm()
},
@@ -310,6 +411,10 @@ function buildPayload(): CreateParams {
group_name: form.group_name.trim(),
enabled: form.enabled,
interval_seconds: form.interval_seconds,
template_id: form.template_id,
extra_headers: form.extra_headers,
body_override_mode: form.body_override_mode,
body_override: form.body_override,
}
}
@@ -329,9 +434,14 @@ async function handleSubmit() {
const target = editing.value
if (target) {
const { api_key, ...rest } = buildPayload()
const req: UpdateParams = rest
const req: UpdateParams = { ...rest }
// Only send api_key if user typed a new value
if (api_key) req.api_key = api_key
// template_id=null 用 clear_template=true 明确告诉后端清空pointer 语义)
if (form.template_id == null) {
req.clear_template = true
delete req.template_id
}
await adminAPI.channelMonitor.update(target.id, req)
appStore.showSuccess(t('admin.channelMonitor.updateSuccess'))
} else {

View File

@@ -0,0 +1,465 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.channelMonitor.template.managerTitle')"
width="wide"
@close="$emit('close')"
>
<!-- provider tabs -->
<div class="mb-4 border-b border-gray-200 dark:border-dark-700">
<div role="tablist" class="flex gap-1">
<button
v-for="tab in providerTabs"
:key="tab.value"
type="button"
role="tab"
:aria-selected="activeProvider === tab.value"
class="px-4 py-2 text-sm font-medium transition-colors"
:class="tabClass(tab.value)"
@click="activeProvider = tab.value"
>
{{ tab.label }}
<span
v-if="countByProvider[tab.value] > 0"
class="ml-1.5 rounded-full bg-gray-100 px-2 py-0.5 text-xs dark:bg-dark-700"
>
{{ countByProvider[tab.value] }}
</span>
</button>
</div>
</div>
<!-- active provider list -->
<div v-if="!editing" class="space-y-2">
<div class="flex justify-end">
<button class="btn btn-primary btn-sm" @click="openCreateForm">
<Icon name="plus" size="sm" class="mr-1" />
{{ t('admin.channelMonitor.template.createButton') }}
</button>
</div>
<div v-if="loading" class="py-8 text-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<div
v-else-if="templatesForActiveProvider.length === 0"
class="py-8 text-center text-sm text-gray-400"
>
{{ t('admin.channelMonitor.template.emptyState') }}
</div>
<div
v-for="tpl in templatesForActiveProvider"
v-else
:key="tpl.id"
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-800"
>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-medium text-gray-900 dark:text-white">{{ tpl.name }}</span>
<span
class="inline-flex items-center rounded-md px-1.5 py-0.5 text-xs"
:class="modeBadgeClass(tpl.body_override_mode)"
>
{{ modeLabel(tpl.body_override_mode) }}
</span>
<span
v-if="tpl.associated_monitors > 0"
class="text-xs text-gray-500 dark:text-gray-400"
>
{{ t('admin.channelMonitor.template.associatedCount', { n: tpl.associated_monitors }) }}
</span>
</div>
<p v-if="tpl.description" class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{{ tpl.description }}
</p>
<p class="mt-1 text-xs text-gray-400">
{{ t('admin.channelMonitor.template.headersSummary', {
n: Object.keys(tpl.extra_headers || {}).length,
}) }}
</p>
</div>
<div class="flex flex-shrink-0 gap-2">
<button
class="btn btn-secondary btn-sm"
:disabled="tpl.associated_monitors === 0"
:title="t('admin.channelMonitor.template.applyTooltip')"
@click="confirmApply(tpl)"
>
<Icon name="refresh" size="sm" class="mr-1" />
{{ t('admin.channelMonitor.template.applyButton') }}
</button>
<button class="btn btn-secondary btn-sm" @click="openEditForm(tpl)">
{{ t('common.edit') }}
</button>
<button class="btn btn-secondary btn-sm text-red-600" @click="handleDelete(tpl)">
{{ t('common.delete') }}
</button>
</div>
</div>
</div>
</div>
<!-- edit / create form -->
<div v-else class="space-y-4">
<div>
<label class="input-label">
{{ t('admin.channelMonitor.template.form.name') }}
<span class="text-red-500">*</span>
</label>
<input
v-model="form.name"
type="text"
required
class="input"
:placeholder="t('admin.channelMonitor.template.form.namePlaceholder')"
/>
</div>
<div v-if="editing === 'new'">
<label class="input-label">
{{ t('admin.channelMonitor.form.provider') }}
<span class="text-red-500">*</span>
</label>
<div class="grid grid-cols-3 gap-3">
<button
v-for="opt in providerTabs"
:key="opt.value"
type="button"
class="rounded-lg border-2 px-3 py-2 text-sm font-medium transition-colors"
:class="providerPickerClass(opt.value, form.provider === opt.value)"
@click="form.provider = opt.value"
>
{{ opt.label }}
</button>
</div>
</div>
<div>
<label class="input-label">
{{ t('admin.channelMonitor.template.form.description') }}
</label>
<input
v-model="form.description"
type="text"
class="input"
:placeholder="t('admin.channelMonitor.template.form.descriptionPlaceholder')"
/>
</div>
<MonitorAdvancedRequestConfig
:extra-headers="form.extra_headers"
:body-override-mode="form.body_override_mode"
:body-override="form.body_override"
@update:extra-headers="form.extra_headers = $event"
@update:body-override-mode="form.body_override_mode = $event"
@update:body-override="form.body_override = $event"
/>
</div>
<template #footer>
<div class="flex w-full items-center justify-between">
<!-- Left: back to list / nothing -->
<div>
<button v-if="editing" class="btn btn-secondary" @click="backToList">
{{ t('common.back') }}
</button>
</div>
<!-- Right: save or close -->
<div class="flex gap-2">
<button class="btn btn-secondary" @click="$emit('close')">
{{ t('common.close') }}
</button>
<button v-if="editing" class="btn btn-primary" :disabled="submitting" @click="handleSubmit">
{{ submitting ? t('common.submitting') : editing === 'new' ? t('common.create') : t('common.update') }}
</button>
</div>
</div>
</template>
</BaseDialog>
<ConfirmDialog
:show="confirmApply_.show"
:title="t('admin.channelMonitor.template.applyTitle')"
:message="confirmApplyMessage"
:confirm-text="t('admin.channelMonitor.template.applyConfirm')"
:cancel-text="t('common.cancel')"
@confirm="doApply"
@cancel="confirmApply_.show = false"
/>
<ConfirmDialog
:show="confirmDelete.show"
:title="t('common.delete')"
:message="confirmDeleteMessage"
:confirm-text="t('common.delete')"
:cancel-text="t('common.cancel')"
:danger="true"
@confirm="doDelete"
@cancel="confirmDelete.show = false"
/>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { extractApiErrorMessage } from '@/utils/apiError'
import { adminAPI } from '@/api/admin'
import type {
BodyOverrideMode,
Provider,
} from '@/api/admin/channelMonitor'
import type { ChannelMonitorTemplate } from '@/api/admin/channelMonitorTemplate'
import BaseDialog from '@/components/common/BaseDialog.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import MonitorAdvancedRequestConfig from '@/components/admin/monitor/MonitorAdvancedRequestConfig.vue'
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
import {
PROVIDER_ANTHROPIC,
PROVIDER_OPENAI,
PROVIDER_GEMINI,
} from '@/constants/channelMonitor'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{
(e: 'close'): void
/** Fired when any template changed (create / update / delete / apply). */
(e: 'updated'): void
}>()
const { t } = useI18n()
const appStore = useAppStore()
const { providerPickerClass } = useChannelMonitorFormat()
const providerTabs = computed<{ value: Provider; label: string }[]>(() => [
{ value: PROVIDER_ANTHROPIC, label: t('monitorCommon.providers.anthropic') },
{ value: PROVIDER_OPENAI, label: t('monitorCommon.providers.openai') },
{ value: PROVIDER_GEMINI, label: t('monitorCommon.providers.gemini') },
])
const activeProvider = ref<Provider>(PROVIDER_ANTHROPIC)
const templates = ref<ChannelMonitorTemplate[]>([])
const loading = ref(false)
const templatesForActiveProvider = computed(() =>
templates.value.filter((t) => t.provider === activeProvider.value),
)
const countByProvider = computed<Record<Provider, number>>(() => {
const out: Record<Provider, number> = {
anthropic: 0,
openai: 0,
gemini: 0,
}
for (const t of templates.value) out[t.provider]++
return out
})
// --- form state ---
interface TemplateForm {
id: number | null
name: string
provider: Provider
description: string
extra_headers: Record<string, string>
body_override_mode: BodyOverrideMode
body_override: Record<string, unknown> | null
}
const editing = ref<null | 'new' | number>(null) // null = list view; 'new' = create; <id> = edit
const submitting = ref(false)
const form = reactive<TemplateForm>(emptyForm(PROVIDER_ANTHROPIC))
function emptyForm(provider: Provider): TemplateForm {
return {
id: null,
name: '',
provider,
description: '',
extra_headers: {},
body_override_mode: 'off',
body_override: null,
}
}
function loadForm(tpl: ChannelMonitorTemplate) {
form.id = tpl.id
form.name = tpl.name
form.provider = tpl.provider
form.description = tpl.description
form.extra_headers = { ...(tpl.extra_headers || {}) }
form.body_override_mode = tpl.body_override_mode
form.body_override = tpl.body_override ? { ...tpl.body_override } : null
}
function openCreateForm() {
Object.assign(form, emptyForm(activeProvider.value))
editing.value = 'new'
}
function openEditForm(tpl: ChannelMonitorTemplate) {
loadForm(tpl)
editing.value = tpl.id
}
function backToList() {
editing.value = null
}
// --- data fetch ---
async function fetchTemplates() {
loading.value = true
try {
const { items } = await adminAPI.channelMonitorTemplate.list()
templates.value = items
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
loading.value = false
}
}
watch(
() => props.show,
(show) => {
if (show) {
editing.value = null
fetchTemplates()
}
},
{ immediate: true },
)
// --- submit ---
async function handleSubmit() {
if (submitting.value) return
if (!form.name.trim()) {
appStore.showError(t('admin.channelMonitor.template.missingName'))
return
}
submitting.value = true
try {
if (editing.value === 'new') {
await adminAPI.channelMonitorTemplate.create({
name: form.name.trim(),
provider: form.provider,
description: form.description.trim(),
extra_headers: form.extra_headers,
body_override_mode: form.body_override_mode,
body_override: form.body_override,
})
appStore.showSuccess(t('admin.channelMonitor.template.createSuccess'))
} else if (typeof editing.value === 'number') {
await adminAPI.channelMonitorTemplate.update(editing.value, {
name: form.name.trim(),
description: form.description.trim(),
extra_headers: form.extra_headers,
body_override_mode: form.body_override_mode,
body_override: form.body_override,
})
appStore.showSuccess(t('admin.channelMonitor.template.updateSuccess'))
}
await fetchTemplates()
emit('updated')
editing.value = null
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
} finally {
submitting.value = false
}
}
// --- apply to monitors ---
const confirmApply_ = reactive<{ show: boolean; tpl: ChannelMonitorTemplate | null }>({
show: false,
tpl: null,
})
function confirmApply(tpl: ChannelMonitorTemplate) {
confirmApply_.tpl = tpl
confirmApply_.show = true
}
const confirmApplyMessage = computed(() => {
const tpl = confirmApply_.tpl
if (!tpl) return ''
return t('admin.channelMonitor.template.applyConfirmMessage', {
name: tpl.name,
n: tpl.associated_monitors,
})
})
async function doApply() {
const tpl = confirmApply_.tpl
confirmApply_.show = false
if (!tpl) return
try {
const { affected } = await adminAPI.channelMonitorTemplate.apply(tpl.id)
appStore.showSuccess(t('admin.channelMonitor.template.applySuccess', { n: affected }))
await fetchTemplates()
emit('updated')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
// --- delete ---
const confirmDelete = reactive<{ show: boolean; tpl: ChannelMonitorTemplate | null }>({
show: false,
tpl: null,
})
function handleDelete(tpl: ChannelMonitorTemplate) {
confirmDelete.tpl = tpl
confirmDelete.show = true
}
const confirmDeleteMessage = computed(() => {
const tpl = confirmDelete.tpl
if (!tpl) return ''
return t('admin.channelMonitor.template.deleteConfirm', {
name: tpl.name,
n: tpl.associated_monitors,
})
})
async function doDelete() {
const tpl = confirmDelete.tpl
confirmDelete.show = false
if (!tpl) return
try {
await adminAPI.channelMonitorTemplate.del(tpl.id)
appStore.showSuccess(t('admin.channelMonitor.template.deleteSuccess'))
await fetchTemplates()
emit('updated')
} catch (err: unknown) {
appStore.showError(extractApiErrorMessage(err, t('common.error')))
}
}
// --- misc ---
function tabClass(value: Provider): string {
return activeProvider.value === value
? 'border-b-2 border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-b-2 border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}
function modeBadgeClass(mode: BodyOverrideMode): string {
switch (mode) {
case 'merge':
return 'bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300'
case 'replace':
return 'bg-purple-100 text-purple-700 dark:bg-purple-500/15 dark:text-purple-300'
default:
return 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
}
}
function modeLabel(mode: BodyOverrideMode): string {
return t(`admin.channelMonitor.advanced.bodyMode${mode.charAt(0).toUpperCase()}${mode.slice(1)}`)
}
</script>

View File

@@ -1,52 +1,49 @@
<template>
<section class="pt-6 pb-6 md:pb-8">
<div class="flex flex-col gap-6 md:flex-row md:items-end md:justify-end">
<div class="flex flex-col items-start md:items-end gap-2.5">
<div
role="tablist"
class="inline-flex p-0.5 rounded-xl bg-gray-100 dark:bg-dark-800 border border-gray-200/60 dark:border-dark-700/60 text-xs"
<section class="py-3 md:py-4">
<div class="flex items-center justify-end gap-3 flex-wrap">
<div
role="tablist"
class="inline-flex p-0.5 rounded-xl bg-gray-100 dark:bg-dark-800 border border-gray-200/60 dark:border-dark-700/60 text-xs"
>
<button
v-for="opt in windowOptions"
:key="opt.value"
type="button"
role="tab"
:aria-selected="window === opt.value"
class="px-3 py-1 rounded-lg transition-colors"
:class="window === opt.value
? 'bg-white dark:bg-dark-700 shadow-sm text-gray-900 dark:text-white font-semibold'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:window', opt.value)"
>
<button
v-for="opt in windowOptions"
:key="opt.value"
type="button"
role="tab"
:aria-selected="window === opt.value"
class="px-3 py-1.5 rounded-lg transition-colors"
:class="window === opt.value
? 'bg-white dark:bg-dark-700 shadow-sm text-gray-900 dark:text-white font-semibold'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'"
@click="emit('update:window', opt.value)"
>
{{ opt.label }}
</button>
</div>
{{ opt.label }}
</button>
</div>
<div class="flex items-center gap-2">
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold tracking-wider uppercase"
:class="overallChipClass"
>
<span
class="w-1.5 h-1.5 rounded-full mr-1.5"
:class="overallDotClass"
></span>
{{ overallLabel }}
</span>
<button
type="button"
class="h-8 w-8 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-dark-700 transition-colors disabled:opacity-50"
:disabled="loading"
:title="t('common.refresh')"
@click="emit('refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
</div>
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold tracking-wider uppercase"
:class="overallChipClass"
>
<span
class="w-1.5 h-1.5 rounded-full mr-1.5"
:class="overallDotClass"
></span>
{{ overallLabel }}
</span>
<div class="text-xs text-gray-500 dark:text-gray-400 tabular-nums text-right">
{{ updatedLabel }}<span v-if="intervalSeconds > 0"> · {{ t('monitorCommon.pollEvery', { n: intervalSeconds }) }}</span>
</div>
<button
type="button"
class="h-8 w-8 rounded-lg flex items-center justify-center text-gray-500 hover:text-gray-700 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-200 dark:hover:bg-dark-700 transition-colors disabled:opacity-50"
:disabled="loading"
:title="t('common.refresh')"
@click="emit('refresh')"
>
<Icon name="refresh" size="md" :class="loading ? 'animate-spin' : ''" />
</button>
<div class="text-xs text-gray-500 dark:text-gray-400 tabular-nums">
{{ updatedLabel }}<span v-if="intervalSeconds > 0"> · {{ t('monitorCommon.pollEvery', { n: intervalSeconds }) }}</span>
</div>
</div>
</section>

View File

@@ -2156,7 +2156,57 @@ export default {
},
runResultTitle: 'Check Result',
noMonitorsYet: 'No monitors yet',
createFirstMonitor: 'Create your first monitor to track channel availability'
createFirstMonitor: 'Create your first monitor to track channel availability',
advanced: {
section: 'Advanced (optional)',
sectionHint: 'Customize request headers and body to bypass upstream client-detection (e.g. "only Claude Code clients allowed").',
headers: 'Custom request headers',
headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219',
headersHint: 'One Key: Value per line; merged on top of adapter defaults (user wins). Hop-by-hop headers (Host / Content-Length / ...) are ignored.',
headersParseError: 'Cannot parse line: {line}',
bodyMode: 'Body handling',
bodyModeOff: 'Default',
bodyModeMerge: 'Merge',
bodyModeReplace: 'Replace',
bodyModeHintOff: 'Use the adapter default body (includes challenge validation).',
bodyModeHintMerge: 'Shallow-merge with the default body; user fields win but model / messages / contents are protected (use Replace to change those).',
bodyModeHintReplace: 'Use the JSON below as the complete body. Challenge validation is skipped; HTTP 2xx + non-empty response text is treated as operational.',
bodyJson: 'Body JSON',
bodyJsonHint: 'Parsed on blur. Empty means no override.',
bodyJsonError: 'JSON parse failed',
bodyJsonObjectError: 'Body must be a JSON object (no arrays or primitives)'
},
templateField: {
label: 'Request template',
none: 'No template',
placeholder: 'Pick a template (filtered by current provider)',
applyHint: 'Picking a template copies its headers and body to this monitor (snapshot). Later template edits are not auto-synced.'
},
template: {
manageButton: 'Templates',
managerTitle: 'Request template manager',
createButton: 'New template',
emptyState: 'No templates for this provider yet',
missingName: 'Template name is required',
createSuccess: 'Template created',
updateSuccess: 'Template updated',
deleteSuccess: 'Template deleted',
applyButton: 'Apply to monitors',
applyTooltip: 'Overwrite snapshot fields on all associated monitors',
applyTitle: 'Apply template',
applyConfirm: 'Apply',
applyConfirmMessage: 'Overwrite {n} associated monitor(s) with the current configuration of "{name}"? Any local customizations on those monitors will be discarded.',
applySuccess: 'Applied to {n} monitor(s)',
deleteConfirm: 'Delete template "{name}"? {n} associated monitor(s) will be disassociated but keep their current snapshot and continue running.',
associatedCount: '{n} associated monitor(s)',
headersSummary: '{n} custom header(s)',
form: {
name: 'Template name',
namePlaceholder: 'e.g. Claude Code mimicry',
description: 'Description',
descriptionPlaceholder: 'Optional: what this template is for, capture date, etc.'
}
}
},
// Subscriptions

View File

@@ -2235,7 +2235,57 @@ export default {
},
runResultTitle: '检测结果',
noMonitorsYet: '暂无监控',
createFirstMonitor: '创建第一个监控来跟踪渠道可用性'
createFirstMonitor: '创建第一个监控来跟踪渠道可用性',
advanced: {
section: '高级(可选)',
sectionHint: '自定义请求头和请求体,用于突破上游的客户端识别限制(如仅允许 Claude Code 客户端)。',
headers: '自定义请求头',
headersPlaceholder: 'User-Agent: claude-cli/1.0.83 (external, cli)\nx-app: cli\nanthropic-beta: claude-code-20250219',
headersHint: '每行一对 Key: Value会与默认请求头合并用户值优先。hop-by-hop 类 headerHost/Content-Length/...)会被忽略。',
headersParseError: '无法解析这一行:{line}',
bodyMode: '请求体处理',
bodyModeOff: '默认',
bodyModeMerge: '合并',
bodyModeReplace: '覆盖',
bodyModeHintOff: '使用 adapter 默认请求体(带 challenge 数学题校验)。',
bodyModeHintMerge: '与默认请求体浅合并,用户字段优先;但 model / messages / contents 会被保护不允许覆盖(动这些字段请用「覆盖」模式)。',
bodyModeHintReplace: '完全用下方 JSON 作为请求体。注意:此模式下跳过 challenge 校验,改为 HTTP 2xx + 响应文本非空即视为可用。',
bodyJson: 'Body JSON',
bodyJsonHint: '失焦时自动解析校验。留空等价于没有覆盖。',
bodyJsonError: 'JSON 解析失败',
bodyJsonObjectError: '请求体必须是一个 JSON 对象(不能是数组或基本类型)'
},
templateField: {
label: '请求模板',
none: '不使用模板',
placeholder: '选择一个模板(按当前平台过滤)',
applyHint: '选中模板后,会把模板的请求头和请求体拷贝到此监控(快照)。后续模板变动不自动同步。'
},
template: {
manageButton: '模板管理',
managerTitle: '请求模板管理',
createButton: '新建模板',
emptyState: '当前平台下还没有请求模板',
missingName: '请输入模板名称',
createSuccess: '模板创建成功',
updateSuccess: '模板更新成功',
deleteSuccess: '模板删除成功',
applyButton: '应用到关联监控',
applyTooltip: '把当前模板配置覆盖到所有关联的监控上',
applyTitle: '应用模板',
applyConfirm: '确认应用',
applyConfirmMessage: '将把模板「{name}」的当前配置覆盖到 {n} 个关联监控。监控本地已编辑的自定义修改会被丢弃,是否继续?',
applySuccess: '已应用到 {n} 个监控',
deleteConfirm: '确定要删除模板「{name}」吗?{n} 个关联监控会解除关联但保留自己的快照继续工作。',
associatedCount: '{n} 个关联监控',
headersSummary: '{n} 个自定义请求头',
form: {
name: '模板名称',
namePlaceholder: '例Claude Code 伪装',
description: '说明',
descriptionPlaceholder: '可选:说明这个模板的用途和来源(抓包日期等)'
}
}
},
// Subscriptions Management

View File

@@ -9,6 +9,7 @@
:loading="loading"
@reload="reload"
@create="openCreateDialog"
@manage-templates="showTemplateManager = true"
@search-input="handleSearch"
/>
</template>
@@ -86,6 +87,12 @@
@saved="reload"
/>
<MonitorTemplateManagerDialog
:show="showTemplateManager"
@close="showTemplateManager = false"
@updated="reload"
/>
<MonitorRunResultDialog
:show="showRunResult"
:results="runResults"
@@ -129,6 +136,7 @@ import Icon from '@/components/icons/Icon.vue'
import Toggle from '@/components/common/Toggle.vue'
import MonitorFiltersBar from '@/components/admin/monitor/MonitorFiltersBar.vue'
import MonitorFormDialog from '@/components/admin/monitor/MonitorFormDialog.vue'
import MonitorTemplateManagerDialog from '@/components/admin/monitor/MonitorTemplateManagerDialog.vue'
import MonitorRunResultDialog from '@/components/admin/monitor/MonitorRunResultDialog.vue'
import MonitorPrimaryModelCell from '@/components/admin/monitor/MonitorPrimaryModelCell.vue'
import MonitorActionsCell from '@/components/admin/monitor/MonitorActionsCell.vue'
@@ -153,6 +161,7 @@ const enabledFilter = ref<'' | 'true' | 'false'>('')
const pagination = reactive({ page: 1, page_size: getPersistedPageSize(), total: 0 })
const showDialog = ref(false)
const showTemplateManager = ref(false)
const editing = ref<ChannelMonitor | null>(null)
const showDeleteDialog = ref(false)
const deleting = ref<ChannelMonitor | null>(null)