From f7d06f82bc27b70650a1288cb5fca302da6d8160 Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Wed, 21 Oct 2026 16:56:19 +0200 Subject: [PATCH 1/2] fix(ui): break the test history chart lines at gaps between runs The MGas/s chart of a test history did not match the runs table in two ways: - The chart hid the symbols of all clients when one client had more than 30 runs. With 54 geth runs, the 4 besu runs looked like one continuous line. Each client line now hides its symbols only when it has more than 30 runs itself. - A line joined two runs across a gap of days, so it looked like a slow trend with no runs in it. On the time axis, a line now breaks where the gap is 5x the median interval of the client and at least 6 hours. A run with a gap on both sides shows as a dot. --- .../test-history/TestHistoryChart.tsx | 83 +++++++++++++++---- ui/src/utils/testHistory.test.ts | 33 +++++++- ui/src/utils/testHistory.ts | 20 +++++ 3 files changed, 118 insertions(+), 18 deletions(-) diff --git a/ui/src/components/test-history/TestHistoryChart.tsx b/ui/src/components/test-history/TestHistoryChart.tsx index ddd79e22..76dd224e 100644 --- a/ui/src/components/test-history/TestHistoryChart.tsx +++ b/ui/src/components/test-history/TestHistoryChart.tsx @@ -2,7 +2,13 @@ import { useMemo } from 'react' import ReactECharts from 'echarts-for-react' import { getClientChartColor } from '@/utils/client-colors' import { formatDuration } from '@/utils/format' -import { formatHistoryValue, HISTORY_METRIC_LABELS, type HistoryMetric, type HistoryPoint } from '@/utils/testHistory' +import { + findRunGaps, + formatHistoryValue, + HISTORY_METRIC_LABELS, + type HistoryMetric, + type HistoryPoint, +} from '@/utils/testHistory' export type HistoryXAxis = 'time' | 'runCount' @@ -14,12 +20,14 @@ interface TestHistoryChartProps { onRunClick: (runId: string) => void } -// Above this many points per client, the chart hides the symbols and adds a -// zoom slider. +// Above this many points, a client line hides its symbols. The chart adds a +// zoom slider when a client has this many points. const LARGE_SERIES = 30 // [x, value, runId, image, runStart, runNumber] type ChartDatum = [number, number, string, string, number, number] +// A point with no value breaks the line. +type GapDatum = [number, null] function capitalizeFirst(str: string): string { return str ? str.charAt(0).toUpperCase() + str.slice(1) : str @@ -35,21 +43,62 @@ export function TestHistoryChart({ pointsByClient, metric, xAxis, isDark, onRunC const maxSeriesLength = Math.max(0, ...[...pointsByClient.values()].map((p) => p.length)) const isLarge = maxSeriesLength > LARGE_SERIES - const series = [...pointsByClient.entries()].map(([client, points]) => ({ - name: capitalizeFirst(client), - type: 'line' as const, - // Run #1 is the most recent run of the client. - data: points.map((p, i): ChartDatum => { + const series = [...pointsByClient.entries()].flatMap(([client, points]) => { + const large = points.length > LARGE_SERIES + // On the time axis, the line breaks at a gap of days between runs. + const gaps = xAxis === 'time' ? findRunGaps(points.map((p) => p.runStart)) : new Set() + const data: (ChartDatum | GapDatum)[] = [] + // The points with a gap on both sides. A line with hidden symbols does + // not show them, so a scatter series does. + const isolated: ChartDatum[] = [] + + points.forEach((p, i) => { + // Run #1 is the most recent run of the client. const runNumber = points.length - i - return [xAxis === 'time' ? p.runStart * 1000 : runNumber, p.value, p.runId, p.image ?? '', p.runStart, runNumber] - }), - showSymbol: !isLarge, - symbolSize: isLarge ? 4 : 7, - lineStyle: { width: isLarge ? 1.5 : 2 }, - itemStyle: { color: getClientChartColor(client) }, - emphasis: { focus: 'series' as const }, - cursor: 'pointer', - })) + const datum: ChartDatum = [ + xAxis === 'time' ? p.runStart * 1000 : runNumber, + p.value, + p.runId, + p.image ?? '', + p.runStart, + runNumber, + ] + + if (gaps.has(i)) data.push([datum[0], null]) + data.push(datum) + + if ((i === 0 || gaps.has(i)) && (i === points.length - 1 || gaps.has(i + 1))) isolated.push(datum) + }) + + const name = capitalizeFirst(client) + const color = getClientChartColor(client) + const line = { + name, + type: 'line' as const, + data, + showSymbol: !large, + symbolSize: large ? 4 : 7, + lineStyle: { width: large ? 1.5 : 2 }, + itemStyle: { color }, + emphasis: { focus: 'series' as const }, + cursor: 'pointer', + } + if (!large || isolated.length === 0) return [line] + + // The same name, so the legend shows and hides both series together. + return [ + line, + { + name, + type: 'scatter' as const, + data: isolated, + symbolSize: 5, + itemStyle: { color }, + emphasis: { focus: 'series' as const }, + cursor: 'pointer', + }, + ] + }) const xAxisConfig = xAxis === 'time' diff --git a/ui/src/utils/testHistory.test.ts b/ui/src/utils/testHistory.test.ts index 8bbe81c4..12dceee3 100644 --- a/ui/src/utils/testHistory.test.ts +++ b/ui/src/utils/testHistory.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from 'vitest' import type { TestHistoryDuration } from '@/api/types' -import { buildHistoryPoints, historyMetricValue, median, summarizeClient, type HistoryPoint } from './testHistory' +import { + buildHistoryPoints, + findRunGaps, + historyMetricValue, + median, + summarizeClient, + type HistoryPoint, +} from './testHistory' function duration(overrides: Partial): TestHistoryDuration { return { @@ -104,3 +111,27 @@ describe('summarizeClient', () => { expect(summarizeClient('geth', [])).toBeUndefined() }) }) + +describe('findRunGaps', () => { + const hour = 3600 + const day = 24 * hour + + it('breaks the line at a gap of days between runs every few hours', () => { + // One run, a gap of 5 days, then a run every 7 hours. + expect([...findRunGaps([0, 5 * day, 5 * day + 7 * hour, 5 * day + 14 * hour])]).toEqual([1]) + }) + + it('keeps a steady series in one line', () => { + expect(findRunGaps([0, day, 2 * day, 3 * day, 4 * day]).size).toBe(0) + }) + + it('ignores a short gap between frequent runs', () => { + // Runs every 30 minutes, with one 4 hour gap: below the 6 hour minimum. + const starts = [0, 1800, 3600, 3600 + 4 * hour, 5400 + 4 * hour] + expect(findRunGaps(starts).size).toBe(0) + }) + + it('needs at least two intervals', () => { + expect(findRunGaps([0, 10 * day]).size).toBe(0) + }) +}) diff --git a/ui/src/utils/testHistory.ts b/ui/src/utils/testHistory.ts index 60c543d8..638279ab 100644 --- a/ui/src/utils/testHistory.ts +++ b/ui/src/utils/testHistory.ts @@ -282,6 +282,26 @@ export function summarizeClient( } } +// A gap between two runs breaks the chart line when it is GAP_FACTOR times the +// median interval of the client, and at least MIN_GAP_SECONDS. A line across +// a gap of days looks like a slow trend, but there are no runs in it. +const GAP_FACTOR = 5 +const MIN_GAP_SECONDS = 6 * 3600 + +// Returns the indexes of the points that start a new line after a gap. The +// run starts must be in Unix seconds, oldest first. +export function findRunGaps(runStarts: number[]): Set { + const intervals = runStarts.slice(1).map((t, i) => t - runStarts[i]) + const gaps = new Set() + if (intervals.length < 2) return gaps + + const limit = Math.max(GAP_FACTOR * median(intervals), MIN_GAP_SECONDS) + intervals.forEach((interval, i) => { + if (interval > limit) gaps.add(i + 1) + }) + return gaps +} + // Formats a metric value for display. export function formatHistoryValue(value: number, metric: HistoryMetric): string { return metric === 'mgas' ? `${value.toFixed(2)} MGas/s` : formatDuration(Math.round(value)) From 69392cfd9b4afef6dad21011ad735f2c91932eae Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Wed, 21 Oct 2026 17:00:48 +0200 Subject: [PATCH 2/2] fix(ui): show the test history dots up to 100 runs, with Run # first A client line hid its dots above 30 runs, so the 54 geth runs of a test showed as a plain line. A line now shows small dots up to 100 runs, the default runs per client, and hides them only above that. The x axis now opens on Run #. It spaces the runs evenly, so a gap of days between runs does not squeeze the recent runs into a corner. The time axis stays one click away, with ?x=time in the URL. --- ui/src/components/test-history/TestHistoryChart.tsx | 12 ++++++++---- ui/src/pages/TestHistoryPage.tsx | 8 +++++--- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/ui/src/components/test-history/TestHistoryChart.tsx b/ui/src/components/test-history/TestHistoryChart.tsx index 76dd224e..a6c5c4ee 100644 --- a/ui/src/components/test-history/TestHistoryChart.tsx +++ b/ui/src/components/test-history/TestHistoryChart.tsx @@ -20,9 +20,12 @@ interface TestHistoryChartProps { onRunClick: (runId: string) => void } -// Above this many points, a client line hides its symbols. The chart adds a -// zoom slider when a client has this many points. +// Above this many points, a client line has small symbols and a thin line, +// and the chart adds a zoom slider. const LARGE_SERIES = 30 +// Above this many points, a client line hides its symbols. They would cover +// the line. +const DENSE_SERIES = 100 // [x, value, runId, image, runStart, runNumber] type ChartDatum = [number, number, string, string, number, number] @@ -45,6 +48,7 @@ export function TestHistoryChart({ pointsByClient, metric, xAxis, isDark, onRunC const series = [...pointsByClient.entries()].flatMap(([client, points]) => { const large = points.length > LARGE_SERIES + const dense = points.length > DENSE_SERIES // On the time axis, the line breaks at a gap of days between runs. const gaps = xAxis === 'time' ? findRunGaps(points.map((p) => p.runStart)) : new Set() const data: (ChartDatum | GapDatum)[] = [] @@ -76,14 +80,14 @@ export function TestHistoryChart({ pointsByClient, metric, xAxis, isDark, onRunC name, type: 'line' as const, data, - showSymbol: !large, + showSymbol: !dense, symbolSize: large ? 4 : 7, lineStyle: { width: large ? 1.5 : 2 }, itemStyle: { color }, emphasis: { focus: 'series' as const }, cursor: 'pointer', } - if (!large || isolated.length === 0) return [line] + if (!dense || isolated.length === 0) return [line] // The same name, so the legend shows and hides both series together. return [ diff --git a/ui/src/pages/TestHistoryPage.tsx b/ui/src/pages/TestHistoryPage.tsx index f0c2947a..8474c54f 100644 --- a/ui/src/pages/TestHistoryPage.tsx +++ b/ui/src/pages/TestHistoryPage.tsx @@ -171,7 +171,9 @@ export function TestHistoryPage() { const runsPerClient = RUNS_PER_CLIENT_OPTIONS.includes(search.n as (typeof RUNS_PER_CLIENT_OPTIONS)[number]) ? search.n! : DEFAULT_RUNS_PER_CLIENT - const xAxis: HistoryXAxis = search.x === 'runCount' ? 'runCount' : 'time' + // Run # is the default: it spaces the runs evenly, so a gap of days between + // runs does not squeeze the recent runs. + const xAxis: HistoryXAxis = search.x === 'time' ? 'time' : 'runCount' const stepWindow = CHANGE_WINDOW_OPTIONS.includes(search.w as (typeof CHANGE_WINDOW_OPTIONS)[number]) ? search.w! : DEFAULT_CHANGE_WINDOW @@ -348,10 +350,10 @@ export function TestHistoryPage() { ariaLabel="X axis" value={xAxis} options={[ - { value: 'time', label: 'Time' }, { value: 'runCount', label: 'Run #' }, + { value: 'time', label: 'Time' }, ]} - onChange={(value) => updateSearch({ x: value === 'time' ? undefined : value })} + onChange={(value) => updateSearch({ x: value === 'runCount' ? undefined : value })} />