From c4f3b6dee01e4be1880594018e769b2186a973e7 Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Thu, 23 Apr 2026 18:38:38 -0300 Subject: [PATCH 1/6] fix: optimize TreeCommitHistory endpoint and add tree commits list view * Improve query performance for commit history * Include platform filter on query to speedup plot on hardware page * Add TreeCommitsListView and type models * Update frontend API and CommitNavigationGraph * Include tree_name and git_url on frontend requests --- .../kernelCI_app/constants/localization.py | 4 + backend/kernelCI_app/helpers/database.py | 12 + backend/kernelCI_app/queries/duration.py | 43 +++ backend/kernelCI_app/queries/hardware.py | 58 +--- backend/kernelCI_app/queries/tree.py | 266 +++++++++++++++++- .../treeCommitsHistoryList_test.py | 110 ++++++++ .../integrationTests/treeCommitsList_test.py | 66 +++++ .../tests/unitTests/queries/tree_test.py | 30 -- .../views/treeCommitsHistory_test.py | 125 ++++++++ .../views/treeCommitsListView_test.py | 83 ++++++ .../tests/utils/client/treeClient.py | 18 ++ .../kernelCI_app/typeModels/treeCommits.py | 42 +++ .../kernelCI_app/typeModels/treeListing.py | 26 +- backend/kernelCI_app/urls.py | 10 + .../kernelCI_app/views/treeCommitsHistory.py | 216 +++++++++++++- .../kernelCI_app/views/treeCommitsListView.py | 51 ++++ backend/schema.yml | 179 +++++++++++- dashboard/src/api/commitHistory.ts | 54 +++- .../CommitNavigationGraph.tsx | 18 +- .../pages/TreeDetails/Tabs/Boots/BootsTab.tsx | 3 + .../pages/TreeDetails/Tabs/Build/BuildTab.tsx | 3 + .../pages/TreeDetails/Tabs/Tests/TestsTab.tsx | 3 + .../Tabs/TreeCommitNavigationGraph.tsx | 32 ++- .../Tabs/HardwareCommitNavigationGraph.tsx | 56 +++- dashboard/src/types/tree/TreeDetails.tsx | 7 + 25 files changed, 1395 insertions(+), 120 deletions(-) create mode 100644 backend/kernelCI_app/queries/duration.py create mode 100644 backend/kernelCI_app/tests/integrationTests/treeCommitsHistoryList_test.py create mode 100644 backend/kernelCI_app/tests/integrationTests/treeCommitsList_test.py create mode 100644 backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py create mode 100644 backend/kernelCI_app/views/treeCommitsListView.py diff --git a/backend/kernelCI_app/constants/localization.py b/backend/kernelCI_app/constants/localization.py index d63dd24fc..d0ebbc828 100644 --- a/backend/kernelCI_app/constants/localization.py +++ b/backend/kernelCI_app/constants/localization.py @@ -111,6 +111,10 @@ class DocStrings: TREE_LATEST_COMMIT_HASH_DESCRIPTION = "Commit hash to retrieve tree information" TREE_LATEST_ORIGIN_DESCRIPTION = "Origin filter to retrieve tree information" + TREE_LIST_COMMIT_HASH_DESCRIPTION = ( + "Comma-separated list of commit hashes to get history for" + ) + TREE_QUERY_ORIGIN_DESCRIPTION = "Origin of the tree" TREE_QUERY_GIT_URL_DESCRIPTION = "Git repository URL of the tree" diff --git a/backend/kernelCI_app/helpers/database.py b/backend/kernelCI_app/helpers/database.py index 8e3feafd6..4c1516dff 100644 --- a/backend/kernelCI_app/helpers/database.py +++ b/backend/kernelCI_app/helpers/database.py @@ -6,3 +6,15 @@ def dict_fetchall(cursor) -> list[dict]: """ columns = [col[0] for col in cursor.description] return [dict(zip(columns, row, strict=False)) for row in cursor.fetchall()] + + +def debug_query(cursor, query, params) -> tuple[str, str]: + sql = cursor.mogrify(query, params) + profile = "\n".join( + row for row, *_ in cursor.execute(f"EXPLAIN (ANALYZE, BUFFERS) {query}", params) + ) + return (sql, profile) + + +def print_debug_query(cursor, query, params): + print("{}\n{}\n".format(*debug_query(cursor, query, params))) diff --git a/backend/kernelCI_app/queries/duration.py b/backend/kernelCI_app/queries/duration.py new file mode 100644 index 000000000..cd655c022 --- /dev/null +++ b/backend/kernelCI_app/queries/duration.py @@ -0,0 +1,43 @@ +from typing import Optional + + +def get_build_duration_clause( + builds_duration: tuple[Optional[int], Optional[int]], +) -> str: + clause = "" + duration_min, duration_max = builds_duration + if duration_min is not None: + clause += "AND builds.duration >= %(build_duration_min)s\n" + if duration_max is not None: + clause += "AND builds.duration <= %(build_duration_max)s\n" + return clause + + +def get_boot_test_duration_clause( + boots_duration: tuple[Optional[int], Optional[int]], + tests_duration: tuple[Optional[int], Optional[int]], +) -> str: + clause = "" + duration_min, duration_max = tests_duration + if duration_min is not None: + clause += ( + "AND ((tests.path like 'boot.%%' or tests.path = 'boot') " + "OR tests.duration >= %(test_duration_min)s)\n" + ) + if duration_max is not None: + clause += ( + "AND ((tests.path like 'boot.%%' or tests.path = 'boot') " + "OR tests.duration <= %(test_duration_max)s)\n" + ) + duration_min, duration_max = boots_duration + if duration_min is not None: + clause += ( + "AND (NOT (tests.path like 'boot.%%' or tests.path = 'boot') " + "OR tests.duration >= %(boot_duration_min)s)\n" + ) + if duration_max is not None: + clause += ( + "AND (NOT (tests.path like 'boot.%%' or tests.path = 'boot') " + "OR tests.duration <= %(boot_duration_max)s)\n" + ) + return clause diff --git a/backend/kernelCI_app/queries/hardware.py b/backend/kernelCI_app/queries/hardware.py index 9fb34b4b7..1bb410747 100644 --- a/backend/kernelCI_app/queries/hardware.py +++ b/backend/kernelCI_app/queries/hardware.py @@ -5,6 +5,10 @@ from kernelCI_app.cache import get_query_cache, set_query_cache from kernelCI_app.helpers.database import dict_fetchall +from kernelCI_app.queries.duration import ( + get_boot_test_duration_clause, + get_build_duration_clause, +) from kernelCI_app.typeModels.hardwareDetails import CommitHead, Tree @@ -340,56 +344,6 @@ def get_hardware_details_data( return records -def _get_build_duration_clause( - builds_duration: tuple[Optional[int], Optional[int]], -) -> str: - clause = "" - - # builds - duration_min, duration_max = builds_duration - if duration_min: - clause += "AND builds.duration >= %(build_duration_min)s\n" - if duration_max: - clause += "AND builds.duration <= %(build_duration_max)s\n" - - return clause - - -def _get_boot_test_duration_clause( - boots_duration: tuple[Optional[int], Optional[int]], - tests_duration: tuple[Optional[int], Optional[int]], -) -> str: - clause = "" - - # tests - duration_min, duration_max = tests_duration - if duration_min: - clause += ( - "AND ((tests.path like 'boot.%%' or tests.path = 'boot') " - "OR tests.duration >= %(test_duration_min)s)\n" - ) - if duration_max: - clause += ( - "AND ((tests.path like 'boot.%%' or tests.path = 'boot') " - "OR tests.duration <= %(test_duration_max)s)\n" - ) - - # boots - duration_min, duration_max = boots_duration - if duration_min: - clause += ( - "AND (NOT (tests.path like 'boot.%%' or tests.path = 'boot') " - "OR tests.duration >= %(boot_duration_min)s)\n" - ) - if duration_max: - clause += ( - "AND (NOT (tests.path like 'boot.%%' or tests.path = 'boot') " - "OR tests.duration <= %(boot_duration_max)s)\n" - ) - - return clause - - def get_hardware_details_summary( *, hardware_id: str, @@ -427,8 +381,8 @@ def get_hardware_details_summary( if query_rows is not None: return query_rows - builds_duration_clause = _get_build_duration_clause(builds_duration) - boots_tests_duration_clause = _get_boot_test_duration_clause( + builds_duration_clause = get_build_duration_clause(builds_duration) + boots_tests_duration_clause = get_boot_test_duration_clause( boots_duration, tests_duration ) diff --git a/backend/kernelCI_app/queries/tree.py b/backend/kernelCI_app/queries/tree.py index a4dad5bd0..f46d7ab0a 100644 --- a/backend/kernelCI_app/queries/tree.py +++ b/backend/kernelCI_app/queries/tree.py @@ -7,6 +7,10 @@ from kernelCI_app.helpers.database import dict_fetchall from kernelCI_app.helpers.treeDetails import create_checkouts_where_clauses from kernelCI_app.models import Checkouts +from kernelCI_app.queries.duration import ( + get_boot_test_duration_clause, + get_build_duration_clause, +) from kernelCI_app.utils import get_query_time_interval @@ -895,6 +899,263 @@ def _create_selected_checkouts_clause( return selected_checkouts_clause +def get_tree_commits( + *, + origin: Optional[str], + git_url: Optional[str], + git_branch: Optional[str], + tree_name: Optional[str], +): + cache_key = "treeCommits" + + params = { + "git_repository_url": git_url, + "git_branch": git_branch, + "tree_name": tree_name, + "origin": origin, + } + + rows = get_query_cache(cache_key, params) + if rows is not None: + return rows + + if origin: + origin_clause = "\nAND origin = %(origin)s" + else: + origin_clause = "\nAND origin IS NULL" + + url_clause = "" + if git_url: + url_clause = "\nAND git_repository_url = %(git_repository_url)s" + + query = f""" + select + git_commit_hash, + max(start_time) as start_time_end + from + checkouts + where + tree_name = %(tree_name)s + and git_repository_branch = %(git_branch)s + {url_clause} + {origin_clause} + group by + git_commit_hash + order by + start_time_end desc; + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor) + set_query_cache(key=cache_key, params=params, rows=rows) + return rows + + +def union_all(queries: list[str]) -> str: + return " UNION ALL ".join(f"({query})" for query in queries) + + +def _get_platform_filter_clause(platform_filter: Optional[list[str]]) -> str: + if platform_filter: + return """ + AND (tests.environment_compatible && %(platform)s::text[] + OR tests.environment_misc->>'platform' = ANY(%(platform)s::text[])) + """ + return "" + + +def _get_builds_platform_filter_clause(platform_filter: Optional[list[str]]) -> str: + if platform_filter: + return """ + AND EXISTS ( + SELECT 1 FROM tests + WHERE tests.build_id = builds.id + AND (tests.environment_compatible && %(platform)s::text[] + OR tests.environment_misc->>'platform' = ANY(%(platform)s::text[])) + ) + """ + return "" + + +def get_tree_commit_history_hashes_aggregated( + *, + commit_hashes: list[str], + origin: str, + git_url: Optional[str], + git_branch: Optional[str], + tree_name: Optional[str], + platform_filter: list[str] = None, + include_types: Optional[list[str]] = None, + builds_duration: tuple[Optional[int], Optional[int]] = (None, None), + boots_duration: tuple[Optional[int], Optional[int]] = (None, None), + tests_duration: tuple[Optional[int], Optional[int]] = (None, None), +) -> list[dict]: + + if not commit_hashes: + return [] + + if not include_types: + include_types = ["builds", "boots", "tests"] + + include_types = [t.lower() for t in include_types] + + build_duration_min, build_duration_max = builds_duration + boot_duration_min, boot_duration_max = boots_duration + test_duration_min, test_duration_max = tests_duration + + build_duration_clause = get_build_duration_clause(builds_duration) + boots_tests_duration_clause = get_boot_test_duration_clause( + boots_duration, tests_duration + ) + + params = { + "commit_hashes": commit_hashes, + "origin_param": origin, + "git_url_param": git_url, + "git_branch_param": git_branch, + "tree_name": tree_name, + "platform": platform_filter, + "build_duration_min": build_duration_min, + "build_duration_max": build_duration_max, + "boot_duration_min": boot_duration_min, + "boot_duration_max": boot_duration_max, + "test_duration_min": test_duration_min, + "test_duration_max": test_duration_max, + } + + cache_key = "treeCommitHistoryHashesAggregatedNoCompatibles" + cache_params = { + **params, + "include_types": tuple(sorted(include_types)), + } + rows = get_query_cache(cache_key, cache_params) + if rows is not None: + return rows + + checkout_clauses = create_checkouts_where_clauses( + git_url=git_url, git_branch=git_branch, tree_name=tree_name + ) + + git_branch_clause = checkout_clauses.get("git_branch_clause") + tree_name_clause = checkout_clauses.get("tree_name_clause") + git_url_clause = checkout_clauses.get("git_url_clause") + tree_name_full_clause = "\nAND " + tree_name_clause if tree_name_clause else "" + git_url_full_clause = "\nAND " + git_url_clause if git_url_clause else "" + git_branch_full_clause = "\nAND " + git_branch_clause if git_branch_clause else "" + + include_builds = "builds" in include_types + include_tests = "tests" in include_types + include_boots = "boots" in include_types + + platform_filter_clause = _get_platform_filter_clause(platform_filter) + + builds_platform_filter_clause = _get_builds_platform_filter_clause(platform_filter) + + builds_query = f""" + SELECT + COUNT(DISTINCT builds.id) AS count, + c.git_commit_hash, + c.git_commit_name, + c.git_commit_tags, + c.start_time, + c.origin, + builds.status AS status, + array[builds.compiler, builds.architecture] AS compiler_arch, + builds.config_name AS config_name, + builds.misc->>'lab' AS lab, + ARRAY_AGG(DISTINCT ic.issue_id || ',' || ic.issue_version::text) AS known_issues, + true AS is_build, + false AS is_boot, + false AS is_test + FROM checkouts c + INNER JOIN builds ON c.id = builds.checkout_id + LEFT JOIN incidents ic ON builds.id = ic.build_id + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + AND c.origin = %(origin_param)s + AND builds.config_name IS NOT NULL + AND builds.id NOT LIKE 'maestro:dummy_%%' + {builds_platform_filter_clause} + {git_branch_full_clause} + {git_url_full_clause} + {tree_name_full_clause} + {build_duration_clause} + GROUP BY + c.id, + builds.status, + builds.compiler, + builds.architecture, + builds.config_name, + lab + """ + + boot_filter = "" + if include_boots and not include_tests: + boot_filter = "\nAND (tests.path ='boot' OR tests.path LIKE 'boot.%%')" + elif include_tests and not include_boots: + boot_filter = "\nAND (tests.path != 'boot' AND tests.path NOT LIKE 'boot.%%')" + + tests_query = f""" + SELECT + COUNT(DISTINCT tests.id) AS count, + c.git_commit_hash, + c.git_commit_name, + c.git_commit_tags, + c.start_time, + c.origin, + tests.status AS status, + array[builds.compiler, builds.architecture] AS compiler_arch, + builds.config_name AS config_name, + tests.misc->>'runtime' AS lab, + ARRAY_AGG(DISTINCT ic.issue_id || ',' || ic.issue_version::text) AS known_issues, + false AS is_build, + true AS is_test, + (tests.path like 'boot.%%' or tests.path = 'boot') AS is_boot + FROM checkouts c + INNER JOIN builds ON c.id = builds.checkout_id + INNER JOIN tests ON tests.build_id = builds.id {boot_filter} + LEFT JOIN incidents ic ON tests.id = ic.test_id + LEFT JOIN issues i ON ic.issue_id = i.id + WHERE + c.git_commit_hash = ANY(%(commit_hashes)s) + AND c.origin = %(origin_param)s + {platform_filter_clause} + {git_branch_full_clause} + {git_url_full_clause} + {tree_name_full_clause} + {boots_tests_duration_clause} + GROUP BY + c.id, + c.start_time, + c.origin, + tests.status, + is_boot, + builds.compiler, + builds.architecture, + builds.config_name, + lab + """ + + queries = [] + if include_builds: + queries.append(builds_query) + if include_boots or include_tests: + queries.append(tests_query) + + if not queries: + set_query_cache(key=cache_key, params=cache_params, rows=[]) + return [] + + query = union_all(queries) + + with connection.cursor() as cursor: + cursor.execute(query, params) + rows = dict_fetchall(cursor) + set_query_cache(key=cache_key, params=cache_params, rows=rows) + return rows + + def get_tree_commit_history( *, commit_hash: str, @@ -1083,10 +1344,7 @@ def get_tree_commit_history( """ with connection.cursor() as cursor: - cursor.execute( - query, - field_values, - ) + cursor.execute(query, field_values) return cursor.fetchall() diff --git a/backend/kernelCI_app/tests/integrationTests/treeCommitsHistoryList_test.py b/backend/kernelCI_app/tests/integrationTests/treeCommitsHistoryList_test.py new file mode 100644 index 000000000..797eb9a04 --- /dev/null +++ b/backend/kernelCI_app/tests/integrationTests/treeCommitsHistoryList_test.py @@ -0,0 +1,110 @@ +from http import HTTPStatus + +import pytest + +from kernelCI_app.tests.utils.asserts import assert_status_code_and_error_response +from kernelCI_app.tests.utils.client.treeClient import TreeClient +from kernelCI_app.utils import string_to_json +from requests import Response + +client = TreeClient() + + +def request_data(query: dict) -> tuple[Response, dict]: + response = client.get_tree_commits_history_list(query=query) + content = string_to_json(response.content.decode()) + return response, content + + +@pytest.mark.parametrize( + "query, status_code, has_error_body", + [ + ( + {"origin": "maestro", "commit_hashes": "invalid_hash"}, + HTTPStatus.OK, + True, + ), + ( + {"origin": "maestro"}, + HTTPStatus.BAD_REQUEST, + True, + ), + ( + { + "origin": "maestro", + "git_url": "https://android.googlesource.com/kernel/common", + "git_branch": "android-mainline", + "commit_hashes": ",".join( + [ + "ef143cc9d68aecf16ec4942e399e7699266b288f", + "fdf4d20b86285d7b4d1c2d3349a1bd1bc41b24ba", + ] + ), + }, + HTTPStatus.OK, + False, + ), + ], +) +def test_tree_commits_history_list( + query: dict, status_code: HTTPStatus, has_error_body: bool +) -> None: + response, content = request_data(query) + actual_status = response.status_code + if isinstance(status_code, list): + assert actual_status in status_code, ( + f"Expected one of {status_code}, got {actual_status}" + ) + else: + assert_status_code_and_error_response( + response=response, + content=content, + status_code=status_code, + should_error=has_error_body, + ) + + if not has_error_body and actual_status == HTTPStatus.OK: + for commit_data in content: + assert "git_commit_hash" in commit_data + assert "builds" in commit_data + assert "boots" in commit_data + assert "tests" in commit_data + + +@pytest.mark.parametrize( + "query", + [ + ( + { + "origin": "maestro", + "commit_hashes": ",".join( + [ + "ef143cc9d68aecf16ec4942e399e7699266b288f", + "fdf4d20b86285d7b4d1c2d3349a1bd1bc41b24ba", + ] + ), + "types": "builds", + } + ), + ( + { + "origin": "maestro", + "commit_hashes": ",".join( + [ + "ef143cc9d68aecf16ec4942e399e7699266b288f", + "fdf4d20b86285d7b4d1c2d3349a1bd1bc41b24ba", + ] + ), + "types": "tests", + } + ), + ], +) +def test_tree_commits_history_list_with_types(query: dict) -> None: + response, content = request_data(query) + assert_status_code_and_error_response( + response=response, + content=content, + status_code=HTTPStatus.OK, + should_error=False, + ) diff --git a/backend/kernelCI_app/tests/integrationTests/treeCommitsList_test.py b/backend/kernelCI_app/tests/integrationTests/treeCommitsList_test.py new file mode 100644 index 000000000..2578b1e00 --- /dev/null +++ b/backend/kernelCI_app/tests/integrationTests/treeCommitsList_test.py @@ -0,0 +1,66 @@ +from http import HTTPStatus + +import pytest + +from kernelCI_app.tests.utils.asserts import assert_status_code_and_error_response +from kernelCI_app.tests.utils.client.treeClient import TreeClient +from kernelCI_app.utils import string_to_json +from requests import Response + +client = TreeClient() + + +def request_data(tree_name: str, git_branch: str, query: dict) -> tuple[Response, dict]: + response = client.get_tree_commits_list( + tree_name=tree_name, git_branch=git_branch, query=query + ) + content = string_to_json(response.content.decode()) + return response, content + + +@pytest.mark.parametrize( + "tree_name, git_branch, query, status_code, has_error_body", + [ + ( + "fluster_mainline", + "master", + { + "origin": "maestro", + "git_url": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + }, + HTTPStatus.OK, + False, + ), + ( + "nonexistent", + "master", + { + "origin": "maestro", + "git_url": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + }, + HTTPStatus.OK, + True, + ), + ], +) +def test_tree_commits_list_view( + tree_name: str, + git_branch: str, + query: dict, + status_code: HTTPStatus, + has_error_body: bool, +) -> None: + response, content = request_data(tree_name, git_branch, query) + assert_status_code_and_error_response( + response=response, + content=content, + status_code=status_code, + should_error=has_error_body, + ) + + assert_status_code_and_error_response( + response=response, + content=content, + status_code=HTTPStatus.OK, + should_error=False, + ) diff --git a/backend/kernelCI_app/tests/unitTests/queries/tree_test.py b/backend/kernelCI_app/tests/unitTests/queries/tree_test.py index 380c5d015..edc3e05ab 100644 --- a/backend/kernelCI_app/tests/unitTests/queries/tree_test.py +++ b/backend/kernelCI_app/tests/unitTests/queries/tree_test.py @@ -2,7 +2,6 @@ from kernelCI_app.queries.tree import ( get_latest_tree, - get_tree_commit_history, get_tree_details_data, get_tree_listing_data, get_tree_listing_data_by_checkout_id, @@ -117,35 +116,6 @@ def test_get_tree_details_data_from_database( mock_set_cache.assert_called_once() -class TestGetTreeCommitHistory: - @patch("kernelCI_app.queries.tree.create_checkouts_where_clauses") - @patch("kernelCI_app.queries.tree.connection") - def test_get_tree_commit_history_success( - self, mock_connection, mock_create_clauses - ): - expected_result = [("abc123", "v6.1", None, "2025-11-10T10:00:00Z")] - mock_create_clauses.return_value = { - "git_branch_clause": "git_repository_branch = %(git_branch_param)s", - "tree_name_clause": "tree_name = %(tree_name)s", - "git_url_clause": "git_repository_url = %(git_url_param)s", - } - mock_cursor = setup_mock_cursor(mock_connection) - mock_cursor.fetchall.return_value = expected_result - - result = get_tree_commit_history( - commit_hash="abc123", - origin="maestro", - git_url="https://my_url.com", - git_branch="master", - tree_name="mainline", - ) - - assert result == expected_result - mock_create_clauses.assert_called_once_with( - git_url="https://my_url.com", git_branch="master", tree_name="mainline" - ) - - class TestGetLatestTree: @patch("kernelCI_app.queries.tree.Checkouts") def test_get_latest_tree_success(self, mock_checkouts_model): diff --git a/backend/kernelCI_app/tests/unitTests/views/treeCommitsHistory_test.py b/backend/kernelCI_app/tests/unitTests/views/treeCommitsHistory_test.py index 25fd1f5d5..8b86f5d21 100644 --- a/backend/kernelCI_app/tests/unitTests/views/treeCommitsHistory_test.py +++ b/backend/kernelCI_app/tests/unitTests/views/treeCommitsHistory_test.py @@ -4,9 +4,11 @@ from django.test import SimpleTestCase from rest_framework.test import APIRequestFactory +from kernelCI_app.constants.localization import ClientStrings from kernelCI_app.views.treeCommitsHistory import ( TreeCommitsHistory, TreeCommitsHistoryDirect, + TreeCommitsHistoryList, ) @@ -278,3 +280,126 @@ def test_direct_tree_details_builds_with_hardware_filter_is_not_empty( self.assertEqual(response.status_code, 200) self.assertGreater(len(response.data), 0) self.assertEqual(response.data[0]["builds"]["PASS"], 1) + + +class TestTreeCommitsHistoryList(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = TreeCommitsHistoryList() + self.url = "/api/tree/commits" + + @patch( + "kernelCI_app.views.treeCommitsHistory.get_tree_commit_history_hashes_aggregated" + ) + def test_tree_commits_history_list_success(self, mock_get_aggregated): + mock_get_aggregated.return_value = [ + { + "count": 5, + "git_commit_hash": "abc123", + "git_commit_name": "v6.1", + "git_commit_tags": ["v1"], + "start_time": datetime(2026, 2, 1, tzinfo=timezone.utc), + "origin": "maestro", + "status": "PASS", + "compiler_arch": ["gcc", "x86_64"], + "config_name": "defconfig", + "lab": "lab-a", + "known_issues": ["issue-1,1"], + "is_build": True, + "is_boot": False, + "is_test": False, + } + ] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + "commit_hashes": "abc123,def456", + }, + ) + + response = self.view.get(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data[0]["git_commit_hash"], "abc123") + self.assertEqual(response.data[0]["builds"]["PASS"], 5) + mock_get_aggregated.assert_called_once() + + @patch( + "kernelCI_app.views.treeCommitsHistory.get_tree_commit_history_hashes_aggregated" + ) + def test_tree_commits_history_list_with_types_filter(self, mock_aggregated): + mock_aggregated.return_value = [ + { + "count": 10, + "git_commit_hash": "abc123", + "git_commit_name": "v6.1", + "git_commit_tags": None, + "start_time": datetime(2026, 2, 1, tzinfo=timezone.utc), + "origin": "maestro", + "status": "FAIL", + "compiler_arch": ["gcc", "x86_64"], + "config_name": "defconfig", + "lab": "lab-a", + "known_issues": None, + "is_build": False, + "is_boot": False, + "is_test": True, + } + ] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + "commit_hashes": "abc123", + "types": "tests", + }, + ) + + response = self.view.get(request) + + self.assertEqual(response.status_code, 200) + mock_aggregated.assert_called_once() + + @patch( + "kernelCI_app.views.treeCommitsHistory.get_tree_commit_history_hashes_aggregated" + ) + def test_tree_commits_history_list_with_no_commit_hashes_returns_error( + self, mock_aggregated + ): + request = self.factory.get( + self.url, + { + "origin": "maestro", + }, + ) + + response = self.view.get(request) + + self.assertEqual(response.status_code, 400) + mock_aggregated.assert_not_called() + + @patch( + "kernelCI_app.views.treeCommitsHistory.get_tree_commit_history_hashes_aggregated" + ) + def test_tree_commits_history_list_empty_results(self, mock_aggregated): + mock_aggregated.return_value = [] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + "commit_hashes": "nonexistent_hash", + }, + ) + + response = self.view.get(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.data, + {"error": ClientStrings.TREE_COMMITS_HISTORY_NOT_FOUND}, + ) + mock_aggregated.assert_called_once() diff --git a/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py b/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py new file mode 100644 index 000000000..20b345f1d --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/treeCommitsListView_test.py @@ -0,0 +1,83 @@ +from unittest.mock import patch + +from django.test import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.views.treeCommitsListView import TreeCommitsListView + + +class TestTreeCommitsListView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = TreeCommitsListView() + self.url = "/api/tree/mainline/master/commits" + + @patch("kernelCI_app.views.treeCommitsListView.get_tree_commits") + def test_tree_commits_list_view_success(self, mock_get_commits): + mock_get_commits.return_value = [ + {"git_commit_hash": "abc123", "start_time_end": "2025-11-10T10:00:00Z"} + ] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + }, + ) + + response = self.view.get( + request, + tree_name="mainline", + git_branch="master", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.data), 1) + + @patch("kernelCI_app.views.treeCommitsListView.get_tree_commits") + def test_tree_commits_list_view_empty(self, mock_get_commits): + mock_get_commits.return_value = [] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + }, + ) + + response = self.view.get( + request, + tree_name="mainline", + git_branch="master", + ) + + self.assertEqual(response.status_code, 200) + assert "error" in response.data + + @patch("kernelCI_app.views.treeCommitsListView.get_tree_commits") + def test_tree_commits_list_view_with_git_url(self, mock_get_commits): + mock_get_commits.return_value = [ + {"git_commit_hash": "abc123", "start_time_end": "2025-11-10T10:00:00Z"} + ] + + request = self.factory.get( + self.url, + { + "origin": "maestro", + "git_url": "https://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git", + }, + ) + + response = self.view.get( + request, + tree_name="mainline", + git_branch="master", + ) + + self.assertEqual(response.status_code, 200) + mock_get_commits.assert_called_once_with( + origin="maestro", + tree_name="mainline", + git_branch="master", + git_url="https://git.kernel.org/pub/scm/linux/kernel/git/arm64/linux.git", + ) diff --git a/backend/kernelCI_app/tests/utils/client/treeClient.py b/backend/kernelCI_app/tests/utils/client/treeClient.py index db936fb37..9bdea5b57 100644 --- a/backend/kernelCI_app/tests/utils/client/treeClient.py +++ b/backend/kernelCI_app/tests/utils/client/treeClient.py @@ -117,3 +117,21 @@ def get_tree_details_specific_direct( path = reverse(base_path, kwargs=path_params.model_dump()) url = self.get_endpoint(path=path, query=query.model_dump(), filters=filters) return requests.get(url) + + def get_tree_commits_history_list(self, *, query: dict) -> requests.Response: + path = reverse("treeCommitsHistory") + url = self.get_endpoint(path=path, query=query) + return requests.get(url) + + def get_tree_commits_list( + self, *, tree_name: str, git_branch: str, query: dict + ) -> requests.Response: + path = reverse( + "treeCommitsList", + kwargs={ + "tree_name": tree_name, + "git_branch": git_branch, + }, + ) + url = self.get_endpoint(path=path, query=query) + return requests.get(url) diff --git a/backend/kernelCI_app/typeModels/treeCommits.py b/backend/kernelCI_app/typeModels/treeCommits.py index 100d37d5d..997696410 100644 --- a/backend/kernelCI_app/typeModels/treeCommits.py +++ b/backend/kernelCI_app/typeModels/treeCommits.py @@ -63,6 +63,44 @@ class TreeCommitsQueryParameters(DirectTreeCommitsQueryParameters): ) +class TreeCommitsListQueryParameters(BaseModel): + origin: Optional[str] = Field( + None, description=DocStrings.TREE_COMMIT_ORIGIN_DESCRIPTION + ) + git_url: Optional[str] = Field( + None, description=DocStrings.TREE_COMMIT_GIT_URL_DESCRIPTION + ) + + +class TreeCommitsHistoryQueryParameters(DirectTreeCommitsQueryParameters): + tree_name: Optional[str] = Field( + None, description=DocStrings.TREE_NAME_PATH_DESCRIPTION + ) + git_branch: Optional[str] = Field( + None, description=DocStrings.TREE_COMMIT_GIT_BRANCH_DESCRIPTION + ) + git_url: Optional[str] = Field( + None, description=DocStrings.TREE_COMMIT_GIT_URL_DESCRIPTION + ) + commit_hashes: list[str] = Field( + None, description=DocStrings.TREE_LIST_COMMIT_HASH_DESCRIPTION + ) + + @field_validator("commit_hashes", mode="before") + @classmethod + def validate_commit_hashes(cls, value): + if not value: + return None + if isinstance(value, str): + return [h.strip() for h in value.split(",") if h.strip()] + return value + + +class TreeCommitItem(BaseModel): + git_commit_hash: Checkout__GitCommitHash + last_checkout: Optional[datetime] = Field(None, alias="start_time_end") + + class TreeCommitsData(BaseModel): git_commit_hash: Checkout__GitCommitHash git_commit_name: Checkout__GitCommitName @@ -75,3 +113,7 @@ class TreeCommitsData(BaseModel): class TreeCommitsResponse(RootModel): root: List[TreeCommitsData] + + +class TreeCommitsListResponse(RootModel): + root: List[TreeCommitItem] diff --git a/backend/kernelCI_app/typeModels/treeListing.py b/backend/kernelCI_app/typeModels/treeListing.py index f67f5bc3c..919f6f18b 100644 --- a/backend/kernelCI_app/typeModels/treeListing.py +++ b/backend/kernelCI_app/typeModels/treeListing.py @@ -1,7 +1,8 @@ -from typing import List +from typing import List, Optional from pydantic import BaseModel, Field, RootModel +from kernelCI_app.helpers.logger import log_message from kernelCI_app.typeModels.common import StatusCount from kernelCI_app.typeModels.commonListing import StatusCountV2 from kernelCI_app.typeModels.databases import ( @@ -24,13 +25,13 @@ class TestStatusCount(BaseModel): # Disables automatic pytest test discovery for this class __test__ = False - pass_count: int = Field(alias="pass") - error_count: int = Field(alias="error") - fail_count: int = Field(alias="fail") - skip_count: int = Field(alias="skip") - miss_count: int = Field(alias="miss") - done_count: int = Field(alias="done") - null_count: int = Field(alias="null") + pass_count: int = Field(alias="pass", default=0) + error_count: int = Field(alias="error", default=0) + fail_count: int = Field(alias="fail", default=0) + skip_count: int = Field(alias="skip", default=0) + miss_count: int = Field(alias="miss", default=0) + done_count: int = Field(alias="done", default=0) + null_count: int = Field(alias="null", default=0) def __add__(self, other: "TestStatusCount") -> "TestStatusCount": return TestStatusCount( @@ -45,6 +46,15 @@ def __add__(self, other: "TestStatusCount") -> "TestStatusCount": } ) + def increment(self, status: Optional[str], count: int = 1) -> None: + if status is None: + status = "NULL" + try: + status_prop = f"{status.lower()}_count" + setattr(self, status_prop, getattr(self, status_prop) + count) + except AttributeError: + log_message(f"Unknown status: {status}") + class BaseCheckouts(BaseModel): git_repository_url: Checkout__GitRepositoryUrl diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index dc3891417..6b2ae6d49 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -57,6 +57,16 @@ def view_cache(view): view_cache(views.TreeCommitsHistory), name="treeCommits", ), + path( + "tree/commits-history", + view_cache(views.TreeCommitsHistoryList), + name="treeCommitsHistory", + ), + path( + "tree///commits", + view_cache(views.TreeCommitsListView), + name="treeCommitsList", + ), path( "tree////commits", view_cache(views.TreeCommitsHistoryDirect), diff --git a/backend/kernelCI_app/views/treeCommitsHistory.py b/backend/kernelCI_app/views/treeCommitsHistory.py index 0fe134cbf..0d1860580 100644 --- a/backend/kernelCI_app/views/treeCommitsHistory.py +++ b/backend/kernelCI_app/views/treeCommitsHistory.py @@ -18,13 +18,19 @@ from kernelCI_app.helpers.filters import ( FilterParams, InvalidComparisonOPError, + is_filtered_out, ) +from kernelCI_app.helpers.issueExtras import parse_issue from kernelCI_app.helpers.logger import log_message from kernelCI_app.helpers.misc import ( handle_misc, misc_value_or_default, ) -from kernelCI_app.queries.tree import get_tree_commit_history +from kernelCI_app.queries.tree import ( + get_tree_commit_history, + get_tree_commit_history_hashes_aggregated, +) +from kernelCI_app.typeModels.common import StatusCount from kernelCI_app.typeModels.commonOpenApiParameters import ( COMMIT_HASH_PATH_PARAM, GIT_BRANCH_PATH_PARAM, @@ -33,10 +39,13 @@ from kernelCI_app.typeModels.databases import FAIL_STATUS, NULL_STATUS, StatusValues from kernelCI_app.typeModels.treeCommits import ( DirectTreeCommitsQueryParameters, + TreeCommitsData, + TreeCommitsHistoryQueryParameters, TreeCommitsQueryParameters, TreeCommitsResponse, TreeEntityTypes, ) +from kernelCI_app.typeModels.treeListing import TestStatusCount from kernelCI_app.utils import is_boot, sanitize_dict @@ -538,3 +547,208 @@ class TreeCommitsHistory(BaseTreeCommitsHistory): ) def get(self, request, commit_hash: str) -> Response: return super().get(request=request, commit_hash=commit_hash) + + +class TreeCommitsHistoryList(BaseTreeCommitsHistory): + def get_filter_type( + self, is_build: bool, is_boot: bool, is_test: bool, **kwargs + ) -> str: + if is_build: + return "build" + if is_boot: + return "boot" + if is_test: + return "test" + raise ValueError("Invalid filter type") + + def filter_instance( + self, + *, + config: str, + lab: str, + compiler: str, + architecture: str, + status: str, + known_issues: set[str], + is_build: bool, + is_boot: bool, + is_test: bool, + ) -> bool: + filters: FilterParams = self.filterParams + filter_type = self.get_filter_type(is_build, is_boot, is_test) + status_filter_map = { + "build": filters.filterBuildStatus, + "boot": filters.filterBootStatus, + "test": filters.filterTestStatus, + } + if is_filtered_out(status, status_filter_map[filter_type]): + return True + if is_filtered_out(compiler, filters.filterCompiler): + return True + if is_filtered_out(config, filters.filterConfigs): + return True + if is_filtered_out(lab, filters.filter_labs): + return True + 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): + return True + + return False + + def aggregate_commits(self, commit_hashes: list[str], instances: list[dict]): + results = { + commit_hash: TreeCommitsData( + git_commit_hash=commit_hash, + git_commit_name="", + git_commit_tags=[], + earliest_start_time=datetime.now(timezone.utc), + builds=StatusCount(), + boots=TestStatusCount(), + tests=TestStatusCount(), + ) + for commit_hash in commit_hashes + } + + for instance in instances: + count = instance["count"] + commit_hash = instance["git_commit_hash"] + commit_name = instance["git_commit_name"] + status = instance["status"] + config = instance["config_name"] + lab = instance["lab"] + (compiler, architecture) = [ + (val or UNKNOWN_STRING).strip(" []'") + for val in (instance["compiler_arch"] or [None, None]) + ] + known_issues = instance["known_issues"] + start_time = instance["start_time"] + status = instance["status"] + commit_tags = set(instance["git_commit_tags"] or []) + known_issues = set( + [parse_issue(issue) for issue in (instance["known_issues"] or [])] + ) + is_build = instance["is_build"] + is_test = instance["is_test"] + is_boot = instance["is_boot"] + + if self.filter_instance( + config=config, + lab=lab, + compiler=compiler, + architecture=architecture, + known_issues=known_issues, + status=status, + is_build=is_build, + is_boot=is_boot, + is_test=is_test, + ): + continue + + data = results[commit_hash] + data.git_commit_hash = commit_hash + data.git_commit_name = commit_name + data.git_commit_tags = list({*data.git_commit_tags, *commit_tags}) + data.earliest_start_time = min(data.earliest_start_time, start_time) + if is_build: + data.builds.increment(status, count) + elif is_boot: + data.boots.increment(status, count) + else: + data.tests.increment(status, count) + return results + + @extend_schema( + responses=TreeCommitsResponse, + parameters=[TreeCommitsHistoryQueryParameters], + methods=["GET"], + ) + def get(self, request: HttpRequest) -> Response: + try: + params = TreeCommitsHistoryQueryParameters( + origin=request.GET.get("origin"), + git_url=request.GET.get("git_url"), + tree_name=request.GET.get("tree_name"), + git_branch=request.GET.get("git_branch"), + commit_hashes=request.GET.get("commit_hashes"), + start_timestamp_in_seconds=request.GET.get( + "start_timestamp_in_seconds" + ), + end_timestamp_in_seconds=request.GET.get("end_timestamp_in_seconds"), + types=request.GET.get("types"), + builds_related_to_filtered_tests_only=request.GET.get( + "builds_related_to_filtered_tests_only", False + ), + ) + except ValidationError as e: + return create_api_error_response(error_message=e.json()) + + commit_hashes = params.commit_hashes + if not commit_hashes: + return create_api_error_response( + error_message="commit_hashes query parameter is required", + status_code=HTTPStatus.BAD_REQUEST, + ) + + start_timestamp = params.start_timestamp_in_seconds + end_timestamp = params.end_timestamp_in_seconds + if None not in (start_timestamp, end_timestamp): + self._process_time_range( + start_timestamp=start_timestamp, end_timestamp=end_timestamp + ) + + try: + self.filterParams = FilterParams(request) + self.setup_filters() + except InvalidComparisonOPError as e: + return create_api_error_response(error_message=str(e)) + + self.builds_related_to_filtered_tests_only = ( + params.builds_related_to_filtered_tests_only + ) + + include_types = params.types + if params.types == ["builds"] and ( + self.builds_related_to_filtered_tests_only or len(self.filterHardware) > 0 + ): + include_types = ["builds", "boots", "tests"] + + commit_history_list = get_tree_commit_history_hashes_aggregated( + commit_hashes=commit_hashes, + origin=params.origin, + git_url=params.git_url, + git_branch=params.git_branch, + tree_name=params.tree_name, + include_types=include_types, + platform_filter=list(self.filterParams.filterHardware), + builds_duration=( + self.filterParams.filterBuildDurationMin, + self.filterParams.filterBuildDurationMax, + ), + boots_duration=( + self.filterParams.filterBootDurationMin, + self.filterParams.filterBootDurationMax, + ), + tests_duration=( + self.filterParams.filterTestDurationMin, + self.filterParams.filterTestDurationMax, + ), + ) + + if not commit_history_list: + return create_api_error_response( + error_message=ClientStrings.TREE_COMMITS_HISTORY_NOT_FOUND, + status_code=HTTPStatus.OK, + ) + + results = self.aggregate_commits(commit_hashes, commit_history_list) + + try: + valid_response = TreeCommitsResponse( + root=[results[commit_hash] for commit_hash in commit_hashes] + ) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return Response(valid_response.model_dump(by_alias=True)) diff --git a/backend/kernelCI_app/views/treeCommitsListView.py b/backend/kernelCI_app/views/treeCommitsListView.py new file mode 100644 index 000000000..50cd68c82 --- /dev/null +++ b/backend/kernelCI_app/views/treeCommitsListView.py @@ -0,0 +1,51 @@ +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.localization import ClientStrings +from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.queries.tree import get_tree_commits +from kernelCI_app.typeModels.treeCommits import ( + TreeCommitsListQueryParameters, + TreeCommitsListResponse, +) + + +class TreeCommitsListView(APIView): + @extend_schema( + parameters=[TreeCommitsListQueryParameters], + responses=TreeCommitsListResponse, + ) + def get(self, request: HttpRequest, tree_name: str, git_branch: str) -> Response: + + try: + query_params = TreeCommitsListQueryParameters( + origin=request.GET.get("origin"), + git_url=request.GET.get("git_url"), + ) + except ValidationError: + return create_api_error_response( + status_code=HTTPStatus.BAD_REQUEST, + error_message=ClientStrings.TREE_COMMITS_HISTORY_NOT_FOUND, + ) + + commits = get_tree_commits( + origin=query_params.origin, + tree_name=tree_name, + git_branch=git_branch, + git_url=query_params.git_url, + ) + + if not commits: + return create_api_error_response( + status_code=HTTPStatus.OK, + error_message=ClientStrings.TREE_COMMITS_HISTORY_NOT_FOUND, + ) + + result = TreeCommitsListResponse(root=commits) + + return Response(data=result.model_dump(), status=HTTPStatus.OK) diff --git a/backend/schema.yml b/backend/schema.yml index dc10acfa1..ba606c1bc 100644 --- a/backend/schema.yml +++ b/backend/schema.yml @@ -1552,7 +1552,7 @@ paths: description: '' /api/tree/{tree_name}/{git_branch}/{commit_hash}/commits: get: - operationId: tree_commits_retrieve_2 + operationId: tree_commits_retrieve_3 parameters: - in: query name: builds_related_to_filtered_tests_only @@ -1763,6 +1763,148 @@ paths: schema: $ref: '#/components/schemas/CommonDetailsTestsResponse' description: '' + /api/tree/{tree_name}/{git_branch}/commits: + get: + operationId: tree_commits_retrieve_2 + parameters: + - in: path + name: git_branch + schema: + type: string + required: true + - in: query + name: git_url + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Git Url + description: Git repository URL to retrieve the tree commits + - in: query + name: origin + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Origin + description: Origin to retrieve the tree commits + - in: path + name: tree_name + schema: + type: string + required: true + tags: + - tree + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TreeCommitsListResponse' + description: '' + /api/tree/commits-history: + get: + operationId: tree_commits_history_retrieve + parameters: + - in: query + name: builds_related_to_filtered_tests_only + schema: + default: false + title: Builds Related To Filtered Tests Only + type: boolean + description: When true, and requesting only builds, count only builds related + to tests/boots that pass the current filters. + - in: query + name: commit_hashes + schema: + default: null + items: + type: string + title: Commit Hashes + type: array + description: Comma-separated list of commit hashes to get history for + - in: query + name: end_timestamp_in_seconds + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: End Timestamp In Seconds + description: End time filter in seconds for tree commits + - in: query + name: git_branch + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Git Branch + description: Git branch name to retrieve the tree commits + - in: query + name: git_url + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Git Url + description: Git repository URL to retrieve the tree commits + - in: query + name: origin + schema: + default: maestro + title: Origin + type: string + description: Origin to retrieve the tree commits + - in: query + name: start_timestamp_in_seconds + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Start Timestamp In Seconds + description: Start time filter in seconds for tree commits + - in: query + name: tree_name + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Tree Name + description: Name of the tree + - in: query + name: types + schema: + anyOf: + - items: + $ref: '#/components/schemas/TreeEntityTypes' + type: array + - type: 'null' + default: null + title: Types + description: List of types to include (builds, boots, tests) + tags: + - tree + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/TreeCommitsResponse' + description: '' components: schemas: BuildArchitectures: @@ -3707,34 +3849,33 @@ components: TestStatusCount: properties: pass: + default: 0 title: Pass type: integer error: + default: 0 title: Error type: integer fail: + default: 0 title: Fail type: integer skip: + default: 0 title: Skip type: integer miss: + default: 0 title: Miss type: integer done: + default: 0 title: Done type: integer 'null': + default: 0 title: 'Null' type: integer - required: - - pass - - error - - fail - - skip - - miss - - done - - 'null' title: TestStatusCount type: object TestStatusHistoryItem: @@ -3970,6 +4111,21 @@ components: - is_selected title: Tree type: object + TreeCommitItem: + properties: + git_commit_hash: + $ref: '#/components/schemas/Checkout__GitCommitHash' + start_time_end: + anyOf: + - format: date-time + type: string + - type: 'null' + default: null + title: Start Time End + required: + - git_commit_hash + title: TreeCommitItem + type: object TreeCommitsData: properties: git_commit_hash: @@ -3998,6 +4154,11 @@ components: - tests title: TreeCommitsData type: object + TreeCommitsListResponse: + items: + $ref: '#/components/schemas/TreeCommitItem' + title: TreeCommitsListResponse + type: array TreeCommitsResponse: items: $ref: '#/components/schemas/TreeCommitsData' diff --git a/dashboard/src/api/commitHistory.ts b/dashboard/src/api/commitHistory.ts index 6123946f9..bfd15045d 100644 --- a/dashboard/src/api/commitHistory.ts +++ b/dashboard/src/api/commitHistory.ts @@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'; import { treeDetailsDirectRouteName } from '@/types/tree/TreeDetails'; import type { + TreeCommitsResponse, TreeDetailsRouteFrom, TTreeCommitHistoryResponse, TTreeDetailsFilter, @@ -16,7 +17,7 @@ import type { TFilter, TreeEntityTypes } from '@/types/general'; import { RequestData } from './commonRequest'; const fetchCommitHistory = async ( - commitHash: string, + commitHash: string | string[], origin: string, gitUrl: string, gitBranch: string, @@ -41,6 +42,20 @@ const fetchCommitHistory = async ( ...filtersFormatted, }; + // TODO: may be create a new function??? + if (Array.isArray(commitHash)) { + return await RequestData.get( + '/api/tree/commits-history', + { + params: { + ...params, + tree_name: treeName, + commit_hashes: commitHash.join(), + }, + }, + ); + } + const baseUrl = treeUrlFrom === treeDetailsDirectRouteName ? `/api/tree/${treeName}/${gitBranch}/${commitHash}` @@ -69,7 +84,7 @@ export const useCommitHistory = ({ types, buildsRelatedToFilteredTestsOnly, }: { - commitHash: string; + commitHash: string | string[]; origin: string; gitUrl: string; gitBranch: string; @@ -118,5 +133,40 @@ export const useCommitHistory = ({ types, buildsRelatedToFilteredTestsOnly, ), + enabled: !!commitHash?.length, + }); +}; + +const fetchCommits = async ( + origin: string, + gitUrl: string, + gitBranch: string, + treeName?: string, +): Promise => { + const params = { + origin, + git_url: gitUrl, + }; + + const url = `/api/tree/${treeName}/${gitBranch}/commits`; + const data = await RequestData.get(url, { params }); + + return data; +}; + +export const useCommits = ({ + origin, + gitUrl, + gitBranch, + treeName, +}: { + origin: string; + gitUrl: string; + gitBranch: string; + treeName?: string; +}): UseQueryResult => { + return useQuery({ + queryKey: ['treeCommits', origin, gitUrl, gitBranch, treeName], + queryFn: () => fetchCommits(origin, gitUrl, gitBranch, treeName), }); }; diff --git a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx index 8659655e9..c55bf048b 100644 --- a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx +++ b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx @@ -53,6 +53,7 @@ interface ICommitNavigationGraph { gitBranch?: string; headCommitHash?: string; treeId?: string; + commitsList: string[]; startTimestampInSeconds?: number; endTimestampInSeconds?: number; onMarkClick: (commitHash: string, commitName?: string) => void; @@ -61,6 +62,20 @@ interface ICommitNavigationGraph { buildsRelatedToFilteredTestsOnly?: boolean; } +const selectedCommits = ( + allCommits: string[] | undefined, + headCommit: string | undefined, +): string[] => { + allCommits = allCommits || []; + headCommit = headCommit || ''; + const NUM_SELECTED_COMMITS = 6; + const headIndex = allCommits.findIndex(x => x === headCommit); + if (headIndex < 0) { + return []; + } + return allCommits.slice(headIndex, headIndex + NUM_SELECTED_COMMITS); +}; + const CommitNavigationGraph = ({ origin, currentPageTab, @@ -69,6 +84,7 @@ const CommitNavigationGraph = ({ gitBranch, headCommitHash, treeId, + commitsList, onMarkClick, endTimestampInSeconds, startTimestampInSeconds, @@ -96,7 +112,7 @@ const CommitNavigationGraph = ({ const { data, status, error, isLoading } = useCommitHistory({ gitBranch: gitBranch ?? '', gitUrl: gitUrl ?? '', - commitHash: headCommitHash ?? '', + commitHash: selectedCommits(commitsList, headCommitHash), origin: origin, filter: reqFilter, endTimestampInSeconds, diff --git a/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx index 864c4db7b..9bc275961 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Boots/BootsTab.tsx @@ -259,6 +259,7 @@ const BootsTab = ({ key="commitGraph" urlFrom={urlFrom} treeName={sanitizedTreeInfo.treeName} + summaryTreeUrl={summaryData?.common.tree_url} />, ], bodyCards: [ @@ -316,6 +317,7 @@ const BootsTab = ({ hardwareData, sanitizedTreeInfo, summaryBootsData, + summaryData?.common.tree_url, toggleFilterBySection, treeDetailsLazyLoaded.issuesExtras, urlFrom, @@ -371,6 +373,7 @@ const BootsTab = ({ )} diff --git a/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx index 990886c4b..916ada30e 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Build/BuildTab.tsx @@ -209,6 +209,7 @@ const BuildTab = ({ key="commitGraph" urlFrom={urlFrom} treeName={sanitizedTreeInfo.treeName} + summaryTreeUrl={summaryData?.common.tree_url} />, ], bodyCards: [ @@ -252,6 +253,7 @@ const BuildTab = ({ }, [ diffFilter, sanitizedTreeInfo, + summaryData?.common.tree_url, toggleFilterBySection, treeDetailsData, treeDetailsLazyLoaded.issuesExtras, @@ -319,6 +321,7 @@ const BuildTab = ({ )} diff --git a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx index f961b89db..9bcd96765 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/Tests/TestsTab.tsx @@ -257,6 +257,7 @@ const TestsTab = ({ key="commitGraph" urlFrom={urlFrom} treeName={sanitizedTreeInfo.treeName} + summaryTreeUrl={summaryData?.common.tree_url} />, ], bodyCards: [ @@ -314,6 +315,7 @@ const TestsTab = ({ hardwareData, sanitizedTreeInfo, summaryTestsData, + summaryData?.common.tree_url, toggleFilterBySection, treeDetailsLazyLoaded.issuesExtras, urlFrom, @@ -370,6 +372,7 @@ const TestsTab = ({ )} diff --git a/dashboard/src/pages/TreeDetails/Tabs/TreeCommitNavigationGraph.tsx b/dashboard/src/pages/TreeDetails/Tabs/TreeCommitNavigationGraph.tsx index 7461e61c1..6d63471dd 100644 --- a/dashboard/src/pages/TreeDetails/Tabs/TreeCommitNavigationGraph.tsx +++ b/dashboard/src/pages/TreeDetails/Tabs/TreeCommitNavigationGraph.tsx @@ -2,24 +2,24 @@ import { useNavigate, useParams, useSearch } from '@tanstack/react-router'; import { useCallback, useMemo } from 'react'; +import { useCommits } from '@/api/commitHistory'; import CommitNavigationGraph from '@/components/CommitNavigationGraph/CommitNavigationGraph'; -import type { - TTreeInformation, - TreeDetailsRouteFrom, -} from '@/types/tree/TreeDetails'; - import { treeDetailsDirectRouteName, treeDetailsFromMap, + type TTreeInformation, + type TreeDetailsRouteFrom, } from '@/types/tree/TreeDetails'; import { sanitizeTreeinfo } from '@/utils/treeDetails'; const TreeCommitNavigationGraph = ({ urlFrom, treeName, + summaryTreeUrl, }: { urlFrom: TreeDetailsRouteFrom; treeName?: string; + summaryTreeUrl?: string; }): React.ReactNode => { const { origin, currentPageTab, diffFilter, treeInfo } = useSearch({ from: urlFrom, @@ -32,8 +32,25 @@ const TreeCommitNavigationGraph = ({ const sanitizedTreeInfo = useMemo((): TTreeInformation & { hash: string; } => { - return sanitizeTreeinfo({ treeInfo, params, urlFrom }); - }, [params, treeInfo, urlFrom]); + return sanitizeTreeinfo({ + treeInfo, + params, + urlFrom, + summaryUrl: summaryTreeUrl, + }); + }, [params, summaryTreeUrl, treeInfo, urlFrom]); + + const { data: commitsData } = useCommits({ + origin, + gitUrl: sanitizedTreeInfo.gitUrl ?? '', + gitBranch: sanitizedTreeInfo.gitBranch ?? '', + treeName, + }); + + const commitsList = useMemo( + () => commitsData?.map(c => c.git_commit_hash) ?? [], + [commitsData], + ); const navigate = useNavigate({ from: treeDetailsFromMap[urlFrom], @@ -98,6 +115,7 @@ const TreeCommitNavigationGraph = ({ gitUrl={sanitizedTreeInfo.gitUrl} treeId={sanitizedTreeInfo.hash} headCommitHash={sanitizedTreeInfo.headCommitHash} + commitsList={commitsList} onMarkClick={markClickHandle} diffFilter={diffFilter} currentPageTab={currentPageTab} diff --git a/dashboard/src/pages/hardwareDetails/Tabs/HardwareCommitNavigationGraph.tsx b/dashboard/src/pages/hardwareDetails/Tabs/HardwareCommitNavigationGraph.tsx index 35947f846..8b9b0f446 100644 --- a/dashboard/src/pages/hardwareDetails/Tabs/HardwareCommitNavigationGraph.tsx +++ b/dashboard/src/pages/hardwareDetails/Tabs/HardwareCommitNavigationGraph.tsx @@ -2,20 +2,27 @@ import { useNavigate, useSearch } from '@tanstack/react-router'; import { useCallback, useMemo } from 'react'; -import type { HardwareDetailsSummary } from '@/types/hardware/hardwareDetails'; +import { useHardwareDetailsCommitHistory } from '@/api/hardwareDetails'; +import type { + CommitHead, + HardwareDetailsSummary, +} from '@/types/hardware/hardwareDetails'; import CommitNavigationGraph from '@/components/CommitNavigationGraph/CommitNavigationGraph'; +import { makeTreeIdentifierKey } from '@/utils/trees'; -interface ICommitNavigationGraph { +interface HardwareCommitNavigationGraphProps { trees: HardwareDetailsSummary['common']['trees']; hardwareId: string; } + const HardwareCommitNavigationGraph = ({ trees, hardwareId, -}: ICommitNavigationGraph): React.ReactNode => { - const { diffFilter, treeIndexes, currentPageTab, treeCommits } = useSearch({ - from: '/_main/hardware/$hardwareId', - }); +}: HardwareCommitNavigationGraphProps): React.ReactNode => { + const { diffFilter, treeIndexes, currentPageTab, treeCommits, origin } = + useSearch({ + from: '/_main/hardware/$hardwareId', + }); const { startTimestampInSeconds, endTimestampInSeconds } = useSearch({ from: '/_main/hardware/$hardwareId/', }); @@ -31,6 +38,41 @@ const HardwareCommitNavigationGraph = ({ trees.length === 1 ? 0 : treeIndexes?.length === 1 ? treeIndexes[0] : null; const tree = treeIdx !== null && trees[treeIdx]; + const commitHeads = useMemo( + (): CommitHead[] => + trees.map(treeItem => ({ + treeName: treeItem.tree_name ?? '', + repositoryUrl: treeItem.git_repository_url ?? '', + branch: treeItem.git_repository_branch ?? '', + commitHash: treeItem.head_git_commit_hash ?? '', + })), + [trees], + ); + + const { data: commitHistoryData } = useHardwareDetailsCommitHistory( + { + origin, + hardwareId, + endTimestampInSeconds, + startTimestampInSeconds, + commitHeads, + }, + { enabled: trees.length > 0 }, + ); + + const commitsList = useMemo(() => { + const treeForIdentifier = treeIdx !== null ? trees[treeIdx] : undefined; + const key = treeForIdentifier + ? makeTreeIdentifierKey({ + treeName: treeForIdentifier.tree_name ?? '', + gitRepositoryBranch: treeForIdentifier.git_repository_branch ?? '', + gitRepositoryUrl: treeForIdentifier.git_repository_url ?? '', + }) + : ''; + const entries = commitHistoryData?.commit_history_table?.[key] ?? []; + return entries.map(c => c.git_commit_hash); + }, [commitHistoryData?.commit_history_table, treeIdx, trees]); + const markClickHandle = useCallback( (commitHash: string) => { if (treeIdx === null) { @@ -60,7 +102,9 @@ const HardwareCommitNavigationGraph = ({ gitBranch={tree.git_repository_branch} gitUrl={tree.git_repository_url} treeId={treeId} + treeName={tree.tree_name} headCommitHash={tree.head_git_commit_hash} + commitsList={commitsList} onMarkClick={markClickHandle} diffFilter={diffFilterWithHardware} currentPageTab={currentPageTab} diff --git a/dashboard/src/types/tree/TreeDetails.tsx b/dashboard/src/types/tree/TreeDetails.tsx index 559b3e0c3..b3afc6702 100644 --- a/dashboard/src/types/tree/TreeDetails.tsx +++ b/dashboard/src/types/tree/TreeDetails.tsx @@ -184,6 +184,11 @@ export type PaginatedCommitHistoryByTree = { tests: TableTestStatus; }; +export type Commit = { + git_commit_hash: string; + earliest_checkout: string; +}; + export type BuildCountsResponse = { log_excerpt?: string; build_counts: { @@ -212,6 +217,8 @@ export type LogFilesResponse = { export type TTreeCommitHistoryResponse = PaginatedCommitHistoryByTree[]; +export type TreeCommitsResponse = Commit[]; + // TODO: These variables could be defined in the route files but it would cause // a circular dependency, requiring rewiring of the imports. export const treeDetailsDirectRouteName = '/_main/tree/$treeName/$branch/$hash'; From 048be06cf79eb1001ffdc0d96d1bb1957359490e Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Thu, 23 Apr 2026 18:38:38 -0300 Subject: [PATCH 2/6] fix: optimize TreeCommitHistory endpoint and add tree commits list view * Improve query performance for commit history * Include platform filter on query to speedup plot on hardware page * Add TreeCommitsListView and type models * Update frontend API and CommitNavigationGraph * Include tree_name and git_url on frontend requests --- .../CommitNavigationGraph.tsx | 385 ++++++++++++------ .../src/components/LineChart/LineChart.tsx | 3 + dashboard/src/locales/messages/index.ts | 3 + 3 files changed, 269 insertions(+), 122 deletions(-) diff --git a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx index c55bf048b..34a38c1cb 100644 --- a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx +++ b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx @@ -1,6 +1,13 @@ import { useIntl } from 'react-intl'; -import { memo, useMemo, type JSX } from 'react'; +import { + memo, + useCallback, + useEffect, + useMemo, + useState, + type JSX, +} from 'react'; import { z } from 'zod'; @@ -8,6 +15,13 @@ import { useMediaQuery } from '@mui/material'; import { useTheme } from '@mui/material/styles'; +import { + MdArrowBackIos, + MdArrowForwardIos, + MdFirstPage, + MdLastPage, +} from 'react-icons/md'; + import { Colors } from '@/components/StatusChart/StatusCharts'; import { LineChart } from '@/components/LineChart'; import BaseCard from '@/components/Cards/BaseCard'; @@ -18,14 +32,21 @@ import { formatDate } from '@/utils/utils'; import { mapFilterToReq } from '@/components/Tabs/Filters'; import { useCommitHistory } from '@/api/commitHistory'; import type { TFilter, TreeEntityTypes } from '@/types/general'; +import type { + PaginatedCommitHistoryByTree, + TreeDetailsRouteFrom, +} from '@/types/tree/TreeDetails'; import { MemoizedSectionError } from '@/components/DetailsPages/SectionError'; import type { gitValues } from '@/components/Tooltip/CommitTagTooltip'; -import type { TreeDetailsRouteFrom } from '@/types/tree/TreeDetails'; + +import { Button } from '@/components/ui/button'; const graphDisplaySize = 8; +const NUM_SELECTED_COMMITS = 6; + export const getChartXLabel = ({ commitTags, commitHash, @@ -52,8 +73,8 @@ interface ICommitNavigationGraph { gitUrl?: string; gitBranch?: string; headCommitHash?: string; + commitsList?: string[]; treeId?: string; - commitsList: string[]; startTimestampInSeconds?: number; endTimestampInSeconds?: number; onMarkClick: (commitHash: string, commitName?: string) => void; @@ -62,20 +83,6 @@ interface ICommitNavigationGraph { buildsRelatedToFilteredTestsOnly?: boolean; } -const selectedCommits = ( - allCommits: string[] | undefined, - headCommit: string | undefined, -): string[] => { - allCommits = allCommits || []; - headCommit = headCommit || ''; - const NUM_SELECTED_COMMITS = 6; - const headIndex = allCommits.findIndex(x => x === headCommit); - if (headIndex < 0) { - return []; - } - return allCommits.slice(headIndex, headIndex + NUM_SELECTED_COMMITS); -}; - const CommitNavigationGraph = ({ origin, currentPageTab, @@ -83,8 +90,8 @@ const CommitNavigationGraph = ({ gitUrl, gitBranch, headCommitHash, - treeId, commitsList, + treeId, onMarkClick, endTimestampInSeconds, startTimestampInSeconds, @@ -96,6 +103,11 @@ const CommitNavigationGraph = ({ const reqFilter = mapFilterToReq(diffFilter); + const [allCommits, setAllCommits] = useState< + Map + >(new Map()); + const [visibleRange, setVisibleRange] = useState<[number, number]>([0, 0]); + const types: TreeEntityTypes[] = useMemo(() => { switch (currentPageTab) { case 'global.builds': @@ -109,10 +121,33 @@ const CommitNavigationGraph = ({ } }, [currentPageTab]); + useEffect(() => { + const commits = commitsList; + if (!commits?.length) { + setVisibleRange([0, 0]); + return; + } + const start = commits.findIndex(c => c === headCommitHash); + const last = Math.min(start + NUM_SELECTED_COMMITS, commits.length); + setVisibleRange([start, last]); + }, [commitsList, headCommitHash]); + + const commitHashes = useMemo( + () => + commitsList + ?.slice(visibleRange[0], visibleRange[1]) ?? [], + [commitsList, visibleRange], + ); + + const missingCommitHashes = useMemo( + () => commitHashes.filter(h => !allCommits.has(h)), + [commitHashes, allCommits], + ); + const { data, status, error, isLoading } = useCommitHistory({ gitBranch: gitBranch ?? '', gitUrl: gitUrl ?? '', - commitHash: selectedCommits(commitsList, headCommitHash), + commitHash: missingCommitHashes, origin: origin, filter: reqFilter, endTimestampInSeconds, @@ -123,7 +158,63 @@ const CommitNavigationGraph = ({ buildsRelatedToFilteredTestsOnly, }); - const displayableData = data ? data : null; + useEffect(() => { + if (!data?.length) { + return; + } + setAllCommits(prev => { + let next: Map | null = null; + missingCommitHashes.forEach((commit, idx) => { + const incoming = data[idx]; + if (incoming === undefined) { + return; + } + if (prev.get(commit) !== incoming) { + if (!next) { + next = new Map(prev); + } + next.set(commit, incoming); + } + }); + return next ?? prev; + }); + }, [data, missingCommitHashes]); + + const canGoNewer = visibleRange[0] > 0; + const canGoOlder = visibleRange[1] < (commitsList?.length || 1) - 1; + + const goNewer = useCallback(() => { + setVisibleRange(([start, end]) => [ + Math.max(start - NUM_SELECTED_COMMITS, 0), + Math.max(end - NUM_SELECTED_COMMITS, NUM_SELECTED_COMMITS), + ]); + }, []); + + const goNewest = useCallback(() => { + setVisibleRange(_ => [0, NUM_SELECTED_COMMITS] as [number, number]); + }, []); + + const commitsLength = commitsList?.length ?? 0; + + const goOlder = useCallback(() => { + const last = Math.max(commitsLength - 1, 0); + setVisibleRange( + ([start, end]) => + [ + Math.min(start + NUM_SELECTED_COMMITS, last - NUM_SELECTED_COMMITS), + Math.min(end + NUM_SELECTED_COMMITS, last), + ] as [number, number], + ); + }, [commitsLength]); + + const goOldest = useCallback(() => { + const last = Math.max(commitsLength - 1, 0); + setVisibleRange(_ => [last - NUM_SELECTED_COMMITS, last]); + }, [commitsLength]); + + const visibleCommits = commitHashes + .map(commit => allCommits.get(commit)) + .reverse(); type MessagesID = { graphName: MessagesKey; @@ -161,7 +252,6 @@ const CommitNavigationGraph = ({ const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); - // Transform the data to fit the format required by the MUI LineChart component const series: TLineChartProps['series'] = [ { id: 'good', @@ -195,8 +285,13 @@ const CommitNavigationGraph = ({ const commitData: TCommitValue[] = []; const xAxisIndexes: number[] = []; - // TODO Extract the magic code to outside the component - data?.forEach((item, index) => { + + // visibleCommits is already in chronological order (oldest first) + visibleCommits.forEach(item => { + if (!item) { + return; + } + if (currentPageTab === 'global.builds') { const inconclusiveCount = item.builds.MISS + @@ -204,9 +299,9 @@ const CommitNavigationGraph = ({ item.builds.ERROR + item.builds.DONE + item.builds.NULL; - series[0].data?.unshift(item.builds.PASS); - series[1].data?.unshift(item.builds.FAIL); - series[2].data?.unshift(inconclusiveCount); + series[0].data?.push(item.builds.PASS); + series[1].data?.push(item.builds.FAIL); + series[2].data?.push(inconclusiveCount); } if (currentPageTab === 'global.boots') { const inconclusiveCount = @@ -215,9 +310,9 @@ const CommitNavigationGraph = ({ item.boots.error + item.boots.done + item.boots.null; - series[0].data?.unshift(item.boots.pass); - series[1].data?.unshift(item.boots.fail); - series[2].data?.unshift(inconclusiveCount); + series[0].data?.push(item.boots.pass); + series[1].data?.push(item.boots.fail); + series[2].data?.push(inconclusiveCount); } if (currentPageTab === 'global.tests') { const inconclusiveCount = @@ -226,25 +321,25 @@ const CommitNavigationGraph = ({ item.tests.error + item.tests.done + item.tests.null; - series[0].data?.unshift(item.tests.pass); - series[1].data?.unshift(item.tests.fail); - series[2].data?.unshift(inconclusiveCount); + series[0].data?.push(item.tests.pass); + series[1].data?.push(item.tests.fail); + series[2].data?.push(inconclusiveCount); } - commitData.unshift({ + commitData.push({ commitHash: item.git_commit_hash, commitName: item.git_commit_name, commitTags: item.git_commit_tags, earliestStartTime: item.earliest_start_time, }); - xAxisIndexes.push(index); + xAxisIndexes.push(commitData.length - 1); }); // filter only selected, first and last commit const smallScreenTickFilter = (value: number, _: number): boolean => value === 0 || value === commitData.length - 1 || - commitData[value].commitHash === treeId; + commitData[value]?.commitHash === treeId; // tickLabelInterval can be set to auto, or to a custom filter const tickLabelInterval = isSmallScreen ? smallScreenTickFilter : 'auto'; @@ -257,13 +352,15 @@ const CommitNavigationGraph = ({ valueFormatter: (value: number, context): string => { const currentCommitData = commitData[value]; const currentCommitDateTime = formatDate( - currentCommitData.earliestStartTime ?? '-', + currentCommitData?.earliestStartTime ?? '-', true, ); if (context.location === 'tooltip') { return ( - (currentCommitData.commitName ?? currentCommitData.commitHash) + + (currentCommitData?.commitName ?? + currentCommitData?.commitHash ?? + '') + ' - ' + currentCommitDateTime ); @@ -275,13 +372,15 @@ const CommitNavigationGraph = ({ }, ]; + const querySwitcherStatus = allCommits?.size > 0 ? 'success' : status; + return ( + { + let displayText = chartTextProps.text; + const splitResult = chartTextProps.text.split('-'); + + const possibleIdentifier = splitResult[0]; + + let isCurrentCommit = false; + if (possibleIdentifier === 'commitIndex') { + const possibleIndex = splitResult[1]; + const possibleIndexNumber = parseInt(possibleIndex); + const parsedPossibleIndex = z + .number() + .catch(e => { + console.error('Error parsing index', e); + return 0; + }) + .parse(possibleIndexNumber); + + const row = commitData[parsedPossibleIndex]; + isCurrentCommit = treeId === row?.commitHash; + + if (row) { + displayText = getChartXLabel(row); + } + } + + return ( + <> + {isCurrentCommit && ( + <> + + + + )} + + + + {displayText} + + + + ); }, - }} - slotProps={{ - legend: { - itemGap: 2, - position: { vertical: 'top', horizontal: 'middle' }, - }, - }} - slots={{ - axisTickLabel: chartTextProps => { - let displayText = chartTextProps.text; - const splitResult = chartTextProps.text.split('-'); - - const possibleIdentifier = splitResult[0]; - - let isCurrentCommit = false; - if (possibleIdentifier === 'commitIndex') { - const possibleIndex = splitResult[1]; - const possibleIndexNumber = parseInt(possibleIndex); - const parsedPossibleIndex = z - .number() - .catch(e => { - console.error('Error parsing index', e); - return 0; - }) - .parse(possibleIndexNumber); - - isCurrentCommit = - treeId === commitData[parsedPossibleIndex].commitHash; - - displayText = getChartXLabel(commitData[parsedPossibleIndex]); + }} + onMarkClick={(_event, payload) => { + const commitIndex = payload.dataIndex ?? 0; + const row = commitData[commitIndex]; + if (row?.commitHash) { + onMarkClick(row.commitHash, row.commitName); } - - return ( - <> - {isCurrentCommit && ( - <> - - - - )} - - - - {displayText} - - - - ); - }, - }} - onMarkClick={(_event, payload) => { - const commitIndex = payload.dataIndex ?? 0; - const commitHash = commitData[commitIndex].commitHash; - const commitName = commitData[commitIndex].commitName; - if (commitHash) { - onMarkClick(commitHash, commitName); - } - }} - /> + }} + /> +
+ + + + +
+ } />
diff --git a/dashboard/src/components/LineChart/LineChart.tsx b/dashboard/src/components/LineChart/LineChart.tsx index 828134026..ad9fd146d 100644 --- a/dashboard/src/components/LineChart/LineChart.tsx +++ b/dashboard/src/components/LineChart/LineChart.tsx @@ -36,6 +36,7 @@ export type TLineChartProps = { slotProps?: MUILineChartProps['slotProps']; height?: MUILineChartProps['height']; margin?: MUILineChartProps['margin']; + isLoading?: boolean; }; export const LineChart = ({ @@ -48,6 +49,7 @@ export const LineChart = ({ height, margin, onMarkClick, + isLoading, }: TLineChartProps): JSX.Element => { return (
@@ -64,6 +66,7 @@ export const LineChart = ({ onMarkClick={onMarkClick} height={height} margin={margin} + loading={isLoading} />
); diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index d08269b67..4fb76dd50 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -142,6 +142,7 @@ export const messages = { 'global.fails': 'Fails', 'global.filter': 'Filter', 'global.filters': 'Filters', + 'global.first': 'First', 'global.fullLogs': 'Full logs', 'global.gitHubIssue': 'GitHub Issue', 'global.hardware': 'Hardware', @@ -160,10 +161,12 @@ export const messages = { 'global.logs': 'Logs', 'global.name': 'Name', 'global.new': 'New', + 'global.newer': 'Newer', 'global.next': 'Next', 'global.noDataAvailable': 'No data available', 'global.noResults': 'No results were found', 'global.notFound': 'Page not found', + 'global.older': 'Older', 'global.origin': 'Origin', 'global.others': 'Others', 'global.path': 'Path', From 500c93b167008eaf3d5ef602e5c3909de3a64044 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luiz=20Abdalla=20Silveira?= Date: Tue, 14 Apr 2026 14:54:05 -0300 Subject: [PATCH 3/6] feat: add sliding window state and data accumulation for commit history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a commit buffer that accumulates data from the backend and a sliding window of 4 commits over it. When the user navigates past the buffer boundary, a new fetch is triggered using the oldest known commit as anchor, and results are merged with deduplication. Navigation callbacks (goOlder, goNewer, goOldest, goNewest) are wired but not yet exposed in the UI — buttons come in the next commit. --- .../CommitNavigationGraph.tsx | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) diff --git a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx index 34a38c1cb..2c7678baa 100644 --- a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx +++ b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx @@ -5,6 +5,10 @@ import { useCallback, useEffect, useMemo, +<<<<<<< HEAD +======= + useRef, +>>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) useState, type JSX, } from 'react'; @@ -44,6 +48,8 @@ import type { gitValues } from '@/components/Tooltip/CommitTagTooltip'; import { Button } from '@/components/ui/button'; const graphDisplaySize = 8; +const WINDOW_SIZE = 4; +const BACKEND_PAGE_SIZE = 6; const NUM_SELECTED_COMMITS = 6; @@ -103,10 +109,43 @@ const CommitNavigationGraph = ({ const reqFilter = mapFilterToReq(diffFilter); +<<<<<<< HEAD const [allCommits, setAllCommits] = useState< Map >(new Map()); const [visibleRange, setVisibleRange] = useState<[number, number]>([0, 0]); +======= + // Buffer of all fetched commits (oldest first, i.e. chronological order) + const [allCommits, setAllCommits] = useState< + PaginatedCommitHistoryByTree[] + >([]); + // Index of the rightmost visible commit in allCommits + const [windowEnd, setWindowEnd] = useState(-1); + // Anchor commit hash for fetching older data + const [fetchAnchor, setFetchAnchor] = useState( + undefined, + ); + // Whether the backend has no more older commits + const [exhausted, setExhausted] = useState(false); + + // Reset everything when key props change + const prevDepsRef = useRef({ headCommitHash, currentPageTab }); + useEffect(() => { + const prev = prevDepsRef.current; + if ( + prev.headCommitHash !== headCommitHash || + prev.currentPageTab !== currentPageTab + ) { + setAllCommits([]); + setWindowEnd(-1); + setFetchAnchor(undefined); + setExhausted(false); + prevDepsRef.current = { headCommitHash, currentPageTab }; + } + }, [headCommitHash, currentPageTab]); + + const effectiveAnchor = fetchAnchor ?? headCommitHash ?? ''; +>>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) const types: TreeEntityTypes[] = useMemo(() => { switch (currentPageTab) { @@ -158,6 +197,7 @@ const CommitNavigationGraph = ({ buildsRelatedToFilteredTestsOnly, }); +<<<<<<< HEAD useEffect(() => { if (!data?.length) { return; @@ -215,6 +255,97 @@ const CommitNavigationGraph = ({ const visibleCommits = commitHashes .map(commit => allCommits.get(commit)) .reverse(); +======= + // Merge fetched data into the buffer when it arrives + const lastMergedAnchorRef = useRef(''); + useEffect(() => { + if (!data || data.length === 0) return; + if (lastMergedAnchorRef.current === effectiveAnchor) return; + lastMergedAnchorRef.current = effectiveAnchor; + + if (data.length < BACKEND_PAGE_SIZE) { + setExhausted(true); + } + + // Data from API is newest-first; reverse to get chronological (oldest-first) + const newCommits = [...data].reverse(); + + setAllCommits(prev => { + if (prev.length === 0) { + return newCommits; + } + + // Deduplicate: only prepend commits not already in the buffer + const existingHashes = new Set(prev.map(c => c.git_commit_hash)); + const uniqueNew = newCommits.filter( + c => !existingHashes.has(c.git_commit_hash), + ); + + if (uniqueNew.length === 0) return prev; + + const merged = [...uniqueNew, ...prev]; + // Shift window to account for prepended items + setWindowEnd(w => + w === -1 ? merged.length - 1 : w + uniqueNew.length, + ); + return merged; + }); + }, [data, effectiveAnchor]); + + // Initialize windowEnd when allCommits first populates + useEffect(() => { + if (allCommits.length > 0 && windowEnd === -1) { + setWindowEnd(allCommits.length - 1); + } + }, [allCommits.length, windowEnd]); + + // Compute visible window + const windowStart = Math.max(0, windowEnd - WINDOW_SIZE + 1); + const visibleCommits = allCommits.slice(windowStart, windowEnd + 1); + + // Navigation + const canGoNewer = windowEnd < allCommits.length - 1; + const canGoOlder = windowStart > 0 || !exhausted; + + const goNewer = useCallback(() => { + setWindowEnd(w => Math.min(w + 1, allCommits.length - 1)); + }, [allCommits.length]); + + const goNewest = useCallback(() => { + setWindowEnd(allCommits.length - 1); + }, [allCommits.length]); + + const goOlder = useCallback(() => { + if (windowStart > 0) { + setWindowEnd(w => w - 1); + } else if (!exhausted && allCommits.length > 0) { + const oldestHash = allCommits[0].git_commit_hash; + setFetchAnchor(oldestHash); + } + }, [windowStart, exhausted, allCommits]); + + const goOldest = useCallback(() => { + if (exhausted) { + setWindowEnd(WINDOW_SIZE - 1); + } else if (allCommits.length > 0) { + const oldestHash = allCommits[0].git_commit_hash; + setFetchAnchor(oldestHash); + setWindowEnd(WINDOW_SIZE - 1); + } + }, [exhausted, allCommits]); + + // Keep goNewer, goNewest, goOlder, goOldest, canGoNewer, canGoOlder + // available for the next commit that adds navigation buttons. + // For now, suppress unused warnings: + void goNewer; + void goNewest; + void goOlder; + void goOldest; + void canGoNewer; + void canGoOlder; + + const displayableData = visibleCommits.length > 0 ? visibleCommits : null; +>>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) type MessagesID = { graphName: MessagesKey; @@ -252,6 +383,10 @@ const CommitNavigationGraph = ({ const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); +<<<<<<< HEAD +======= + // Transform the visible window data for the MUI LineChart +>>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) const series: TLineChartProps['series'] = [ { id: 'good', @@ -287,11 +422,15 @@ const CommitNavigationGraph = ({ const xAxisIndexes: number[] = []; // visibleCommits is already in chronological order (oldest first) +<<<<<<< HEAD visibleCommits.forEach(item => { if (!item) { return; } +======= + visibleCommits.forEach((item, index) => { +>>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) if (currentPageTab === 'global.builds') { const inconclusiveCount = item.builds.MISS + @@ -431,6 +570,7 @@ const CommitNavigationGraph = ({ const row = commitData[parsedPossibleIndex]; isCurrentCommit = treeId === row?.commitHash; +<<<<<<< HEAD if (row) { displayText = getChartXLabel(row); } @@ -474,6 +614,11 @@ const CommitNavigationGraph = ({ const row = commitData[commitIndex]; if (row?.commitHash) { onMarkClick(row.commitHash, row.commitName); +======= + displayText = getChartXLabel( + commitData[parsedPossibleIndex], + ); +>>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) } }} /> From e26e36704f732808ae6bfad73e79161c8b4810a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luiz=20Abdalla=20Silveira?= Date: Tue, 14 Apr 2026 14:54:47 -0300 Subject: [PATCH 4/6] feat: add navigation buttons to commit history graph (#1840) Add first/prev/next/last buttons below the commit history chart, allowing users to navigate through older commits one at a time. Uses MdFirstPage, MdArrowBackIos, MdArrowForwardIos, MdLastPage icons consistent with existing pagination controls. --- .../CommitNavigationGraph.tsx | 147 +----------------- 1 file changed, 1 insertion(+), 146 deletions(-) diff --git a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx index 2c7678baa..d0b07ae45 100644 --- a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx +++ b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx @@ -5,10 +5,6 @@ import { useCallback, useEffect, useMemo, -<<<<<<< HEAD -======= - useRef, ->>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) useState, type JSX, } from 'react'; @@ -48,8 +44,6 @@ import type { gitValues } from '@/components/Tooltip/CommitTagTooltip'; import { Button } from '@/components/ui/button'; const graphDisplaySize = 8; -const WINDOW_SIZE = 4; -const BACKEND_PAGE_SIZE = 6; const NUM_SELECTED_COMMITS = 6; @@ -109,43 +103,10 @@ const CommitNavigationGraph = ({ const reqFilter = mapFilterToReq(diffFilter); -<<<<<<< HEAD const [allCommits, setAllCommits] = useState< Map >(new Map()); const [visibleRange, setVisibleRange] = useState<[number, number]>([0, 0]); -======= - // Buffer of all fetched commits (oldest first, i.e. chronological order) - const [allCommits, setAllCommits] = useState< - PaginatedCommitHistoryByTree[] - >([]); - // Index of the rightmost visible commit in allCommits - const [windowEnd, setWindowEnd] = useState(-1); - // Anchor commit hash for fetching older data - const [fetchAnchor, setFetchAnchor] = useState( - undefined, - ); - // Whether the backend has no more older commits - const [exhausted, setExhausted] = useState(false); - - // Reset everything when key props change - const prevDepsRef = useRef({ headCommitHash, currentPageTab }); - useEffect(() => { - const prev = prevDepsRef.current; - if ( - prev.headCommitHash !== headCommitHash || - prev.currentPageTab !== currentPageTab - ) { - setAllCommits([]); - setWindowEnd(-1); - setFetchAnchor(undefined); - setExhausted(false); - prevDepsRef.current = { headCommitHash, currentPageTab }; - } - }, [headCommitHash, currentPageTab]); - - const effectiveAnchor = fetchAnchor ?? headCommitHash ?? ''; ->>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) const types: TreeEntityTypes[] = useMemo(() => { switch (currentPageTab) { @@ -197,7 +158,6 @@ const CommitNavigationGraph = ({ buildsRelatedToFilteredTestsOnly, }); -<<<<<<< HEAD useEffect(() => { if (!data?.length) { return; @@ -255,97 +215,6 @@ const CommitNavigationGraph = ({ const visibleCommits = commitHashes .map(commit => allCommits.get(commit)) .reverse(); -======= - // Merge fetched data into the buffer when it arrives - const lastMergedAnchorRef = useRef(''); - useEffect(() => { - if (!data || data.length === 0) return; - if (lastMergedAnchorRef.current === effectiveAnchor) return; - lastMergedAnchorRef.current = effectiveAnchor; - - if (data.length < BACKEND_PAGE_SIZE) { - setExhausted(true); - } - - // Data from API is newest-first; reverse to get chronological (oldest-first) - const newCommits = [...data].reverse(); - - setAllCommits(prev => { - if (prev.length === 0) { - return newCommits; - } - - // Deduplicate: only prepend commits not already in the buffer - const existingHashes = new Set(prev.map(c => c.git_commit_hash)); - const uniqueNew = newCommits.filter( - c => !existingHashes.has(c.git_commit_hash), - ); - - if (uniqueNew.length === 0) return prev; - - const merged = [...uniqueNew, ...prev]; - // Shift window to account for prepended items - setWindowEnd(w => - w === -1 ? merged.length - 1 : w + uniqueNew.length, - ); - return merged; - }); - }, [data, effectiveAnchor]); - - // Initialize windowEnd when allCommits first populates - useEffect(() => { - if (allCommits.length > 0 && windowEnd === -1) { - setWindowEnd(allCommits.length - 1); - } - }, [allCommits.length, windowEnd]); - - // Compute visible window - const windowStart = Math.max(0, windowEnd - WINDOW_SIZE + 1); - const visibleCommits = allCommits.slice(windowStart, windowEnd + 1); - - // Navigation - const canGoNewer = windowEnd < allCommits.length - 1; - const canGoOlder = windowStart > 0 || !exhausted; - - const goNewer = useCallback(() => { - setWindowEnd(w => Math.min(w + 1, allCommits.length - 1)); - }, [allCommits.length]); - - const goNewest = useCallback(() => { - setWindowEnd(allCommits.length - 1); - }, [allCommits.length]); - - const goOlder = useCallback(() => { - if (windowStart > 0) { - setWindowEnd(w => w - 1); - } else if (!exhausted && allCommits.length > 0) { - const oldestHash = allCommits[0].git_commit_hash; - setFetchAnchor(oldestHash); - } - }, [windowStart, exhausted, allCommits]); - - const goOldest = useCallback(() => { - if (exhausted) { - setWindowEnd(WINDOW_SIZE - 1); - } else if (allCommits.length > 0) { - const oldestHash = allCommits[0].git_commit_hash; - setFetchAnchor(oldestHash); - setWindowEnd(WINDOW_SIZE - 1); - } - }, [exhausted, allCommits]); - - // Keep goNewer, goNewest, goOlder, goOldest, canGoNewer, canGoOlder - // available for the next commit that adds navigation buttons. - // For now, suppress unused warnings: - void goNewer; - void goNewest; - void goOlder; - void goOldest; - void canGoNewer; - void canGoOlder; - - const displayableData = visibleCommits.length > 0 ? visibleCommits : null; ->>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) type MessagesID = { graphName: MessagesKey; @@ -383,10 +252,7 @@ const CommitNavigationGraph = ({ const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); -<<<<<<< HEAD -======= // Transform the visible window data for the MUI LineChart ->>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) const series: TLineChartProps['series'] = [ { id: 'good', @@ -422,15 +288,10 @@ const CommitNavigationGraph = ({ const xAxisIndexes: number[] = []; // visibleCommits is already in chronological order (oldest first) -<<<<<<< HEAD visibleCommits.forEach(item => { if (!item) { return; } - -======= - visibleCommits.forEach((item, index) => { ->>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) if (currentPageTab === 'global.builds') { const inconclusiveCount = item.builds.MISS + @@ -544,7 +405,7 @@ const CommitNavigationGraph = ({ }} slotProps={{ legend: { - itemGap: 4, + itemGap: 2, position: { vertical: 'top', horizontal: 'middle' }, }, }} @@ -570,7 +431,6 @@ const CommitNavigationGraph = ({ const row = commitData[parsedPossibleIndex]; isCurrentCommit = treeId === row?.commitHash; -<<<<<<< HEAD if (row) { displayText = getChartXLabel(row); } @@ -614,11 +474,6 @@ const CommitNavigationGraph = ({ const row = commitData[commitIndex]; if (row?.commitHash) { onMarkClick(row.commitHash, row.commitName); -======= - displayText = getChartXLabel( - commitData[parsedPossibleIndex], - ); ->>>>>>> b07c7b34b (feat: add sliding window state and data accumulation for commit history) } }} /> From cdca9f26990cf30205ed7ed2d8ce2f9989bea456 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Luiz=20Abdalla=20Silveira?= Date: Tue, 14 Apr 2026 16:11:43 -0300 Subject: [PATCH 5/6] fix: prevent chart flash and stuck navigation on older page fetch (#1840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When navigating past the initial 6-commit batch, the graph would flash back to the newest commits and get stuck because: 1. Changing the fetch anchor reset the TanStack Query status to 'pending', causing QuerySwitcher to replace the chart with a loading skeleton — even though we had buffered data to display. 2. setWindowEnd was called inside setAllCommits's updater function, which is a side effect in a pure function with undefined behavior in React, leading to inconsistent window positioning after merge. 3. Two competing effects (merge + init) both set windowEnd, creating race conditions on the window position. Refactored to: use a ref mirror (allCommitsRef) for synchronous buffer access, a single merge effect that handles both initial load and subsequent merges, and an effectiveStatus that keeps the chart visible during background fetches. Navigation buttons are disabled while a fetch is in flight to prevent double-triggers. --- .../CommitNavigationGraph.tsx | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx index d0b07ae45..dff738253 100644 --- a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx +++ b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx @@ -103,10 +103,46 @@ const CommitNavigationGraph = ({ const reqFilter = mapFilterToReq(diffFilter); +<<<<<<< HEAD const [allCommits, setAllCommits] = useState< Map >(new Map()); const [visibleRange, setVisibleRange] = useState<[number, number]>([0, 0]); +======= + // Buffer of all fetched commits (oldest first, i.e. chronological order) + const [allCommits, setAllCommits] = useState( + [], + ); + // Index of the rightmost visible commit in allCommits + const [windowEnd, setWindowEnd] = useState(-1); + // Anchor commit hash for fetching older data + const [fetchAnchor, setFetchAnchor] = useState(undefined); + // Whether the backend has no more older commits + const [exhausted, setExhausted] = useState(false); + // When true, the next merge should position the window at the oldest end + const jumpToOldestRef = useRef(false); + + // Reset everything when key props change + const prevDepsRef = useRef({ headCommitHash, currentPageTab }); + useEffect(() => { + const prev = prevDepsRef.current; + if ( + prev.headCommitHash !== headCommitHash || + prev.currentPageTab !== currentPageTab + ) { + allCommitsRef.current = []; + setAllCommits([]); + setWindowEnd(-1); + setFetchAnchor(undefined); + setExhausted(false); + jumpToOldestRef.current = false; + lastMergedAnchorRef.current = ''; + prevDepsRef.current = { headCommitHash, currentPageTab }; + } + }, [headCommitHash, currentPageTab]); + + const effectiveAnchor = fetchAnchor ?? headCommitHash ?? ''; +>>>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) const types: TreeEntityTypes[] = useMemo(() => { switch (currentPageTab) { @@ -158,10 +194,19 @@ const CommitNavigationGraph = ({ buildsRelatedToFilteredTestsOnly, }); +<<<<<<< HEAD +======= + // Ref mirror of allCommits for synchronous access in the merge effect + const allCommitsRef = useRef([]); + + // Merge fetched data into the buffer when it arrives + const lastMergedAnchorRef = useRef(''); +>>>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) useEffect(() => { if (!data?.length) { return; } +<<<<<<< HEAD setAllCommits(prev => { let next: Map | null = null; missingCommitHashes.forEach((commit, idx) => { @@ -182,6 +227,83 @@ const CommitNavigationGraph = ({ const canGoNewer = visibleRange[0] > 0; const canGoOlder = visibleRange[1] < (commitsList?.length || 1) - 1; +======= + if (lastMergedAnchorRef.current === effectiveAnchor) { + return; + } + lastMergedAnchorRef.current = effectiveAnchor; + + if (data.length < BACKEND_PAGE_SIZE) { + setExhausted(true); + } + + // Data from API is newest-first; reverse to get chronological (oldest-first) + const newCommits = [...data].reverse(); + const prev = allCommitsRef.current; + + if (prev.length === 0) { + // First load — position window to include current commit if present + allCommitsRef.current = newCommits; + setAllCommits(newCommits); + + const currentIdx = treeId + ? newCommits.findIndex(c => c.git_commit_hash === treeId) + : -1; + if (currentIdx >= 0) { + setWindowEnd( + Math.max( + currentIdx, + Math.min(WINDOW_SIZE - 1, newCommits.length - 1), + ), + ); + } else { + setWindowEnd(newCommits.length - 1); + } + return; + } + + // Deduplicate: only prepend commits not already in the buffer + const existingHashes = new Set(prev.map(c => c.git_commit_hash)); + const uniqueNew = newCommits.filter( + c => !existingHashes.has(c.git_commit_hash), + ); + + if (uniqueNew.length === 0) { + return; + } + + const merged = [...uniqueNew, ...prev]; + allCommitsRef.current = merged; + setAllCommits(merged); + + // Adjust window position for the prepended items + if (jumpToOldestRef.current) { + jumpToOldestRef.current = false; + setWindowEnd(Math.min(WINDOW_SIZE - 1, merged.length - 1)); + } else { + setWindowEnd(w => w + uniqueNew.length); + } + }, [data, effectiveAnchor, treeId]); + + // Compute visible window — clamp to valid range and enforce minimum WINDOW_SIZE + const clampedWindowEnd = + allCommits.length > 0 + ? Math.min( + Math.max(windowEnd, Math.min(WINDOW_SIZE - 1, allCommits.length - 1)), + allCommits.length - 1, + ) + : 0; + const windowStart = Math.max(0, clampedWindowEnd - WINDOW_SIZE + 1); + const visibleCommits = + allCommits.length > 0 + ? allCommits.slice(windowStart, clampedWindowEnd + 1) + : []; + + // Navigation + const isFetchingOlder = status === 'pending' && allCommits.length > 0; + const canGoNewer = clampedWindowEnd < allCommits.length - 1; + const canGoOlder = (windowStart > 0 || !exhausted) && !isFetchingOlder; +>>>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) const goNewer = useCallback(() => { setVisibleRange(([start, end]) => [ @@ -197,6 +319,7 @@ const CommitNavigationGraph = ({ const commitsLength = commitsList?.length ?? 0; const goOlder = useCallback(() => { +<<<<<<< HEAD const last = Math.max(commitsLength - 1, 0); setVisibleRange( ([start, end]) => @@ -211,11 +334,42 @@ const CommitNavigationGraph = ({ const last = Math.max(commitsLength - 1, 0); setVisibleRange(_ => [last - NUM_SELECTED_COMMITS, last]); }, [commitsLength]); +======= + if (windowStart > 0) { + setWindowEnd(w => w - 1); + } else if (!exhausted && allCommits.length > 0) { + // Need to fetch more: use the oldest known commit as anchor + const oldestHash = allCommits[0].git_commit_hash; + setFetchAnchor(oldestHash); + // Pre-decrement so after merge shift (+uniqueNew.length), the net + // effect is one position to the left — the user sees the next older commit + // immediately once data arrives. The effectiveWindowEnd clamp keeps + // the display stable (≥ WINDOW_SIZE items) during the loading gap. + setWindowEnd(w => w - 1); + } + }, [windowStart, exhausted, allCommits]); + + const goOldest = useCallback(() => { + if (exhausted) { + setWindowEnd(WINDOW_SIZE - 1); + } else if (allCommits.length > 0) { + // Flag the merge effect to position window at oldest end after fetch + jumpToOldestRef.current = true; + const oldestHash = allCommits[0].git_commit_hash; + setFetchAnchor(oldestHash); + } + }, [exhausted, allCommits]); +>>>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) const visibleCommits = commitHashes .map(commit => allCommits.get(commit)) .reverse(); + // Only show loading skeleton on the initial fetch. + // When fetching older pages, keep displaying the buffered chart. + const effectiveStatus = + allCommits.length > 0 && status === 'pending' ? 'success' : status; + type MessagesID = { graphName: MessagesKey; good: MessagesKey; @@ -376,8 +530,13 @@ const CommitNavigationGraph = ({ return ( >>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) customError={ Date: Fri, 8 May 2026 13:28:51 -0300 Subject: [PATCH 6/6] feat: Using the new endpoint to navigate pages using commits list --- .../CommitNavigationGraph.tsx | 169 +----------------- .../src/components/LineChart/LineChart.tsx | 9 +- 2 files changed, 10 insertions(+), 168 deletions(-) diff --git a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx index dff738253..460db06d1 100644 --- a/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx +++ b/dashboard/src/components/CommitNavigationGraph/CommitNavigationGraph.tsx @@ -103,46 +103,10 @@ const CommitNavigationGraph = ({ const reqFilter = mapFilterToReq(diffFilter); -<<<<<<< HEAD const [allCommits, setAllCommits] = useState< Map >(new Map()); const [visibleRange, setVisibleRange] = useState<[number, number]>([0, 0]); -======= - // Buffer of all fetched commits (oldest first, i.e. chronological order) - const [allCommits, setAllCommits] = useState( - [], - ); - // Index of the rightmost visible commit in allCommits - const [windowEnd, setWindowEnd] = useState(-1); - // Anchor commit hash for fetching older data - const [fetchAnchor, setFetchAnchor] = useState(undefined); - // Whether the backend has no more older commits - const [exhausted, setExhausted] = useState(false); - // When true, the next merge should position the window at the oldest end - const jumpToOldestRef = useRef(false); - - // Reset everything when key props change - const prevDepsRef = useRef({ headCommitHash, currentPageTab }); - useEffect(() => { - const prev = prevDepsRef.current; - if ( - prev.headCommitHash !== headCommitHash || - prev.currentPageTab !== currentPageTab - ) { - allCommitsRef.current = []; - setAllCommits([]); - setWindowEnd(-1); - setFetchAnchor(undefined); - setExhausted(false); - jumpToOldestRef.current = false; - lastMergedAnchorRef.current = ''; - prevDepsRef.current = { headCommitHash, currentPageTab }; - } - }, [headCommitHash, currentPageTab]); - - const effectiveAnchor = fetchAnchor ?? headCommitHash ?? ''; ->>>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) const types: TreeEntityTypes[] = useMemo(() => { switch (currentPageTab) { @@ -169,9 +133,7 @@ const CommitNavigationGraph = ({ }, [commitsList, headCommitHash]); const commitHashes = useMemo( - () => - commitsList - ?.slice(visibleRange[0], visibleRange[1]) ?? [], + () => commitsList?.slice(visibleRange[0], visibleRange[1]) ?? [], [commitsList, visibleRange], ); @@ -194,19 +156,10 @@ const CommitNavigationGraph = ({ buildsRelatedToFilteredTestsOnly, }); -<<<<<<< HEAD -======= - // Ref mirror of allCommits for synchronous access in the merge effect - const allCommitsRef = useRef([]); - - // Merge fetched data into the buffer when it arrives - const lastMergedAnchorRef = useRef(''); ->>>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) useEffect(() => { if (!data?.length) { return; } -<<<<<<< HEAD setAllCommits(prev => { let next: Map | null = null; missingCommitHashes.forEach((commit, idx) => { @@ -227,83 +180,6 @@ const CommitNavigationGraph = ({ const canGoNewer = visibleRange[0] > 0; const canGoOlder = visibleRange[1] < (commitsList?.length || 1) - 1; -======= - if (lastMergedAnchorRef.current === effectiveAnchor) { - return; - } - lastMergedAnchorRef.current = effectiveAnchor; - - if (data.length < BACKEND_PAGE_SIZE) { - setExhausted(true); - } - - // Data from API is newest-first; reverse to get chronological (oldest-first) - const newCommits = [...data].reverse(); - const prev = allCommitsRef.current; - - if (prev.length === 0) { - // First load — position window to include current commit if present - allCommitsRef.current = newCommits; - setAllCommits(newCommits); - - const currentIdx = treeId - ? newCommits.findIndex(c => c.git_commit_hash === treeId) - : -1; - if (currentIdx >= 0) { - setWindowEnd( - Math.max( - currentIdx, - Math.min(WINDOW_SIZE - 1, newCommits.length - 1), - ), - ); - } else { - setWindowEnd(newCommits.length - 1); - } - return; - } - - // Deduplicate: only prepend commits not already in the buffer - const existingHashes = new Set(prev.map(c => c.git_commit_hash)); - const uniqueNew = newCommits.filter( - c => !existingHashes.has(c.git_commit_hash), - ); - - if (uniqueNew.length === 0) { - return; - } - - const merged = [...uniqueNew, ...prev]; - allCommitsRef.current = merged; - setAllCommits(merged); - - // Adjust window position for the prepended items - if (jumpToOldestRef.current) { - jumpToOldestRef.current = false; - setWindowEnd(Math.min(WINDOW_SIZE - 1, merged.length - 1)); - } else { - setWindowEnd(w => w + uniqueNew.length); - } - }, [data, effectiveAnchor, treeId]); - - // Compute visible window — clamp to valid range and enforce minimum WINDOW_SIZE - const clampedWindowEnd = - allCommits.length > 0 - ? Math.min( - Math.max(windowEnd, Math.min(WINDOW_SIZE - 1, allCommits.length - 1)), - allCommits.length - 1, - ) - : 0; - const windowStart = Math.max(0, clampedWindowEnd - WINDOW_SIZE + 1); - const visibleCommits = - allCommits.length > 0 - ? allCommits.slice(windowStart, clampedWindowEnd + 1) - : []; - - // Navigation - const isFetchingOlder = status === 'pending' && allCommits.length > 0; - const canGoNewer = clampedWindowEnd < allCommits.length - 1; - const canGoOlder = (windowStart > 0 || !exhausted) && !isFetchingOlder; ->>>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) const goNewer = useCallback(() => { setVisibleRange(([start, end]) => [ @@ -319,7 +195,6 @@ const CommitNavigationGraph = ({ const commitsLength = commitsList?.length ?? 0; const goOlder = useCallback(() => { -<<<<<<< HEAD const last = Math.max(commitsLength - 1, 0); setVisibleRange( ([start, end]) => @@ -334,42 +209,11 @@ const CommitNavigationGraph = ({ const last = Math.max(commitsLength - 1, 0); setVisibleRange(_ => [last - NUM_SELECTED_COMMITS, last]); }, [commitsLength]); -======= - if (windowStart > 0) { - setWindowEnd(w => w - 1); - } else if (!exhausted && allCommits.length > 0) { - // Need to fetch more: use the oldest known commit as anchor - const oldestHash = allCommits[0].git_commit_hash; - setFetchAnchor(oldestHash); - // Pre-decrement so after merge shift (+uniqueNew.length), the net - // effect is one position to the left — the user sees the next older commit - // immediately once data arrives. The effectiveWindowEnd clamp keeps - // the display stable (≥ WINDOW_SIZE items) during the loading gap. - setWindowEnd(w => w - 1); - } - }, [windowStart, exhausted, allCommits]); - - const goOldest = useCallback(() => { - if (exhausted) { - setWindowEnd(WINDOW_SIZE - 1); - } else if (allCommits.length > 0) { - // Flag the merge effect to position window at oldest end after fetch - jumpToOldestRef.current = true; - const oldestHash = allCommits[0].git_commit_hash; - setFetchAnchor(oldestHash); - } - }, [exhausted, allCommits]); ->>>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) const visibleCommits = commitHashes .map(commit => allCommits.get(commit)) .reverse(); - // Only show loading skeleton on the initial fetch. - // When fetching older pages, keep displaying the buffered chart. - const effectiveStatus = - allCommits.length > 0 && status === 'pending' ? 'success' : status; - type MessagesID = { graphName: MessagesKey; good: MessagesKey; @@ -406,7 +250,6 @@ const CommitNavigationGraph = ({ const theme = useTheme(); const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')); - // Transform the visible window data for the MUI LineChart const series: TLineChartProps['series'] = [ { id: 'good', @@ -441,7 +284,6 @@ const CommitNavigationGraph = ({ const commitData: TCommitValue[] = []; const xAxisIndexes: number[] = []; - // visibleCommits is already in chronological order (oldest first) visibleCommits.forEach(item => { if (!item) { return; @@ -489,13 +331,11 @@ const CommitNavigationGraph = ({ xAxisIndexes.push(commitData.length - 1); }); - // filter only selected, first and last commit const smallScreenTickFilter = (value: number, _: number): boolean => value === 0 || value === commitData.length - 1 || commitData[value]?.commitHash === treeId; - // tickLabelInterval can be set to auto, or to a custom filter const tickLabelInterval = isSmallScreen ? smallScreenTickFilter : 'auto'; const xAxis: TLineChartProps['xAxis'] = [ @@ -530,13 +370,8 @@ const CommitNavigationGraph = ({ return ( >>>>>> 0dc162982 (fix: prevent chart flash and stuck navigation on older page fetch (#1840)) customError={ null; + export const LineChart = ({ labels, series, @@ -51,6 +53,11 @@ export const LineChart = ({ onMarkClick, isLoading, }: TLineChartProps): JSX.Element => { + const mergedSlots = { + ...slots, + noDataOverlay: slots?.noDataOverlay ?? EmptyNoDataOverlay, + }; + return (
{labels && ( @@ -60,7 +67,7 @@ export const LineChart = ({ className="w-full" xAxis={xAxis} sx={sx} - slots={slots} + slots={mergedSlots} slotProps={slotProps} series={series} onMarkClick={onMarkClick}