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 (