diff --git a/Makefile b/Makefile index d5c4b5e36..59d2a4d82 100644 --- a/Makefile +++ b/Makefile @@ -50,6 +50,9 @@ ci: ## Reproduce full CI pipeline (lint + build + test + integration) dev: ## Start development environment with docker-compose.dev.yml docker compose -f docker-compose.dev.yml up -d +dev-down: ## Stop development environment with docker-compose.dev.yml + docker compose -f docker-compose.dev.yml down + dev-build: ## Start development environment and rebuild images docker compose -f docker-compose.dev.yml up --build -d diff --git a/backend/kernelCI_app/helpers/treeCompare.py b/backend/kernelCI_app/helpers/treeCompare.py new file mode 100644 index 000000000..be208ebd7 --- /dev/null +++ b/backend/kernelCI_app/helpers/treeCompare.py @@ -0,0 +1,220 @@ +from collections import defaultdict +from dataclasses import dataclass, field +from typing import Callable, Literal, Optional + +from kernelCI_app.constants.general import UNKNOWN_STRING +from kernelCI_app.constants.process_pending import ROLLUP_STATUS_FIELDS +from kernelCI_app.typeModels.treeCompare import ( + CompareDelta, + CompareEntitySummary, + CompareGroupRow, + CompareGroups, + CompareStatusCounts, + CompareSummary, + TreeCompareResponse, +) + +BucketKey = Literal["pass", "fail", "inconclusive"] + + +@dataclass +class _HashAccumulator: + builds: CompareStatusCounts = field(default_factory=CompareStatusCounts) + boots: CompareStatusCounts = field(default_factory=CompareStatusCounts) + tests: CompareStatusCounts = field(default_factory=CompareStatusCounts) + build_groups: dict[str, CompareStatusCounts] = field( + default_factory=lambda: defaultdict(CompareStatusCounts) + ) + boot_groups: dict[str, CompareStatusCounts] = field( + default_factory=lambda: defaultdict(CompareStatusCounts) + ) + test_groups: dict[str, CompareStatusCounts] = field( + default_factory=lambda: defaultdict(CompareStatusCounts) + ) + + +def _empty_counts() -> CompareStatusCounts: + return CompareStatusCounts() + + +def _status_to_bucket(status: Optional[str]) -> BucketKey: + if status is None: + return "inconclusive" + normalized = status.upper() + if normalized == "PASS": + return "pass" + if normalized == "FAIL": + return "fail" + return "inconclusive" + + +def _increment_bucket( + counts: CompareStatusCounts, bucket: BucketKey, amount: int +) -> None: + if amount <= 0: + return + if bucket == "pass": + counts.pass_count += amount + elif bucket == "fail": + counts.fail_count += amount + else: + counts.inconclusive += amount + + +def _rollup_status_to_bucket(status_name: str) -> BucketKey: + if status_name == "PASS": + return "pass" + if status_name == "FAIL": + return "fail" + return "inconclusive" + + +def _boot_group_key(row_dict: dict) -> str: + return ( + row_dict.get("test_platform") + or row_dict.get("hardware_key") + or UNKNOWN_STRING + ) + + +def _test_group_key(row_dict: dict) -> str: + path_group = row_dict.get("path_group") or UNKNOWN_STRING + arch = row_dict.get("build_architecture") or UNKNOWN_STRING + if arch == UNKNOWN_STRING: + return path_group + return f"{path_group}/{arch}" + + +def process_rollup_rows( + *, + rows: list[dict], + commit_hashes: list[str], +) -> dict[str, _HashAccumulator]: + accumulators = {commit_hash: _HashAccumulator() for commit_hash in commit_hashes} + + for row_dict in rows: + commit_hash = row_dict["git_commit_hash"] + is_boot_row = row_dict["is_boot"] + acc = accumulators.setdefault(commit_hash, _HashAccumulator()) + group_id = _boot_group_key(row_dict) if is_boot_row else _test_group_key(row_dict) + target = acc.boots if is_boot_row else acc.tests + group_map = acc.boot_groups if is_boot_row else acc.test_groups + + for status_name, field_name in ROLLUP_STATUS_FIELDS.items(): + count = row_dict.get(field_name, 0) or 0 + if count <= 0: + continue + bucket = _rollup_status_to_bucket(status_name) + _increment_bucket(target, bucket, count) + _increment_bucket(group_map[group_id], bucket, count) + + return accumulators + + +def process_build_rows( + *, + rows: list[dict], + commit_hashes: list[str], +) -> dict[str, _HashAccumulator]: + accumulators = {commit_hash: _HashAccumulator() for commit_hash in commit_hashes} + + for row in rows: + commit_hash = row["git_commit_hash"] + config = row.get("config_name") or UNKNOWN_STRING + count = row.get("count") or 0 + acc = accumulators.setdefault(commit_hash, _HashAccumulator()) + bucket = _status_to_bucket(row.get("status")) + _increment_bucket(acc.builds, bucket, count) + _increment_bucket(acc.build_groups[config], bucket, count) + + return accumulators + + +def _make_delta(side_a: CompareStatusCounts, side_b: CompareStatusCounts) -> CompareDelta: + return CompareDelta( + **{ + "pass": side_b.pass_count - side_a.pass_count, + "fail": side_b.fail_count - side_a.fail_count, + } + ) + + +def _make_entity_summary( + *, + side_a: CompareStatusCounts, + side_b: CompareStatusCounts, +) -> CompareEntitySummary: + return CompareEntitySummary( + sideA=side_a, + sideB=side_b, + delta=_make_delta(side_a, side_b), + ) + + +def _make_group_rows( + *, + group_maps: tuple[dict[str, CompareStatusCounts], dict[str, CompareStatusCounts]], + label_fn: Optional[Callable[[str], str]] = None, +) -> list[CompareGroupRow]: + side_a_groups, side_b_groups = group_maps + all_ids = set(side_a_groups) | set(side_b_groups) + rows: list[CompareGroupRow] = [] + + for group_id in all_ids: + side_a = side_a_groups.get(group_id, _empty_counts()) + side_b = side_b_groups.get(group_id, _empty_counts()) + label = label_fn(group_id) if label_fn else group_id + rows.append( + CompareGroupRow( + id=group_id, + label=label, + sideA=side_a, + sideB=side_b, + delta=_make_delta(side_a, side_b), + ) + ) + + return rows + + +def _test_group_label(group_id: str) -> str: + if "/" in group_id: + path_group, arch = group_id.split("/", 1) + return f"{path_group} · {arch}" + return group_id + + +def build_compare_response( + *, + hash_a: str, + hash_b: str, + tree_name: str, + branch: str, + git_url: str, + accumulators: dict[str, _HashAccumulator], +) -> TreeCompareResponse: + acc_a = accumulators.get(hash_a, _HashAccumulator()) + acc_b = accumulators.get(hash_b, _HashAccumulator()) + + return TreeCompareResponse( + treeName=tree_name, + branch=branch, + gitUrl=git_url, + summary=CompareSummary( + builds=_make_entity_summary(side_a=acc_a.builds, side_b=acc_b.builds), + boots=_make_entity_summary(side_a=acc_a.boots, side_b=acc_b.boots), + tests=_make_entity_summary(side_a=acc_a.tests, side_b=acc_b.tests), + ), + groups=CompareGroups( + builds=_make_group_rows( + group_maps=(acc_a.build_groups, acc_b.build_groups), + ), + boots=_make_group_rows( + group_maps=(acc_a.boot_groups, acc_b.boot_groups), + ), + tests=_make_group_rows( + group_maps=(acc_a.test_groups, acc_b.test_groups), + label_fn=_test_group_label, + ), + ), + ) diff --git a/backend/kernelCI_app/queries/tree.py b/backend/kernelCI_app/queries/tree.py index 52f755a1f..0e1d64b49 100644 --- a/backend/kernelCI_app/queries/tree.py +++ b/backend/kernelCI_app/queries/tree.py @@ -1204,3 +1204,175 @@ def get_latest_tree( query = query.order_by("-start_time").first() return query + + +def _get_compare_checkout_clauses( + *, + git_branch_param: Optional[str], + tree_name: Optional[str], +) -> tuple[str, str]: + checkout_clauses = create_checkouts_where_clauses( + git_url=None, + git_branch=git_branch_param, + tree_name=tree_name, + ) + + git_branch_clause = checkout_clauses.get("git_branch_clause") + tree_name_clause = checkout_clauses.get("tree_name_clause") + tree_name_full_clause = "AND " + tree_name_clause if tree_name_clause else "" + + return git_branch_clause, tree_name_full_clause + + +def get_tree_compare_rollup( + *, + commit_hashes: list[str], + origin_param: str, + git_branch_param: Optional[str], + tree_name: Optional[str] = None, +) -> list[dict]: + if not commit_hashes: + return [] + + cache_key = "treeCompareRollup" + params = { + "commit_hashes": commit_hashes, + "tree_name": tree_name, + "origin_param": origin_param, + "git_branch_param": git_branch_param, + } + + rows = get_query_cache(cache_key, params) + if rows is not None: + return rows + + git_branch_clause, tree_name_full_clause = _get_compare_checkout_clauses( + git_branch_param=git_branch_param, + tree_name=tree_name, + ) + + query = f""" + WITH RELEVANT_CHECKOUTS AS ( + SELECT DISTINCT ON (c.git_commit_hash) + c.git_commit_hash, + c.tree_name, + c.git_repository_branch, + c.git_repository_url, + c.origin + FROM + checkouts c + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + {tree_name_full_clause} + AND {git_branch_clause} + AND c.origin = %(origin_param)s + ORDER BY + c.git_commit_hash, + c._timestamp DESC + ) + SELECT + tr.git_commit_hash, + tr.path_group, + tr.build_architecture, + tr.hardware_key, + tr.test_platform, + tr.is_boot, + tr.pass_tests, + tr.fail_tests, + tr.skip_tests, + tr.error_tests, + tr.miss_tests, + tr.done_tests, + tr.null_tests, + tr.total_tests + FROM + tree_tests_rollup tr + INNER JOIN RELEVANT_CHECKOUTS rc ON ( + tr.git_commit_hash = rc.git_commit_hash + AND tr.origin = rc.origin + AND tr.tree_name IS NOT DISTINCT FROM rc.tree_name + AND tr.git_repository_branch IS NOT DISTINCT FROM rc.git_repository_branch + AND tr.git_repository_url IS NOT DISTINCT FROM rc.git_repository_url + ) + ORDER BY + tr.total_tests DESC + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor=cursor) + set_query_cache(key=cache_key, params=params, rows=rows) + + return rows + + +def get_tree_compare_builds( + *, + commit_hashes: list[str], + origin_param: str, + git_branch_param: Optional[str], + tree_name: Optional[str] = None, +) -> list[dict]: + if not commit_hashes: + return [] + + cache_key = "treeCompareBuilds" + params = { + "commit_hashes": commit_hashes, + "tree_name": tree_name, + "origin_param": origin_param, + "git_branch_param": git_branch_param, + } + + rows = get_query_cache(cache_key, params) + if rows is not None: + return rows + + git_branch_clause, tree_name_full_clause = _get_compare_checkout_clauses( + git_branch_param=git_branch_param, + tree_name=tree_name, + ) + + query = f""" + WITH RELEVANT_CHECKOUTS AS ( + SELECT DISTINCT ON (c.git_commit_hash) + c.id AS checkout_id, + c.git_commit_hash, + c.git_repository_url + FROM + checkouts c + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + {tree_name_full_clause} + AND {git_branch_clause} + AND c.origin = %(origin_param)s + ORDER BY + c.git_commit_hash, + c._timestamp DESC + ) + SELECT + rc.git_commit_hash, + rc.git_repository_url, + b.config_name, + b.status, + COUNT(DISTINCT b.id) AS count + FROM + RELEVANT_CHECKOUTS rc + INNER JOIN builds b ON b.checkout_id = rc.checkout_id + WHERE + b.config_name IS NOT NULL + AND b.id NOT LIKE 'maestro:dummy_%%' + GROUP BY + rc.git_commit_hash, + rc.git_repository_url, + b.config_name, + b.status + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor=cursor) + set_query_cache(key=cache_key, params=params, rows=rows) + + return rows + diff --git a/backend/kernelCI_app/tests/unitTests/queries/treeCompareQueries_test.py b/backend/kernelCI_app/tests/unitTests/queries/treeCompareQueries_test.py new file mode 100644 index 000000000..0d213e398 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/queries/treeCompareQueries_test.py @@ -0,0 +1,67 @@ +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase + +from kernelCI_app.queries.tree import ( + get_tree_compare_builds, + get_tree_compare_rollup, +) + + +class TestTreeCompareQueries(SimpleTestCase): + """Query-shape checks for the compare endpoint performance path.""" + + @patch("kernelCI_app.queries.tree.connection") + @patch("kernelCI_app.queries.tree.get_query_cache", return_value=None) + @patch("kernelCI_app.queries.tree.set_query_cache") + @patch("kernelCI_app.queries.tree.dict_fetchall", return_value=[]) + def test_compare_rollup_uses_single_any_query( + self, + mock_fetchall, + mock_set_cache, + mock_get_cache, + mock_connection, + ): + mock_cursor = MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + + get_tree_compare_rollup( + commit_hashes=["hash_a", "hash_b"], + origin_param="maestro", + git_branch_param="master", + tree_name="linux", + ) + + executed_query = mock_cursor.execute.call_args[0][0] + params = mock_cursor.execute.call_args[0][1] + self.assertIn("git_commit_hash = ANY(%(commit_hashes)s)", executed_query) + self.assertIn("DISTINCT ON (c.git_commit_hash)", executed_query) + self.assertIn("tree_tests_rollup", executed_query) + self.assertNotIn("git_url_param", params) + + @patch("kernelCI_app.queries.tree.connection") + @patch("kernelCI_app.queries.tree.get_query_cache", return_value=None) + @patch("kernelCI_app.queries.tree.set_query_cache") + @patch("kernelCI_app.queries.tree.dict_fetchall", return_value=[]) + def test_compare_builds_aggregates_in_sql( + self, + mock_fetchall, + mock_set_cache, + mock_get_cache, + mock_connection, + ): + mock_cursor = MagicMock() + mock_connection.cursor.return_value.__enter__.return_value = mock_cursor + + get_tree_compare_builds( + commit_hashes=["hash_a", "hash_b"], + origin_param="maestro", + git_branch_param="master", + tree_name="linux", + ) + + executed_query = mock_cursor.execute.call_args[0][0] + self.assertIn("COUNT(DISTINCT b.id)", executed_query) + self.assertIn("GROUP BY", executed_query) + self.assertNotIn("known_issues", executed_query) + self.assertNotIn("SELECT\n b.id AS build_id", executed_query) diff --git a/backend/kernelCI_app/tests/unitTests/views/treeCompareView_test.py b/backend/kernelCI_app/tests/unitTests/views/treeCompareView_test.py new file mode 100644 index 000000000..b188375c2 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/treeCompareView_test.py @@ -0,0 +1,196 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.helpers.treeCompare import ( + build_compare_response, + process_build_rows, + process_rollup_rows, +) +from kernelCI_app.views.treeCompareView import TreeCompareView + +HASH_A = "a" * 40 +HASH_B = "b" * 40 + +ROLLUP_BOOT_ROW = { + "git_commit_hash": HASH_A, + "path_group": "boot", + "build_architecture": "arm64", + "hardware_key": "qemu-arm64", + "test_platform": "qemu-arm64", + "is_boot": True, + "pass_tests": 10, + "fail_tests": 2, + "skip_tests": 0, + "error_tests": 1, + "miss_tests": 0, + "done_tests": 0, + "null_tests": 0, + "total_tests": 13, +} + +ROLLUP_TEST_ROW_A = { + **ROLLUP_BOOT_ROW, + "git_commit_hash": HASH_A, + "path_group": "kselftest", + "build_architecture": "x86_64", + "is_boot": False, + "pass_tests": 20, + "fail_tests": 5, + "error_tests": 0, + "total_tests": 25, +} + +ROLLUP_TEST_ROW_B = { + **ROLLUP_TEST_ROW_A, + "git_commit_hash": HASH_B, + "pass_tests": 18, + "fail_tests": 7, + "total_tests": 25, +} + +BUILD_ROW_A = { + "git_commit_hash": HASH_A, + "git_repository_url": "https://git.kernel.org/linux.git", + "config_name": "arm64-defconfig", + "status": "PASS", + "count": 3, +} + +BUILD_ROW_B = { + **BUILD_ROW_A, + "git_commit_hash": HASH_B, + "status": "FAIL", + "count": 2, +} + + +class TestTreeCompareHelper(SimpleTestCase): + def test_process_rollup_rows_aggregates_summary_and_groups(self): + accumulators = process_rollup_rows( + rows=[ROLLUP_BOOT_ROW, ROLLUP_TEST_ROW_A], + commit_hashes=[HASH_A], + ) + + acc = accumulators[HASH_A] + self.assertEqual(acc.boots.pass_count, 10) + self.assertEqual(acc.boots.fail_count, 2) + self.assertEqual(acc.boots.inconclusive, 1) + self.assertEqual(acc.tests.pass_count, 20) + self.assertEqual(acc.tests.fail_count, 5) + self.assertIn("qemu-arm64", acc.boot_groups) + self.assertIn("kselftest/x86_64", acc.test_groups) + + def test_process_build_rows_groups_by_config(self): + accumulators = process_build_rows( + rows=[BUILD_ROW_A, BUILD_ROW_B], + commit_hashes=[HASH_A, HASH_B], + ) + + self.assertEqual(accumulators[HASH_A].builds.pass_count, 3) + self.assertEqual(accumulators[HASH_B].builds.fail_count, 2) + self.assertEqual( + accumulators[HASH_A].build_groups["arm64-defconfig"].pass_count, 3 + ) + + def test_build_compare_response_computes_deltas(self): + rollup_data = process_rollup_rows( + rows=[ROLLUP_TEST_ROW_A, ROLLUP_TEST_ROW_B], + commit_hashes=[HASH_A, HASH_B], + ) + build_data = process_build_rows( + rows=[BUILD_ROW_A, BUILD_ROW_B], + commit_hashes=[HASH_A, HASH_B], + ) + + accumulators = { + HASH_A: rollup_data[HASH_A], + HASH_B: rollup_data[HASH_B], + } + accumulators[HASH_A].builds = build_data[HASH_A].builds + accumulators[HASH_A].build_groups = build_data[HASH_A].build_groups + accumulators[HASH_B].builds = build_data[HASH_B].builds + accumulators[HASH_B].build_groups = build_data[HASH_B].build_groups + + response = build_compare_response( + hash_a=HASH_A, + hash_b=HASH_B, + tree_name="linux", + branch="master", + git_url="https://git.kernel.org/linux.git", + accumulators=accumulators, + ) + + payload = response.model_dump(by_alias=True) + self.assertEqual(payload["summary"]["tests"]["delta"]["pass"], -2) + self.assertEqual(payload["summary"]["tests"]["delta"]["fail"], 2) + self.assertEqual(payload["summary"]["builds"]["delta"]["pass"], -3) + self.assertEqual(payload["summary"]["builds"]["delta"]["fail"], 2) + + +class TestTreeCompareView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = TreeCompareView.as_view() + self.url = "/api/tree/linux/master/compare" + + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_rollup") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_builds") + def test_get_returns_compare_payload(self, mock_builds, mock_rollup): + mock_builds.return_value = [BUILD_ROW_A, BUILD_ROW_B] + mock_rollup.return_value = [ + ROLLUP_BOOT_ROW, + ROLLUP_TEST_ROW_A, + ROLLUP_TEST_ROW_B, + ] + + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "hash_b": HASH_B, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["treeName"], "linux") + self.assertEqual(response.data["branch"], "master") + self.assertIn("summary", response.data) + self.assertIn("groups", response.data) + mock_builds.assert_called_once() + mock_rollup.assert_called_once() + self.assertNotIn("git_url_param", mock_builds.call_args.kwargs) + + def test_missing_hashes_returns_400(self): + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + self.assertEqual(response.status_code, 400) + + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_rollup") + @patch("kernelCI_app.views.treeCompareView.get_tree_compare_builds") + def test_missing_checkout_returns_zero_counts(self, mock_builds, mock_rollup): + mock_builds.return_value = [] + mock_rollup.return_value = [] + + request = self.factory.get( + self.url, + { + "hash_a": HASH_A, + "hash_b": HASH_B, + "origin": "maestro", + }, + ) + response = self.view(request, tree_name="linux", git_branch="master") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["summary"]["builds"]["sideA"]["pass"], 0) + self.assertEqual(response.data["summary"]["tests"]["sideB"]["fail"], 0) diff --git a/backend/kernelCI_app/typeModels/treeCompare.py b/backend/kernelCI_app/typeModels/treeCompare.py new file mode 100644 index 000000000..bd303e09d --- /dev/null +++ b/backend/kernelCI_app/typeModels/treeCompare.py @@ -0,0 +1,68 @@ +from pydantic import BaseModel, ConfigDict, Field + +from kernelCI_app.constants.general import DEFAULT_ORIGIN +from kernelCI_app.constants.localization import DocStrings + + +class CompareStatusCounts(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + pass_count: int = Field(alias="pass", default=0) + fail_count: int = Field(alias="fail", default=0) + inconclusive: int = 0 + + +class CompareDelta(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + pass_count: int = Field(alias="pass", default=0) + fail_count: int = Field(alias="fail", default=0) + + +class CompareEntitySummary(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + side_a: CompareStatusCounts = Field(alias="sideA") + side_b: CompareStatusCounts = Field(alias="sideB") + delta: CompareDelta + + +class CompareGroupRow(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + id: str + label: str + side_a: CompareStatusCounts = Field(alias="sideA") + side_b: CompareStatusCounts = Field(alias="sideB") + delta: CompareDelta + + +class CompareSummary(BaseModel): + builds: CompareEntitySummary + boots: CompareEntitySummary + tests: CompareEntitySummary + + +class CompareGroups(BaseModel): + builds: list[CompareGroupRow] + boots: list[CompareGroupRow] + tests: list[CompareGroupRow] + + +class TreeCompareResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True) + + tree_name: str = Field(alias="treeName") + branch: str + git_url: str = Field(alias="gitUrl", default="") + summary: CompareSummary + groups: CompareGroups + + +class TreeCompareQueryParameters(BaseModel): + hash_a: str = Field(description="Commit hash for side A") + hash_b: str = Field(description="Commit hash for side B") + origin: str = Field( + default=DEFAULT_ORIGIN, + description=DocStrings.TREE_QUERY_ORIGIN_DESCRIPTION, + ) diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index a89890862..57f5a37fa 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -92,6 +92,11 @@ def view_cache(view, timeout: int = settings.CACHE_TIMEOUT): views.TreeDetailsTestsDirect.as_view(), name="treeDetailsTestsDirectView", ), + path( + "tree///compare", + view_cache(views.TreeCompareView), + name="treeCompare", + ), path( "tree//", view_cache(views.TreeLatest), diff --git a/backend/kernelCI_app/views/hardwareDetailsSummaryView.py b/backend/kernelCI_app/views/hardwareDetailsSummaryView.py index 499236e62..48d869c35 100644 --- a/backend/kernelCI_app/views/hardwareDetailsSummaryView.py +++ b/backend/kernelCI_app/views/hardwareDetailsSummaryView.py @@ -128,7 +128,7 @@ def filter_instance( return True filtered_issues = filters.filterIssues.get(filter_type, set()) - if filtered_issues and not known_issues.issubset(filtered_issues): + if filtered_issues and known_issues.isdisjoint(filtered_issues): return True return False diff --git a/backend/kernelCI_app/views/treeCommitsHistory.py b/backend/kernelCI_app/views/treeCommitsHistory.py index 0d1860580..f658194cc 100644 --- a/backend/kernelCI_app/views/treeCommitsHistory.py +++ b/backend/kernelCI_app/views/treeCommitsHistory.py @@ -592,7 +592,7 @@ def filter_instance( if is_filtered_out(architecture, filters.filterArchitecture): return True filtered_issues = filters.filterIssues.get(filter_type, set()) - if filtered_issues and not known_issues.issubset(filtered_issues): + if filtered_issues and known_issues.isdisjoint(filtered_issues): return True return False diff --git a/backend/kernelCI_app/views/treeCompareView.py b/backend/kernelCI_app/views/treeCompareView.py new file mode 100644 index 000000000..533695029 --- /dev/null +++ b/backend/kernelCI_app/views/treeCompareView.py @@ -0,0 +1,123 @@ +from http import HTTPStatus + +from django.http import HttpRequest +from drf_spectacular.utils import extend_schema +from pydantic import ValidationError +from rest_framework.response import Response +from rest_framework.views import APIView + +from kernelCI_app.constants.general import DEFAULT_ORIGIN +from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.helpers.treeCompare import ( + _HashAccumulator, + build_compare_response, + process_build_rows, + process_rollup_rows, +) +from kernelCI_app.queries.tree import ( + get_tree_compare_builds, + get_tree_compare_rollup, +) +from kernelCI_app.typeModels.commonOpenApiParameters import ( + GIT_BRANCH_PATH_PARAM, + TREE_NAME_PATH_PARAM, +) +from kernelCI_app.typeModels.treeCompare import ( + TreeCompareQueryParameters, + TreeCompareResponse, +) + + +class TreeCompareView(APIView): + def _merge_accumulators( + self, + *, + target: dict[str, _HashAccumulator], + builds_data: dict[str, _HashAccumulator], + boots_tests_data: dict[str, _HashAccumulator], + ) -> None: + for commit_hash, build_acc in builds_data.items(): + target_acc = target.setdefault(commit_hash, _HashAccumulator()) + target_acc.builds = build_acc.builds + target_acc.build_groups = build_acc.build_groups + + for commit_hash, entity_acc in boots_tests_data.items(): + target_acc = target.setdefault(commit_hash, _HashAccumulator()) + target_acc.boots = entity_acc.boots + target_acc.tests = entity_acc.tests + target_acc.boot_groups = entity_acc.boot_groups + target_acc.test_groups = entity_acc.test_groups + + @extend_schema( + parameters=[ + TREE_NAME_PATH_PARAM, + GIT_BRANCH_PATH_PARAM, + TreeCompareQueryParameters, + ], + responses=TreeCompareResponse, + ) + def get( + self, + request: HttpRequest, + tree_name: str, + git_branch: str, + ) -> Response: + try: + query_params = TreeCompareQueryParameters( + hash_a=request.GET.get("hash_a", ""), + hash_b=request.GET.get("hash_b", ""), + origin=request.GET.get("origin", DEFAULT_ORIGIN), + ) + except ValidationError as error: + return Response(data=error.json(), status=HTTPStatus.BAD_REQUEST) + + if not query_params.hash_a or not query_params.hash_b: + return create_api_error_response( + status_code=HTTPStatus.BAD_REQUEST, + error_message="hash_a and hash_b are required", + ) + + commit_hashes = [query_params.hash_a, query_params.hash_b] + query_kwargs = { + "commit_hashes": commit_hashes, + "origin_param": query_params.origin, + "git_branch_param": git_branch, + "tree_name": tree_name, + } + + build_rows = get_tree_compare_builds(**query_kwargs) + builds_data = process_build_rows( + rows=build_rows, + commit_hashes=commit_hashes, + ) + boots_tests_data = process_rollup_rows( + rows=get_tree_compare_rollup(**query_kwargs), + commit_hashes=commit_hashes, + ) + + accumulators = { + commit_hash: _HashAccumulator() for commit_hash in commit_hashes + } + self._merge_accumulators( + target=accumulators, + builds_data=builds_data, + boots_tests_data=boots_tests_data, + ) + + git_url = "" + if build_rows: + git_url = build_rows[0].get("git_repository_url") or "" + + response = build_compare_response( + hash_a=query_params.hash_a, + hash_b=query_params.hash_b, + tree_name=tree_name, + branch=git_branch, + git_url=git_url, + accumulators=accumulators, + ) + + return Response( + data=response.model_dump(by_alias=True), + status=HTTPStatus.OK, + ) diff --git a/backend/requests/tree-compare-get.sh b/backend/requests/tree-compare-get.sh new file mode 100644 index 000000000..3457b2117 --- /dev/null +++ b/backend/requests/tree-compare-get.sh @@ -0,0 +1,21 @@ +http 'http://localhost:8000/api/tree/linux/master/compare' \ + origin==maestro \ + hash_a==abc1234567890abcdef1234567890abcdef12 \ + hash_b==def5678901234abcdef5678901234abcdef56 + +# HTTP/1.1 200 OK +# { +# "treeName": "linux", +# "branch": "master", +# "gitUrl": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", +# "summary": { +# "builds": { "sideA": { "pass": 0, "fail": 0, "inconclusive": 0 }, ... }, +# "boots": { ... }, +# "tests": { ... } +# }, +# "groups": { +# "builds": [], +# "boots": [], +# "tests": [] +# } +# } diff --git a/dashboard/e2e/tree-compare.spec.ts b/dashboard/e2e/tree-compare.spec.ts new file mode 100644 index 000000000..6f8e804f4 --- /dev/null +++ b/dashboard/e2e/tree-compare.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; + +const FULL_HASH_LENGTH = 40; +const HASH_A = 'a'.repeat(FULL_HASH_LENGTH); +const HASH_B = 'b'.repeat(FULL_HASH_LENGTH); + +test('loads revisions and comparison data from the API', async ({ page }) => { + await page.route('**/api/tree/linux/master/commits?**', route => + route.fulfill({ + json: [ + { + git_commit_hash: HASH_A, + last_checkout: '2026-07-14T10:00:00Z', + }, + { + git_commit_hash: HASH_B, + last_checkout: '2026-07-13T10:00:00Z', + }, + ], + }), + ); + + await page.route('**/api/tree/linux/master/compare?**', route => + route.fulfill({ + json: { + treeName: 'linux', + branch: 'master', + gitUrl: 'https://git.kernel.org/linux.git', + summary: { + builds: { + sideA: { pass: 42, fail: 1, inconclusive: 0 }, + sideB: { pass: 40, fail: 3, inconclusive: 0 }, + delta: { pass: -2, fail: 2 }, + }, + boots: { + sideA: { pass: 20, fail: 0, inconclusive: 1 }, + sideB: { pass: 18, fail: 2, inconclusive: 1 }, + delta: { pass: -2, fail: 2 }, + }, + tests: { + sideA: { pass: 100, fail: 5, inconclusive: 2 }, + sideB: { pass: 95, fail: 10, inconclusive: 2 }, + delta: { pass: -5, fail: 5 }, + }, + }, + groups: { + builds: [], + boots: [], + tests: [], + }, + }, + }), + ); + + await page.goto( + `/tree/linux/master/compare?hashA=${HASH_A}&hashB=${HASH_B}&origin=maestro`, + ); + + await expect(page.getByText('Tree summary')).toBeVisible(); + await expect(page.getByText('42', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('100', { exact: true }).first()).toBeVisible(); + await expect(page.getByText(/data is mocked/i)).toHaveCount(0); +}); diff --git a/dashboard/src/api/treeCompare.ts b/dashboard/src/api/treeCompare.ts new file mode 100644 index 000000000..ddc8a591c --- /dev/null +++ b/dashboard/src/api/treeCompare.ts @@ -0,0 +1,46 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; + +import type { TreeCompareData } from '@/types/tree/TreeCompare'; + +import { RequestData } from './commonRequest'; + +const fetchTreeCompare = async ({ + treeName, + branch, + hashA, + hashB, + origin, +}: { + treeName: string; + branch: string; + hashA: string; + hashB: string; + origin: string; +}): Promise => + RequestData.get(`/api/tree/${treeName}/${branch}/compare`, { + params: { + hash_a: hashA, + hash_b: hashB, + origin, + }, + }); + +export const useTreeCompare = ({ + treeName, + branch, + hashA, + hashB, + origin, +}: { + treeName: string; + branch: string; + hashA: string; + hashB: string; + origin: string; +}): UseQueryResult => + useQuery({ + queryKey: ['treeCompare', treeName, branch, hashA, hashB, origin], + queryFn: () => fetchTreeCompare({ treeName, branch, hashA, hashB, origin }), + enabled: !!hashA && !!hashB, + }); diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index e1e111107..6ec841520 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -373,10 +373,32 @@ export const messages = { 'title.hardwareDetails': 'Hardware: {hardwareName}', 'title.issueDetails': 'Issue: {issueName}', 'title.testDetails': 'Test: {testName}', + 'title.treeCompare': 'Compare: {treeName}', 'title.treeDetails': 'Tree: {treeName}', 'tree.details': 'Trees Details', 'tree.path': 'Trees', 'tree.searchPlaceholder': 'Search by tree, branch or tag with a regex', + 'treeCompare.backToDetails': 'Back to tree details', + 'treeCompare.breadcrumb': 'Compare', + 'treeCompare.breakdownTitle': 'Grouped breakdown', + 'treeCompare.changed': 'Changed', + 'treeCompare.delta': 'Delta', + 'treeCompare.deltaFail': 'Δ fail', + 'treeCompare.deltaPass': 'Δ pass', + 'treeCompare.description': + 'Compare pass/fail counts between two revisions on the same tree and branch.', + 'treeCompare.group.boots': 'Platform', + 'treeCompare.group.builds': 'Config', + 'treeCompare.group.tests': 'Path', + 'treeCompare.openCompare': 'Compare revisions', + 'treeCompare.selectRevision': 'Select a revision', + 'treeCompare.sideA': 'Side A', + 'treeCompare.sideB': 'Side B', + 'treeCompare.suggestion.branchHead': 'Branch head', + 'treeCompare.suggestion.previous': 'Previous commit', + 'treeCompare.suggestion.swap': 'Swap sides', + 'treeCompare.suggestions': 'Suggestions', + 'treeCompare.summaryTitle': 'Tree summary', '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 new file mode 100644 index 000000000..48806f30b --- /dev/null +++ b/dashboard/src/pages/TreeCompare/TreeCompareLink.tsx @@ -0,0 +1,35 @@ +import { Link } from '@tanstack/react-router'; +import type { JSX } from 'react'; + +import { GitCompareArrows } from 'lucide-react'; +import { FormattedMessage } from 'react-intl'; + +import { Button } from '@/components/ui/button'; + +interface TreeCompareLinkProps { + treeName: string; + branch: string; + hash: string; + origin: string; +} + +export function TreeCompareLink({ + treeName, + branch, + hash, + origin, +}: TreeCompareLinkProps): JSX.Element { + return ( + + ); +} diff --git a/dashboard/src/pages/TreeCompare/TreeComparePage.tsx b/dashboard/src/pages/TreeCompare/TreeComparePage.tsx new file mode 100644 index 000000000..5728a0184 --- /dev/null +++ b/dashboard/src/pages/TreeCompare/TreeComparePage.tsx @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useMemo, type JSX } from 'react'; + +import { + Link, + useNavigate, + useParams, + useSearch, +} from '@tanstack/react-router'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/Breadcrumb/Breadcrumb'; + +import PageWithTitle from '@/components/PageWithTitle'; +import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; +import Tabs from '@/components/Tabs/Tabs'; +import type { ITabItem } from '@/components/Tabs/Tabs'; + +import { useCommits } from '@/api/commitHistory'; +import { useTreeCompare } from '@/api/treeCompare'; + +import { + compareNavigateFrom, + compareRouteName, +} from '@/types/tree/TreeCompare'; +import type { PossibleTabs } from '@/types/tree/TreeDetails'; + +import { CompareDeltaTable } from './components/CompareDeltaTable'; +import { CompareSummary } from './components/CompareSummary'; +import { RevisionSelectorBar } from './components/RevisionSelector'; + +const SHORT_HASH_LENGTH = 7; + +const TreeComparePage = (): JSX.Element => { + const { formatMessage } = useIntl(); + const { treeName, branch } = useParams({ from: compareRouteName }); + const { hashA, hashB, origin, currentPageTab } = useSearch({ + from: compareRouteName, + }); + const navigate = useNavigate({ from: compareNavigateFrom }); + + const commitsQuery = useCommits({ + origin, + gitUrl: '', + gitBranch: branch, + treeName, + }); + + const revisions = useMemo( + () => + (commitsQuery.data ?? []).map(commit => ({ + hash: commit.git_commit_hash, + shortHash: commit.git_commit_hash.slice(0, SHORT_HASH_LENGTH), + commitName: commit.git_commit_name ?? '', + date: commit.last_checkout ?? commit.earliest_checkout ?? '', + })), + [commitsQuery.data], + ); + + const resolvedHashA = hashA || revisions[0]?.hash || ''; + const resolvedHashB = + hashB || + revisions.find(revision => revision.hash !== resolvedHashA)?.hash || + ''; + + const compareQuery = useTreeCompare({ + treeName, + branch, + hashA: resolvedHashA, + hashB: resolvedHashB, + origin, + }); + + const updateSearch = useCallback( + (updates: { + hashA?: string; + hashB?: string; + currentPageTab?: PossibleTabs; + }) => { + navigate({ + search: previous => ({ + ...previous, + hashA: updates.hashA ?? previous.hashA, + hashB: updates.hashB ?? previous.hashB, + currentPageTab: + updates.currentPageTab ?? + previous.currentPageTab ?? + 'global.builds', + }), + params: { treeName, branch }, + }); + }, + [navigate, treeName, branch], + ); + + useEffect(() => { + if ((!hashA || !hashB) && resolvedHashA && resolvedHashB) { + updateSearch({ hashA: resolvedHashA, hashB: resolvedHashB }); + } + }, [hashA, hashB, resolvedHashA, resolvedHashB, updateSearch]); + + const handleSuggestion = useCallback( + (suggestion: 'previous' | 'branchHead' | 'swap') => { + if (suggestion === 'swap') { + updateSearch({ hashA: resolvedHashB, hashB: resolvedHashA }); + return; + } + + if (suggestion === 'previous') { + const currentIndex = revisions.findIndex(r => r.hash === resolvedHashB); + const previousIndex = Math.min( + revisions.length - 1, + Math.max(currentIndex, 0) + 1, + ); + updateSearch({ + hashA: resolvedHashB, + hashB: revisions[previousIndex]?.hash ?? resolvedHashA, + }); + return; + } + + updateSearch({ + hashA: resolvedHashB, + hashB: revisions[0]?.hash ?? resolvedHashA, + }); + }, + [revisions, resolvedHashA, resolvedHashB, updateSearch], + ); + + const tabs: ITabItem[] = useMemo( + () => [ + { + name: 'global.builds', + content: ( + + ), + }, + { + name: 'global.boots', + content: ( + + ), + }, + { + name: 'global.tests', + content: ( + + ), + }, + ], + [compareQuery.data?.groups], + ); + + const pageTitle = formatMessage( + { id: 'title.treeCompare' }, + { treeName: `${treeName}/${branch}` }, + ); + + return ( + +
+ + + + s}> + + + + + + s} + > + + + + + + + + + + + + +
+

{pageTitle}

+

+ +

+
+ +
+
+
+ {treeName} + · + {branch} +
+ s} + > + + +
+ + updateSearch({ hashA: value })} + onHashBChange={value => updateSearch({ hashB: value })} + onSuggestion={handleSuggestion} + /> + +
+ + + {compareQuery.data && ( + <> + + +
+

+ +

+ + updateSearch({ currentPageTab: value as PossibleTabs }) + } + /> +
+ + )} +
+
+
+ ); +}; + +export default TreeComparePage; diff --git a/dashboard/src/pages/TreeCompare/components/CompareDeltaTable.tsx b/dashboard/src/pages/TreeCompare/components/CompareDeltaTable.tsx new file mode 100644 index 000000000..b499699df --- /dev/null +++ b/dashboard/src/pages/TreeCompare/components/CompareDeltaTable.tsx @@ -0,0 +1,96 @@ +import type { JSX } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +import type { CompareGroupRow } from '@/types/tree/TreeCompare'; + +import { cn } from '@/lib/utils'; + +import { DeltaPair, StatusCountsDisplay } from './CompareStatusDisplay'; + +interface CompareDeltaTableProps { + rows: CompareGroupRow[]; + groupColumnLabelId: 'treeCompare.group.builds' | 'treeCompare.group.boots' | 'treeCompare.group.tests'; +} + +export function CompareDeltaTable({ + rows, + groupColumnLabelId, +}: CompareDeltaTableProps): JSX.Element { + const sortedRows = [...rows].sort((a, b) => { + const aImpact = Math.abs(a.delta.pass) + Math.abs(a.delta.fail); + const bImpact = Math.abs(b.delta.pass) + Math.abs(b.delta.fail); + if (aImpact !== bImpact) { + return bImpact - aImpact; + } + return a.label.localeCompare(b.label); + }); + + return ( +
+ + + + + + + + + + + + + + + + + + + + {sortedRows.map(row => { + const hasChange = row.delta.pass !== 0 || row.delta.fail !== 0; + + return ( + + + {row.label} + + +
+ +
+
+ + +
+ +
+
+ +
+ +
+
+
+ ); + })} +
+
+
+ ); +} diff --git a/dashboard/src/pages/TreeCompare/components/CompareStatusDisplay.tsx b/dashboard/src/pages/TreeCompare/components/CompareStatusDisplay.tsx new file mode 100644 index 000000000..6ac91be1e --- /dev/null +++ b/dashboard/src/pages/TreeCompare/components/CompareStatusDisplay.tsx @@ -0,0 +1,88 @@ +import type { JSX } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { GroupedTestStatus } from '@/components/Status/Status'; + +import type { CompareStatusCounts } from '@/types/tree/TreeCompare'; + +import { cn } from '@/lib/utils'; + +function formatSigned(n: number): string { + if (n === 0) { + return '0'; + } + return n > 0 ? `+${n.toLocaleString()}` : n.toLocaleString(); +} + +export function StatusCountsDisplay({ + counts, + hideInconclusive = false, +}: { + counts: CompareStatusCounts; + hideInconclusive?: boolean; +}): JSX.Element { + return ( + + ); +} + +export function DeltaPair({ + passDelta, + failDelta, + className, +}: { + passDelta: number; + failDelta: number; + className?: string; +}): JSX.Element { + const passColor = + passDelta === 0 + ? 'text-dim-gray' + : passDelta > 0 + ? 'text-dark-green' + : 'text-red'; + const failColor = + failDelta === 0 + ? 'text-dim-gray' + : failDelta > 0 + ? 'text-red' + : 'text-dark-green'; + + return ( +
+ + :{' '} + {formatSigned(passDelta)} + + + :{' '} + {formatSigned(failDelta)} + +
+ ); +} + +export function CountsWithLabel({ + label, + counts, +}: { + label: string; + counts: CompareStatusCounts; +}): JSX.Element { + return ( +
+ + {label} + + +
+ ); +} diff --git a/dashboard/src/pages/TreeCompare/components/CompareSummary.tsx b/dashboard/src/pages/TreeCompare/components/CompareSummary.tsx new file mode 100644 index 000000000..1614a599c --- /dev/null +++ b/dashboard/src/pages/TreeCompare/components/CompareSummary.tsx @@ -0,0 +1,95 @@ +import type { JSX } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import type { CompareEntitySummary } from '@/types/tree/TreeCompare'; + +import { + CountsWithLabel, + DeltaPair, + StatusCountsDisplay, +} from './CompareStatusDisplay'; + +interface CompareSummaryCardProps { + titleId: 'global.builds' | 'global.boots' | 'global.tests'; + summary: CompareEntitySummary; +} + +function CompareSummaryCard({ + titleId, + summary, +}: CompareSummaryCardProps): JSX.Element { + const hasChange = summary.delta.pass !== 0 || summary.delta.fail !== 0; + + return ( +
+

+ +

+ +
+ + +
+ + + + +
+
+ +
+
+ + : + + + + + : + + +
+ {hasChange && ( + + + + )} +
+
+ ); +} + +interface CompareSummaryProps { + builds: CompareEntitySummary; + boots: CompareEntitySummary; + tests: CompareEntitySummary; +} + +export function CompareSummary({ + builds, + boots, + tests, +}: CompareSummaryProps): JSX.Element { + return ( +
+

+ +

+
+ + + +
+
+ ); +} diff --git a/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx b/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx new file mode 100644 index 000000000..b5d938719 --- /dev/null +++ b/dashboard/src/pages/TreeCompare/components/RevisionSelector.tsx @@ -0,0 +1,161 @@ +import type { JSX } from 'react'; + +import { ArrowLeftRight } from 'lucide-react'; +import { FormattedMessage } from 'react-intl'; + +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; + +import type { CompareRevision } from '@/types/tree/TreeCompare'; + +import { cn } from '@/lib/utils'; + +type RevisionSide = 'A' | 'B'; + +function RevisionCard({ + side, + selectedHash, + revisions, + onSelect, +}: { + side: RevisionSide; + selectedHash: string; + revisions: CompareRevision[]; + onSelect: (hash: string) => void; +}): JSX.Element { + const selected = revisions.find(r => r.hash === selectedHash); + + return ( +
+
+ + {side} + + + + +
+ + + + {selected && ( +
+ {selected.commitName && ( +

+ {selected.commitName} +

+ )} +

{selected.shortHash}

+

{selected.date}

+
+ )} +
+ ); +} + +interface RevisionSelectorBarProps { + hashA: string; + hashB: string; + revisions: CompareRevision[]; + onHashAChange: (hash: string) => void; + onHashBChange: (hash: string) => void; + onSuggestion: (suggestion: 'previous' | 'branchHead' | 'swap') => void; +} + +export function RevisionSelectorBar({ + hashA, + hashB, + revisions, + onHashAChange, + onHashBChange, + onSuggestion, +}: RevisionSelectorBarProps): JSX.Element { + return ( +
+
+ + +
+
+ +
+
+ + +
+ +
+ + : + + + + +
+
+ ); +} diff --git a/dashboard/src/pages/TreeDetails/TreeDetails.tsx b/dashboard/src/pages/TreeDetails/TreeDetails.tsx index 2374dbe4b..be4020be2 100644 --- a/dashboard/src/pages/TreeDetails/TreeDetails.tsx +++ b/dashboard/src/pages/TreeDetails/TreeDetails.tsx @@ -69,6 +69,8 @@ import { isEmptyObject } from '@/utils/utils'; import { sanitizeTreeinfo } from '@/utils/treeDetails'; +import { TreeCompareLink } from '@/pages/TreeCompare/TreeCompareLink'; + import TreeDetailsFilter from './TreeDetailsFilter'; import TreeDetailsTab from './Tabs/TreeDetailsTab'; @@ -411,7 +413,7 @@ const TreeDetails = ({ -
+
+ {sanitizedTreeInfo.treeName && sanitizedTreeInfo.gitBranch && ( +
+ +
+ )}
MainalternativesBBuildIdRouteRoute, } as any) +const MainTreeTreeNameBranchCompareRouteRoute = + MainTreeTreeNameBranchCompareRouteRouteImport.update({ + id: '/$treeName/$branch/compare', + path: '/$treeName/$branch/compare', + getParentRoute: () => MainTreeRouteRoute, + } as any) const MainTreeTreeNameBranchHashRouteRoute = MainTreeTreeNameBranchHashRouteRouteImport.update({ id: '/$treeName/$branch/$hash', path: '/$treeName/$branch/$hash', getParentRoute: () => MainTreeRouteRoute, } as any) +const MainTreeTreeNameBranchCompareIndexRoute = + MainTreeTreeNameBranchCompareIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => MainTreeTreeNameBranchCompareRouteRoute, + } as any) const MainTreeTreeNameBranchHashIndexRoute = MainTreeTreeNameBranchHashIndexRouteImport.update({ id: '/', @@ -324,6 +338,7 @@ export interface FileRoutesByFullPath { '/test/$testId/': typeof MainTestTestIdIndexRoute '/tree/$treeId/': typeof MainTreeTreeIdIndexRoute '/tree/$treeName/$branch/$hash': typeof MainTreeTreeNameBranchHashRouteRouteWithChildren + '/tree/$treeName/$branch/compare': typeof MainTreeTreeNameBranchCompareRouteRouteWithChildren '/b/$buildId/': typeof MainalternativesBBuildIdIndexRoute '/i/$issueId/': typeof MainalternativesIIssueIdIndexRoute '/t/$testId/': typeof MainalternativesTTestIdIndexRoute @@ -340,6 +355,7 @@ export interface FileRoutesByFullPath { '/tree/$treeId/build/$buildId/': typeof MainTreeTreeIdBuildBuildIdIndexRoute '/tree/$treeId/test/$testId/': typeof MainTreeTreeIdTestTestIdIndexRoute '/tree/$treeName/$branch/$hash/': typeof MainTreeTreeNameBranchHashIndexRoute + '/tree/$treeName/$branch/compare/': typeof MainTreeTreeNameBranchCompareIndexRoute '/c/$treeName/$branch/$hash/': typeof MainalternativesCTreeNameBranchHashIndexRoute } export interface FileRoutesByTo { @@ -372,6 +388,7 @@ export interface FileRoutesByTo { '/tree/$treeId/build/$buildId': typeof MainTreeTreeIdBuildBuildIdIndexRoute '/tree/$treeId/test/$testId': typeof MainTreeTreeIdTestTestIdIndexRoute '/tree/$treeName/$branch/$hash': typeof MainTreeTreeNameBranchHashIndexRoute + '/tree/$treeName/$branch/compare': typeof MainTreeTreeNameBranchCompareIndexRoute '/c/$treeName/$branch/$hash': typeof MainalternativesCTreeNameBranchHashIndexRoute } export interface FileRoutesById { @@ -403,6 +420,7 @@ export interface FileRoutesById { '/_main/test/$testId/': typeof MainTestTestIdIndexRoute '/_main/tree/$treeId/': typeof MainTreeTreeIdIndexRoute '/_main/tree/$treeName/$branch/$hash': typeof MainTreeTreeNameBranchHashRouteRouteWithChildren + '/_main/tree/$treeName/$branch/compare': typeof MainTreeTreeNameBranchCompareRouteRouteWithChildren '/_main/(alternatives)/b/$buildId/': typeof MainalternativesBBuildIdIndexRoute '/_main/(alternatives)/i/$issueId/': typeof MainalternativesIIssueIdIndexRoute '/_main/(alternatives)/t/$testId/': typeof MainalternativesTTestIdIndexRoute @@ -419,6 +437,7 @@ export interface FileRoutesById { '/_main/tree/$treeId/build/$buildId/': typeof MainTreeTreeIdBuildBuildIdIndexRoute '/_main/tree/$treeId/test/$testId/': typeof MainTreeTreeIdTestTestIdIndexRoute '/_main/tree/$treeName/$branch/$hash/': typeof MainTreeTreeNameBranchHashIndexRoute + '/_main/tree/$treeName/$branch/compare/': typeof MainTreeTreeNameBranchCompareIndexRoute '/_main/(alternatives)/c/$treeName/$branch/$hash/': typeof MainalternativesCTreeNameBranchHashIndexRoute } export interface FileRouteTypes { @@ -450,6 +469,7 @@ export interface FileRouteTypes { | '/test/$testId/' | '/tree/$treeId/' | '/tree/$treeName/$branch/$hash' + | '/tree/$treeName/$branch/compare' | '/b/$buildId/' | '/i/$issueId/' | '/t/$testId/' @@ -466,6 +486,7 @@ export interface FileRouteTypes { | '/tree/$treeId/build/$buildId/' | '/tree/$treeId/test/$testId/' | '/tree/$treeName/$branch/$hash/' + | '/tree/$treeName/$branch/compare/' | '/c/$treeName/$branch/$hash/' fileRoutesByTo: FileRoutesByTo to: @@ -498,6 +519,7 @@ export interface FileRouteTypes { | '/tree/$treeId/build/$buildId' | '/tree/$treeId/test/$testId' | '/tree/$treeName/$branch/$hash' + | '/tree/$treeName/$branch/compare' | '/c/$treeName/$branch/$hash' id: | '__root__' @@ -528,6 +550,7 @@ export interface FileRouteTypes { | '/_main/test/$testId/' | '/_main/tree/$treeId/' | '/_main/tree/$treeName/$branch/$hash' + | '/_main/tree/$treeName/$branch/compare' | '/_main/(alternatives)/b/$buildId/' | '/_main/(alternatives)/i/$issueId/' | '/_main/(alternatives)/t/$testId/' @@ -544,6 +567,7 @@ export interface FileRouteTypes { | '/_main/tree/$treeId/build/$buildId/' | '/_main/tree/$treeId/test/$testId/' | '/_main/tree/$treeName/$branch/$hash/' + | '/_main/tree/$treeName/$branch/compare/' | '/_main/(alternatives)/c/$treeName/$branch/$hash/' fileRoutesById: FileRoutesById } @@ -793,6 +817,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainalternativesBBuildIdIndexRouteImport parentRoute: typeof MainalternativesBBuildIdRouteRoute } + '/_main/tree/$treeName/$branch/compare': { + id: '/_main/tree/$treeName/$branch/compare' + path: '/$treeName/$branch/compare' + fullPath: '/tree/$treeName/$branch/compare' + preLoaderRoute: typeof MainTreeTreeNameBranchCompareRouteRouteImport + parentRoute: typeof MainTreeRouteRoute + } '/_main/tree/$treeName/$branch/$hash': { id: '/_main/tree/$treeName/$branch/$hash' path: '/$treeName/$branch/$hash' @@ -800,6 +831,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainTreeTreeNameBranchHashRouteRouteImport parentRoute: typeof MainTreeRouteRoute } + '/_main/tree/$treeName/$branch/compare/': { + id: '/_main/tree/$treeName/$branch/compare/' + path: '/' + fullPath: '/tree/$treeName/$branch/compare/' + preLoaderRoute: typeof MainTreeTreeNameBranchCompareIndexRouteImport + parentRoute: typeof MainTreeTreeNameBranchCompareRouteRoute + } '/_main/tree/$treeName/$branch/$hash/': { id: '/_main/tree/$treeName/$branch/$hash/' path: '/' @@ -962,10 +1000,26 @@ const MainTreeTreeNameBranchHashRouteRouteWithChildren = MainTreeTreeNameBranchHashRouteRouteChildren, ) +interface MainTreeTreeNameBranchCompareRouteRouteChildren { + MainTreeTreeNameBranchCompareIndexRoute: typeof MainTreeTreeNameBranchCompareIndexRoute +} + +const MainTreeTreeNameBranchCompareRouteRouteChildren: MainTreeTreeNameBranchCompareRouteRouteChildren = + { + MainTreeTreeNameBranchCompareIndexRoute: + MainTreeTreeNameBranchCompareIndexRoute, + } + +const MainTreeTreeNameBranchCompareRouteRouteWithChildren = + MainTreeTreeNameBranchCompareRouteRoute._addFileChildren( + MainTreeTreeNameBranchCompareRouteRouteChildren, + ) + interface MainTreeRouteRouteChildren { MainTreeTreeIdRouteRoute: typeof MainTreeTreeIdRouteRouteWithChildren MainTreeIndexRoute: typeof MainTreeIndexRoute MainTreeTreeNameBranchHashRouteRoute: typeof MainTreeTreeNameBranchHashRouteRouteWithChildren + MainTreeTreeNameBranchCompareRouteRoute: typeof MainTreeTreeNameBranchCompareRouteRouteWithChildren MainTreeTreeNameBranchIndexRoute: typeof MainTreeTreeNameBranchIndexRoute } @@ -974,6 +1028,8 @@ const MainTreeRouteRouteChildren: MainTreeRouteRouteChildren = { MainTreeIndexRoute: MainTreeIndexRoute, MainTreeTreeNameBranchHashRouteRoute: MainTreeTreeNameBranchHashRouteRouteWithChildren, + MainTreeTreeNameBranchCompareRouteRoute: + MainTreeTreeNameBranchCompareRouteRouteWithChildren, MainTreeTreeNameBranchIndexRoute: MainTreeTreeNameBranchIndexRoute, } diff --git a/dashboard/src/routes/_main/tree/$treeName/$branch/compare/index.tsx b/dashboard/src/routes/_main/tree/$treeName/$branch/compare/index.tsx new file mode 100644 index 000000000..79f114e00 --- /dev/null +++ b/dashboard/src/routes/_main/tree/$treeName/$branch/compare/index.tsx @@ -0,0 +1,9 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import TreeComparePage from '@/pages/TreeCompare/TreeComparePage'; + +export const Route = createFileRoute( + '/_main/tree/$treeName/$branch/compare/', +)({ + component: TreeComparePage, +}); diff --git a/dashboard/src/routes/_main/tree/$treeName/$branch/compare/route.tsx b/dashboard/src/routes/_main/tree/$treeName/$branch/compare/route.tsx new file mode 100644 index 000000000..eb23dbb98 --- /dev/null +++ b/dashboard/src/routes/_main/tree/$treeName/$branch/compare/route.tsx @@ -0,0 +1,13 @@ +import { createFileRoute, stripSearchParams } from '@tanstack/react-router'; + +import { + compareDefaultValues, + compareSearchSchema, +} from '@/types/tree/TreeCompare'; + +export const Route = createFileRoute( + '/_main/tree/$treeName/$branch/compare', +)({ + validateSearch: compareSearchSchema, + search: { middlewares: [stripSearchParams(compareDefaultValues)] }, +}); diff --git a/dashboard/src/types/tree/TreeCompare.ts b/dashboard/src/types/tree/TreeCompare.ts new file mode 100644 index 000000000..7a7692cb1 --- /dev/null +++ b/dashboard/src/types/tree/TreeCompare.ts @@ -0,0 +1,76 @@ +import { z } from 'zod'; + +import { possibleTabs } from '@/types/tree/TreeDetails'; + +export type CompareStatusCounts = { + pass: number; + fail: number; + inconclusive: number; +}; + +export type CompareDelta = { + pass: number; + fail: number; +}; + +export type CompareEntitySummary = { + sideA: CompareStatusCounts; + sideB: CompareStatusCounts; + delta: CompareDelta; +}; + +export type CompareGroupRow = { + id: string; + label: string; + sideA: CompareStatusCounts; + sideB: CompareStatusCounts; + delta: CompareDelta; +}; + +export type CompareRevision = { + hash: string; + shortHash: string; + commitName: string; + date: string; +}; + +export type TreeCompareData = { + treeName: string; + branch: string; + gitUrl: string; + summary: { + builds: CompareEntitySummary; + boots: CompareEntitySummary; + tests: CompareEntitySummary; + }; + groups: { + builds: CompareGroupRow[]; + boots: CompareGroupRow[]; + tests: CompareGroupRow[]; + }; +}; + +export const compareDefaultValues = { + hashA: '', + hashB: '', + origin: 'maestro', + currentPageTab: 'global.builds' as const, +}; + +export const compareSearchSchema = z.object({ + hashA: z.string().catch(''), + hashB: z.string().catch(''), + origin: z + .string() + .default(compareDefaultValues.origin) + .catch(compareDefaultValues.origin), + currentPageTab: z + .enum(possibleTabs) + .default(compareDefaultValues.currentPageTab) + .catch(compareDefaultValues.currentPageTab), +}); + +export type CompareSearch = z.infer; + +export const compareRouteName = '/_main/tree/$treeName/$branch/compare'; +export const compareNavigateFrom = '/tree/$treeName/$branch/compare'; diff --git a/dashboard/src/types/tree/TreeDetails.tsx b/dashboard/src/types/tree/TreeDetails.tsx index b3afc6702..acfef4f22 100644 --- a/dashboard/src/types/tree/TreeDetails.tsx +++ b/dashboard/src/types/tree/TreeDetails.tsx @@ -186,7 +186,9 @@ export type PaginatedCommitHistoryByTree = { export type Commit = { git_commit_hash: string; - earliest_checkout: string; + git_commit_name?: string | null; + earliest_checkout?: string; + last_checkout?: string; }; export type BuildCountsResponse = {