feat(channel-monitor-v2): 实现用户端 Ops 风格被动监控界面

提供筛选/矩阵/趋势/排行等组件与 en/zh 文案,按 mode 切换 V1/V2 壳层,
并统一 useI18n 避免语言包未挂载时键路径裸露。
This commit is contained in:
IanShaw027 committed 2026-08-07 11:05:32 +08:00
1 parent a5beecb92a
commit d8c5024cee
24 files changed
+4153 -166

No files matched your search

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { apiClient } from '../client'
import { getMatrix, repeatedArrayParamsSerializer } from '../channelMonitorV2'
afterEach(() => vi.restoreAllMocks())
describe('channel monitor V2 query serialization', () => {
it('uses repeated keys without bracket suffixes for array filters', () => {
const query = repeatedArrayParamsSerializer({
range: '90m',
platform: ['openai', 'grok'],
group_id: [1, 2],
model: undefined,
group_by: 'platform_group_model',
})
expect(query).toBe('range=90m&platform=openai&platform=grok&group_id=1&group_id=2&group_by=platform_group_model')
expect(query).not.toContain('%5B%5D')
})
it('sends the matrix grouping with the shared filters', async () => {
const get = vi.spyOn(apiClient, 'get').mockResolvedValue({
data: { coverage: {}, group_by: 'platform_group', items: [] },
})
await getMatrix({ range: '24h', platforms: ['openai'], groupIds: [7], models: [] }, 'platform_group', true)
expect(get).toHaveBeenCalledWith('/admin/channel-monitor-v2/matrix', expect.objectContaining({
params: {
range: '24h',
platform: ['openai'],
group_id: [7],
model: undefined,
group_by: 'platform_group',
},
}))
})
})
+259
View File
@@ -0,0 +1,259 @@
import { apiClient } from './client'
export type MonitorRange = '90m' | '24h' | '7d' | '30d'
export type HealthState = 'unknown' | 'healthy' | 'warning' | 'critical'
/** Fine-grained score band for multi-stop green→yellow→red gradients (score0..score10). */
export type HealthScoreBand =
| 'unknown'
| 'score0'
| 'score1'
| 'score2'
| 'score3'
| 'score4'
| 'score5'
| 'score6'
| 'score7'
| 'score8'
| 'score9'
| 'score10'
export type MonitorMatrixGroupBy = 'platform' | 'platform_group' | 'platform_model' | 'platform_group_model'
export interface MonitorFilter {
range: MonitorRange
platforms: string[]
groupIds: number[]
models: string[]
}
export interface LatencyMetric {
sample_count: number
p50_ms: number | null
p90_ms?: number | null
p95_ms: number | null
avg_ms: number | null
}
export interface MonitorMetric {
success_requests: number
error_requests: number
request_count: number
token_count: number
rpm: number
tpm: number
error_rate: number
cache_rate: number
cache_rate_numerator: number
cache_rate_denominator: number
ttft: LatencyMetric
duration: LatencyMetric
upstream_affected_requests?: number
upstream_attempt_count?: number
}
export interface MonitorHealth {
overall: HealthState
error_rate: HealthState
ttft: HealthState
cache?: HealthState
/** 0–100 blended score when samples are sufficient. */
score?: number | null
error_rate_score?: number | null
ttft_score?: number | null
cache_score?: number | null
minimum_sample: number
thresholds?: {
minimum_sample?: number
warning_error_rate: number
critical_error_rate: number
target_ttft_ms: number
warning_ttft_ms: number
critical_ttft_ms: number
warning_cache_rate?: number
critical_cache_rate?: number
error_weight: number
ttft_weight: number
cache_weight?: number
}
}
export interface MonitorCoverage {
requested_start: string
coverage_start: string
data_through: string
computed_at: string
aggregation_lag_seconds: number
coverage_complete: boolean
bucket_seconds: number
}
export interface MonitorConfig {
version: number
enabled: boolean
refresh_interval_seconds: 60 | 300
platforms: Array<{ platform: string; enabled: boolean; models: string[] }>
group_ids: number[]
health_thresholds: {
minimum_sample: number
warning_error_rate: number
critical_error_rate: number
target_ttft_ms: number
warning_ttft_ms: number
critical_ttft_ms: number
warning_cache_rate: number
critical_cache_rate: number
error_weight: number
ttft_weight: number
cache_weight: number
}
/** Categories excluded from error_rate / health; still listed in error breakdown. */
ignored_error_categories?: string[]
}
/** Ordered taxonomy mirrored from backend ChannelMonitorV2ErrorCategories. */
export const MONITOR_ERROR_CATEGORIES = [
'content_policy',
'authentication',
'context_limit',
'invalid_request',
'model_unsupported',
'group_access',
'quota_or_balance',
'account_pool_unavailable',
'rate_or_capacity',
'timeout',
'transport_or_stream',
'upstream_forbidden',
'not_found',
'client_cancelled',
'upstream_5xx',
'internal',
'other',
] as const
export type MonitorErrorCategory = (typeof MONITOR_ERROR_CATEGORIES)[number]
export interface MonitorSnapshot {
config: MonitorConfig
coverage: MonitorCoverage
metrics: MonitorMetric
health: MonitorHealth
trend: Array<{ bucket_start: string; metrics: MonitorMetric; health: MonitorHealth }>
}
export interface MonitorMatrixBucket {
bucket_start: string
metrics: MonitorMetric
health: MonitorHealth
}
export interface MonitorMatrixRow {
platform: string
group_id?: number
group_name?: string
model?: string
metrics: MonitorMetric
health: MonitorHealth
buckets: MonitorMatrixBucket[]
}
export interface MonitorMatrixResponse {
coverage: MonitorCoverage
group_by: MonitorMatrixGroupBy
items: MonitorMatrixRow[]
}
export interface MonitorDimensions {
platforms: Array<{ value: string; label: string; request_count: number }>
groups: Array<{ id: number; name: string; platform?: string; request_count: number }>
models: Array<{ value: string; label: string; platform?: string; request_count: number }>
}
export interface MonitorModelRow { platform: string; model: string; metrics: MonitorMetric; health: MonitorHealth }
export interface MonitorErrorRow {
category: string
count: number
rate: number
details?: Array<{
platform?: string
model?: string
error_type?: string
status_code?: number
upstream_status_code?: number
message?: string
count: number
}>
/** true when category is in config.ignored_error_categories */
ignored?: boolean
}
export interface MonitorUserRow {
user_id?: number
rank: number
email?: string
username?: string
display_label: string
is_self: boolean
can_drilldown: boolean
metrics: MonitorMetric
}
function params(filter: MonitorFilter) {
return {
range: filter.range,
platform: filter.platforms.length ? filter.platforms : undefined,
group_id: filter.groupIds.length ? filter.groupIds : undefined,
model: filter.models.length ? filter.models : undefined,
}
}
export function repeatedArrayParamsSerializer(values: Record<string, unknown>): string {
const search = new URLSearchParams()
for (const [key, rawValue] of Object.entries(values)) {
if (rawValue == null || rawValue === '') continue
const entries = Array.isArray(rawValue) ? rawValue : [rawValue]
for (const value of entries) {
if (value != null && value !== '') search.append(key, String(value))
}
}
return search.toString()
}
const requestConfig = (filter: MonitorFilter, signal?: AbortSignal, extraParams: Record<string, unknown> = {}) => ({
params: { ...params(filter), ...extraParams },
paramsSerializer: { serialize: repeatedArrayParamsSerializer },
signal,
})
function base(admin: boolean) { return admin ? '/admin/channel-monitor-v2' : '/channel-monitor-v2' }
export async function getDimensions(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorDimensions>(`${base(admin)}/dimensions`, requestConfig(filter, signal))
return data
}
export async function getSnapshot(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorSnapshot>(`${base(admin)}/snapshot`, requestConfig(filter, signal))
return data
}
export async function getMatrix(filter: MonitorFilter, groupBy: MonitorMatrixGroupBy, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<MonitorMatrixResponse>(`${base(admin)}/matrix`, requestConfig(filter, signal, { group_by: groupBy }))
return data
}
export async function getModels(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorModelRow[] }>(`${base(admin)}/models`, requestConfig(filter, signal))
return data
}
export async function getErrors(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorErrorRow[] }>(`${base(admin)}/errors`, requestConfig(filter, signal))
return data
}
export async function getUsers(filter: MonitorFilter, admin = false, signal?: AbortSignal) {
const { data } = await apiClient.get<{ coverage: MonitorCoverage; items: MonitorUserRow[] }>(`${base(admin)}/users`, requestConfig(filter, signal))
return data
}
export async function getConfig() {
const { data } = await apiClient.get<MonitorConfig>('/admin/channel-monitor-v2/config')
return data
}
export async function updateConfig(config: MonitorConfig) {
const { data } = await apiClient.put<MonitorConfig>('/admin/channel-monitor-v2/config', config)
return data
}