From 04d9eeaf07c52ef226f9dbce5d3feecd1ec4049f Mon Sep 17 00:00:00 2001 From: IanShaw027 Date: Sat, 8 Aug 2026 14:46:35 +0800 Subject: [PATCH] fix(channel-monitor-v2): clear CI lint and align trend axis with range zoom Fix golangci unused/gofmt on the gentle-backfill path. Plot matrix and line-chart X axes on the selected [requested_start, requested_end) window (empty slots while backfill lags), and let plain mouse-wheel zoom narrow the visible interval so pulse blocks grow wider. --- .../repository/channel_monitor_v2_repo.go | 2 + .../internal/service/channel_monitor_v2.go | 6 ++- .../service/channel_monitor_v2_aggregator.go | 20 ------- backend/internal/service/setting_parse.go | 6 +-- frontend/src/api/channelMonitorV2.ts | 2 + .../channel-monitor-v2/MonitorTrendChart.vue | 5 +- .../channel-monitor-v2/RelayPulseMatrix.vue | 52 ++++++++++++------- .../__tests__/RelayPulseMatrix.spec.ts | 34 ++++++++++++ .../src/i18n/locales/en/channelMonitorV2.ts | 2 +- .../src/i18n/locales/zh/channelMonitorV2.ts | 2 +- 10 files changed, 83 insertions(+), 48 deletions(-) diff --git a/backend/internal/repository/channel_monitor_v2_repo.go b/backend/internal/repository/channel_monitor_v2_repo.go index 823731d70..c9ad5031f 100644 --- a/backend/internal/repository/channel_monitor_v2_repo.go +++ b/backend/internal/repository/channel_monitor_v2_repo.go @@ -1019,6 +1019,7 @@ func (r *channelMonitorV2Repository) loadCoverage(ctx context.Context, filter se if !wm.HasData || wm.DataThrough.IsZero() || wm.LastSuccessfulAt.IsZero() { return &service.ChannelMonitorV2Coverage{ RequestedStart: filter.Start, + RequestedEnd: filter.End, CoverageStart: filter.End, DataThrough: filter.Start, ComputedAt: time.Time{}, @@ -1053,6 +1054,7 @@ func (r *channelMonitorV2Repository) loadCoverage(ctx context.Context, filter se // minute-level lag would otherwise always show "partial historical coverage". return &service.ChannelMonitorV2Coverage{ RequestedStart: filter.Start, + RequestedEnd: filter.End, CoverageStart: coverageStart, DataThrough: through, ComputedAt: computedAt, diff --git a/backend/internal/service/channel_monitor_v2.go b/backend/internal/service/channel_monitor_v2.go index df0cd71bc..8d6198918 100644 --- a/backend/internal/service/channel_monitor_v2.go +++ b/backend/internal/service/channel_monitor_v2.go @@ -149,7 +149,11 @@ type ChannelMonitorV2HealthThresholds struct { } type ChannelMonitorV2Coverage struct { - RequestedStart time.Time `json:"requested_start"` + RequestedStart time.Time `json:"requested_start"` + // RequestedEnd is the exclusive upper bound of the UI-selected window + // (filter.End). Charts/matrices should plot [RequestedStart, RequestedEnd) + // even when CoverageStart is later (partial backfill). + RequestedEnd time.Time `json:"requested_end"` CoverageStart time.Time `json:"coverage_start"` DataThrough time.Time `json:"data_through"` ComputedAt time.Time `json:"computed_at"` diff --git a/backend/internal/service/channel_monitor_v2_aggregator.go b/backend/internal/service/channel_monitor_v2_aggregator.go index 2ea4b56bd..3c18b0375 100644 --- a/backend/internal/service/channel_monitor_v2_aggregator.go +++ b/backend/internal/service/channel_monitor_v2_aggregator.go @@ -15,9 +15,6 @@ const ( // Retention walks back to the longest stored tier (1d rollup = 90d). Per-tier // prune in the repository drops short-lived 1m/user/hist facts earlier. channelMonitorV2RetentionMax = 90 * 24 * time.Hour - // Product bootstrap goal: fill the UI ranges 90m / 24h / 7d / 30d first. - // Banner hides once coveredFrom reaches this depth; 90d retention continues silently. - channelMonitorV2BootstrapWindow = ChannelMonitorV2BootstrapProductWindow // First tick after upgrade prioritizes the default 90m view (with small padding). channelMonitorV2BootstrapFirst = 2 * time.Hour // Always refresh a small trailing window so late writes land without @@ -188,23 +185,6 @@ func (s *ChannelMonitorV2Aggregator) loop() { } } -func (s *ChannelMonitorV2Aggregator) bootstrapActive() bool { - s.mu.Lock() - defer s.mu.Unlock() - if !s.cursorLoaded { - return true - } - if !s.hasAggregated { - return true - } - if s.backfillFailures >= 3 { - return false - } - now := time.Now().UTC().Truncate(time.Minute) - target := now.Add(-channelMonitorV2BootstrapWindow) - return s.backfillAt.IsZero() || s.backfillAt.After(target) -} - func (s *ChannelMonitorV2Aggregator) passiveAggregationAllowed(ctx context.Context) bool { if s == nil || s.settings == nil { // Fail closed without settings: do not aggregate under ambiguous mode. diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go index 169202b6a..92df463a3 100644 --- a/backend/internal/service/setting_parse.go +++ b/backend/internal/service/setting_parse.go @@ -787,9 +787,9 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval( settings[SettingKeyChannelMonitorDefaultIntervalSeconds], ) -// 默认隐藏吞吐(迁移 206 的隐私默认):未配置时必须与 setting_public.go 的 -// 公开读取路径给出同一个值,否则管理端看到“未隐藏”而用户端实际已隐藏。 -result.ChannelMonitorHideThroughput = !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]) + // 默认隐藏吞吐(迁移 206 的隐私默认):未配置时必须与 setting_public.go 的 + // 公开读取路径给出同一个值,否则管理端看到“未隐藏”而用户端实际已隐藏。 + result.ChannelMonitorHideThroughput = !isFalseSettingValue(settings[SettingKeyChannelMonitorHideThroughput]) // Available channels feature (default: disabled; strict true) result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true" diff --git a/frontend/src/api/channelMonitorV2.ts b/frontend/src/api/channelMonitorV2.ts index 49433468c..2320140bf 100644 --- a/frontend/src/api/channelMonitorV2.ts +++ b/frontend/src/api/channelMonitorV2.ts @@ -86,6 +86,8 @@ export interface MonitorBootstrap { export interface MonitorCoverage { requested_start: string + /** Exclusive upper bound of the UI-selected range (filter end). */ + requested_end?: string coverage_start: string data_through: string computed_at: string diff --git a/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue b/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue index 725e2b60a..012eeb463 100644 --- a/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue +++ b/frontend/src/features/channel-monitor-v2/MonitorTrendChart.vue @@ -179,10 +179,7 @@ const chartData = computed(() => { const visibleTrend = computed(() => sliceByZoom(props.trend || [], zoom.value)) function onChartWheel(event: WheelEvent) { - // Ctrl/⌘+wheel or shift/horizontal pan; leave plain vertical wheel for page scroll. - const wantsZoom = - event.ctrlKey || event.metaKey || event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY) - if (!wantsZoom) return + // Plain vertical wheel zooms X (narrower time range); shift/horizontal pans. event.preventDefault() const ratio = clientXRatio(event.clientX, chartRef.value) zoom.value = applyWheelZoom(zoom.value, event, ratio) diff --git a/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue b/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue index ad44a93e9..70e307c46 100644 --- a/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue +++ b/frontend/src/features/channel-monitor-v2/RelayPulseMatrix.vue @@ -225,14 +225,21 @@ const zoom = ref(resetZoom()) const zoomed = computed(() => isZoomed(zoom.value)) const allBucketStarts = computed(() => { + // X-axis always spans the UI-selected range [requested_start, requested_end). + // Partial backfill leaves empty cells until coverage_start/data_through fill in. const step = Math.max(60, props.coverage.bucket_seconds) * 1000 - const coverageStart = new Date(props.coverage.coverage_start).getTime() const requestedStart = new Date(props.coverage.requested_start).getTime() - const end = new Date(props.coverage.data_through).getTime() - const effectiveStart = Math.max(coverageStart, requestedStart) - if (![effectiveStart, end].every(Number.isFinite) || effectiveStart >= end) return [] + const requestedEndRaw = props.coverage.requested_end + ? new Date(props.coverage.requested_end).getTime() + : NaN + // Fallback for older payloads without requested_end. + const dataThrough = new Date(props.coverage.data_through).getTime() + const end = Number.isFinite(requestedEndRaw) && requestedEndRaw > requestedStart + ? requestedEndRaw + : dataThrough + if (![requestedStart, end].every(Number.isFinite) || requestedStart >= end) return [] const starts: string[] = [] - for (let cursor = Math.floor(effectiveStart / step) * step; cursor < end; cursor += step) { + for (let cursor = Math.floor(requestedStart / step) * step; cursor < end; cursor += step) { starts.push(new Date(cursor).toISOString()) } return starts @@ -246,18 +253,19 @@ const tableStyle = computed(() => ({ const pulseMinWidth = computed(() => { const count = Math.max(1, bucketStarts.value.length) if (!zoomed.value) return '0px' - // Wider cells when more zoomed-in (smaller span) - const intensity = Math.min(8, Math.round((1 - zoom.value.span) / 0.1)) - const width = 4 + intensity * 3 - const gap = intensity >= 5 ? 3 : 2 + // Zoom in = fewer columns + wider min cell (span shrinks → intensity grows). + const intensity = Math.min(12, Math.round((1 - zoom.value.span) / 0.08)) + const width = 6 + intensity * 4 + const gap = intensity >= 4 ? 3 : 2 return `${count * width + Math.max(0, count - 1) * gap}px` }) const pulseStyle = computed(() => { const count = Math.max(1, bucketStarts.value.length) - const intensity = zoomed.value ? Math.min(8, Math.round((1 - zoom.value.span) / 0.1)) : 0 - const gapPx = !zoomed.value ? (count > 24 ? 1 : 2) : intensity >= 5 ? 3 : 2 + const intensity = zoomed.value ? Math.min(12, Math.round((1 - zoom.value.span) / 0.08)) : 0 + const gapPx = !zoomed.value ? (count > 24 ? 1 : 2) : intensity >= 4 ? 3 : 2 const heightPx = 16 - const minCell = !zoomed.value ? '0' : `${4 + intensity * 3}px` + // Unzoomed: equal flex fractions. Zoomed: enforce growing min width so blocks lengthen. + const minCell = !zoomed.value ? '0' : `${6 + intensity * 4}px` return { gridTemplateColumns: `repeat(${count}, minmax(${minCell}, 1fr))`, gap: `${gapPx}px`, @@ -305,11 +313,13 @@ function onMatrixWheel(event: WheelEvent) { const track = scrollRef.value const target = event.target as HTMLElement | null const pulse = target?.closest('.pulse-track') as HTMLElement | null - // Only zoom when over a pulse track (or Ctrl/⌘+wheel); otherwise allow page/matrix scroll. - const wantsZoom = Boolean(pulse) || event.ctrlKey || event.metaKey - if (!wantsZoom && !event.shiftKey && Math.abs(event.deltaX) <= Math.abs(event.deltaY)) { - return - } + const overMatrix = Boolean(target?.closest('.matrix-scroll')) + // Plain vertical wheel over the matrix zooms X (narrower range → wider cells). + // Shift+wheel or horizontal delta pans; leave non-matrix page scroll alone. + const isPan = event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY) + if (!overMatrix && !pulse) return + // When not zoomed and user scrolls vertically outside pulse, still zoom if over matrix body. + if (!overMatrix && !isPan) return event.preventDefault() const ratioEl = pulse || track const ratio = clientXRatio(event.clientX, ratioEl) @@ -321,7 +331,13 @@ function resetMatrixZoom() { } watch( - () => [props.coverage.coverage_start, props.coverage.data_through, props.coverage.bucket_seconds], + () => [ + props.coverage.requested_start, + props.coverage.requested_end, + props.coverage.coverage_start, + props.coverage.data_through, + props.coverage.bucket_seconds, + ], () => { zoom.value = resetZoom() }, diff --git a/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts b/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts index 2eaf9fe27..e096a5d74 100644 --- a/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts +++ b/frontend/src/features/channel-monitor-v2/__tests__/RelayPulseMatrix.spec.ts @@ -99,6 +99,7 @@ describe('RelayPulseMatrix', () => { }], coverage: { requested_start: '2026-08-01T00:00:00Z', + requested_end: '2026-08-01T00:03:00Z', coverage_start: '2026-08-01T00:00:00Z', data_through: '2026-08-01T00:03:00Z', computed_at: '2026-08-01T00:03:00Z', @@ -140,3 +141,36 @@ describe('RelayPulseMatrix', () => { expect(wrapper.find('[role="dialog"]').exists()).toBe(false) }) }) + +describe('RelayPulseMatrix axis range', () => { + it('uses selected requested range for the X axis even with partial coverage', () => { + 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:02:00Z', metrics: metrics(10), health }, + ], + }], + coverage: { + requested_start: '2026-08-01T00:00:00Z', + requested_end: '2026-08-01T00:05:00Z', + coverage_start: '2026-08-01T00:02:00Z', + data_through: '2026-08-01T00:03:00Z', + computed_at: '2026-08-01T00:03:00Z', + aggregation_lag_seconds: 0, + coverage_complete: false, + bucket_seconds: 60, + }, + healthMode: 'overall', + }, + }) + // 5 minutes @ 60s buckets → 5 cells spanning the selected range + expect(wrapper.findAll('.pulse-cell')).toHaveLength(5) + }) +}) diff --git a/frontend/src/i18n/locales/en/channelMonitorV2.ts b/frontend/src/i18n/locales/en/channelMonitorV2.ts index 9862d5554..1d94a154d 100644 --- a/frontend/src/i18n/locales/en/channelMonitorV2.ts +++ b/frontend/src/i18n/locales/en/channelMonitorV2.ts @@ -60,7 +60,7 @@ export default { 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' + title: 'Availability trend', description: 'Each row is a channel dimension and each block is an aggregate interval; hover for details', wheelZoom: 'Scroll over blocks to zoom in (narrower range, wider blocks)', wheelZoomX: 'Scroll over blocks to zoom in (narrower range, wider blocks)', 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' diff --git a/frontend/src/i18n/locales/zh/channelMonitorV2.ts b/frontend/src/i18n/locales/zh/channelMonitorV2.ts index ca4518bc0..1df03acbd 100644 --- a/frontend/src/i18n/locales/zh/channelMonitorV2.ts +++ b/frontend/src/i18n/locales/zh/channelMonitorV2.ts @@ -60,7 +60,7 @@ export default { 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: '重置缩放' + 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: '重置缩放'