mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:48:45 +08:00
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:
@@ -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: '重置缩放'
|
||||
|
||||
Reference in New Issue
Block a user