From 3ad101338ef5d7fa0c5b65ffb518e3e5e243f749 Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Wed, 21 Oct 2026 17:11:48 +0200 Subject: [PATCH] feat(ui): sort the test history runs and diff their configs The runs table of a test history had no sort and no way to see what changed between two runs. - The columns Time, Client, MGas/s, Duration, Reject time and Gas used sort on a click. The sort is in the URL (sort, dir). A run with no value comes last in both directions. - A row expands to the config diff to the previous run of its client, with the "previous run on the same host" option. This is the view of the regressions tab, now a shared PreviousRunDiff component. - A checkbox selects 2-5 runs. The selected runs show their config diff inline, or open on the compare page. - A "new tag" marker shows where the image tag of a client changed. The history has the tag, not the digest, so the row diff shows a new image under a floating tag such as :master. - With all clients shown, a click on a client card shows only that client. Before, the click hid it. --- .../PreviousRunDiff.tsx} | 57 ++- .../suite-detail/RegressionsPanel.tsx | 16 +- .../test-history/HistoryRunsTable.tsx | 403 ++++++++++++++++++ ui/src/pages/TestHistoryPage.tsx | 124 ++---- ui/src/utils/testHistory.test.ts | 46 ++ ui/src/utils/testHistory.ts | 61 +++ 6 files changed, 603 insertions(+), 104 deletions(-) rename ui/src/components/{suite-detail/RegressionDetail.tsx => compare/PreviousRunDiff.tsx} (77%) create mode 100644 ui/src/components/test-history/HistoryRunsTable.tsx diff --git a/ui/src/components/suite-detail/RegressionDetail.tsx b/ui/src/components/compare/PreviousRunDiff.tsx similarity index 77% rename from ui/src/components/suite-detail/RegressionDetail.tsx rename to ui/src/components/compare/PreviousRunDiff.tsx index 410dda10..05e8c8e8 100644 --- a/ui/src/components/suite-detail/RegressionDetail.tsx +++ b/ui/src/components/compare/PreviousRunDiff.tsx @@ -8,35 +8,52 @@ import { useTestHistory } from '@/api/hooks/useTestHistory' import { ConfigDiff } from '@/components/compare/ConfigDiff' import { SegmentedControl } from '@/components/shared/SegmentedControl' import { Spinner } from '@/components/shared/Spinner' -import { MAX_SAME_HOST_LOOKBACK, sameHostCandidates, type TestChange } from '@/utils/regressions' +import { MAX_SAME_HOST_LOOKBACK, sameHostCandidates } from '@/utils/regressions' type Reference = 'previous' | 'sameHost' -interface RegressionDetailProps { - change: TestChange +interface PreviousRunDiffProps { suiteHash: string - // The steps search param of the suite page, passed on to the compare page. + testName: string + client: string + // The run to explain, and the last run of the same client before it. + runId: string + runStart: number + previousRunId: string + // The steps search param, passed on to the compare page. steps?: string - // The window of the step search. The step lies inside it, so the test - // history of this many runs holds the runs to search for the same host. - stepWindow: number + // The runs per client of the test history that holds runId. The search + // for a run on the same host reads this history. + historyRuns: number + // The column labels of the two runs, before their start times. + labels?: [before: string, after: string] } function formatRunStart(runStart: number): string { return new Date(runStart * 1000).toLocaleString() } -// RegressionDetail compares the config of the first run since a step with the -// last run before it, and with the last run before it on the same host. A -// pool of runners can differ a little in hardware, so the same-host diff -// shows a config change without the hardware noise. -export function RegressionDetail({ change, suiteHash, steps, stepWindow }: RegressionDetailProps) { +// PreviousRunDiff compares the config of a run with the last run of the same +// client before it, and with the last run before it on the same host. A pool +// of runners can differ a little in hardware, so the same-host diff shows a +// config change without the hardware noise. +export function PreviousRunDiff({ + suiteHash, + testName, + client, + runId, + runStart, + previousRunId, + steps, + historyRuns, + labels = ['Before', 'Since'], +}: PreviousRunDiffProps) { const queryClient = useQueryClient() const [reference, setReference] = useState('previous') const [onlyDifferences, setOnlyDifferences] = useState(true) - const sinceRunId = change.step.run_id - const lastRunId = change.baseline.last_run_id + const sinceRunId = runId + const lastRunId = previousRunId const since = useRunConfig(sinceRunId) const last = useRunConfig(lastRunId) @@ -45,9 +62,9 @@ export function RegressionDetail({ change, suiteHash, steps, stepWindow }: Regre // Search older runs only when the last run before the step ran elsewhere. const needsSearch = !!since.data && !!last.data && !lastOnSameHost - const history = useTestHistory(suiteHash, needsSearch ? change.testName : undefined, stepWindow) + const history = useTestHistory(suiteHash, needsSearch ? testName : undefined, historyRuns) const candidates = history.data - ? sameHostCandidates(history.data.durations, change.client, sinceRunId, lastRunId) + ? sameHostCandidates(history.data.durations, client, sinceRunId, lastRunId) : undefined const sameHost = useQuery({ @@ -84,7 +101,7 @@ export function RegressionDetail({ change, suiteHash, steps, stepWindow }: Regre if (!since.data || !last.data) { return (

- The config of {since.data ? 'the last run before the step' : 'the first run since the step'} is not available. + The config of {since.data ? 'the previous run' : 'this run'} is not available.

) } @@ -143,7 +160,7 @@ export function RegressionDetail({ change, suiteHash, steps, stepWindow }: Regre ) : showSameHost && !sameHost.data ? (

- No run on {host} in the {MAX_SAME_HOST_LOOKBACK} runs before the step. + No run on {host} in the {MAX_SAME_HOST_LOOKBACK} runs before this run.

) : ( diff --git a/ui/src/components/suite-detail/RegressionsPanel.tsx b/ui/src/components/suite-detail/RegressionsPanel.tsx index ab3c1f41..5b8a242b 100644 --- a/ui/src/components/suite-detail/RegressionsPanel.tsx +++ b/ui/src/components/suite-detail/RegressionsPanel.tsx @@ -6,7 +6,7 @@ import { ClientBadge } from '@/components/shared/ClientBadge' import { Pagination } from '@/components/shared/Pagination' import { SegmentedControl } from '@/components/shared/SegmentedControl' import { TestName } from '@/components/shared/TestName' -import { RegressionDetail } from './RegressionDetail' +import { PreviousRunDiff } from '@/components/compare/PreviousRunDiff' import { formatDuration } from '@/utils/format' import { MIN_DURATION_OPTIONS_MS, @@ -357,7 +357,19 @@ export function RegressionsPanel({
- + {/* The step lies inside the step window, so the test + history of that many runs holds the runs to search + for the same host. */} +
diff --git a/ui/src/components/test-history/HistoryRunsTable.tsx b/ui/src/components/test-history/HistoryRunsTable.tsx new file mode 100644 index 00000000..95582c5f --- /dev/null +++ b/ui/src/components/test-history/HistoryRunsTable.tsx @@ -0,0 +1,403 @@ +import { Fragment, useMemo, useState } from 'react' +import { Link } from '@tanstack/react-router' +import { useQueries } from '@tanstack/react-query' +import clsx from 'clsx' +import { ChevronRight, GitCompare, X } from 'lucide-react' +import { fetchRunConfig, runConfigQueryKey } from '@/api/hooks/useRunConfig' +import { getRunDurationAggregatedStats, type IndexStepType, type TestHistoryDuration } from '@/api/types' +import { ConfigDiff } from '@/components/compare/ConfigDiff' +import { MAX_COMPARE_RUNS, MIN_COMPARE_RUNS } from '@/components/compare/constants' +import { PreviousRunDiff } from '@/components/compare/PreviousRunDiff' +import { ClientBadge } from '@/components/shared/ClientBadge' +import { Pagination } from '@/components/shared/Pagination' +import { Spinner } from '@/components/shared/Spinner' +import { formatNumber } from '@/utils/format' +import { + formatHistoryValue, + historyMetricValue, + previousRuns, + sortHistoryRuns, + type HistorySortColumn, + type SortDirection, +} from '@/utils/testHistory' + +const PAGE_SIZE = 50 + +interface HistoryRunsTableProps { + // The runs of the shown clients, in any order. + runs: TestHistoryDuration[] + // All runs of the test history. The previous run of a client comes from + // here, so a hidden client does not change it. + allRuns: TestHistoryDuration[] + suiteHash: string + testName: string + stepFilter: IndexStepType[] + // The steps search param, passed on to the compare page. + steps?: string + showRejected: boolean + // The runs per client of the test history. + historyRuns: number + sort: HistorySortColumn + sortDir: SortDirection + onSortChange: (sort: HistorySortColumn, sortDir: SortDirection) => void +} + +function SortIcon({ direction, active }: { direction: SortDirection; active: boolean }) { + return ( + + {direction === 'asc' ? : } + + ) +} + +function SortableHeader({ + label, + column, + sort, + sortDir, + onSort, + align = 'left', +}: { + label: string + column: HistorySortColumn + sort: HistorySortColumn + sortDir: SortDirection + onSort: (column: HistorySortColumn) => void + align?: 'left' | 'right' +}) { + const active = sort === column + return ( + + + + ) +} + +function formatRunStart(runStart: number): string { + return new Date(runStart * 1000).toLocaleString() +} + +// SelectedRunsDiff shows the config diff of the selected runs, oldest first. +function SelectedRunsDiff({ runs }: { runs: TestHistoryDuration[] }) { + const [onlyDifferences, setOnlyDifferences] = useState(true) + const configs = useQueries({ + queries: runs.map((d) => ({ + queryKey: runConfigQueryKey(d.id), + queryFn: () => fetchRunConfig(d.id), + retry: 1, + })), + }) + + if (configs.some((q) => q.isLoading)) { + return ( +
+ Loading the run configs... +
+ ) + } + + const missing = runs.filter((_, i) => !configs[i].data) + if (missing.length > 0) { + return ( +

+ The config of {missing.map((d) => `${d.client} · ${formatRunStart(d.run_start)}`).join(', ')} is not available. +

+ ) + } + + return ( +
+ + ({ runId: d.id, config: configs[i].data!, result: null, index: i }))} + labelMode="default" + labels={runs.map((d) => `${d.client} · ${formatRunStart(d.run_start)}`)} + onlyDifferences={onlyDifferences} + /> +
+ ) +} + +// HistoryRunsTable lists the runs of a test history. A row opens the config +// diff to the previous run of its client. Selected rows show their config +// diff, or open on the compare page. +export function HistoryRunsTable({ + runs, + allRuns, + suiteHash, + testName, + stepFilter, + steps, + showRejected, + historyRuns, + sort, + sortDir, + onSortChange, +}: HistoryRunsTableProps) { + const [page, setPage] = useState(1) + const [openRows, setOpenRows] = useState>(new Set()) + const [selected, setSelected] = useState([]) + const [showDiff, setShowDiff] = useState(false) + + const sortedRuns = useMemo(() => sortHistoryRuns(runs, sort, sortDir, stepFilter), [runs, sort, sortDir, stepFilter]) + const previous = useMemo(() => previousRuns(allRuns), [allRuns]) + const runById = useMemo(() => new Map(allRuns.map((d) => [d.id, d])), [allRuns]) + + // The selected runs that are still in the history, oldest first. + const selectedRuns = selected + .map((id) => runById.get(id)) + .filter((d): d is TestHistoryDuration => d !== undefined) + .sort((a, b) => a.run_start - b.run_start || a.id.localeCompare(b.id)) + const canCompare = selectedRuns.length >= MIN_COMPARE_RUNS + + const totalPages = Math.max(1, Math.ceil(sortedRuns.length / PAGE_SIZE)) + const currentPage = Math.min(page, totalPages) + const pagedRuns = sortedRuns.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE) + const colCount = 9 + (showRejected ? 1 : 0) + + const handleSort = (column: HistorySortColumn) => { + setPage(1) + // A new column starts with the largest value, or A to Z for the client. + if (column !== sort) onSortChange(column, column === 'client' ? 'asc' : 'desc') + else onSortChange(column, sortDir === 'asc' ? 'desc' : 'asc') + } + + const toggleRow = (id: string) => + setOpenRows((rows) => { + const next = new Set(rows) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + + const toggleSelected = (id: string) => + setSelected((ids) => (ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id])) + + const clearSelection = () => { + setSelected([]) + setShowDiff(false) + } + + return ( +
+ {selectedRuns.length > 0 && ( +
+
+ + {selectedRuns.length} of up to {MAX_COMPARE_RUNS} runs selected + {!canCompare && ' · select one more run to compare'} + +
+ + {canCompare && ( + d.id).join(','), steps }} + className="inline-flex items-center gap-1 text-xs/5 font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" + > + + Compare runs + + )} + +
+
+ {showDiff && canCompare && } +
+ )} + +
+ + + + + + + + + + + {showRejected && ( + + )} + + + + + {pagedRuns.map((d) => { + const mgas = historyMetricValue(d, 'mgas', stepFilter) + const duration = historyMetricValue(d, 'duration', stepFilter) + const rejected = historyMetricValue(d, 'rejected', stepFilter) + const { gasUsed } = getRunDurationAggregatedStats(d, stepFilter) + const prev = previous.get(d.id) + // The history has the image tag, not the digest. A floating tag + // such as :master can change its image with no new tag, and the + // config diff of the row shows that. + const newTag = !!prev?.image && !!d.image && prev.image !== d.image + const open = openRows.has(d.id) && !!prev + const isSelected = selected.includes(d.id) + const selectionFull = !isSelected && selected.length >= MAX_COMPARE_RUNS + return ( + + + + + + + + + + {showRejected && ( + + )} + + + + {open && ( + + + + )} + + ) + })} + +
+ Config diff to the previous run + + Select + Image +
+ {prev && ( + + )} + + toggleSelected(d.id)} + aria-label={`Select the ${d.client} run of ${formatRunStart(d.run_start)}`} + title={selectionFull ? `Select up to ${MAX_COMPARE_RUNS} runs` : 'Select to compare'} + className="rounded-xs border-gray-300 disabled:opacity-40 dark:border-gray-600" + /> + + {formatRunStart(d.run_start)} + + + +
+ + {d.image ?? '-'} + + {newTag && ( + + new tag + + )} +
+
+ {mgas !== undefined ? mgas.toFixed(2) : '-'} + + {duration !== undefined ? formatHistoryValue(duration, 'duration') : '-'} + + {rejected !== undefined ? formatHistoryValue(rejected, 'rejected') : '-'} + + {formatNumber(gasUsed)} + + + Open run + +
+
+ +
+
+
+ +
+ ) +} diff --git a/ui/src/pages/TestHistoryPage.tsx b/ui/src/pages/TestHistoryPage.tsx index 8474c54f..551615c5 100644 --- a/ui/src/pages/TestHistoryPage.tsx +++ b/ui/src/pages/TestHistoryPage.tsx @@ -4,18 +4,17 @@ import clsx from 'clsx' import { ExternalLink } from 'lucide-react' import { useSuite } from '@/api/hooks/useSuite' import { useTestHistory } from '@/api/hooks/useTestHistory' -import { ALL_INDEX_STEP_TYPES, DEFAULT_INDEX_STEP_FILTER, getRunDurationAggregatedStats, type IndexStepType } from '@/api/types' +import { ALL_INDEX_STEP_TYPES, DEFAULT_INDEX_STEP_FILTER, type IndexStepType } from '@/api/types' import { Card } from '@/components/shared/Card' import { ClientBadge } from '@/components/shared/ClientBadge' import { EmptyState } from '@/components/shared/EmptyState' import { ErrorState } from '@/components/shared/ErrorState' import { JDenticon } from '@/components/shared/JDenticon' -import { Pagination } from '@/components/shared/Pagination' import { SegmentedControl } from '@/components/shared/SegmentedControl' import { LoadingState } from '@/components/shared/Spinner' import { TestName } from '@/components/shared/TestName' +import { HistoryRunsTable } from '@/components/test-history/HistoryRunsTable' import { TestHistoryChart, type HistoryXAxis } from '@/components/test-history/TestHistoryChart' -import { formatNumber } from '@/utils/format' import { classifyChange, DEFAULT_REGRESSION_THRESHOLD, significantChange } from '@/utils/regressions' import { BASELINE_RUNS, @@ -25,15 +24,16 @@ import { formatHistoryValue, hasRejectedTime, HISTORY_METRIC_LABELS, - historyMetricValue, + HISTORY_SORT_COLUMNS, summarizeClient, type ClientHistorySummary, type HistoryMetric, + type HistorySortColumn, + type SortDirection, } from '@/utils/testHistory' const RUNS_PER_CLIENT_OPTIONS = [25, 50, 100, 250, 500, 1000] as const const DEFAULT_RUNS_PER_CLIENT = 100 -const TABLE_PAGE_SIZE = 50 interface TestHistorySearch { test?: string @@ -45,6 +45,9 @@ interface TestHistorySearch { clients?: string // The number of recent runs per client that the step search looks at. w?: number + // The sort of the runs table. The newest run first is the default. + sort?: HistorySortColumn + dir?: SortDirection } function parseStepFilter(param: string | undefined): IndexStepType[] { @@ -108,20 +111,23 @@ function ClientSummaryCard({ metric, stepWindow, hidden, + filtered, onToggle, }: { summary: ClientHistorySummary metric: HistoryMetric stepWindow: number hidden: boolean + // True when the clients filter is on, so some clients are hidden. + filtered: boolean onToggle: () => void }) { return (