From d8c5024cee278137349f213f3d08fc7455d85f2f Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Fri, 7 Aug 2026 11:01:22 +0800 Subject: [PATCH] =?UTF-8?q?feat(channel-monitor-v2):=20=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=AB=AF=20Ops=20=E9=A3=8E=E6=A0=BC=E8=A2=AB?= =?UTF-8?q?=E5=8A=A8=E7=9B=91=E6=8E=A7=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提供筛选/矩阵/趋势/排行等组件与 en/zh 文案,按 mode 切换 V1/V2 壳层, 并统一 useI18n 避免语言包未挂载时键路径裸露。 --- .../api/__tests__/channelMonitorV2.spec.ts | 38 + frontend/src/api/channelMonitorV2.ts | 259 +++++ frontend/src/components/icons/Icon.vue | 2 + .../channel-monitor-v2/FilterMultiSelect.vue | 271 ++++++ .../channel-monitor-v2/MetricCell.vue | 73 ++ .../channel-monitor-v2/MonitorRankBadge.vue | 122 +++ .../MonitorSettingsPanel.vue | 441 +++++++++ .../channel-monitor-v2/MonitorTrendChart.vue | 276 ++++++ .../channel-monitor-v2/RelayPulseMatrix.vue | 609 ++++++++++++ .../__tests__/MetricCell.spec.ts | 50 + .../__tests__/RelayPulseMatrix.spec.ts | 132 +++ .../__tests__/designSystem.structure.spec.ts | 104 ++ .../__tests__/monitorFormat.spec.ts | 110 +++ .../__tests__/monitorZoom.spec.ts | 59 ++ .../channel-monitor-v2/monitorFormat.ts | 160 ++++ .../channel-monitor-v2/monitorZoom.ts | 103 ++ .../src/i18n/locales/en/channelMonitorV2.ts | 121 +++ frontend/src/i18n/locales/en/index.ts | 2 + .../src/i18n/locales/zh/channelMonitorV2.ts | 116 +++ frontend/src/i18n/locales/zh/index.ts | 2 + .../src/views/user/ChannelStatusV1View.vue | 172 ++++ .../src/views/user/ChannelStatusV2View.vue | 886 ++++++++++++++++++ frontend/src/views/user/ChannelStatusView.vue | 173 +--- .../__tests__/ChannelStatusView.mode.spec.ts | 38 + 24 files changed, 4153 insertions(+), 166 deletions(-) create mode 100644 frontend/src/api/__tests__/channelMonitorV2.spec.ts create mode 100644 frontend/src/api/channelMonitorV2.ts create mode 100644 frontend/src/features/channel-monitor-v2/FilterMultiSelect.vue create mode 100644 frontend/src/features/channel-monitor-v2/MetricCell.vue create mode 100644 frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue create mode 100644 frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue create mode 100644 frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue create mode 100644 frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue create mode 100644 frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts create mode 100644 frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts create mode 100644 frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts create mode 100644 frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts create mode 100644 frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts create mode 100644 frontend/src/features/channel-monitor-v2/monitorFormat.ts create mode 100644 frontend/src/features/channel-monitor-v2/monitorZoom.ts create mode 100644 frontend/src/i18n/locales/en/channelMonitorV2.ts create mode 100644 frontend/src/i18n/locales/zh/channelMonitorV2.ts create mode 100644 frontend/src/views/user/ChannelStatusV1View.vue create mode 100644 frontend/src/views/user/ChannelStatusV2View.vue create mode 100644 frontend/src/views/user/__tests__/ChannelStatusView.mode.spec.ts diff --git a/frontend/src/api/__tests__/channelMonitorV2.spec.ts b/frontend/src/api/__tests__/channelMonitorV2.spec.ts new file mode 100644 index 0000000000..403e0739d6 --- /dev/null +++ b/frontend/src/api/__tests__/channelMonitorV2.spec.ts @@ -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', + }, + })) + }) +}) diff --git a/frontend/src/api/channelMonitorV2.ts b/frontend/src/api/channelMonitorV2.ts new file mode 100644 index 0000000000..5dac6f6270 --- /dev/null +++ b/frontend/src/api/channelMonitorV2.ts @@ -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 { + 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 = {}) => ({ + 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(`${base(admin)}/dimensions`, requestConfig(filter, signal)) + return data +} +export async function getSnapshot(filter: MonitorFilter, admin = false, signal?: AbortSignal) { + const { data } = await apiClient.get(`${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(`${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('/admin/channel-monitor-v2/config') + return data +} +export async function updateConfig(config: MonitorConfig) { + const { data } = await apiClient.put('/admin/channel-monitor-v2/config', config) + return data +} diff --git a/frontend/src/components/icons/Icon.vue b/frontend/src/components/icons/Icon.vue index 6d0ba8a3f2..7859adee70 100644 --- a/frontend/src/components/icons/Icon.vue +++ b/frontend/src/components/icons/Icon.vue @@ -67,6 +67,8 @@ const icons = { xCircle: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z', exclamationCircle: 'M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z', exclamationTriangle: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z', + // Awards + trophy: 'M16.5 18.75h-9m9 0a3 3 0 013 3h-15a3 3 0 013-3m9 0v-4.5A3.375 3.375 0 0012.75 11.25h-1.5A3.375 3.375 0 008.25 14.25v4.5m8.25-12V6.75a.75.75 0 00-.75-.75h-1.5a.75.75 0 00-.75.75v1.5m0 0V9a.75.75 0 01-.75.75H9.75A.75.75 0 019 9V8.25m0 0V6.75a.75.75 0 00-.75-.75h-1.5a.75.75 0 00-.75.75v1.5m12 0h.008v.008h-.008V8.25zm-12 0h.008v.008H5.25V8.25z', infoCircle: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z', questionCircle: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', diff --git a/frontend/src/features/channel-monitor-v2/FilterMultiSelect.vue b/frontend/src/features/channel-monitor-v2/FilterMultiSelect.vue new file mode 100644 index 0000000000..2eea94796a --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/FilterMultiSelect.vue @@ -0,0 +1,271 @@ + + + + + diff --git a/frontend/src/features/channel-monitor-v2/MetricCell.vue b/frontend/src/features/channel-monitor-v2/MetricCell.vue new file mode 100644 index 0000000000..aa3eee65eb --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MetricCell.vue @@ -0,0 +1,73 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue b/frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue new file mode 100644 index 0000000000..14e2932a88 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MonitorRankBadge.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue b/frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue new file mode 100644 index 0000000000..3fdf1e584a --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MonitorSettingsPanel.vue @@ -0,0 +1,441 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue b/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue new file mode 100644 index 0000000000..725e2b60a9 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue @@ -0,0 +1,276 @@ + + + diff --git a/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue b/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue new file mode 100644 index 0000000000..b030b60bcb --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue @@ -0,0 +1,609 @@ + + + + + diff --git a/frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts new file mode 100644 index 0000000000..420d166279 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/MetricCell.spec.ts @@ -0,0 +1,50 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import MetricCell from '../MetricCell.vue' + +describe('MetricCell', () => { + it('renders label/value/detail through shipped template with design-system classes', () => { + const wrapper = mount(MetricCell, { + props: { + label: '请求', + value: '1,234', + detail: '12.5 RPM', + state: 'healthy', + }, + }) + + expect(wrapper.classes().join(' ') + wrapper.html()).toContain('stat-card') + expect(wrapper.text()).toContain('请求') + expect(wrapper.text()).toContain('1,234') + expect(wrapper.text()).toContain('12.5 RPM') + expect(wrapper.find('strong').classes().join(' ')).toMatch(/emerald/) + }) + + it('maps warning and critical health states to distinct colors', () => { + const warning = mount(MetricCell, { + props: { label: '错误', value: '10%', detail: '1 次', state: 'warning' }, + }) + expect(warning.find('strong').classes().join(' ')).toMatch(/amber/) + + const critical = mount(MetricCell, { + props: { label: '错误', value: '50%', detail: '5 次', state: 'critical' }, + }) + expect(critical.find('strong').classes().join(' ')).toMatch(/red/) + }) + + it('renders multi-part detail as non-truncated chips (AVG · P90 fully visible)', () => { + const wrapper = mount(MetricCell, { + props: { + label: '首 Token P50', + value: '400ms', + detail: 'AVG 475ms · P90 800ms', + state: 'healthy', + }, + }) + expect(wrapper.find('small.truncate').exists()).toBe(false) + expect(wrapper.find('.whitespace-nowrap').exists()).toBe(true) + expect(wrapper.text()).toContain('AVG 475ms') + expect(wrapper.text()).toContain('P90 800ms') + expect(wrapper.text()).not.toContain('…') + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts new file mode 100644 index 0000000000..1c44b0c0d4 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts @@ -0,0 +1,132 @@ +const i18nT = (key: string, params?: Record) => { + const map: Record = { + 'channelMonitorV2.matrix.title': '色块矩阵', + 'channelMonitorV2.matrix.description': '趋势色块视图', + 'channelMonitorV2.bucket.minutes': '{count}分钟', + 'channelMonitorV2.matrix.wheelZoomX': '滚轮缩放横向', + 'channelMonitorV2.matrix.resetZoom': '重置缩放', + 'channelMonitorV2.matrix.dimension': '维度', + 'channelMonitorV2.metrics.successRate': '成功率', + 'channelMonitorV2.metrics.ttft': '首 Token', + 'channelMonitorV2.matrix.scoreLine': '评分 {score}', + 'channelMonitorV2.metrics.successRateValue': '成功率 {value}', + 'channelMonitorV2.metrics.errorRateValue': '错误率 {value}', + 'channelMonitorV2.metrics.rpmValue': 'RPM {value}', + 'channelMonitorV2.metrics.tpmValue': 'TPM {value}', + 'channelMonitorV2.metrics.ttftValue': '首 Token {value}', + 'channelMonitorV2.metrics.durationValue': '时长 {value}', + 'channelMonitorV2.metrics.cacheRateValue': '缓存率 {value}', + 'channelMonitorV2.matrix.noTrafficAt': '{time} 无流量', + 'channelMonitorV2.matrix.noTraffic': '无流量', + 'channelMonitorV2.matrix.legendAria': '图例', + 'channelMonitorV2.matrix.bad': '差', + 'channelMonitorV2.matrix.good': '好', + 'channelMonitorV2.matrix.healthyLegend': '健康', + 'channelMonitorV2.matrix.warningLegend': '警告', + 'channelMonitorV2.matrix.criticalLegend': '异常', + 'channelMonitorV2.matrix.unknownLegend': '未知', + } + const template = map[key] || key + return template.replace(/\{(\w+)\}/g, (_, name) => String(params?.[name] ?? '')) +} + +vi.mock('vue-i18n', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useI18n: () => ({ + t: i18nT, + te: (key: string) => key.startsWith('channelMonitorV2.'), + locale: { value: 'zh' }, + }), + } +}) + +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import RelayPulseMatrix from '../RelayPulseMatrix.vue' +import type { MonitorHealth, MonitorMetric } from '@/api/channelMonitorV2' + +const health: MonitorHealth = { + overall: 'warning', + error_rate: 'warning', + ttft: 'healthy', + cache: 'warning', + score: 52, + error_rate_score: 40, + ttft_score: 100, + cache_score: 50, + minimum_sample: 20, +} + +function metrics(requestCount: number): MonitorMetric { + return { + success_requests: requestCount ? requestCount - 1 : 0, + error_requests: requestCount ? 1 : 0, + request_count: requestCount, + token_count: 100, + rpm: 1.5, + tpm: 10.2, + error_rate: requestCount ? 1 / requestCount : 0, + cache_rate: 0.5, + cache_rate_numerator: 50, + cache_rate_denominator: 100, + ttft: { sample_count: requestCount, p50_ms: 100, p95_ms: 300, avg_ms: 150 }, + duration: { sample_count: requestCount, p50_ms: 500, p95_ms: 900, avg_ms: 600 }, + upstream_affected_requests: 2, + upstream_attempt_count: 3, + } +} + +describe('RelayPulseMatrix', () => { + it('shows privacy-safe hover tooltips and multi-band colors without click modal', async () => { + const wrapper = mount(RelayPulseMatrix, { + props: { + rows: [{ + platform: 'openai', + group_id: 7, + group_name: '默认组', + model: 'gpt-5', + metrics: metrics(10), + health, + buckets: [ + { bucket_start: '2026-08-01T00:00:00Z', metrics: metrics(10), health }, + { bucket_start: '2026-08-01T00:01:00Z', metrics: metrics(0), health }, + ], + }], + coverage: { + requested_start: '2026-08-01T00:00:00Z', + coverage_start: '2026-08-01T00:00:00Z', + data_through: '2026-08-01T00:03:00Z', + computed_at: '2026-08-01T00:03:00Z', + aggregation_lag_seconds: 0, + coverage_complete: true, + bucket_seconds: 60, + }, + healthMode: 'overall', + }, + }) + + const cells = wrapper.findAll('.pulse-cell') + expect(cells).toHaveLength(3) + // Hover tooltip content (privacy-safe: no absolute request/error counts) + const tip = cells[0].text() + expect(tip).toContain('成功率') + expect(tip).toContain('90.0%') + expect(tip).toContain('RPM') + expect(tip).toContain('TPM') + expect(tip).toContain('首 Token') + expect(tip).not.toContain('请求数') + expect(tip).not.toContain('用户错误') + expect(tip).not.toContain('上游受影响') + // Multi-band class from score 52 → score5 + expect(cells[0].classes().some((c) => c.startsWith('health-score'))).toBe(true) + // Redacted user payloads may have request_count=0 but still include score. + expect(cells[1].classes().some((c) => c.startsWith('health-score'))).toBe(true) + expect(cells[2].classes()).toContain('health-unknown') + + // No click-to-open modal + await cells[0].trigger('click') + expect(wrapper.find('[role="dialog"]').exists()).toBe(false) + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts new file mode 100644 index 0000000000..9aa2a23522 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts @@ -0,0 +1,104 @@ +/** + * Structure contracts: channel-monitor-v2 + studio shells must use project + * design-system utility classes rather than isolated flat RGB skins. + */ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +const root = resolve(__dirname, '../../..') + +function read(rel: string) { + return readFileSync(resolve(root, rel), 'utf8') +} + +describe('channel-monitor-v2 design system structure', () => { + it('user ChannelStatus V2 shell uses page-header, card, btn, tabs utilities', () => { + // Route wrapper may switch V1/V2; design chrome lives on the V2 implementation. + const src = read('views/user/ChannelStatusV2View.vue') + expect(src).toContain('page-header') + expect(src).toContain('page-title') + expect(src).toContain('class="card') + expect(src).toContain('btn btn-secondary') + expect(src).toContain('class="tab') + expect(src).toContain('tab-active') + expect(src).toContain('badge badge-warning') + // Compact single-row toolbar + expect(src).toContain('monitor-toolbar') + expect(src).toContain('clearFilters') + expect(src).toContain('healthModeOptions') + expect(src).toContain("'cache'") + // Ops elevation: rounded-3xl + ring surfaces + expect(src).toContain('rounded-3xl') + expect(src).toContain('ring-1 ring-gray-900/5') + // Overview-first KPI strip before primary viz + expect(src.indexOf('summaryAria')).toBeLessThan(src.indexOf('MonitorTrendChart')) + // No page-level fixed min-width that forces viewport horizontal scroll + expect(src).not.toMatch(/min-width:\s*980px/) + expect(src).not.toMatch(/min-w-\[980px\]/) + // Dense tables scroll internally + expect(src).toMatch(/max-h-\[min\(52vh/) + expect(src).toContain('overflow-auto') + // Trend view toggle (pulse matrix / line chart) + default platform/group dimension + expect(src).toContain("trendView") + expect(src).toContain("'platform_group'") + expect(src).toContain('MonitorTrendChart') + }) + + it('RelayPulseMatrix uses card chrome, matrix scroll, and hover tooltips (no click modal)', () => { + const src = read('features/channel-monitor-v2/RelayPulseMatrix.vue') + expect(src).toContain('class="card') + expect(src).toContain('card-header') + expect(src).toContain('card-body') + expect(src).toContain('matrix-scroll') + expect(src).toMatch(/max-h-\[min\(42vh/) + expect(src).toContain('overflow-auto') + expect(src).toContain('pulse-tooltip') + expect(src).toContain('rounded-3xl') + expect(src).toContain('ring-1 ring-gray-900/5') + expect(src).not.toContain('modal-overlay') + expect(src).not.toContain('modal-content') + }) + + it('MetricCell uses stat-card utility', () => { + const src = read('features/channel-monitor-v2/MetricCell.vue') + expect(src).toContain('stat-card') + expect(src).toContain('stat-label') + expect(src).toContain('stat-value') + expect(src).toContain('rounded-3xl') + }) + + it('MonitorTrendChart uses Ops chart shell tokens', () => { + const src = read('features/channel-monitor-v2/MonitorTrendChart.vue') + expect(src).toContain('class="card') + expect(src).toContain('rounded-3xl') + expect(src).toContain('ring-1 ring-gray-900/5') + expect(src).toContain('EmptyState') + expect(src).toContain('min-h-[360px]') + }) + + it('FilterMultiSelect uses rounded-xl input chrome and dropdown utility', () => { + const src = read('features/channel-monitor-v2/FilterMultiSelect.vue') + expect(src).toContain('rounded-xl') + expect(src).toContain('dropdown') + expect(src).toContain('dropdown-item') + }) + + it('MonitorSettingsPanel uses page-header, card, btn-primary, tabs', () => { + const src = read('features/channel-monitor-v2/MonitorSettingsPanel.vue') + expect(src).toContain('page-header') + expect(src).toContain('btn btn-primary') + expect(src).toContain('class="card') + expect(src).toContain('tab-active') + expect(src).toMatch(/max-h-\[min\(40vh/) + }) + + it('admin ChannelMonitorView V2 tab chrome uses project tabs', () => { + const src = read('views/admin/ChannelMonitorView.vue') + expect(src).toContain('page-header') + expect(src).toContain('page-title') + expect(src).toContain('class="tabs') + expect(src).toContain('tab-active') + expect(src).toContain('MonitorSettingsPanel') + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts new file mode 100644 index 0000000000..8a62ab67c6 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { + formatLatencyKpiSecondary, + formatLatencyPrivacy, + formatMonitorMs, + formatMonitorNumber, + formatMonitorPercent, + formatMonitorRate, + formatMonitorSuccessRate, + formatMonitorSuccessRateFromError, + formatMonitorThroughput, + healthScoreClass, + healthStateClass, + scoreToBand, +} from '../monitorFormat' +import type { MonitorHealth } from '@/api/channelMonitorV2' + +describe('monitorFormat accuracy', () => { + it('formats request/token counts with compact notation above threshold', () => { + expect(formatMonitorNumber(10)).toBe('10') + expect(formatMonitorNumber(9999)).toBe('9,999') + // compact at >= 10000 + expect(formatMonitorNumber(10000)).toMatch(/1/) + expect(formatMonitorNumber(0)).toBe('0') + }) + + it('formats rates and percents from raw API fractions without hardcoding pretty values', () => { + expect(formatMonitorRate(12.56)).toBe('12.6') + expect(formatMonitorPercent(0.1)).toBe('10.0%') + expect(formatMonitorPercent(0.005)).toBe('0.50%') + // value < 0.01 uses 2 decimal places (includes exact 0) + expect(formatMonitorPercent(0)).toBe('0.00%') + expect(formatMonitorPercent(0.5)).toBe('50.0%') + }) + + it('formats RPM/TPM throughput with K/M compact notation', () => { + expect(formatMonitorThroughput(12.56)).toBe('12.6') + expect(formatMonitorThroughput(999)).toMatch(/999/) + expect(formatMonitorThroughput(1000)).toBe('1.0K') + expect(formatMonitorThroughput(1500)).toBe('1.5K') + expect(formatMonitorThroughput(1_000_000)).toBe('1.0M') + expect(formatMonitorThroughput(1_500_000)).toBe('1.5M') + expect(formatMonitorThroughput(0)).toBe('0') + }) + + it('formats latency ms/s boundaries from API fields', () => { + expect(formatMonitorMs(null)).toBe('-') + expect(formatMonitorMs(300)).toBe('300ms') + expect(formatMonitorMs(1500)).toBe('1.5s') + expect(formatMonitorMs(999)).toBe('999ms') + }) + + it('formats KPI secondary latency as AVG · P90 (P50 is primary)', () => { + expect(formatLatencyKpiSecondary(150, 400)).toBe('AVG 150ms · P90 400ms') + expect(formatLatencyKpiSecondary(1500, null, 2000)).toBe('AVG 1.5s · P95 2.0s') + expect(formatLatencyKpiSecondary(null, null)).toBe('-') + }) + + it('computes success rate from success_requests / request_count', () => { + expect(formatMonitorSuccessRate(9, 10)).toBe('90.0%') + expect(formatMonitorSuccessRate(0, 0)).toBe('-') + expect(formatMonitorSuccessRate(1, 3)).toBe('33.3%') + }) + + it('derives success rate from error_rate without absolute counts', () => { + expect(formatMonitorSuccessRateFromError(0.1)).toBe('90.0%') + expect(formatMonitorSuccessRateFromError(0)).toBe('100.0%') + }) + + it('maps continuous scores to multi-stop bands', () => { + expect(scoreToBand(100)).toBe('score10') + expect(scoreToBand(95)).toBe('score10') + expect(scoreToBand(80)).toBe('score8') + expect(scoreToBand(50)).toBe('score5') + expect(scoreToBand(0)).toBe('score0') + expect(scoreToBand(null)).toBe('unknown') + }) + + it('builds score-based health classes for matrix cells', () => { + const health: MonitorHealth = { + overall: 'warning', + error_rate: 'warning', + ttft: 'healthy', + cache: 'warning', + score: 52, + error_rate_score: 40, + ttft_score: 100, + cache_score: 50, + minimum_sample: 20, + } + expect(healthScoreClass(health, 'overall', 10)).toBe('health-score5') + expect(healthScoreClass(health, 'success', 10)).toBe('health-score4') + expect(healthScoreClass(health, 'ttft', 10)).toBe('health-score10') + expect(healthScoreClass(health, 'cache', 10)).toBe('health-score5') + // Redacted user payloads have request_count=0 but keep score fields. + expect(healthScoreClass(health, 'overall', 0)).toBe('health-score5') + expect(healthScoreClass({ ...health, score: null }, 'overall', 0)).toBe('health-unknown') + }) + + it('maps health states for status dots', () => { + expect(healthStateClass('healthy')).toBe('health-healthy') + expect(healthStateClass(undefined)).toBe('health-unknown') + }) + + it('formats privacy-safe latency lines with avg/p50/p90', () => { + expect(formatLatencyPrivacy(100, 250, 120, 300)).toBe('AVG 120ms · P50 100ms · P90 250ms') + expect(formatLatencyPrivacy(100, null, null, 300)).toBe('P50 100ms · P95 300ms') + expect(formatLatencyPrivacy(null, null)).toBe('-') + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts new file mode 100644 index 0000000000..3630160ea8 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + DEFAULT_ZOOM, + MIN_ZOOM_SPAN, + applyWheelZoom, + clampZoom, + isZoomed, + resetZoom, + sliceByZoom, +} from '../monitorZoom' + +describe('monitorZoom', () => { + it('resets to full span', () => { + expect(resetZoom()).toEqual(DEFAULT_ZOOM) + expect(isZoomed(DEFAULT_ZOOM)).toBe(false) + }) + + it('zooms in around cursor without always pinning to the end', () => { + const mid = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0.5) + expect(mid.span).toBeLessThan(1) + expect(mid.span).toBeGreaterThanOrEqual(MIN_ZOOM_SPAN) + // Center zoom keeps window roughly centered + expect(mid.offset).toBeGreaterThan(0) + expect(mid.offset + mid.span).toBeLessThanOrEqual(1.001) + + const left = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 0) + expect(left.offset).toBeLessThanOrEqual(0.001) + + const right = applyWheelZoom(DEFAULT_ZOOM, { deltaY: -100, deltaX: 0, shiftKey: false }, 1) + expect(right.offset + right.span).toBeGreaterThan(0.99) + }) + + it('pans with shift+wheel', () => { + const zoomed = clampZoom({ span: 0.4, offset: 0.1 }) + const panned = applyWheelZoom(zoomed, { deltaY: 200, deltaX: 0, shiftKey: true }, 0.5) + expect(panned.span).toBeCloseTo(zoomed.span, 5) + expect(panned.offset).toBeGreaterThan(zoomed.offset) + }) + + it('clamps span to MIN_ZOOM_SPAN and keeps window in range', () => { + const tight = clampZoom({ span: 0.01, offset: 0.9 }) + expect(tight.span).toBe(MIN_ZOOM_SPAN) + expect(tight.offset + tight.span).toBeLessThanOrEqual(1.001) + }) + + it('slices series by window, not always last N points', () => { + const items = Array.from({ length: 100 }, (_, i) => i) + const startWindow = sliceByZoom(items, { span: 0.2, offset: 0 }) + expect(startWindow[0]).toBe(0) + expect(startWindow.length).toBe(20) + + const midWindow = sliceByZoom(items, { span: 0.2, offset: 0.4 }) + expect(midWindow[0]).toBe(40) + expect(midWindow[midWindow.length - 1]).toBe(59) + + const full = sliceByZoom(items, DEFAULT_ZOOM) + expect(full).toHaveLength(100) + }) +}) diff --git a/frontend/src/features/channel-monitor-v2/monitorFormat.ts b/frontend/src/features/channel-monitor-v2/monitorFormat.ts new file mode 100644 index 0000000000..fd3212af6d --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/monitorFormat.ts @@ -0,0 +1,160 @@ +/** + * Shared display formatters for channel-monitor-v2. + * Kept pure so unit tests can lock metric presentation accuracy. + * + * Privacy: prefer rates (error_rate, RPM, TPM, success %) over absolute + * request/error/token counts in user-facing surfaces. + */ + +import type { HealthScoreBand, MonitorHealth } from '@/api/channelMonitorV2' +import { formatCompactNumber } from '@/utils/format' + +export function monitorIntlLocale(): string { + if (typeof document !== 'undefined') { + const htmlLang = document.documentElement.getAttribute('lang')?.trim() + if (htmlLang) return htmlLang + } + if (typeof navigator !== 'undefined' && navigator.language) return navigator.language + return 'zh-CN' +} + +export function formatMonitorNumber(value: number, compactAt = 10000, locale = monitorIntlLocale()): string { + return Intl.NumberFormat(locale, { + notation: value >= compactAt ? 'compact' : 'standard', + maximumFractionDigits: 1, + }).format(value || 0) +} + +export function formatMonitorRate(value: number, locale = monitorIntlLocale()): string { + return Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value || 0) +} + +/** + * RPM/TPM throughput display: always K/M compact (1.0K, 1.5M) for ops-readable density. + * Prefer this over formatMonitorRate for rate KPIs and table cells. + */ +export function formatMonitorThroughput(value: number | null | undefined): string { + if (value == null || Number.isNaN(Number(value))) return '0' + const n = Number(value) + if (Math.abs(n) < 1000) { + return Intl.NumberFormat(monitorIntlLocale(), { maximumFractionDigits: 1 }).format(n) + } + return formatCompactNumber(n) +} + + +export function formatMonitorPercent(value: number, locale = monitorIntlLocale()): string { + return `${new Intl.NumberFormat(locale, { + minimumFractionDigits: value < 0.01 ? 2 : 1, + maximumFractionDigits: value < 0.01 ? 2 : 1, + }).format((value || 0) * 100)}%` +} + +export function formatMonitorMs(value: number | null | undefined): string { + if (value == null) return '-' + return value >= 1000 ? `${(value / 1000).toFixed(1)}s` : `${Math.round(value)}ms` +} + +export function formatMonitorSuccessRate(successRequests: number, requestCount: number): string { + if (!requestCount) return '-' + return formatMonitorPercent(successRequests / requestCount) +} + +export function formatMonitorSuccessRateFromError(errorRate: number): string { + return formatMonitorPercent(1 - (errorRate || 0)) +} + +/** + * Map continuous 0–100 score to 11 fine bands for multi-stop green→yellow→red. + * score10 = best (green), score0 = worst (red). + */ +export function scoreToBand(score: number | null | undefined): HealthScoreBand { + if (score == null || Number.isNaN(score)) return 'unknown' + const clamped = Math.max(0, Math.min(100, score)) + // 0–100 → score0..score10 (11 stops for green→yellow→red gradient) + const band = Math.round(clamped / 10) + return `score${Math.max(0, Math.min(10, band))}` as HealthScoreBand +} + +export type HealthDisplayMode = 'overall' | 'success' | 'ttft' | 'cache' + +/** Resolve the score used for a health mode. */ +export function healthModeScore( + health: MonitorHealth, + mode: HealthDisplayMode, +): number | null { + if (mode === 'success') { + return health.error_rate_score ?? null + } + if (mode === 'ttft') { + return health.ttft_score ?? null + } + if (mode === 'cache') { + return health.cache_score ?? null + } + return health.score ?? null +} + +export function healthScoreClass( + health: MonitorHealth, + mode: HealthDisplayMode, + requestCount: number, +): string { + const score = healthModeScore(health, mode) + if (score == null) { + if (requestCount <= 0) return 'health-unknown' + // Fall back to coarse state when score is absent (older payloads). + const coarse = + mode === 'success' + ? health.error_rate + : mode === 'ttft' + ? health.ttft + : mode === 'cache' + ? health.cache + : health.overall + return healthStateClass(coarse) + } + return `health-${scoreToBand(score)}` +} + +export function healthStateClass(state: string | undefined): string { + return `health-${state || 'unknown'}` +} + +/** Privacy-safe latency summary: avg + p50 + p90 (no absolute sample counts). */ +export function formatLatencyPrivacy( + p50: number | null | undefined, + p90: number | null | undefined, + avg?: number | null | undefined, + p95?: number | null | undefined, +): string { + const parts: string[] = [] + if (avg != null) parts.push(`AVG ${formatMonitorMs(avg)}`) + if (p50 != null) parts.push(`P50 ${formatMonitorMs(p50)}`) + if (p90 != null) parts.push(`P90 ${formatMonitorMs(p90)}`) + // p95 only as fallback when p90 missing (older payloads) + if (p90 == null && p95 != null) parts.push(`P95 ${formatMonitorMs(p95)}`) + return parts.length ? parts.join(' · ') : '-' +} + +/** + * KPI secondary line for latency: AVG + P90 only (P50 is the primary value). + * Falls back to P95 when P90 is absent. Delimiter is " · " so MetricCell can + * split into non-truncated chips. + */ +export function formatLatencyKpiSecondary( + avg?: number | null | undefined, + p90?: number | null | undefined, + p95?: number | null | undefined, +): string { + const parts: string[] = [] + if (avg != null && Number.isFinite(avg)) parts.push(`AVG ${formatMonitorMs(avg)}`) + if (p90 != null && Number.isFinite(p90)) parts.push(`P90 ${formatMonitorMs(p90)}`) + else if (p95 != null && Number.isFinite(p95)) parts.push(`P95 ${formatMonitorMs(p95)}`) + return parts.length ? parts.join(' · ') : '-' +} + +/** Fallback when i18n key is missing; prefer `channelMonitorV2.errorCategories.*`. */ +export function monitorErrorCategoryLabel(category: string): string { + return category +} diff --git a/frontend/src/features/channel-monitor-v2/monitorZoom.ts b/frontend/src/features/channel-monitor-v2/monitorZoom.ts new file mode 100644 index 0000000000..8bd6ff9490 --- /dev/null +++ b/frontend/src/features/channel-monitor-v2/monitorZoom.ts @@ -0,0 +1,103 @@ +/** + * Pure X-axis zoom/pan for channel-monitor pulse matrix and line chart. + * No chartjs-plugin-zoom dependency — state is span + offset fractions. + */ + +export interface ZoomState { + /** Visible fraction of the full series (1 = full range). */ + span: number + /** Start of the visible window as a fraction of the full domain [0, 1 - span]. */ + offset: number +} + +export const DEFAULT_ZOOM: ZoomState = { span: 1, offset: 0 } +export const MIN_ZOOM_SPAN = 0.12 + +const ZOOM_STEP = 0.12 + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)) +} + +export function resetZoom(): ZoomState { + return { ...DEFAULT_ZOOM } +} + +export function isZoomed(state: ZoomState): boolean { + return state.span < 0.999 || state.offset > 0.001 +} + +/** Normalize offset so the visible window stays within [0, 1]. */ +export function clampZoom(state: ZoomState): ZoomState { + const span = clamp(state.span, MIN_ZOOM_SPAN, 1) + const maxOffset = Math.max(0, 1 - span) + return { + span, + offset: clamp(state.offset, 0, maxOffset), + } +} + +/** + * Wheel zoom around cursor xRatio (0–1 within the chart/matrix track). + * Shift+wheel or horizontal delta pans offset instead. + */ +export function applyWheelZoom( + state: ZoomState, + event: Pick, + xRatio: number, +): ZoomState { + const ratio = clamp(Number.isFinite(xRatio) ? xRatio : 0.5, 0, 1) + const isPan = event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY) + + if (isPan) { + if (state.span >= 1) return clampZoom(state) + const delta = event.shiftKey ? event.deltaY : event.deltaX + // Positive delta → pan right (increase offset) + const panFraction = (delta / 400) * state.span + return clampZoom({ + span: state.span, + offset: state.offset + panFraction, + }) + } + + // Zoom in (deltaY < 0) / out (deltaY > 0) around cursor + const direction = event.deltaY < 0 ? -1 : event.deltaY > 0 ? 1 : 0 + if (direction === 0) return clampZoom(state) + + const nextSpan = clamp(state.span * (1 + direction * ZOOM_STEP), MIN_ZOOM_SPAN, 1) + if (Math.abs(nextSpan - state.span) < 1e-9) return clampZoom(state) + + // Keep the point under the cursor fixed in data space. + // windowStart is offset (domain fraction); dataPos = windowStart + ratio * span + const windowStart = state.offset + const dataPos = windowStart + ratio * state.span + const nextStart = dataPos - ratio * nextSpan + + return clampZoom({ + span: nextSpan, + offset: nextStart, + }) +} + +/** + * Slice an array using zoom state. Cursor-centered windowing — not always "last N". + */ +export function sliceByZoom(items: readonly T[], state: ZoomState): T[] { + if (!items.length) return [] + const zoom = clampZoom(state) + if (zoom.span >= 0.999) return [...items] + + const count = Math.max(1, Math.ceil(items.length * zoom.span)) + const maxStart = Math.max(0, items.length - count) + // offset is window start in [0, 1-span] domain → map to index + const start = Math.min(maxStart, Math.max(0, Math.round(zoom.offset * items.length))) + return items.slice(start, start + count) +} + +/** Map a client X position within an element to 0–1 ratio. */ +export function clientXRatio(clientX: number, element: HTMLElement | null | undefined): number { + if (!element) return 0.5 + const rect = element.getBoundingClientRect() + if (rect.width <= 0) return 0.5 + return clamp((clientX - rect.left) / rect.width, 0, 1) +} diff --git a/frontend/src/i18n/locales/en/channelMonitorV2.ts b/frontend/src/i18n/locales/en/channelMonitorV2.ts new file mode 100644 index 0000000000..3410399cf9 --- /dev/null +++ b/frontend/src/i18n/locales/en/channelMonitorV2.ts @@ -0,0 +1,121 @@ +/** Channel Monitor V2 (user + admin passive monitor UI) */ +export default { + channelMonitorV2: { + title: 'Channel Monitor', + updating: 'Updating data', + updatedTo: 'Updated to {time}', + partialCoverage: 'Partial historical coverage', + timeRange: 'Time range', + clearFilters: 'Reset', + refreshingFilters: 'Filters changed; refreshing matrix, trend, and details…', + switchingData: 'Switching filtered data…', + summaryAria: 'Selected range summary', + loadFailed: 'Failed to load channel monitor', + detailLoadFailed: 'Failed to load channel monitor details', + otherModels: 'Other models', + ignored: 'Ignored', + currentUser: 'Current user', + ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' }, + filters: { + platform: 'Platform', allPlatforms: 'All', group: 'Group', allGroups: 'All', model: 'Model', allModels: 'All', + empty: 'No options', selectedCount: '{count}', labelValue: '{label}: {value}' + }, + groupBy: { + label: 'Group by', platform: 'Platform', platformGroup: 'Platform / Group', platformModel: 'Platform / Model', platformGroupModel: 'Platform / Group / Model' + }, + trendView: { label: 'Trend view', pulse: 'Pulse matrix', line: 'Line chart' }, + healthMode: { label: 'Health display', overall: 'Overall', success: 'Error rate', ttft: 'First token', cache: 'Cache rate' }, + tabs: { aria: 'Detail dimension', models: 'Models', errors: 'Error reasons', users: 'User ranking' }, + metrics: { + rpm: 'RPM', tpm: 'TPM', rpmDetail: 'Request rate', tpmDetail: 'Token rate', errorRate: 'Error rate', ttft: 'First token', ttftP50: 'First token P50', durationP50: 'Duration P50', cacheRate: 'Cache rate', cacheDetail: 'Read cache share', successRate: 'Success rate', + successRateValue: 'Success rate {value}', errorRateValue: 'Error rate {value}', rpmValue: 'RPM {value}', tpmValue: 'TPM {value}', ttftValue: 'First token {value}', durationValue: 'Duration {value}', cacheRateValue: 'Cache rate {value}' + }, + table: { platformModel: 'Platform / Model', rank: 'Rank', user: 'User' }, + empty: { title: 'No data to display', description: 'Try changing the time range or filters' }, + bucket: { minutes: '{count}-minute buckets', hours: '{count}-hour buckets', days: '{count}-day buckets' }, + matrix: { + title: 'Availability trend', description: 'Each row is a channel dimension and each block is an aggregate interval; hover for details', wheelZoom: 'Wheel over blocks to zoom', wheelZoomX: 'Wheel over blocks to zoom horizontally', dimension: 'Channel dimension', emptyTitle: 'No matrix data for the selected window', legendAria: 'Health score legend', bad: 'Bad', good: 'Good', healthyLegend: 'Healthy (≥80)', warningLegend: 'Watch (50–79)', criticalLegend: 'Critical (<50)', unknownLegend: 'No traffic / insufficient samples', noTraffic: 'No traffic in this interval', noTrafficAt: '{time} · no traffic', scoreLine: 'Health score {score}', resetZoom: 'Reset zoom' + }, + chart: { + title: 'Availability trend', description: 'Smoothed trend: error rate · first token P50 · cache rate', emptyTitle: 'No trend data for the selected window', errorLegend: 'Error rate (left axis %)', cacheLegend: 'Cache rate (left axis %)', ttftLegend: 'First token P50 (right axis)', errorDataset: 'Error rate trend %', cacheDataset: 'Cache rate trend %', ttftDataset: 'First token trend P50 (ms)', percentAxis: 'Rate %', resetZoom: 'Reset zoom' + }, + errorDetail: { http: 'HTTP {code}', upstream: 'Upstream {code}', noMessage: 'No error message', empty: 'Category rates only (sample messages are admin-only)' }, + errorCategories: { + content_policy: 'Content policy', authentication: 'Authentication', context_limit: 'Context limit', invalid_request: 'Invalid request', model_unsupported: 'Unsupported model', group_access: 'Group access', quota_or_balance: 'Quota or balance', account_pool_unavailable: 'Account pool unavailable', rate_or_capacity: 'Rate or capacity', timeout: 'Timeout', transport_or_stream: 'Transport or stream', upstream_forbidden: 'Upstream forbidden', not_found: 'Not found', client_cancelled: 'Client cancelled', upstream_5xx: 'Upstream 5xx', internal: 'Internal', other: 'Other' + }, + rank: { + gold: 'Rank 1 gold', + silver: 'Rank 2 silver', + bronze: 'Rank 3 bronze', + place: 'Rank {n}', + unranked: 'Unranked', + }, + settings: { + title: 'V2 data monitor config', + description: + 'Configure passive usage aggregation dimensions (platform / model / group) and refresh cadence. Health colors and details on the user /monitor page show rates, RPM, and TPM — not absolute request volume.', + save: 'Save', + loading: 'Loading…', + loadFailed: 'Failed to load V2 config', + saveSuccess: 'V2 monitor config saved', + saveFailed: 'Failed to save V2 config', + modeBanner: + 'System mode is currently {mode}. V2 minute aggregation will not run; this config can be prepared now and takes effect after switching to {modeV2}. Change mode under System Settings → Feature switches.', + modeClosed: 'Channel monitor disabled', + modeV1: 'V1 active probes', + modeV2: 'V2 passive monitoring', + enableTitle: 'Enable V2 aggregation', + enableHint: + 'Applies when system mode is V2. Turning this off only stops this config’s aggregation; the system mode switch remains under Feature switches.', + refreshTitle: 'Aggregation interval', + refreshHint: 'Affects matrix time granularity and refresh cadence', + refreshAria: 'Aggregation interval', + platformsTitle: 'Platforms and models', + platformsHint: + 'Leave empty = show all real model names; when filled, only listed models get their own rows and the rest roll into “Other”', + modelsPlaceholder: 'Empty = all real models; or list popular models (rest → Other)', + badgeAllModels: 'All models', + badgeOther: '+ Other', + groupsTitle: 'Monitored groups', + groupsSelected: '{count} groups selected', + groupsAll: 'All groups', + groupsEmpty: 'No groups available', + errorsTitle: 'Error categories and ignores', + errorsHint: + 'Checked “ignore” categories are excluded from error rate and health score, but still appear greyed in the error breakdown. Unmatched errors roll into “Other”.', + ignoredSummary: 'Ignored {ignored} categories · counted in error rate {counted} categories', + healthTitle: 'Health thresholds', + healthHint: + 'Controls user-facing color bands and overall score. Defaults are tolerant so small error rates or low cache do not immediately show as unhealthy.', + fields: { + minimumSample: 'Minimum samples', + warningError: 'Error rate watch %', + criticalError: 'Error rate critical %', + targetTtft: 'TTFT target ms', + warningTtft: 'TTFT watch ms', + criticalTtft: 'TTFT critical ms', + warningCache: 'Cache rate watch %', + criticalCache: 'Cache rate critical %', + }, + namedModelsEmpty: 'Platform model lists are empty: every real model name will be shown (not folded into “Other”).', + namedModelsCount: 'Showing {count} named model dimensions; unlisted models fold into per-platform “Other”.', + userContractTitle: 'User-facing display contract', + userContract: { + health: 'Health color weights: error rate 60% + first-token P50 20% + cache rate 20% (thresholds configurable above)', + trend: 'Trend can switch between pulse matrix and line chart (error · cache · first token)', + latency: 'Latency shows AVG · P50 · P90; absolute request / error counts are not shown', + models: 'Empty model lists show real names and never dump everything into “Other”', + }, + }, + admin: { + descriptionV1: + 'System mode is V1 active probes: manage probe monitors and run checks now; V2 aggregation does not run.', + descriptionV2: + 'System mode is V2 passive monitoring: configure aggregation dimensions; V1 active probes do not run.', + tabAria: 'Monitor management', + tabV2: 'V2 data monitor config', + tabV1Active: 'V1 active probes', + tabV1History: 'V1 history (probes not active in current mode)', + }, + }, +} diff --git a/frontend/src/i18n/locales/en/index.ts b/frontend/src/i18n/locales/en/index.ts index a855df543a..ac2867bfda 100644 --- a/frontend/src/i18n/locales/en/index.ts +++ b/frontend/src/i18n/locales/en/index.ts @@ -1,6 +1,7 @@ import landing from './landing' import common from './common' import dashboard from './dashboard' +import channelMonitorV2 from './channelMonitorV2' import batchImage from './batchImage' import admin from './admin' import misc from './misc' @@ -9,6 +10,7 @@ export default { ...landing, ...common, ...dashboard, + ...channelMonitorV2, ...batchImage, admin, ...misc, diff --git a/frontend/src/i18n/locales/zh/channelMonitorV2.ts b/frontend/src/i18n/locales/zh/channelMonitorV2.ts new file mode 100644 index 0000000000..874823e6d7 --- /dev/null +++ b/frontend/src/i18n/locales/zh/channelMonitorV2.ts @@ -0,0 +1,116 @@ +/** Channel Monitor V2 (user + admin passive monitor UI) */ +export default { + channelMonitorV2: { + title: '渠道监控', + updating: '正在更新数据', + updatedTo: '更新至 {time}', + partialCoverage: '部分历史覆盖', + timeRange: '时间范围', + clearFilters: '重置', + refreshingFilters: '筛选条件已变化,正在刷新矩阵、趋势和明细…', + switchingData: '正在切换筛选数据…', + summaryAria: '筛选范围整体汇总', + loadFailed: '渠道监控加载失败', + detailLoadFailed: '渠道监控明细加载失败', + otherModels: '其他模型', + ignored: '忽略', + currentUser: '当前用户', + ranges: { '90m': '90m', '24h': '24h', '7d': '7d', '30d': '30d' }, + filters: { + platform: '平台', allPlatforms: '全部', group: '分组', allGroups: '全部', model: '模型', allModels: '全部', + empty: '暂无可选项', selectedCount: '{count} 项', labelValue: '{label}:{value}' + }, + groupBy: { + label: '展示维度', platform: '平台', platformGroup: '平台 / 分组', platformModel: '平台 / 模型', platformGroupModel: '平台 / 分组 / 模型' + }, + trendView: { label: '趋势视图', pulse: '色块矩阵', line: '折线图' }, + healthMode: { label: '健康显示', overall: '综合', success: '错误率', ttft: '首 Token', cache: '缓存率' }, + tabs: { aria: '明细维度', models: '模型', errors: '错误原因', users: '用户排行' }, + metrics: { + rpm: 'RPM', tpm: 'TPM', rpmDetail: '请求速率', tpmDetail: 'Token 速率', errorRate: '错误率', ttft: '首 Token', ttftP50: '首 Token P50', durationP50: '请求时长 P50', cacheRate: '缓存率', cacheDetail: '读缓存占比', successRate: '成功率', + successRateValue: '成功率 {value}', errorRateValue: '错误率 {value}', rpmValue: 'RPM {value}', tpmValue: 'TPM {value}', ttftValue: '首 Token {value}', durationValue: '请求时长 {value}', cacheRateValue: '缓存率 {value}' + }, + table: { platformModel: '平台 / 模型', rank: '排名', user: '用户' }, + empty: { title: '没有可展示的数据', description: '尝试调整时间范围或筛选条件' }, + bucket: { minutes: '{count} 分钟粒度', hours: '{count} 小时粒度', days: '{count} 天粒度' }, + matrix: { + title: '可用性趋势', description: '每行是一种渠道组合,每个色块代表一个统计区间;悬停查看明细', wheelZoom: '在色块上滚轮缩放', wheelZoomX: '在色块上滚轮横向缩放', dimension: '渠道维度', emptyTitle: '当前筛选窗口没有矩阵数据', legendAria: '健康分数图例', bad: '差', good: '好', healthyLegend: '健康 (≥80)', warningLegend: '需关注 (50–79)', criticalLegend: '异常 (<50)', unknownLegend: '无流量 / 样本不足', noTraffic: '该区间无流量', noTrafficAt: '{time} · 无流量', scoreLine: '健康分 {score}', resetZoom: '重置缩放' + }, + chart: { + title: '可用性趋势', description: '平滑趋势:错误率 · 首 Token P50 · 缓存率', emptyTitle: '当前筛选窗口没有趋势数据', errorLegend: '错误率(左轴 %)', cacheLegend: '缓存率(左轴 %)', ttftLegend: '首 Token P50(右轴)', errorDataset: '错误率趋势 %', cacheDataset: '缓存率趋势 %', ttftDataset: '首 Token 趋势 P50 (ms)', percentAxis: '比率 %', resetZoom: '重置缩放' + }, + errorDetail: { http: 'HTTP {code}', upstream: '上游 {code}', noMessage: '无错误消息', empty: '仅展示分类占比(样本消息仅管理员可见)' }, + errorCategories: { + 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: '上游 5xx', internal: '内部错误', other: '其他' + }, + rank: { + gold: '第 1 名 金', + silver: '第 2 名 银', + bronze: '第 3 名 铜', + place: '第 {n} 名', + unranked: '未上榜', + }, + settings: { + title: 'V2 数据监控配置', + description: + '配置被动用量汇总维度(平台 / 模型 / 分组)与刷新频率。健康色与明细在用户端 /monitor 以比例、RPM/TPM 展示,不暴露绝对请求量。', + save: '保存', + loading: '加载中...', + loadFailed: 'V2 配置加载失败', + saveSuccess: 'V2 监控配置已保存', + saveFailed: 'V2 配置保存失败', + modeBanner: + '当前系统设置为 {mode}。V2 分钟聚合不会运行;此处配置可预先保存,切换到 {modeV2} 后立即生效。可在系统设置 → 功能开关调整。', + modeClosed: '渠道监控已关闭', + modeV1: 'V1 主动探测', + modeV2: 'V2 被动监控', + enableTitle: '启用 V2 汇总', + enableHint: '在系统模式为 V2 时生效;关闭后仅停止本配置的汇总,系统模式开关仍在「功能开关」', + refreshTitle: '汇总频率', + refreshHint: '影响矩阵时间粒度与刷新节奏', + refreshAria: '汇总频率', + platformsTitle: '平台与模型', + platformsHint: '留空 = 展示全部真实模型名;填写后仅名单内单独成行,其余归入「其他」', + modelsPlaceholder: '留空=全部真实模型;或填写主流模型名单(其余归其他)', + badgeAllModels: '全部模型', + badgeOther: '+ 其他', + groupsTitle: '监控分组', + groupsSelected: '已选择 {count} 个分组', + groupsAll: '全部分组', + groupsEmpty: '没有可选择的分组', + errorsTitle: '错误分类与忽略', + errorsHint: + '勾选「忽略」的类别不计入错误率与健康分,仍在错误原因列表中以灰色显示并标记忽略。未匹配的错误归入「其他」。', + ignoredSummary: '已忽略 {ignored} 类 · 计入错误率 {counted} 类', + healthTitle: '健康阈值', + healthHint: '控制用户端色块和整体评分。默认阈值较宽松,避免少量错误或低缓存率立即显示异常。', + fields: { + minimumSample: '最小样本数', + warningError: '错误率关注 %', + criticalError: '错误率异常 %', + targetTtft: 'TTFT 目标 ms', + warningTtft: 'TTFT 关注 ms', + criticalTtft: 'TTFT 异常 ms', + warningCache: '缓存率关注 %', + criticalCache: '缓存率异常 %', + }, + namedModelsEmpty: '各平台模型列表为空:将展示全部真实模型名(不归入「其他」)。', + namedModelsCount: '将展示 {count} 个命名模型维度;名单外模型归入各平台「其他」。', + userContractTitle: '用户端展示约定', + userContract: { + health: '健康色三指标:错误率 60% + 首 Token P50 20% + 缓存率 20%(阈值可在上方配置)', + trend: '趋势可切换色块矩阵 / 折线图(错误率 · 缓存率 · 首 Token)', + latency: '延迟展示 AVG · P50 · P90;不展示绝对请求数 / 错误数', + models: '模型列表留空时展示真实模型名,不会全部归入「其他」', + }, + }, + admin: { + descriptionV1: '当前系统设置为 V1 主动探测:可管理监控项并立即检测;V2 聚合不会运行。', + descriptionV2: '当前系统设置为 V2 被动监控:配置聚合维度;V1 主动探测不会运行。', + tabAria: '监控管理', + tabV2: 'V2 数据监控配置', + tabV1Active: 'V1 主动探测', + tabV1History: 'V1 历史(当前模式未启用探测)', + }, + }, +} diff --git a/frontend/src/i18n/locales/zh/index.ts b/frontend/src/i18n/locales/zh/index.ts index a855df543a..ac2867bfda 100644 --- a/frontend/src/i18n/locales/zh/index.ts +++ b/frontend/src/i18n/locales/zh/index.ts @@ -1,6 +1,7 @@ import landing from './landing' import common from './common' import dashboard from './dashboard' +import channelMonitorV2 from './channelMonitorV2' import batchImage from './batchImage' import admin from './admin' import misc from './misc' @@ -9,6 +10,7 @@ export default { ...landing, ...common, ...dashboard, + ...channelMonitorV2, ...batchImage, admin, ...misc, diff --git a/frontend/src/views/user/ChannelStatusV1View.vue b/frontend/src/views/user/ChannelStatusV1View.vue new file mode 100644 index 0000000000..61b4da97db --- /dev/null +++ b/frontend/src/views/user/ChannelStatusV1View.vue @@ -0,0 +1,172 @@ + + + diff --git a/frontend/src/views/user/ChannelStatusV2View.vue b/frontend/src/views/user/ChannelStatusV2View.vue new file mode 100644 index 0000000000..be6f5a15b0 --- /dev/null +++ b/frontend/src/views/user/ChannelStatusV2View.vue @@ -0,0 +1,886 @@ + + + + + diff --git a/frontend/src/views/user/ChannelStatusView.vue b/frontend/src/views/user/ChannelStatusView.vue index 61b4da97db..8007eb165f 100644 --- a/frontend/src/views/user/ChannelStatusView.vue +++ b/frontend/src/views/user/ChannelStatusView.vue @@ -1,172 +1,13 @@ diff --git a/frontend/src/views/user/__tests__/ChannelStatusView.mode.spec.ts b/frontend/src/views/user/__tests__/ChannelStatusView.mode.spec.ts new file mode 100644 index 0000000000..aa4064fc52 --- /dev/null +++ b/frontend/src/views/user/__tests__/ChannelStatusView.mode.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { defineComponent, h } from 'vue' +import { mount } from '@vue/test-utils' + +const isV1 = vi.fn(() => false) + +vi.mock('@/utils/featureFlags', () => ({ + isChannelMonitorV1Mode: () => isV1(), +})) + +vi.mock('../ChannelStatusV1View.vue', () => ({ + default: defineComponent({ name: 'ChannelStatusV1View', setup: () => () => h('div', { 'data-testid': 'v1' }) }), +})) +vi.mock('../ChannelStatusV2View.vue', () => ({ + default: defineComponent({ name: 'ChannelStatusV2View', setup: () => () => h('div', { 'data-testid': 'v2' }) }), +})) + +import ChannelStatusView from '../ChannelStatusView.vue' + +describe('ChannelStatusView mode switch', () => { + beforeEach(() => { + isV1.mockReset() + }) + + it('renders V2 when not in v1 mode', () => { + isV1.mockReturnValue(false) + const wrapper = mount(ChannelStatusView) + expect(wrapper.find('[data-testid="v2"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="v1"]').exists()).toBe(false) + }) + + it('renders V1 when in v1 mode', () => { + isV1.mockReturnValue(true) + const wrapper = mount(ChannelStatusView) + expect(wrapper.find('[data-testid="v1"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="v2"]').exists()).toBe(false) + }) +})