feat(monitor): 30-day raw retention + timeline 4-tier style + CC template seed + JSON format button

- History retention 1d → 30d(60s × 30d ≈ 43200 行/model,PG 无压力);
  ComputeAvailability* 不再 UNION rollup 表,直接扫 histories 精度更高。
- Timeline bar 四级高度+颜色双重编码:operational 高+绿 / degraded 中+黄 /
  failed+error 短+红 / 未测试 很短+灰。
- migration 113 seed「Claude Code 伪装」模板(ON CONFLICT DO NOTHING)。
  user_id 用 legacy 格式(user_<64hex>_account_<uuid>_session_<uuid>),
  避免新版 JSON 字符串内嵌 JSON 在编辑器里一长串 \" 难读。
- MonitorAdvancedRequestConfig 加「格式化」按钮 + white-space:pre
  让 body textarea 对长字符串不压扁。
This commit is contained in:
erio
2026-04-21 15:24:48 +08:00
parent 6925ac25c4
commit a7415d4d2e
7 changed files with 102 additions and 64 deletions

View File

@@ -38,12 +38,24 @@
<!-- Body JSON (仅当 mode != off) -->
<div v-if="bodyOverrideMode !== 'off'">
<label class="input-label">{{ t('admin.channelMonitor.advanced.bodyJson') }}</label>
<div class="mb-1 flex items-center justify-between">
<label class="input-label !mb-0">{{ t('admin.channelMonitor.advanced.bodyJson') }}</label>
<button
type="button"
class="text-xs text-primary-600 hover:underline disabled:cursor-not-allowed disabled:text-gray-400 disabled:no-underline dark:text-primary-400"
:disabled="!bodyText.trim()"
@click="formatBody"
>
{{ t('admin.channelMonitor.advanced.bodyJsonFormat') }}
</button>
</div>
<textarea
v-model="bodyText"
rows="8"
rows="10"
:placeholder="bodyPlaceholder"
class="input font-mono text-xs"
style="white-space: pre; overflow-wrap: normal; overflow-x: auto;"
spellcheck="false"
@blur="commitBody"
/>
<p v-if="bodyError" class="mt-1 text-xs text-red-500">{{ bodyError }}</p>
@@ -158,6 +170,25 @@ function commitBody() {
}
}
function formatBody() {
const trimmed = bodyText.value.trim()
if (trimmed === '') return
try {
const parsed = JSON.parse(trimmed)
bodyText.value = JSON.stringify(parsed, null, 2)
bodyError.value = ''
// 同步把校验过的对象提交,避免格式化后焦点未移走时父组件读到旧值
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
emit('update:bodyOverride', parsed as Record<string, unknown>)
}
} 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)

View File

@@ -59,19 +59,21 @@ interface Bar {
title: string
}
// 4 级高度 + 颜色双重编码:高=好+绿,短=坏+红,灰=未测试。
// 长绿(正常) > 中黄(降级) > 短红(失败/系统错误) > 很短灰(未测试)。
const STATUS_HEIGHT: Record<string, number> = {
operational: 100,
degraded: 70,
failed: 55,
degraded: 65,
failed: 35,
error: 35,
empty: 20,
empty: 15,
}
const STATUS_COLOR: Record<string, string> = {
operational: 'bg-emerald-500',
degraded: 'bg-amber-500',
failed: 'bg-red-500',
error: 'bg-gray-400 dark:bg-dark-500',
error: 'bg-red-500',
empty: 'bg-gray-300 dark:bg-dark-600',
}

View File

@@ -2173,6 +2173,7 @@ export default {
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',
bodyJsonFormat: 'Format',
bodyJsonHint: 'Parsed on blur. Empty means no override.',
bodyJsonError: 'JSON parse failed',
bodyJsonObjectError: 'Body must be a JSON object (no arrays or primitives)'

View File

@@ -2252,6 +2252,7 @@ export default {
bodyModeHintMerge: '与默认请求体浅合并,用户字段优先;但 model / messages / contents 会被保护不允许覆盖(动这些字段请用「覆盖」模式)。',
bodyModeHintReplace: '完全用下方 JSON 作为请求体。注意:此模式下跳过 challenge 校验,改为 HTTP 2xx + 响应文本非空即视为可用。',
bodyJson: 'Body JSON',
bodyJsonFormat: '格式化',
bodyJsonHint: '失焦时自动解析校验。留空等价于没有覆盖。',
bodyJsonError: 'JSON 解析失败',
bodyJsonObjectError: '请求体必须是一个 JSON 对象(不能是数组或基本类型)'