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:
@@ -297,41 +297,22 @@ func assignNullInt(dst **int, n sql.NullInt64) {
|
|||||||
// "可用" = status IN (operational, degraded)。
|
// "可用" = status IN (operational, degraded)。
|
||||||
//
|
//
|
||||||
// 数据来源:明细表只保留 1 天;窗口前其余天数走聚合表。
|
// 数据来源:明细表只保留 1 天;窗口前其余天数走聚合表。
|
||||||
// - raw = 今天(CURRENT_DATE 起)的未软删明细,按 model 累加
|
// 明细保留 30 天(monitorHistoryRetentionDays),窗口 <= 30 天时直接扫 histories,
|
||||||
// - rollup = [CURRENT_DATE - windowDays, CURRENT_DATE) 区间的聚合行
|
// 精度到秒,避免与聚合表 UNION 带来的 UTC 日切精度损失。
|
||||||
//
|
|
||||||
// 总窗口为 "今天 + 过去 windowDays 天",比 windowDays 字面值大 1 天,但因为聚合
|
|
||||||
// 是按整 UTC 日切的,这是聚合化无法避免的精度损失,且偏宽不偏窄(数据更全)。
|
|
||||||
func (r *channelMonitorRepository) ComputeAvailability(ctx context.Context, monitorID int64, windowDays int) ([]*service.ChannelMonitorAvailability, error) {
|
func (r *channelMonitorRepository) ComputeAvailability(ctx context.Context, monitorID int64, windowDays int) ([]*service.ChannelMonitorAvailability, error) {
|
||||||
if windowDays <= 0 {
|
if windowDays <= 0 {
|
||||||
windowDays = 7
|
windowDays = 7
|
||||||
}
|
}
|
||||||
const q = `
|
const q = `
|
||||||
WITH raw AS (
|
|
||||||
SELECT model,
|
|
||||||
COUNT(*) AS total_checks,
|
|
||||||
COUNT(*) FILTER (WHERE status IN ('operational','degraded')) AS ok_count,
|
|
||||||
COALESCE(SUM(latency_ms) FILTER (WHERE latency_ms IS NOT NULL), 0) AS sum_latency_ms,
|
|
||||||
COUNT(latency_ms) AS count_latency
|
|
||||||
FROM channel_monitor_histories
|
|
||||||
WHERE monitor_id = $1
|
|
||||||
AND checked_at >= CURRENT_DATE
|
|
||||||
GROUP BY model
|
|
||||||
),
|
|
||||||
rollup AS (
|
|
||||||
SELECT model, total_checks, ok_count, sum_latency_ms, count_latency
|
|
||||||
FROM channel_monitor_daily_rollups
|
|
||||||
WHERE monitor_id = $1
|
|
||||||
AND bucket_date >= (CURRENT_DATE - $2::int)
|
|
||||||
AND bucket_date < CURRENT_DATE
|
|
||||||
)
|
|
||||||
SELECT model,
|
SELECT model,
|
||||||
SUM(total_checks) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(ok_count) AS ok,
|
COUNT(*) FILTER (WHERE status IN ('operational','degraded')) AS ok,
|
||||||
CASE WHEN SUM(count_latency) > 0
|
CASE WHEN COUNT(latency_ms) > 0
|
||||||
THEN SUM(sum_latency_ms)::float8 / SUM(count_latency)
|
THEN SUM(latency_ms) FILTER (WHERE latency_ms IS NOT NULL)::float8 / COUNT(latency_ms)
|
||||||
ELSE NULL END AS avg_latency_ms
|
ELSE NULL END AS avg_latency_ms
|
||||||
FROM (SELECT * FROM raw UNION ALL SELECT * FROM rollup) combined
|
FROM channel_monitor_histories
|
||||||
|
WHERE monitor_id = $1
|
||||||
|
AND checked_at >= NOW() - ($2::int || ' days')::interval
|
||||||
GROUP BY model
|
GROUP BY model
|
||||||
`
|
`
|
||||||
rows, err := r.db.QueryContext(ctx, q, monitorID, windowDays)
|
rows, err := r.db.QueryContext(ctx, q, monitorID, windowDays)
|
||||||
@@ -514,7 +495,7 @@ func clampTimelineLimit(n int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ComputeAvailabilityForMonitors 一次性计算多个监控在某个窗口内的每模型可用率与平均延迟。
|
// ComputeAvailabilityForMonitors 一次性计算多个监控在某个窗口内的每模型可用率与平均延迟。
|
||||||
// 与单 monitor 版本同构:明细只覆盖今天,更早走聚合表 UNION 合并。
|
// 明细保留 30 天,直接扫 histories(窗口 <= 30 天时无需聚合)。
|
||||||
func (r *channelMonitorRepository) ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*service.ChannelMonitorAvailability, error) {
|
func (r *channelMonitorRepository) ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*service.ChannelMonitorAvailability, error) {
|
||||||
out := make(map[int64][]*service.ChannelMonitorAvailability, len(ids))
|
out := make(map[int64][]*service.ChannelMonitorAvailability, len(ids))
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
@@ -524,33 +505,16 @@ func (r *channelMonitorRepository) ComputeAvailabilityForMonitors(ctx context.Co
|
|||||||
windowDays = 7
|
windowDays = 7
|
||||||
}
|
}
|
||||||
const q = `
|
const q = `
|
||||||
WITH raw AS (
|
|
||||||
SELECT monitor_id,
|
|
||||||
model,
|
|
||||||
COUNT(*) AS total_checks,
|
|
||||||
COUNT(*) FILTER (WHERE status IN ('operational','degraded')) AS ok_count,
|
|
||||||
COALESCE(SUM(latency_ms) FILTER (WHERE latency_ms IS NOT NULL), 0) AS sum_latency_ms,
|
|
||||||
COUNT(latency_ms) AS count_latency
|
|
||||||
FROM channel_monitor_histories
|
|
||||||
WHERE monitor_id = ANY($1)
|
|
||||||
AND checked_at >= CURRENT_DATE
|
|
||||||
GROUP BY monitor_id, model
|
|
||||||
),
|
|
||||||
rollup AS (
|
|
||||||
SELECT monitor_id, model, total_checks, ok_count, sum_latency_ms, count_latency
|
|
||||||
FROM channel_monitor_daily_rollups
|
|
||||||
WHERE monitor_id = ANY($1)
|
|
||||||
AND bucket_date >= (CURRENT_DATE - $2::int)
|
|
||||||
AND bucket_date < CURRENT_DATE
|
|
||||||
)
|
|
||||||
SELECT monitor_id,
|
SELECT monitor_id,
|
||||||
model,
|
model,
|
||||||
SUM(total_checks) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(ok_count) AS ok,
|
COUNT(*) FILTER (WHERE status IN ('operational','degraded')) AS ok,
|
||||||
CASE WHEN SUM(count_latency) > 0
|
CASE WHEN COUNT(latency_ms) > 0
|
||||||
THEN SUM(sum_latency_ms)::float8 / SUM(count_latency)
|
THEN SUM(latency_ms) FILTER (WHERE latency_ms IS NOT NULL)::float8 / COUNT(latency_ms)
|
||||||
ELSE NULL END AS avg_latency_ms
|
ELSE NULL END AS avg_latency_ms
|
||||||
FROM (SELECT * FROM raw UNION ALL SELECT * FROM rollup) combined
|
FROM channel_monitor_histories
|
||||||
|
WHERE monitor_id = ANY($1)
|
||||||
|
AND checked_at >= NOW() - ($2::int || ' days')::interval
|
||||||
GROUP BY monitor_id, model
|
GROUP BY monitor_id, model
|
||||||
`
|
`
|
||||||
rows, err := r.db.QueryContext(ctx, q, pq.Array(ids), windowDays)
|
rows, err := r.db.QueryContext(ctx, q, pq.Array(ids), windowDays)
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ const (
|
|||||||
// monitorDegradedThreshold 主请求成功但耗时超过该阈值视为 degraded。
|
// monitorDegradedThreshold 主请求成功但耗时超过该阈值视为 degraded。
|
||||||
monitorDegradedThreshold = 6 * time.Second
|
monitorDegradedThreshold = 6 * time.Second
|
||||||
// monitorHistoryRetentionDays 明细历史保留天数。
|
// monitorHistoryRetentionDays 明细历史保留天数。
|
||||||
// 明细只保留 1 天,超出由 SoftDeleteMixin 软删;
|
// 60s 默认间隔 * 30 天 ≈ 43200 行/monitor/model,一般部署总量 <= 2M 行,
|
||||||
// 维护任务每天凌晨跑(由 OpsCleanupService 统一调度)。
|
// PG 无压力;所以直接保留完整明细一个月,可用率查询可以全走原始行不依赖聚合。
|
||||||
monitorHistoryRetentionDays = 1
|
// 聚合表 channel_monitor_daily_rollups 仍然保留,作为长期历史回填/降级查询的兜底。
|
||||||
|
monitorHistoryRetentionDays = 30
|
||||||
// monitorRollupRetentionDays 日聚合保留天数。
|
// monitorRollupRetentionDays 日聚合保留天数。
|
||||||
// 日聚合行由 RunDailyMaintenance 在超过该窗口后软删。
|
// 日聚合行由 RunDailyMaintenance 在超过该窗口后软删。
|
||||||
monitorRollupRetentionDays = 30
|
monitorRollupRetentionDays = 30
|
||||||
|
|||||||
38
backend/migrations/129_seed_claude_code_template.sql
Normal file
38
backend/migrations/129_seed_claude_code_template.sql
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
-- Migration: 129_seed_claude_code_template
|
||||||
|
-- 内置「Claude Code 伪装」请求模板,覆盖 Anthropic 上游对官方 CLI 客户端的所有验证项:
|
||||||
|
-- 1) User-Agent / X-App / anthropic-beta / anthropic-version 等头
|
||||||
|
-- 2) system 数组首项与官方 system prompt 字面一致(Dice >= 0.5)
|
||||||
|
-- 3) metadata.user_id 满足 ParseMetadataUserID — 这里用 legacy 格式(user_<64hex>_account_<uuid>_session_<36char>)
|
||||||
|
-- 避免新版 JSON 字符串内嵌 JSON 在编辑器里出现一长串 \" 转义,便于用户阅读。
|
||||||
|
--
|
||||||
|
-- ON CONFLICT DO NOTHING:已部署环境(手动建过模板)跑此 migration 不会重复 / 覆盖。
|
||||||
|
-- 用户可自行编辑后续覆盖此 seed;CC 升大版时再起一条 migration 提供新模板,不动用户的旧模板。
|
||||||
|
|
||||||
|
INSERT INTO channel_monitor_request_templates (
|
||||||
|
name, provider, description, extra_headers, body_override_mode, body_override
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
'Claude Code 伪装',
|
||||||
|
'anthropic',
|
||||||
|
'完整模拟 Claude Code 2.1.114 客户端:UA + anthropic-beta + system + metadata.user_id 全部对齐,绕过 Anthropic 上游 ''Claude Code only'' 限制(如 Max 套餐)。',
|
||||||
|
'{
|
||||||
|
"User-Agent": "claude-cli/2.1.114 (external, sdk-cli)",
|
||||||
|
"X-App": "cli",
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"anthropic-beta": "claude-code-20250219,interleaved-thinking-2025-05-14,context-management-2025-06-27,prompt-caching-scope-2026-01-05,advisor-tool-2026-03-01",
|
||||||
|
"anthropic-dangerous-direct-browser-access": "true"
|
||||||
|
}'::jsonb,
|
||||||
|
'merge',
|
||||||
|
'{
|
||||||
|
"system": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": "You are Claude Code, Anthropic''s official CLI for Claude."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"metadata": {
|
||||||
|
"user_id": "user_0000000000000000000000000000000000000000000000000000000000000000_account_00000000-0000-0000-0000-000000000000_session_00000000-0000-0000-0000-000000000000"
|
||||||
|
}
|
||||||
|
}'::jsonb
|
||||||
|
)
|
||||||
|
ON CONFLICT (provider, name) DO NOTHING;
|
||||||
@@ -38,12 +38,24 @@
|
|||||||
|
|
||||||
<!-- Body JSON (仅当 mode != off) -->
|
<!-- Body JSON (仅当 mode != off) -->
|
||||||
<div v-if="bodyOverrideMode !== '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
|
<textarea
|
||||||
v-model="bodyText"
|
v-model="bodyText"
|
||||||
rows="8"
|
rows="10"
|
||||||
:placeholder="bodyPlaceholder"
|
:placeholder="bodyPlaceholder"
|
||||||
class="input font-mono text-xs"
|
class="input font-mono text-xs"
|
||||||
|
style="white-space: pre; overflow-wrap: normal; overflow-x: auto;"
|
||||||
|
spellcheck="false"
|
||||||
@blur="commitBody"
|
@blur="commitBody"
|
||||||
/>
|
/>
|
||||||
<p v-if="bodyError" class="mt-1 text-xs text-red-500">{{ bodyError }}</p>
|
<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 {
|
function serializeBody(body: Record<string, unknown> | null): string {
|
||||||
if (!body || Object.keys(body).length === 0) return ''
|
if (!body || Object.keys(body).length === 0) return ''
|
||||||
return JSON.stringify(body, null, 2)
|
return JSON.stringify(body, null, 2)
|
||||||
|
|||||||
@@ -59,19 +59,21 @@ interface Bar {
|
|||||||
title: string
|
title: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4 级高度 + 颜色双重编码:高=好+绿,短=坏+红,灰=未测试。
|
||||||
|
// 长绿(正常) > 中黄(降级) > 短红(失败/系统错误) > 很短灰(未测试)。
|
||||||
const STATUS_HEIGHT: Record<string, number> = {
|
const STATUS_HEIGHT: Record<string, number> = {
|
||||||
operational: 100,
|
operational: 100,
|
||||||
degraded: 70,
|
degraded: 65,
|
||||||
failed: 55,
|
failed: 35,
|
||||||
error: 35,
|
error: 35,
|
||||||
empty: 20,
|
empty: 15,
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
operational: 'bg-emerald-500',
|
operational: 'bg-emerald-500',
|
||||||
degraded: 'bg-amber-500',
|
degraded: 'bg-amber-500',
|
||||||
failed: 'bg-red-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',
|
empty: 'bg-gray-300 dark:bg-dark-600',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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).',
|
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.',
|
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',
|
bodyJson: 'Body JSON',
|
||||||
|
bodyJsonFormat: 'Format',
|
||||||
bodyJsonHint: 'Parsed on blur. Empty means no override.',
|
bodyJsonHint: 'Parsed on blur. Empty means no override.',
|
||||||
bodyJsonError: 'JSON parse failed',
|
bodyJsonError: 'JSON parse failed',
|
||||||
bodyJsonObjectError: 'Body must be a JSON object (no arrays or primitives)'
|
bodyJsonObjectError: 'Body must be a JSON object (no arrays or primitives)'
|
||||||
|
|||||||
@@ -2252,6 +2252,7 @@ export default {
|
|||||||
bodyModeHintMerge: '与默认请求体浅合并,用户字段优先;但 model / messages / contents 会被保护不允许覆盖(动这些字段请用「覆盖」模式)。',
|
bodyModeHintMerge: '与默认请求体浅合并,用户字段优先;但 model / messages / contents 会被保护不允许覆盖(动这些字段请用「覆盖」模式)。',
|
||||||
bodyModeHintReplace: '完全用下方 JSON 作为请求体。注意:此模式下跳过 challenge 校验,改为 HTTP 2xx + 响应文本非空即视为可用。',
|
bodyModeHintReplace: '完全用下方 JSON 作为请求体。注意:此模式下跳过 challenge 校验,改为 HTTP 2xx + 响应文本非空即视为可用。',
|
||||||
bodyJson: 'Body JSON',
|
bodyJson: 'Body JSON',
|
||||||
|
bodyJsonFormat: '格式化',
|
||||||
bodyJsonHint: '失焦时自动解析校验。留空等价于没有覆盖。',
|
bodyJsonHint: '失焦时自动解析校验。留空等价于没有覆盖。',
|
||||||
bodyJsonError: 'JSON 解析失败',
|
bodyJsonError: 'JSON 解析失败',
|
||||||
bodyJsonObjectError: '请求体必须是一个 JSON 对象(不能是数组或基本类型)'
|
bodyJsonObjectError: '请求体必须是一个 JSON 对象(不能是数组或基本类型)'
|
||||||
|
|||||||
Reference in New Issue
Block a user