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
305 lines
10 KiB
Vue
305 lines
10 KiB
Vue
<template>
|
|
<AppLayout>
|
|
<TablePageLayout>
|
|
<template #filters>
|
|
<MonitorFiltersBar
|
|
v-model:search="searchQuery"
|
|
v-model:provider="providerFilter"
|
|
v-model:enabled="enabledFilter"
|
|
:loading="loading"
|
|
@reload="reload"
|
|
@create="openCreateDialog"
|
|
@manage-templates="showTemplateManager = true"
|
|
@search-input="handleSearch"
|
|
/>
|
|
</template>
|
|
|
|
<template #table>
|
|
<DataTable :columns="columns" :data="monitors" :loading="loading">
|
|
<template #cell-name="{ row, value }">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
|
<HelpTooltip v-if="row.api_key_decrypt_failed" :content="t('admin.channelMonitor.apiKeyDecryptFailed')">
|
|
<Icon name="exclamationTriangle" size="sm" class="text-red-500" />
|
|
</HelpTooltip>
|
|
</div>
|
|
</template>
|
|
|
|
<template #cell-provider="{ row }">
|
|
<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>
|
|
</template>
|
|
|
|
<template #cell-primary_model="{ row }">
|
|
<MonitorPrimaryModelCell :row="row" />
|
|
</template>
|
|
|
|
<template #cell-availability_7d="{ row }">
|
|
<span class="text-sm text-gray-900 dark:text-gray-100">{{ formatAvailability(row) }}</span>
|
|
</template>
|
|
|
|
<template #cell-latency="{ row }">
|
|
<span class="text-sm text-gray-900 dark:text-gray-100">{{ formatLatency(row.primary_latency_ms) }}</span>
|
|
</template>
|
|
|
|
<template #cell-enabled="{ row }">
|
|
<Toggle :modelValue="row.enabled" @update:modelValue="toggleEnabled(row)" />
|
|
</template>
|
|
|
|
<template #cell-actions="{ row }">
|
|
<MonitorActionsCell
|
|
:row="row"
|
|
:running="runningId === row.id"
|
|
@run="handleRunNow"
|
|
@edit="openEditDialog"
|
|
@delete="handleDelete"
|
|
/>
|
|
</template>
|
|
|
|
<template #empty>
|
|
<EmptyState
|
|
:title="t('admin.channelMonitor.noMonitorsYet')"
|
|
:description="t('admin.channelMonitor.createFirstMonitor')"
|
|
:action-text="t('admin.channelMonitor.createButton')"
|
|
@action="openCreateDialog"
|
|
/>
|
|
</template>
|
|
</DataTable>
|
|
</template>
|
|
|
|
<template #pagination>
|
|
<Pagination
|
|
v-if="pagination.total > 0"
|
|
:page="pagination.page"
|
|
:total="pagination.total"
|
|
:page-size="pagination.page_size"
|
|
@update:page="onPageChange"
|
|
@update:pageSize="onPageSizeChange"
|
|
/>
|
|
</template>
|
|
</TablePageLayout>
|
|
|
|
<MonitorFormDialog
|
|
:show="showDialog"
|
|
:monitor="editing"
|
|
@close="closeDialog"
|
|
@saved="reload"
|
|
/>
|
|
|
|
<MonitorTemplateManagerDialog
|
|
:show="showTemplateManager"
|
|
@close="showTemplateManager = false"
|
|
@updated="reload"
|
|
/>
|
|
|
|
<MonitorRunResultDialog
|
|
:show="showRunResult"
|
|
:results="runResults"
|
|
@close="showRunResult = false"
|
|
/>
|
|
|
|
<ConfirmDialog
|
|
:show="showDeleteDialog"
|
|
:title="t('common.delete')"
|
|
:message="deleteConfirmMessage"
|
|
:confirm-text="t('common.delete')"
|
|
:cancel-text="t('common.cancel')"
|
|
:danger="true"
|
|
@confirm="confirmDelete"
|
|
@cancel="showDeleteDialog = false"
|
|
/>
|
|
</AppLayout>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useAppStore } from '@/stores/app'
|
|
import { extractApiErrorMessage } from '@/utils/apiError'
|
|
import { adminAPI } from '@/api/admin'
|
|
import type {
|
|
ChannelMonitor,
|
|
CheckResult,
|
|
ListParams,
|
|
Provider,
|
|
} from '@/api/admin/channelMonitor'
|
|
import type { Column } from '@/components/common/types'
|
|
import AppLayout from '@/components/layout/AppLayout.vue'
|
|
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
|
import DataTable from '@/components/common/DataTable.vue'
|
|
import Pagination from '@/components/common/Pagination.vue'
|
|
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
|
import EmptyState from '@/components/common/EmptyState.vue'
|
|
import HelpTooltip from '@/components/common/HelpTooltip.vue'
|
|
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'
|
|
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
|
|
import { useChannelMonitorFormat } from '@/composables/useChannelMonitorFormat'
|
|
|
|
const { t } = useI18n()
|
|
const appStore = useAppStore()
|
|
const {
|
|
providerLabel,
|
|
providerBadgeClass,
|
|
formatLatency,
|
|
formatAvailability,
|
|
} = useChannelMonitorFormat()
|
|
|
|
const monitors = ref<ChannelMonitor[]>([])
|
|
const loading = ref(false)
|
|
const runningId = ref<number | null>(null)
|
|
const searchQuery = ref('')
|
|
const providerFilter = ref<Provider | ''>('')
|
|
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)
|
|
const showRunResult = ref(false)
|
|
const runResults = ref<CheckResult[]>([])
|
|
|
|
let abortController: AbortController | null = null
|
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const columns = computed<Column[]>(() => [
|
|
{ key: 'name', label: t('admin.channelMonitor.columns.name'), sortable: false },
|
|
{ key: 'provider', label: t('admin.channelMonitor.columns.provider'), sortable: false },
|
|
{ key: 'primary_model', label: t('admin.channelMonitor.columns.primaryModel'), sortable: false },
|
|
{ key: 'availability_7d', label: t('admin.channelMonitor.columns.availability7d'), sortable: false },
|
|
{ key: 'latency', label: t('admin.channelMonitor.columns.latency'), sortable: false },
|
|
{ key: 'enabled', label: t('admin.channelMonitor.columns.enabled'), sortable: false },
|
|
{ key: 'actions', label: t('admin.channelMonitor.columns.actions'), sortable: false },
|
|
])
|
|
|
|
const deleteConfirmMessage = computed(() => {
|
|
const name = deleting.value?.name || ''
|
|
return t('admin.channelMonitor.deleteConfirm', { name })
|
|
})
|
|
|
|
async function reload() {
|
|
if (abortController) abortController.abort()
|
|
const ctrl = new AbortController()
|
|
abortController = ctrl
|
|
loading.value = true
|
|
try {
|
|
const params: ListParams = {
|
|
page: pagination.page,
|
|
page_size: pagination.page_size,
|
|
}
|
|
if (providerFilter.value) params.provider = providerFilter.value
|
|
if (enabledFilter.value === 'true') params.enabled = true
|
|
if (enabledFilter.value === 'false') params.enabled = false
|
|
if (searchQuery.value.trim()) params.search = searchQuery.value.trim()
|
|
|
|
const res = await adminAPI.channelMonitor.list(params, { signal: ctrl.signal })
|
|
if (ctrl.signal.aborted || abortController !== ctrl) return
|
|
monitors.value = res.items || []
|
|
pagination.total = res.total
|
|
} catch (err: unknown) {
|
|
const e = err as { name?: string; code?: string }
|
|
if (e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.channelMonitor.loadError')))
|
|
} finally {
|
|
if (abortController === ctrl) {
|
|
loading.value = false
|
|
abortController = null
|
|
}
|
|
}
|
|
}
|
|
|
|
function handleSearch() {
|
|
if (searchTimeout) clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
pagination.page = 1
|
|
reload()
|
|
}, 300)
|
|
}
|
|
|
|
function onPageChange(page: number) {
|
|
pagination.page = page
|
|
reload()
|
|
}
|
|
|
|
function onPageSizeChange(size: number) {
|
|
pagination.page_size = size
|
|
pagination.page = 1
|
|
reload()
|
|
}
|
|
|
|
function openCreateDialog() {
|
|
editing.value = null
|
|
showDialog.value = true
|
|
}
|
|
|
|
function openEditDialog(row: ChannelMonitor) {
|
|
editing.value = row
|
|
showDialog.value = true
|
|
}
|
|
|
|
function closeDialog() {
|
|
showDialog.value = false
|
|
editing.value = null
|
|
}
|
|
|
|
async function toggleEnabled(row: ChannelMonitor) {
|
|
const next = !row.enabled
|
|
try {
|
|
await adminAPI.channelMonitor.update(row.id, { enabled: next })
|
|
row.enabled = next
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
|
}
|
|
}
|
|
|
|
async function handleRunNow(row: ChannelMonitor) {
|
|
if (runningId.value != null) return
|
|
runningId.value = row.id
|
|
try {
|
|
const res = await adminAPI.channelMonitor.runNow(row.id)
|
|
runResults.value = res.results || []
|
|
showRunResult.value = true
|
|
appStore.showSuccess(t('admin.channelMonitor.runSuccess'))
|
|
// Refresh row to get latest status from backend
|
|
void reload()
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('admin.channelMonitor.runFailed')))
|
|
} finally {
|
|
runningId.value = null
|
|
}
|
|
}
|
|
|
|
function handleDelete(row: ChannelMonitor) {
|
|
deleting.value = row
|
|
showDeleteDialog.value = true
|
|
}
|
|
|
|
async function confirmDelete() {
|
|
if (!deleting.value) return
|
|
try {
|
|
await adminAPI.channelMonitor.del(deleting.value.id)
|
|
appStore.showSuccess(t('admin.channelMonitor.deleteSuccess'))
|
|
showDeleteDialog.value = false
|
|
deleting.value = null
|
|
reload()
|
|
} catch (err: unknown) {
|
|
appStore.showError(extractApiErrorMessage(err, t('common.error')))
|
|
}
|
|
}
|
|
|
|
onMounted(reload)
|
|
onUnmounted(() => {
|
|
if (searchTimeout) clearTimeout(searchTimeout)
|
|
abortController?.abort()
|
|
})
|
|
</script>
|