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)。
|
||||
//
|
||||
// 数据来源:明细表只保留 1 天;窗口前其余天数走聚合表。
|
||||
// - raw = 今天(CURRENT_DATE 起)的未软删明细,按 model 累加
|
||||
// - rollup = [CURRENT_DATE - windowDays, CURRENT_DATE) 区间的聚合行
|
||||
//
|
||||
// 总窗口为 "今天 + 过去 windowDays 天",比 windowDays 字面值大 1 天,但因为聚合
|
||||
// 是按整 UTC 日切的,这是聚合化无法避免的精度损失,且偏宽不偏窄(数据更全)。
|
||||
// 明细保留 30 天(monitorHistoryRetentionDays),窗口 <= 30 天时直接扫 histories,
|
||||
// 精度到秒,避免与聚合表 UNION 带来的 UTC 日切精度损失。
|
||||
func (r *channelMonitorRepository) ComputeAvailability(ctx context.Context, monitorID int64, windowDays int) ([]*service.ChannelMonitorAvailability, error) {
|
||||
if windowDays <= 0 {
|
||||
windowDays = 7
|
||||
}
|
||||
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,
|
||||
SUM(total_checks) AS total,
|
||||
SUM(ok_count) AS ok,
|
||||
CASE WHEN SUM(count_latency) > 0
|
||||
THEN SUM(sum_latency_ms)::float8 / SUM(count_latency)
|
||||
ELSE NULL END AS avg_latency_ms
|
||||
FROM (SELECT * FROM raw UNION ALL SELECT * FROM rollup) combined
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status IN ('operational','degraded')) AS ok,
|
||||
CASE WHEN COUNT(latency_ms) > 0
|
||||
THEN SUM(latency_ms) FILTER (WHERE latency_ms IS NOT NULL)::float8 / COUNT(latency_ms)
|
||||
ELSE NULL END AS avg_latency_ms
|
||||
FROM channel_monitor_histories
|
||||
WHERE monitor_id = $1
|
||||
AND checked_at >= NOW() - ($2::int || ' days')::interval
|
||||
GROUP BY model
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, monitorID, windowDays)
|
||||
@@ -514,7 +495,7 @@ func clampTimelineLimit(n int) int {
|
||||
}
|
||||
|
||||
// ComputeAvailabilityForMonitors 一次性计算多个监控在某个窗口内的每模型可用率与平均延迟。
|
||||
// 与单 monitor 版本同构:明细只覆盖今天,更早走聚合表 UNION 合并。
|
||||
// 明细保留 30 天,直接扫 histories(窗口 <= 30 天时无需聚合)。
|
||||
func (r *channelMonitorRepository) ComputeAvailabilityForMonitors(ctx context.Context, ids []int64, windowDays int) (map[int64][]*service.ChannelMonitorAvailability, error) {
|
||||
out := make(map[int64][]*service.ChannelMonitorAvailability, len(ids))
|
||||
if len(ids) == 0 {
|
||||
@@ -524,33 +505,16 @@ func (r *channelMonitorRepository) ComputeAvailabilityForMonitors(ctx context.Co
|
||||
windowDays = 7
|
||||
}
|
||||
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,
|
||||
model,
|
||||
SUM(total_checks) AS total,
|
||||
SUM(ok_count) AS ok,
|
||||
CASE WHEN SUM(count_latency) > 0
|
||||
THEN SUM(sum_latency_ms)::float8 / SUM(count_latency)
|
||||
ELSE NULL END AS avg_latency_ms
|
||||
FROM (SELECT * FROM raw UNION ALL SELECT * FROM rollup) combined
|
||||
COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE status IN ('operational','degraded')) AS ok,
|
||||
CASE WHEN COUNT(latency_ms) > 0
|
||||
THEN SUM(latency_ms) FILTER (WHERE latency_ms IS NOT NULL)::float8 / COUNT(latency_ms)
|
||||
ELSE NULL END AS avg_latency_ms
|
||||
FROM channel_monitor_histories
|
||||
WHERE monitor_id = ANY($1)
|
||||
AND checked_at >= NOW() - ($2::int || ' days')::interval
|
||||
GROUP BY monitor_id, model
|
||||
`
|
||||
rows, err := r.db.QueryContext(ctx, q, pq.Array(ids), windowDays)
|
||||
|
||||
@@ -16,9 +16,10 @@ const (
|
||||
// monitorDegradedThreshold 主请求成功但耗时超过该阈值视为 degraded。
|
||||
monitorDegradedThreshold = 6 * time.Second
|
||||
// monitorHistoryRetentionDays 明细历史保留天数。
|
||||
// 明细只保留 1 天,超出由 SoftDeleteMixin 软删;
|
||||
// 维护任务每天凌晨跑(由 OpsCleanupService 统一调度)。
|
||||
monitorHistoryRetentionDays = 1
|
||||
// 60s 默认间隔 * 30 天 ≈ 43200 行/monitor/model,一般部署总量 <= 2M 行,
|
||||
// PG 无压力;所以直接保留完整明细一个月,可用率查询可以全走原始行不依赖聚合。
|
||||
// 聚合表 channel_monitor_daily_rollups 仍然保留,作为长期历史回填/降级查询的兜底。
|
||||
monitorHistoryRetentionDays = 30
|
||||
// monitorRollupRetentionDays 日聚合保留天数。
|
||||
// 日聚合行由 RunDailyMaintenance 在超过该窗口后软删。
|
||||
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) -->
|
||||
<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)
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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)'
|
||||
|
||||
@@ -2252,6 +2252,7 @@ export default {
|
||||
bodyModeHintMerge: '与默认请求体浅合并,用户字段优先;但 model / messages / contents 会被保护不允许覆盖(动这些字段请用「覆盖」模式)。',
|
||||
bodyModeHintReplace: '完全用下方 JSON 作为请求体。注意:此模式下跳过 challenge 校验,改为 HTTP 2xx + 响应文本非空即视为可用。',
|
||||
bodyJson: 'Body JSON',
|
||||
bodyJsonFormat: '格式化',
|
||||
bodyJsonHint: '失焦时自动解析校验。留空等价于没有覆盖。',
|
||||
bodyJsonError: 'JSON 解析失败',
|
||||
bodyJsonObjectError: '请求体必须是一个 JSON 对象(不能是数组或基本类型)'
|
||||
|
||||
Reference in New Issue
Block a user