diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index f88d8a0c0..dac0e65ce 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -410,6 +410,7 @@ export const messages = { 'tree.path': 'Trees', 'tree.searchPlaceholder': 'Search by tree, branch or tag with a regex', 'treeCompare.backToDetails': 'Back to tree details', + 'treeCompare.base': 'Base', 'treeCompare.breadcrumb': 'Compare', 'treeCompare.breakdownTitle': 'Changed results', 'treeCompare.change.appeared': 'Appeared', @@ -425,10 +426,10 @@ export const messages = { 'treeCompare.change.unchanged': 'Unchanged', 'treeCompare.changed': 'Changed', 'treeCompare.description': - 'Compare pass/fail counts between two revisions on the same tree and branch.', - 'treeCompare.detail.missingSide': 'No result on this side', + 'Compare pass/fail counts from a base revision to a target revision on the same tree and branch.', + 'treeCompare.detail.missingSide': 'No result on this revision', 'treeCompare.drilldownHint': - 'Individual builds, boots, and tests that changed between Side A and Side B.', + 'Individual builds, boots, and tests that changed from the base revision to the target revision.', 'treeCompare.failures.change': 'Change', 'treeCompare.failures.configArch': 'Config / Arch', 'treeCompare.failures.pathArch': 'Path / Arch', @@ -447,8 +448,6 @@ export const messages = { 'Select two different revisions to compare. This tree needs at least two commits with results.', 'treeCompare.openCompare': 'Compare revisions', 'treeCompare.selectRevision': 'Select a revision', - 'treeCompare.sideA': 'Side A', - 'treeCompare.sideB': 'Side B', 'treeCompare.statusPairFilter.active': 'Active status pair filters', 'treeCompare.statusPairFilter.add': 'Add', 'treeCompare.statusPairFilter.from': 'From', @@ -456,15 +455,16 @@ export const messages = { 'PASS — completed successfully{br}' + 'FAIL — completed with a failure{br}' + 'INCONCLUSIVE — no definitive pass or fail result{br}' + - '— — absent on this side', + '— — absent on this revision', 'treeCompare.statusPairFilter.remove': 'Remove {from} to {to} filter', 'treeCompare.statusPairFilter.select': 'Select status', 'treeCompare.statusPairFilter.to': 'To', 'treeCompare.suggestion.branchHead': 'Branch head', 'treeCompare.suggestion.previous': 'Previous commit', - 'treeCompare.suggestion.swap': 'Swap sides', + 'treeCompare.suggestion.swap': 'Swap base and target', 'treeCompare.suggestions': 'Suggestions', 'treeCompare.summaryTitle': 'Tree summary', + 'treeCompare.target': 'Target', 'treeDetails.bootsHistory': 'Boots History', 'treeDetails.branch': 'Branch', 'treeDetails.buildsHistory': 'Builds History', diff --git a/dashboard/src/pages/TreeCompare/TreeCompareLink.tsx b/dashboard/src/pages/TreeCompare/TreeCompareLink.tsx index 48806f30b..3aa565489 100644 --- a/dashboard/src/pages/TreeCompare/TreeCompareLink.tsx +++ b/dashboard/src/pages/TreeCompare/TreeCompareLink.tsx @@ -24,7 +24,7 @@ export function TreeCompareLink({ s} > diff --git a/dashboard/src/pages/TreeCompare/TreeComparePage.tsx b/dashboard/src/pages/TreeCompare/TreeComparePage.tsx index e1869b307..c0f211135 100644 --- a/dashboard/src/pages/TreeCompare/TreeComparePage.tsx +++ b/dashboard/src/pages/TreeCompare/TreeComparePage.tsx @@ -43,6 +43,7 @@ import { mapBootOrTestDiffRows, mapBuildDiffRows, readStoredStatusPairs, + resolveCompareHashes, resolveStatusPairs, serializeStatusPairs, writeStoredStatusPairs, @@ -86,11 +87,10 @@ const TreeComparePage = (): JSX.Element => { [commitsQuery.data], ); - const resolvedHashA = hashA || revisions[0]?.hash || ''; - const resolvedHashB = - hashB || - revisions.find(revision => revision.hash !== resolvedHashA)?.hash || - ''; + const { hashA: resolvedHashA, hashB: resolvedHashB } = useMemo( + () => resolveCompareHashes(revisions, hashA, hashB), + [revisions, hashA, hashB], + ); const canCompare = Boolean(resolvedHashA && resolvedHashB); @@ -315,7 +315,7 @@ const TreeComparePage = (): JSX.Element => { params={{ treeName, branch, - hash: resolvedHashA, + hash: resolvedHashB, }} state={s => s} > @@ -347,7 +347,7 @@ const TreeComparePage = (): JSX.Element => { s} > diff --git a/dashboard/src/pages/TreeCompare/components/CompareDetailSheet.tsx b/dashboard/src/pages/TreeCompare/components/CompareDetailSheet.tsx index fba25970d..348fc5f8b 100644 --- a/dashboard/src/pages/TreeCompare/components/CompareDetailSheet.tsx +++ b/dashboard/src/pages/TreeCompare/components/CompareDetailSheet.tsx @@ -64,7 +64,7 @@ function SideColumn({ logData, isLoading, }: { - labelId: 'treeCompare.sideA' | 'treeCompare.sideB'; + labelId: 'treeCompare.base' | 'treeCompare.target'; status: CompareItemStatus; id: string | null; logType: LogType; @@ -154,7 +154,7 @@ export function CompareDetailSheet({
setSort(current => cycleSort(current, key))} @@ -382,7 +382,7 @@ export function CompareBuildsFailuresTable({ setSort(current => cycleSort(current, key))} @@ -507,7 +507,7 @@ function PathHardwareTable({ /> setSort(current => cycleSort(current, key))} @@ -515,7 +515,7 @@ function PathHardwareTable({ setSort(current => cycleSort(current, key))} diff --git a/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx b/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx index ca68b07fb..ec7a1c823 100644 --- a/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx +++ b/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx @@ -68,11 +68,11 @@ function RevisionCard({ side === 'A' ? 'bg-blue' : 'bg-dim-gray', )} > - {side} + {side === 'A' ? 'B' : 'T'}
@@ -180,7 +180,7 @@ export function RevisionSelectorBar({ type="button" onClick={onSwap} className="bg-medium-gray flex h-10 w-10 items-center justify-center rounded-full" - aria-label="Swap sides" + aria-label="Swap base and target" > diff --git a/dashboard/src/utils/treeCompareDiff.test.ts b/dashboard/src/utils/treeCompareDiff.test.ts index 46d388d83..95a77df6b 100644 --- a/dashboard/src/utils/treeCompareDiff.test.ts +++ b/dashboard/src/utils/treeCompareDiff.test.ts @@ -7,6 +7,7 @@ import { mapBuildDiffRows, compareRowNav, parseStatusPairs, + resolveCompareHashes, resolveStatusPairs, serializeStatusPairs, toggleChangeTypePairs, @@ -44,6 +45,43 @@ describe('deriveCompareChange', () => { }); }); +describe('resolveCompareHashes', () => { + const revisions = [{ hash: 'new' }, { hash: 'mid' }, { hash: 'old' }]; + + it('defaults to the head on B and the revision before it on A', () => { + expect(resolveCompareHashes(revisions, '', '')).toEqual({ + hashA: 'mid', + hashB: 'new', + }); + }); + + it('keeps A one revision older than an explicit B', () => { + expect(resolveCompareHashes(revisions, '', 'mid')).toEqual({ + hashA: 'old', + hashB: 'mid', + }); + }); + + it('never defaults B to the same revision as A', () => { + expect(resolveCompareHashes(revisions, 'new', '')).toEqual({ + hashA: 'new', + hashB: 'mid', + }); + }); + + it('leaves explicit hashes alone and copes with too few revisions', () => { + expect(resolveCompareHashes(revisions, 'old', 'new')).toEqual({ + hashA: 'old', + hashB: 'new', + }); + expect(resolveCompareHashes([{ hash: 'only' }], '', '')).toEqual({ + hashA: '', + hashB: 'only', + }); + expect(resolveCompareHashes([], '', '')).toEqual({ hashA: '', hashB: '' }); + }); +}); + describe('applyStatusPairFilter', () => { const rows = [ { id: '1', sideA: 'PASS' as const, sideB: 'FAIL' as const }, diff --git a/dashboard/src/utils/treeCompareDiff.ts b/dashboard/src/utils/treeCompareDiff.ts index cb313c7cd..87dbde194 100644 --- a/dashboard/src/utils/treeCompareDiff.ts +++ b/dashboard/src/utils/treeCompareDiff.ts @@ -28,7 +28,7 @@ export function apiStatusToItemStatus( return status; } -/** Mirror backend _CHANGE_COUNT_SELECT categories for A→B transitions. */ +/** Mirror backend _CHANGE_COUNT_SELECT categories for base→target transitions. */ export function deriveCompareChange( statusA: CompareItemStatus, statusB: CompareItemStatus, @@ -300,6 +300,25 @@ export function applyStatusPairFilter< ); } +/** + * Fill in missing sides so the default comparison runs oldest → newest: + * B defaults to the branch head, A to the revision right before B. + * `revisions` is newest-first. + */ +export function resolveCompareHashes( + revisions: readonly { hash: string }[], + hashA: string, + hashB: string, +): { hashA: string; hashB: string } { + const resolvedB = + hashB || revisions.find(revision => revision.hash !== hashA)?.hash || ''; + const indexB = revisions.findIndex(revision => revision.hash === resolvedB); + return { + hashA: hashA || revisions[indexB + 1]?.hash || '', + hashB: resolvedB, + }; +} + /** Next/prev over the currently visible (searched/sorted) table rows. */ export function compareRowNav( rows: T[], diff --git a/docs/tree-compare.md b/docs/tree-compare.md index ca455cdaa..90bd8efbf 100644 --- a/docs/tree-compare.md +++ b/docs/tree-compare.md @@ -4,9 +4,9 @@ High-level overview of the Tree Compare feature: side-by-side comparison of buil ## Purpose -Given a tree name and branch, pick two commit hashes (side A and side B) and answer: +Given a tree name and branch, pick two commit hashes (base and target) and answer: -- How did overall pass/fail/inconclusive counts move from A → B? +- How did overall pass/fail/inconclusive counts move from base → target? - Which individual builds / boots / tests changed category (regressions, fixes, new failures, etc.)? Entry point: **Compare revisions** on Tree Details (`TreeCompareLink`), which opens: @@ -15,14 +15,14 @@ Entry point: **Compare revisions** on Tree Details (`TreeCompareLink`), which op ## User flow -1. Open compare from Tree Details (current revision pre-fills as side A). -2. Choose / swap revisions via the revision selector (commit history + shortcuts: previous commit, branch head, swap sides). +1. Open compare from Tree Details (current revision pre-fills as **Target**, **Base** defaults to the revision before it, so base → target reads oldest → newest). URL params stay `hashA` (base) and `hashB` (target). +2. Choose / swap revisions via the revision selector (commit history + shortcuts: previous commit, branch head, swap base and target). 3. Read the summary matrix (fixes, regressions, pass/fail/other counts per builds / boots / tests). 4. Drill into **Changed results** tabs (Builds / Boots / Tests). Quick change-type chips add or remove their status pairs; custom From/To pairs can be added too (default: `PASS → FAIL` and `FAIL → PASS`). Last edited pairs are remembered in localStorage when the URL omits `statusPair`. URL search state owns: `hashA`, `hashB`, `origin`, `currentPageTab`, and optional `statusPair`. -## Change categories (A → B) +## Change categories (base → target) Statuses are grouped into **PASS**, **FAIL**, and **INCONCLUSIVE** (everything else, including null). Absent on one side is treated as missing (`null` / `—`). @@ -34,7 +34,7 @@ Statuses are grouped into **PASS**, **FAIL**, and **INCONCLUSIVE** (everything e | `stillFailing` | FAIL → FAIL | | `newPass` | missing/INCONCLUSIVE → PASS | | `appeared` | missing → INCONCLUSIVE | -| `disappeared` | present on A, missing on B | +| `disappeared` | present on base, missing on target | | `unchanged` | same status on both sides (except FAIL → FAIL); frontend-only, has no backend count and no filter chip | Backend SQL aggregates (`_CHANGE_COUNT_SELECT` in `queries/tree.py`) and the frontend `deriveCompareChange` helper must stay in sync.