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.
This commit is contained in:
IanShaw027
2026-08-08 14:46:35 +08:00
parent 825f9c78d5
commit 04d9eeaf07
10 changed files with 83 additions and 48 deletions
@@ -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,
@@ -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"`
@@ -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.
+3 -3
View File
@@ -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"
+2
View File
@@ -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
@@ -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)
@@ -225,14 +225,21 @@ const zoom = ref<ZoomState>(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()
},
@@ -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)
})
})
@@ -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'
@@ -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: '重置缩放'