Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 70 additions & 17 deletions ui/src/components/test-history/TestHistoryChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -14,12 +20,17 @@ 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 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]
// 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
Expand All @@ -35,21 +46,63 @@ 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
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<number>()
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: !dense,
symbolSize: large ? 4 : 7,
lineStyle: { width: large ? 1.5 : 2 },
itemStyle: { color },
emphasis: { focus: 'series' as const },
cursor: 'pointer',
}
if (!dense || 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'
Expand Down
8 changes: 5 additions & 3 deletions ui/src/pages/TestHistoryPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 })}
/>
</div>
<label className="flex items-center gap-2">
Expand Down
33 changes: 32 additions & 1 deletion ui/src/utils/testHistory.test.ts
Original file line number Diff line number Diff line change
@@ -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>): TestHistoryDuration {
return {
Expand Down Expand Up @@ -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)
})
})
20 changes: 20 additions & 0 deletions ui/src/utils/testHistory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number> {
const intervals = runStarts.slice(1).map((t, i) => t - runStarts[i])
const gaps = new Set<number>()
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))
Expand Down
Loading