From a48846cd23806071b18e17ff2b4f69dd27afa3ca Mon Sep 17 00:00:00 2001 From: Gustavo Flores Date: Thu, 14 May 2026 14:53:32 -0300 Subject: [PATCH 1/3] feat: add hardware selectors and revision API --- backend/kernelCI_app/queries/hardware.py | 128 ++++++++++++++++++ .../tests/unitTests/url_patterns_test.py | 8 ++ .../views/hardwareByRevisionView_test.py | 79 +++++++++++ .../views/hardwareSelectorsView_test.py | 84 ++++++++++++ .../typeModels/hardwareListingByRevision.py | 36 +++++ .../typeModels/hardwareSelectors.py | 48 +++++++ backend/kernelCI_app/urls.py | 10 ++ .../views/hardwareByRevisionView.py | 90 ++++++++++++ .../views/hardwareSelectorsView.py | 101 ++++++++++++++ 9 files changed, 584 insertions(+) create mode 100644 backend/kernelCI_app/tests/unitTests/views/hardwareByRevisionView_test.py create mode 100644 backend/kernelCI_app/tests/unitTests/views/hardwareSelectorsView_test.py create mode 100644 backend/kernelCI_app/typeModels/hardwareListingByRevision.py create mode 100644 backend/kernelCI_app/typeModels/hardwareSelectors.py create mode 100644 backend/kernelCI_app/views/hardwareByRevisionView.py create mode 100644 backend/kernelCI_app/views/hardwareSelectorsView.py diff --git a/backend/kernelCI_app/queries/hardware.py b/backend/kernelCI_app/queries/hardware.py index 1bb410747..9e4f59ec0 100644 --- a/backend/kernelCI_app/queries/hardware.py +++ b/backend/kernelCI_app/queries/hardware.py @@ -179,6 +179,134 @@ def get_hardware_listing_data( return cursor.fetchall() +def get_hardware_selectors(origin: str) -> list[dict]: + params = {"origin": origin} + + query = """ + WITH qualified_revisions AS ( + SELECT + c.tree_name, + c.git_repository_url, + c.git_repository_branch, + c.git_commit_hash, + c.git_commit_name, + MAX(c.start_time) AS latest_start_time + FROM + checkouts c + INNER JOIN builds b ON b.checkout_id = c.id + INNER JOIN tests ON tests.build_id = b.id + WHERE + tests.origin = %(origin)s + AND tests.environment_misc ->> 'platform' IS NOT NULL + GROUP BY + c.tree_name, + c.git_repository_url, + c.git_repository_branch, + c.git_commit_hash, + c.git_commit_name + ) + SELECT + qr.tree_name, + qr.git_repository_url, + qr.git_repository_branch, + qr.git_commit_hash, + qr.git_commit_name, + qr.latest_start_time, + MAX(qr.latest_start_time) OVER ( + PARTITION BY + qr.tree_name, + qr.git_repository_url, + qr.git_repository_branch + ) AS branch_latest_start_time + FROM + qualified_revisions qr + ORDER BY + qr.tree_name ASC, + branch_latest_start_time DESC, + qr.latest_start_time DESC + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + return dict_fetchall(cursor) + + +def get_hardware_listing_data_by_revision( + *, + origin: str, + tree_name: str, + git_repository_url: str, + git_repository_branch: str, + git_commit_hash: str, +) -> list[tuple]: + count_clauses = _get_hardware_listing_count_clauses() + params = { + "origin": origin, + "tree_name": tree_name, + "git_repository_url": git_repository_url, + "git_repository_branch": git_repository_branch, + "git_commit_hash": git_commit_hash, + } + + query = f""" + WITH relevant_tests AS ( + SELECT + tests.environment_compatible, + tests.environment_misc ->> 'platform' AS platform, + tests.status, + tests.path, + tests.id, + b.id AS build_id, + b.status AS build_status + FROM + checkouts c + INNER JOIN builds b ON b.checkout_id = c.id + INNER JOIN tests ON tests.build_id = b.id + WHERE + c.tree_name = %(tree_name)s + AND c.git_repository_url = %(git_repository_url)s + AND c.git_repository_branch = %(git_repository_branch)s + AND c.git_commit_hash = %(git_commit_hash)s + AND tests.origin = %(origin)s + AND tests.environment_misc ->> 'platform' IS NOT NULL + ), + compatible_values AS ( + SELECT + platform, + UNNEST(environment_compatible) AS compatible + FROM + relevant_tests + WHERE + environment_compatible IS NOT NULL + ), + compatible_agg AS ( + SELECT + platform, + ARRAY_AGG(DISTINCT compatible ORDER BY compatible) AS hardware + FROM + compatible_values + GROUP BY + platform + ) + SELECT + relevant_tests.platform, + compatible_agg.hardware, + {count_clauses} + FROM + relevant_tests + LEFT JOIN compatible_agg ON compatible_agg.platform = relevant_tests.platform + GROUP BY + relevant_tests.platform, + compatible_agg.hardware + ORDER BY + relevant_tests.platform ASC + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + return cursor.fetchall() + + def get_hardware_listing_data_bulk( keys: list[tuple[str, str]], start_date: datetime, diff --git a/backend/kernelCI_app/tests/unitTests/url_patterns_test.py b/backend/kernelCI_app/tests/unitTests/url_patterns_test.py index a19a1216d..946a33410 100644 --- a/backend/kernelCI_app/tests/unitTests/url_patterns_test.py +++ b/backend/kernelCI_app/tests/unitTests/url_patterns_test.py @@ -133,3 +133,11 @@ def test_tree_commit_hash_patterns(self): url = reverse("treeDetailsSummaryView", kwargs={"commit_hash": commit_hash}) resolved = resolve(url) assert resolved.kwargs["commit_hash"] == commit_hash + + def test_hardware_selectors_route(self): + url = reverse("hardwareSelectors") + resolved = resolve(url) + + assert url == "/api/hardware/selectors/" + assert resolved.url_name == "hardwareSelectors" + assert "hardware_id" not in resolved.kwargs diff --git a/backend/kernelCI_app/tests/unitTests/views/hardwareByRevisionView_test.py b/backend/kernelCI_app/tests/unitTests/views/hardwareByRevisionView_test.py new file mode 100644 index 000000000..a3931cc65 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/hardwareByRevisionView_test.py @@ -0,0 +1,79 @@ +from http import HTTPStatus +from unittest.mock import patch + +from django.test.testcases import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.views.hardwareByRevisionView import HardwareByRevisionView + + +class TestHardwareByRevisionView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = HardwareByRevisionView() + self.url = "/hardware-by-revision" + self.required_query_params = { + "origin": "origin1", + "tree_name": "mainline", + "git_repository_url": "https://example.com/linux.git", + "git_repository_branch": "master", + "git_commit_hash": "abc123", + } + + @patch( + "kernelCI_app.views.hardwareByRevisionView.get_hardware_listing_data_by_revision" + ) + def test_get_hardware_listing_by_revision_success( + self, mock_get_hardware_listing_data_by_revision + ): + mock_get_hardware_listing_data_by_revision.return_value = [ + ("platform1", ["hardware1"], *range(22)), + ] + + request = self.factory.get(self.url, self.required_query_params) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.data["hardware"][0]["platform"], "platform1") + + def test_get_hardware_listing_by_revision_missing_query_params_returns_bad_request( + self, + ): + request = self.factory.get(self.url, {"origin": "origin1"}) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertIn("tree_name", response.data) + self.assertIn("git_repository_url", response.data) + self.assertIn("git_repository_branch", response.data) + self.assertIn("git_commit_hash", response.data) + + @patch( + "kernelCI_app.views.hardwareByRevisionView.get_hardware_listing_data_by_revision" + ) + def test_get_hardware_listing_by_revision_empty_response( + self, mock_get_hardware_listing_data_by_revision + ): + mock_get_hardware_listing_data_by_revision.return_value = [] + + request = self.factory.get(self.url, self.required_query_params) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.data, {"hardware": []}) + + @patch( + "kernelCI_app.views.hardwareByRevisionView.get_hardware_listing_data_by_revision" + ) + def test_get_hardware_listing_by_revision_sanitize_validation_error_returns_internal_server_error( + self, mock_get_hardware_listing_data_by_revision + ): + mock_get_hardware_listing_data_by_revision.return_value = [ + (None, "hardware1", *range(22)), + ] + + request = self.factory.get(self.url, self.required_query_params) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) + self.assertIn("platform", response.data) diff --git a/backend/kernelCI_app/tests/unitTests/views/hardwareSelectorsView_test.py b/backend/kernelCI_app/tests/unitTests/views/hardwareSelectorsView_test.py new file mode 100644 index 000000000..29e5facc0 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/hardwareSelectorsView_test.py @@ -0,0 +1,84 @@ +from datetime import datetime +from http import HTTPStatus +from unittest.mock import patch + +from django.test.testcases import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.constants.general import DEFAULT_ORIGIN +from kernelCI_app.views.hardwareSelectorsView import HardwareSelectorsView + + +class TestHardwareSelectorsView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = HardwareSelectorsView() + self.url = "/hardware/selectors" + + @patch("kernelCI_app.views.hardwareSelectorsView.get_hardware_selectors") + def test_get_hardware_selectors_success(self, mock_get_hardware_selectors): + mock_get_hardware_selectors.return_value = [ + { + "tree_name": "mainline", + "git_repository_url": "https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git", + "git_repository_branch": "master", + "git_commit_hash": "abc123", + "git_commit_name": "v6.12-rc1", + "latest_start_time": datetime(2026, 1, 1, 10, 0, 0), + "branch_latest_start_time": datetime(2026, 1, 1, 10, 0, 0), + } + ] + + request = self.factory.get(self.url, {"origin": "origin1"}) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.data["trees"][0]["tree_name"], "mainline") + self.assertEqual( + response.data["trees"][0]["branches"][0]["revisions"][0]["git_commit_hash"], + "abc123", + ) + mock_get_hardware_selectors.assert_called_once_with(origin="origin1") + + @patch("kernelCI_app.views.hardwareSelectorsView.get_hardware_selectors") + def test_get_hardware_selectors_defaults_origin(self, mock_get_hardware_selectors): + mock_get_hardware_selectors.return_value = [] + + request = self.factory.get(self.url) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.data, {"trees": []}) + mock_get_hardware_selectors.assert_called_once_with(origin=DEFAULT_ORIGIN) + + @patch("kernelCI_app.views.hardwareSelectorsView.get_hardware_selectors") + def test_get_hardware_selectors_empty_response(self, mock_get_hardware_selectors): + mock_get_hardware_selectors.return_value = [] + + request = self.factory.get(self.url, {"origin": "origin1"}) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.data, {"trees": []}) + + @patch("kernelCI_app.views.hardwareSelectorsView.get_hardware_selectors") + def test_get_hardware_selectors_sanitize_validation_error_returns_internal_server_error( + self, mock_get_hardware_selectors + ): + mock_get_hardware_selectors.return_value = [ + { + "tree_name": "mainline", + "git_repository_url": "https://example.com/linux.git", + "git_repository_branch": "master", + "git_commit_hash": None, + "git_commit_name": "v6.12-rc1", + "latest_start_time": datetime(2026, 1, 1, 10, 0, 0), + "branch_latest_start_time": datetime(2026, 1, 1, 10, 0, 0), + } + ] + + request = self.factory.get(self.url, {"origin": "origin1"}) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) + self.assertIn("git_commit_hash", response.data) diff --git a/backend/kernelCI_app/typeModels/hardwareListingByRevision.py b/backend/kernelCI_app/typeModels/hardwareListingByRevision.py new file mode 100644 index 000000000..34cecdd15 --- /dev/null +++ b/backend/kernelCI_app/typeModels/hardwareListingByRevision.py @@ -0,0 +1,36 @@ +from typing import Annotated + +from pydantic import BaseModel, BeforeValidator, Field + +from kernelCI_app.constants.general import DEFAULT_ORIGIN +from kernelCI_app.constants.localization import DocStrings + + +class HardwareListingByRevisionQueryParamsDocumentationOnly(BaseModel): + origin: Annotated[ + str, + Field( + default=DEFAULT_ORIGIN, + description=DocStrings.HARDWARE_LISTING_ORIGIN_DESCRIPTION, + ), + ] + tree_name: str = Field(description=DocStrings.TREE_NAME_PATH_DESCRIPTION) + git_repository_url: str = Field( + description=DocStrings.TREE_QUERY_GIT_URL_DESCRIPTION + ) + git_repository_branch: str = Field( + description=DocStrings.DEFAULT_GIT_BRANCH_DESCRIPTION + ) + git_commit_hash: str = Field(description=DocStrings.COMMIT_HASH_PATH_DESCRIPTION) + + +class HardwareListingByRevisionQueryParams(BaseModel): + origin: Annotated[ + str, + Field(default=DEFAULT_ORIGIN), + BeforeValidator(lambda o: DEFAULT_ORIGIN if o is None else o), + ] + tree_name: str + git_repository_url: str + git_repository_branch: str + git_commit_hash: str diff --git a/backend/kernelCI_app/typeModels/hardwareSelectors.py b/backend/kernelCI_app/typeModels/hardwareSelectors.py new file mode 100644 index 000000000..25c3e0752 --- /dev/null +++ b/backend/kernelCI_app/typeModels/hardwareSelectors.py @@ -0,0 +1,48 @@ +from datetime import datetime +from typing import Annotated + +from pydantic import BaseModel, BeforeValidator, Field + +from kernelCI_app.constants.general import DEFAULT_ORIGIN +from kernelCI_app.constants.localization import DocStrings + + +class HardwareSelectorRevision(BaseModel): + git_commit_hash: str + git_commit_name: str | None = None + start_time: datetime + + +class HardwareSelectorBranch(BaseModel): + git_repository_url: str + git_repository_branch: str + latest_start_time: datetime + revisions: list[HardwareSelectorRevision] + + +class HardwareSelectorTree(BaseModel): + tree_name: str + latest_start_time: datetime + branches: list[HardwareSelectorBranch] + + +class HardwareSelectorsResponse(BaseModel): + trees: list[HardwareSelectorTree] + + +class HardwareSelectorsQueryParamsDocumentationOnly(BaseModel): + origin: Annotated[ + str, + Field( + default=DEFAULT_ORIGIN, + description=DocStrings.HARDWARE_LISTING_ORIGIN_DESCRIPTION, + ), + ] + + +class HardwareSelectorsQueryParams(BaseModel): + origin: Annotated[ + str, + Field(default=DEFAULT_ORIGIN), + BeforeValidator(lambda o: DEFAULT_ORIGIN if o is None else o), + ] diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index 6b2ae6d49..f57e0f61a 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -115,6 +115,11 @@ def view_cache(view): name="testIssues", ), path("log-downloader/", view_cache(views.LogDownloaderView), name="logDownloader"), + path( + "hardware/selectors/", + view_cache(views.HardwareSelectorsView), + name="hardwareSelectors", + ), path( "hardware/", view_cache(views.HardwareDetails), @@ -146,6 +151,11 @@ def view_cache(view): name="hardwareDetailsTests", ), path("hardware/", view_cache(views.HardwareView), name="hardware"), + path( + "hardware-by-revision/", + view_cache(views.HardwareByRevisionView), + name="hardwareByRevision", + ), path("hardware-v2/", view_cache(views.HardwareViewV2), name="hardware-v2"), path("issue/", view_cache(views.IssueView), name="issue"), path( diff --git a/backend/kernelCI_app/views/hardwareByRevisionView.py b/backend/kernelCI_app/views/hardwareByRevisionView.py new file mode 100644 index 000000000..4f0a90b89 --- /dev/null +++ b/backend/kernelCI_app/views/hardwareByRevisionView.py @@ -0,0 +1,90 @@ +from http import HTTPStatus + +from drf_spectacular.utils import extend_schema +from pydantic import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from kernelCI_app.queries.hardware import get_hardware_listing_data_by_revision +from kernelCI_app.typeModels.hardwareListing import ( + HardwareItem, + HardwareListingResponse, +) +from kernelCI_app.typeModels.hardwareListingByRevision import ( + HardwareListingByRevisionQueryParams, + HardwareListingByRevisionQueryParamsDocumentationOnly, +) + + +class HardwareByRevisionView(APIView): + def _sanitize_records(self, hardwares_raw: list[tuple]) -> list[HardwareItem]: + hardwares = [] + for hardware in hardwares_raw: + hardwares.append( + HardwareItem( + platform=hardware[0], + hardware=hardware[1], + build_status_summary={ + "PASS": hardware[2], + "FAIL": hardware[3], + "NULL": hardware[4], + "ERROR": hardware[5], + "MISS": hardware[6], + "DONE": hardware[7], + "SKIP": hardware[8], + }, + boot_status_summary={ + "PASS": hardware[9], + "FAIL": hardware[10], + "NULL": hardware[11], + "ERROR": hardware[12], + "MISS": hardware[13], + "DONE": hardware[14], + "SKIP": hardware[15], + }, + test_status_summary={ + "PASS": hardware[16], + "FAIL": hardware[17], + "NULL": hardware[18], + "ERROR": hardware[19], + "MISS": hardware[20], + "DONE": hardware[21], + "SKIP": hardware[22], + }, + ) + ) + + return hardwares + + @extend_schema( + parameters=[HardwareListingByRevisionQueryParamsDocumentationOnly], + responses=HardwareListingResponse, + ) + def get(self, request: Request): + try: + query_params = HardwareListingByRevisionQueryParams( + origin=request.GET.get("origin"), + tree_name=request.GET.get("tree_name"), + git_repository_url=request.GET.get("git_repository_url"), + git_repository_branch=request.GET.get("git_repository_branch"), + git_commit_hash=request.GET.get("git_commit_hash"), + ) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST) + + hardwares_raw = get_hardware_listing_data_by_revision( + origin=query_params.origin, + tree_name=query_params.tree_name, + git_repository_url=query_params.git_repository_url, + git_repository_branch=query_params.git_repository_branch, + git_commit_hash=query_params.git_commit_hash, + ) + + try: + sanitized_records = self._sanitize_records(hardwares_raw=hardwares_raw) + result = HardwareListingResponse(hardware=sanitized_records) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return Response(data=result.model_dump(), status=HTTPStatus.OK) diff --git a/backend/kernelCI_app/views/hardwareSelectorsView.py b/backend/kernelCI_app/views/hardwareSelectorsView.py new file mode 100644 index 000000000..2b23a6482 --- /dev/null +++ b/backend/kernelCI_app/views/hardwareSelectorsView.py @@ -0,0 +1,101 @@ +from http import HTTPStatus + +from drf_spectacular.utils import extend_schema +from pydantic import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from kernelCI_app.queries.hardware import get_hardware_selectors +from kernelCI_app.typeModels.hardwareSelectors import ( + HardwareSelectorBranch, + HardwareSelectorRevision, + HardwareSelectorsQueryParams, + HardwareSelectorsQueryParamsDocumentationOnly, + HardwareSelectorsResponse, + HardwareSelectorTree, +) + + +class HardwareSelectorsView(APIView): + def _sanitize_records(self, selectors_raw: list[dict]) -> HardwareSelectorsResponse: + trees: list[dict] = [] + trees_by_name: dict[str, dict] = {} + + for row in selectors_raw: + tree_name = row["tree_name"] + tree = trees_by_name.get(tree_name) + if tree is None: + tree = { + "tree_name": tree_name, + "latest_start_time": row["branch_latest_start_time"], + "branches": [], + "_branches_by_key": {}, + } + trees_by_name[tree_name] = tree + trees.append(tree) + + branch_key = (row["git_repository_url"], row["git_repository_branch"]) + branch = tree["_branches_by_key"].get(branch_key) + if branch is None: + branch = { + "git_repository_url": row["git_repository_url"], + "git_repository_branch": row["git_repository_branch"], + "latest_start_time": row["branch_latest_start_time"], + "revisions": [], + } + tree["_branches_by_key"][branch_key] = branch + tree["branches"].append(branch) + + branch["revisions"].append( + { + "git_commit_hash": row["git_commit_hash"], + "git_commit_name": row["git_commit_name"], + "start_time": row["latest_start_time"], + } + ) + + sanitized_trees: list[HardwareSelectorTree] = [] + for tree in trees: + branches = [ + HardwareSelectorBranch( + git_repository_url=branch["git_repository_url"], + git_repository_branch=branch["git_repository_branch"], + latest_start_time=branch["latest_start_time"], + revisions=[ + HardwareSelectorRevision(**revision) + for revision in branch["revisions"] + ], + ) + for branch in tree["branches"] + ] + sanitized_trees.append( + HardwareSelectorTree( + tree_name=tree["tree_name"], + latest_start_time=tree["latest_start_time"], + branches=branches, + ) + ) + + return HardwareSelectorsResponse(trees=sanitized_trees) + + @extend_schema( + parameters=[HardwareSelectorsQueryParamsDocumentationOnly], + responses=HardwareSelectorsResponse, + ) + def get(self, request: Request): + try: + query_params = HardwareSelectorsQueryParams( + origin=request.GET.get("origin") + ) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST) + + selectors_raw = get_hardware_selectors(origin=query_params.origin) + + try: + result = self._sanitize_records(selectors_raw=selectors_raw) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR) + + return Response(data=result.model_dump(), status=HTTPStatus.OK) From d6051f2476cf9de5318b2a9b1866a5dab594dc3b Mon Sep 17 00:00:00 2001 From: Gustavo Flores Date: Thu, 14 May 2026 20:46:15 -0300 Subject: [PATCH 2/3] feat: add hardware tree selection flow on the frontend --- dashboard/package.json | 2 + dashboard/pnpm-lock.yaml | 89 +++++ dashboard/src/api/hardware.ts | 81 +++-- dashboard/src/components/ui/command.tsx | 153 ++++++++ dashboard/src/components/ui/popover.tsx | 29 ++ dashboard/src/locales/messages/index.ts | 11 + .../pages/Hardware/HardwareListingPageV2.tsx | 326 ++++++++++++------ .../Hardware/HardwareRevisionSelectors.tsx | 249 +++++++++++++ .../src/pages/Hardware/HardwareTable.tsx | 37 +- .../src/pages/Hardware/hardwareSelection.ts | 271 +++++++++++++++ .../src/pages/Hardware/hardwareTableUtils.ts | 39 +++ dashboard/src/routes/_main/hardware/route.tsx | 4 + .../src/routes/_main/hardware/v2/route.tsx | 4 + dashboard/src/types/general.ts | 4 + dashboard/src/types/hardware.ts | 30 ++ dashboard/src/utils/search.ts | 4 + 16 files changed, 1197 insertions(+), 136 deletions(-) create mode 100644 dashboard/src/components/ui/command.tsx create mode 100644 dashboard/src/components/ui/popover.tsx create mode 100644 dashboard/src/pages/Hardware/HardwareRevisionSelectors.tsx create mode 100644 dashboard/src/pages/Hardware/hardwareSelection.ts create mode 100644 dashboard/src/pages/Hardware/hardwareTableUtils.ts diff --git a/dashboard/package.json b/dashboard/package.json index eca7dfa1a..835d5e430 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -34,6 +34,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", @@ -53,6 +54,7 @@ "class-variance-authority": "^0.7.1", "classnames": "^2.5.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "date-fns": "^4.1.0", "eslint-import-resolver-typescript": "^3.10.1", "lodash-es": "^4.18.1", diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml index 070f01b25..7ec44023b 100644 --- a/dashboard/pnpm-lock.yaml +++ b/dashboard/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@radix-ui/react-navigation-menu': specifier: ^1.2.14 version: 1.2.14(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-popover': + specifier: ^1.1.15 + version: 1.1.15(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) '@radix-ui/react-scroll-area': specifier: ^1.2.10 version: 1.2.10(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -107,6 +110,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) date-fns: specifier: ^4.1.0 version: 4.1.0 @@ -1561,6 +1567,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popper@1.2.8': resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} peerDependencies: @@ -1874,66 +1893,79 @@ packages: resolution: {integrity: sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.1': resolution: {integrity: sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.1': resolution: {integrity: sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.1': resolution: {integrity: sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.1': resolution: {integrity: sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.1': resolution: {integrity: sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.1': resolution: {integrity: sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.1': resolution: {integrity: sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.1': resolution: {integrity: sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.1': resolution: {integrity: sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.1': resolution: {integrity: sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.1': resolution: {integrity: sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.1': resolution: {integrity: sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.1': resolution: {integrity: sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==} @@ -2179,24 +2211,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.1': resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.1': resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.1': resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.1': resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} @@ -2630,41 +2666,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -3080,6 +3124,12 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -4067,24 +4117,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -6568,6 +6622,29 @@ snapshots: '@types/react': 19.1.11 '@types/react-dom': 19.1.8(@types/react@19.1.11) + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1) + '@radix-ui/react-context': 1.1.2(@types/react@19.1.11)(react@19.1.1) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.1.11)(react@19.1.1) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.11)(react@19.1.1) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.11)(react@19.1.1) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.11)(react@19.1.1) + aria-hidden: 1.2.6 + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + react-remove-scroll: 2.7.1(@types/react@19.1.11)(react@19.1.1) + optionalDependencies: + '@types/react': 19.1.11 + '@types/react-dom': 19.1.8(@types/react@19.1.11) + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)': dependencies: '@floating-ui/react-dom': 2.1.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1) @@ -8228,6 +8305,18 @@ snapshots: clsx@2.1.1: {} + cmdk@1.1.1(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.11)(react@19.1.1) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + '@radix-ui/react-id': 1.1.1(@types/react@19.1.11)(react@19.1.1) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.8(@types/react@19.1.11))(@types/react@19.1.11)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@1.9.3: dependencies: color-name: 1.1.3 diff --git a/dashboard/src/api/hardware.ts b/dashboard/src/api/hardware.ts index 0c3787dee..e99bbdc0c 100644 --- a/dashboard/src/api/hardware.ts +++ b/dashboard/src/api/hardware.ts @@ -5,7 +5,8 @@ import { useSearch } from '@tanstack/react-router'; import type { HardwareListingResponse, - HardwareListingResponseV2, + HardwareRevisionSelection, + HardwareSelectorsResponse, } from '@/types/hardware'; import type { HardwareListingRoutesMap } from '@/utils/constants/hardwareListing'; @@ -57,17 +58,13 @@ export const useHardwareListing = ( }); }; -const fetchHardwareListingV2 = async ( +const fetchHardwareSelectors = async ( origin: string, - startTimestampInSeconds: number, - endTimestampInSeconds: number, -): Promise => { - const data = await RequestData.get( - '/api/hardware-v2/', +): Promise => { + const data = await RequestData.get( + '/api/hardware/selectors/', { params: { - startTimestampInSeconds, - endTimestampInSeconds, origin, }, }, @@ -76,28 +73,68 @@ const fetchHardwareListingV2 = async ( return data; }; -export const useHardwareListingV2 = ( - startTimestampInSeconds: number, - endTimestampInSeconds: number, +export const useHardwareSelectors = ( + searchFrom: HardwareListingRoutesMap['v2']['search'], +): UseQueryResult => { + const { origin } = useSearch({ from: searchFrom }); + + return useQuery({ + queryKey: ['hardwareSelectors', origin], + queryFn: () => fetchHardwareSelectors(origin), + refetchOnWindowFocus: false, + }); +}; + +const fetchHardwareListingByRevision = async ( + selection: HardwareRevisionSelection, + origin: string, +): Promise => { + const data = await RequestData.get( + '/api/hardware-by-revision/', + { + params: { + origin, + tree_name: selection.treeName, + git_repository_url: selection.gitRepositoryUrl, + git_repository_branch: selection.gitBranch, + git_commit_hash: selection.gitCommitHash, + }, + }, + ); + + return data; +}; + +export const useHardwareListingByRevision = ( + selection: HardwareRevisionSelection | null, searchFrom: HardwareListingRoutesMap['v2']['search'], -): UseQueryResult => { +): UseQueryResult => { const { origin } = useSearch({ from: searchFrom }); const queryKey = [ - 'hardwareListingV2', - startTimestampInSeconds, - endTimestampInSeconds, + 'hardwareListingByRevision', origin, + selection?.treeName, + selection?.gitRepositoryUrl, + selection?.gitBranch, + selection?.gitCommitHash, + selection, ]; return useQuery({ queryKey, - queryFn: () => - fetchHardwareListingV2( - origin, - startTimestampInSeconds, - endTimestampInSeconds, - ), + queryFn: () => { + if (selection === null) { + return { hardware: [] }; + } + return fetchHardwareListingByRevision(selection, origin); + }, + enabled: Boolean( + selection?.treeName && + selection?.gitRepositoryUrl && + selection?.gitBranch && + selection?.gitCommitHash, + ), refetchOnWindowFocus: false, }); }; diff --git a/dashboard/src/components/ui/command.tsx b/dashboard/src/components/ui/command.tsx new file mode 100644 index 000000000..2276d065c --- /dev/null +++ b/dashboard/src/components/ui/command.tsx @@ -0,0 +1,153 @@ +"use client" + +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +const CommandDialog = ({ children, ...props }: DialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/dashboard/src/components/ui/popover.tsx b/dashboard/src/components/ui/popover.tsx new file mode 100644 index 000000000..59f542458 --- /dev/null +++ b/dashboard/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index d08269b67..770edc449 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -214,9 +214,20 @@ export const messages = { 'hardwareDetails.timeFrame': 'Results from {startDate} and {startTime} to {endDate} {endTime}', 'hardwareListing.bannerTitle': 'Hardware Listing', + 'hardwareListing.branchSelectorLabel': 'Branch', 'hardwareListing.description': 'List of hardware from kernel tests', 'hardwareListing.notFound': 'No hardware information available', + 'hardwareListing.revisionCapNote': 'Showing latest 50 revisions', + 'hardwareListing.revisionEmpty': + 'The selected revision has no hardware rows yet. Data ingestion may still be in progress.', + 'hardwareListing.revisionSelectorLabel': 'Revision', + 'hardwareListing.selectionResetDescription': + 'The previous tree/branch/revision selection has no qualifying data for the selected origin and was reset to the latest available revision.', + 'hardwareListing.selectionResetTitle': 'Hardware selection reset', + 'hardwareListing.selectorsNoData': + 'No qualifying hardware data is available for the selected origin yet.', 'hardwareListing.title': 'Hardware Listing ― KCI Dashboard', + 'hardwareListing.treeSelectorLabel': 'Tree', 'issue.alsoPresentTooltip': 'Issue also present in {tree}', 'issue.firstSeen': 'First seen', 'issue.newIssue': 'New issue: This is the first time this issue was seen', diff --git a/dashboard/src/pages/Hardware/HardwareListingPageV2.tsx b/dashboard/src/pages/Hardware/HardwareListingPageV2.tsx index 39a1a3161..e678a0c95 100644 --- a/dashboard/src/pages/Hardware/HardwareListingPageV2.tsx +++ b/dashboard/src/pages/Hardware/HardwareListingPageV2.tsx @@ -1,91 +1,125 @@ -import { useEffect, useMemo, useState, type JSX } from 'react'; -import { roundToNearestMinutes } from 'date-fns'; +import { useMemo, type JSX } from 'react'; +import { FormattedMessage } from 'react-intl'; -import { useSearch } from '@tanstack/react-router'; +import { useNavigate, useSearch } from '@tanstack/react-router'; import { Toaster } from '@/components/ui/toaster'; import type { HardwareItem } from '@/types/hardware'; -import { useHardwareListingV2 } from '@/api/hardware'; +import { + useHardwareListingByRevision, + useHardwareSelectors, +} from '@/api/hardware'; import { dateObjectToTimestampInSeconds, daysToSeconds } from '@/utils/date'; -import type { RequiredStatusCount, StatusCount } from '@/types/general'; - import { - matchesRegexOrIncludes, includesInAnStringOrStringArray, + matchesRegexOrIncludes, } from '@/lib/string'; import { MemoizedKcidevFooter } from '@/components/Footer/KcidevFooter'; +import { REDUCED_TIME_SEARCH } from '@/utils/constants/general'; import type { HardwareListingRoutesMap } from '@/utils/constants/hardwareListing'; import { HardwareTable } from './HardwareTable'; +import { HardwareRevisionSelectors } from './HardwareRevisionSelectors'; +import { + decodeBranchValue, + getBranchBySelection, + getSelectionForBranchChange, + getSelectionForTreeChange, + getTreeBySelection, + resolveHardwareSelection, +} from './hardwareSelection'; interface HardwareListingPageV2Props { inputFilter: string; urlFromMap: HardwareListingRoutesMap['v2']; } -const calculateTimeStamp = ( - intervalInDays: number, -): { - startTimestampInSeconds: number; - endTimestampInSeconds: number; -} => { - // Rounding so cache key doesn't get invalidated every request - const endTimestampInSeconds = dateObjectToTimestampInSeconds( - roundToNearestMinutes(new Date(), { - nearestTo: 30, - }), - ); - const startTimestampInSeconds = - endTimestampInSeconds - daysToSeconds(intervalInDays); - return { startTimestampInSeconds, endTimestampInSeconds }; -}; - -const useHardwareListingTime = ( - searchFrom: HardwareListingPageV2Props['urlFromMap']['search'], -): { - startTimestampInSeconds: number; - endTimestampInSeconds: number; -} => { - const { intervalInDays } = useSearch({ from: searchFrom }); - const [timestamps, setTimeStamps] = useState(() => { - return calculateTimeStamp(intervalInDays); - }); - - useEffect(() => { - setTimeStamps(calculateTimeStamp(intervalInDays)); - }, [intervalInDays]); - - const { startTimestampInSeconds, endTimestampInSeconds } = timestamps; - - return { startTimestampInSeconds, endTimestampInSeconds }; -}; - const HardwareListingPageV2 = ({ inputFilter, urlFromMap, }: HardwareListingPageV2Props): JSX.Element => { - const { startTimestampInSeconds, endTimestampInSeconds } = - useHardwareListingTime(urlFromMap.search); - const { origin } = useSearch({ from: urlFromMap.search }); + const navigate = useNavigate({ from: urlFromMap.navigate }); + const { origin, treeName, gitRepositoryUrl, gitBranch, gitCommitHash } = + useSearch({ from: urlFromMap.search }); + + const { + data: selectorsData, + error: selectorsError, + status: selectorsStatus, + } = useHardwareSelectors(urlFromMap.search); + const selectors = useMemo(() => selectorsData?.trees ?? [], [selectorsData]); + + const hasSelectionParams = Boolean( + treeName || gitRepositoryUrl || gitBranch || gitCommitHash, + ); + + const resolvedSelection = useMemo(() => { + const selectionFromUrl = + treeName && gitRepositoryUrl && gitBranch && gitCommitHash + ? { + treeName, + gitRepositoryUrl, + gitBranch, + gitCommitHash, + } + : null; - const { data, error, status, isLoading } = useHardwareListingV2( - startTimestampInSeconds, - endTimestampInSeconds, + return resolveHardwareSelection({ + trees: selectors, + selectionFromUrl, + hasSelectionParams, + }); + }, [ + selectors, + treeName, + gitRepositoryUrl, + gitBranch, + gitCommitHash, + hasSelectionParams, + ]); + + const { + data: listingData, + error: listingError, + status: listingStatus, + isLoading: isListingLoading, + } = useHardwareListingByRevision( + resolvedSelection.selection, urlFromMap.search, ); + const selectedTree = useMemo(() => { + if (resolvedSelection.selection === null) { + return null; + } + + return getTreeBySelection(selectors, resolvedSelection.selection.treeName); + }, [selectors, resolvedSelection.selection]); + + const selectedBranch = useMemo(() => { + if (resolvedSelection.selection === null || selectedTree === null) { + return null; + } + + return getBranchBySelection( + selectedTree, + resolvedSelection.selection.gitRepositoryUrl, + resolvedSelection.selection.gitBranch, + ); + }, [resolvedSelection.selection, selectedTree]); + const listItems: HardwareItem[] = useMemo(() => { - if (!data || error) { + if (!listingData || listingError) { return []; } - const currentData = data.hardware; + const currentData = listingData.hardware; return currentData .filter(hardware => { @@ -94,47 +128,18 @@ const HardwareListingPageV2 = ({ includesInAnStringOrStringArray(hardware.hardware ?? '', inputFilter) ); }) - .map((hardware): HardwareItem => { - const buildCount: RequiredStatusCount = { - PASS: hardware.build_status_summary?.PASS, - FAIL: hardware.build_status_summary?.FAIL, - NULL: 0, - ERROR: 0, - MISS: 0, - DONE: 0, - SKIP: hardware.build_status_summary?.INCONCLUSIVE, - }; - - const testStatusCount: StatusCount = { - DONE: 0, - ERROR: 0, - FAIL: hardware.test_status_summary.FAIL, - MISS: 0, - PASS: hardware.test_status_summary.PASS, - SKIP: 0, - NULL: hardware.test_status_summary.INCONCLUSIVE, - }; - - const bootStatusCount: StatusCount = { - DONE: 0, - ERROR: 0, - FAIL: hardware.boot_status_summary.FAIL, - MISS: 0, - PASS: hardware.boot_status_summary.PASS, - SKIP: 0, - NULL: hardware.boot_status_summary.INCONCLUSIVE, - }; - - return { - hardware: hardware.hardware, - platform: hardware.platform, - build_status_summary: buildCount, - test_status_summary: testStatusCount, - boot_status_summary: bootStatusCount, - }; - }) .sort((a, b) => a.platform.localeCompare(b.platform)); - }, [data, error, inputFilter]); + }, [listingData, listingError, inputFilter]); + + const revisionStartTimestampInSeconds = resolvedSelection.revisionStartTime + ? dateObjectToTimestampInSeconds( + new Date(resolvedSelection.revisionStartTime), + ) + : 0; + + const revisionEndTimestampInSeconds = revisionStartTimestampInSeconds + ? revisionStartTimestampInSeconds + daysToSeconds(REDUCED_TIME_SEARCH) + : 0; const kcidevComponent = useMemo( () => ( @@ -146,20 +151,137 @@ const HardwareListingPageV2 = ({ [origin], ); + const onTreeChange = (nextTreeName: string): void => { + const nextSelection = getSelectionForTreeChange({ + trees: selectors, + treeName: nextTreeName, + }); + if (nextSelection === null) { + return; + } + + navigate({ + search: previousSearch => ({ + ...previousSearch, + treeName: nextSelection.treeName, + gitRepositoryUrl: nextSelection.gitRepositoryUrl, + gitBranch: nextSelection.gitBranch, + gitCommitHash: nextSelection.gitCommitHash, + }), + state: s => s, + }); + }; + + const onBranchChange = (branchValue: string): void => { + if (selectedTree === null) { + return; + } + + const branchSelection = decodeBranchValue(branchValue); + if (branchSelection === null) { + return; + } + + const nextSelection = getSelectionForBranchChange({ + tree: selectedTree, + gitRepositoryUrl: branchSelection.gitRepositoryUrl, + gitBranch: branchSelection.gitBranch, + }); + if (nextSelection === null) { + return; + } + + navigate({ + search: previousSearch => ({ + ...previousSearch, + treeName: nextSelection.treeName, + gitRepositoryUrl: nextSelection.gitRepositoryUrl, + gitBranch: nextSelection.gitBranch, + gitCommitHash: nextSelection.gitCommitHash, + }), + state: s => s, + }); + }; + + const onRevisionChange = (nextGitCommitHash: string): void => { + navigate({ + search: previousSearch => ({ + ...previousSearch, + gitCommitHash: nextGitCommitHash, + }), + state: s => s, + }); + }; + + const hasSelectors = selectors.length > 0; + const hasListingRows = Boolean((listingData?.hardware.length ?? 0) > 0); + const tableEmptyMessageId = + !hasListingRows && inputFilter.length === 0 + ? 'hardwareListing.revisionEmpty' + : 'hardwareListing.notFound'; + return ( <>
- + {selectorsStatus === 'error' && ( +
+ + {selectorsError?.message} + +
+ )} + + {selectorsStatus === 'pending' && ( +
+ +
+ )} + + {selectorsStatus === 'success' && ( + <> + {!hasSelectors && ( +
+ +
+ )} + + {hasSelectors && ( + <> + + + {!hasListingRows && + inputFilter.length === 0 && + listingStatus === 'success' && ( +

+ +

+ )} + + + + )} + + )}
{kcidevComponent} diff --git a/dashboard/src/pages/Hardware/HardwareRevisionSelectors.tsx b/dashboard/src/pages/Hardware/HardwareRevisionSelectors.tsx new file mode 100644 index 000000000..34cc0e624 --- /dev/null +++ b/dashboard/src/pages/Hardware/HardwareRevisionSelectors.tsx @@ -0,0 +1,249 @@ +import { useState, type JSX } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Check, ChevronsUpDown } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from '@/components/ui/command'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import type { + HardwareRevisionSelection, + HardwareSelectorBranch, + HardwareSelectorTree, +} from '@/types/hardware'; + +import { encodeBranchValue } from './hardwareSelection'; + +const SHORT_HASH_LENGTH = 12; + +type SelectorOption = { + value: string; + label: string; +}; + +const shortHash = (value: string): string => value.slice(0, SHORT_HASH_LENGTH); + +interface HardwareRevisionSelectorsPresentationProps { + treeOptions: SelectorOption[]; + branchOptions: SelectorOption[]; + revisionOptions: SelectorOption[]; + selectedTreeName?: string; + selectedBranchValue?: string; + selectedRevisionHash?: string; + onTreeChange: (nextTreeName: string) => void; + onBranchChange: (nextBranchValue: string) => void; + onRevisionChange: (nextRevisionHash: string) => void; +} + +interface HardwareRevisionComboboxProps { + options: SelectorOption[]; + selectedValue?: string; + onValueChange: (nextValue: string) => void; + placeholder: string; + searchPlaceholder: string; + emptyMessage: string; + dataTestId: string; + disabled?: boolean; +} + +const HardwareRevisionCombobox = ({ + options, + selectedValue, + onValueChange, + placeholder, + searchPlaceholder, + emptyMessage, + dataTestId, + disabled = false, +}: HardwareRevisionComboboxProps): JSX.Element => { + const [open, setOpen] = useState(false); + const selectedOption = options.find(option => option.value === selectedValue); + + return ( + + + + + + + + + {emptyMessage} + + {options.map(option => ( + { + onValueChange(option.value); + setOpen(false); + }} + value={option.value} + > + {option.label} + + + ))} + + + + + + ); +}; + +const HardwareRevisionSelectorsPresentation = ({ + treeOptions, + branchOptions, + revisionOptions, + selectedTreeName, + selectedBranchValue, + selectedRevisionHash, + onTreeChange, + onBranchChange, + onRevisionChange, +}: HardwareRevisionSelectorsPresentationProps): JSX.Element => { + return ( +
+
+ + + + +
+ +
+ + + + +
+ +
+ + + + +
+
+ ); +}; + +interface HardwareRevisionSelectorsProps { + selectors: HardwareSelectorTree[]; + selectedTree: HardwareSelectorTree | null; + selectedBranch: HardwareSelectorBranch | null; + selection: HardwareRevisionSelection | null; + onTreeChange: (nextTreeName: string) => void; + onBranchChange: (nextBranchValue: string) => void; + onRevisionChange: (nextRevisionHash: string) => void; +} + +export const HardwareRevisionSelectors = ({ + selectors, + selectedTree, + selectedBranch, + selection, + onTreeChange, + onBranchChange, + onRevisionChange, +}: HardwareRevisionSelectorsProps): JSX.Element => { + const treeOptions: SelectorOption[] = selectors.map(tree => ({ + value: tree.tree_name, + label: tree.tree_name, + })); + + const branchOptions: SelectorOption[] = (selectedTree?.branches ?? []).map( + branch => ({ + value: encodeBranchValue( + branch.git_repository_url, + branch.git_repository_branch, + ), + label: branch.git_repository_branch, + }), + ); + + const revisionOptions: SelectorOption[] = ( + selectedBranch?.revisions ?? [] + ).map(revision => ({ + value: revision.git_commit_hash, + label: revision.git_commit_name ?? shortHash(revision.git_commit_hash), + })); + + const selectedBranchValue = selection + ? encodeBranchValue(selection.gitRepositoryUrl, selection.gitBranch) + : undefined; + + return ( + + ); +}; diff --git a/dashboard/src/pages/Hardware/HardwareTable.tsx b/dashboard/src/pages/Hardware/HardwareTable.tsx index 0ea2864c5..7bbb3d2e4 100644 --- a/dashboard/src/pages/Hardware/HardwareTable.tsx +++ b/dashboard/src/pages/Hardware/HardwareTable.tsx @@ -24,6 +24,7 @@ import { useNavigate, useSearch, type LinkProps } from '@tanstack/react-router'; import BaseTable, { TableHead } from '@/components/Table/BaseTable'; import { formattedBreakLineValue } from '@/locales/messages'; +import type { MessagesKey } from '@/locales/messages'; import { TableBody, TableCell, TableRow } from '@/components/ui/table'; import { ConditionalTableCell } from '@/components/Table/ConditionalTableCell'; @@ -57,6 +58,8 @@ import { Badge } from '@/components/ui/badge'; import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; import { MemoizedSectionError } from '@/components/DetailsPages/SectionError'; +import { buildHardwareDetailsSearch } from './hardwareTableUtils'; + // TODO Extract and reuse the table interface IHardwareTable { treeTableRows: HardwareItem[]; @@ -67,6 +70,8 @@ interface IHardwareTable { error?: Error | null; isLoading?: boolean; navigateFrom: HardwareListingRoutes; + showTimeFilterInput?: boolean; + emptyMessageId?: MessagesKey; } type HardwareListingRoutes = '/hardware' | '/hardware/v1' | '/hardware/v2'; @@ -79,17 +84,20 @@ const getLinkProps = ( tabTarget?: string, newDiffFilter?: TFilter, ): LinkProps => { + const currentPageTab = zPossibleTabValidator.parse(tabTarget); + return { from: navigateFrom, to: '/hardware/$hardwareId', params: { hardwareId: row.original.platform }, - search: previousSearch => ({ - ...previousSearch, - currentPageTab: zPossibleTabValidator.parse(tabTarget), - startTimestampInSeconds, - endTimestampInSeconds, - diffFilter: { ...previousSearch.diffFilter, ...newDiffFilter }, - }), + search: previousSearch => + buildHardwareDetailsSearch({ + previousSearch, + currentPageTab, + startTimestampInSeconds, + endTimestampInSeconds, + newDiffFilter, + }), state: s => ({ ...s, id: row.original.platform, @@ -372,6 +380,8 @@ export function HardwareTable({ error, isLoading, navigateFrom, + showTimeFilterInput = true, + emptyMessageId = 'hardwareListing.notFound', }: IHardwareTable): JSX.Element { const { listingSize } = useSearch({ strict: false }); const navigate = useNavigate({ from: navigateFrom }); @@ -458,11 +468,12 @@ export function HardwareTable({ ) : ( - + ); }, [ + emptyMessageId, modelRows, navigateFrom, columns.length, @@ -490,10 +501,12 @@ export function HardwareTable({ />
- + {showTimeFilterInput && ( + + )} { + return new Date(timestamp).getTime(); +}; + +const getLatestRevision = ( + revisions: HardwareSelectorRevision[], +): HardwareSelectorRevision | null => { + return ( + revisions.reduce((latest, revision) => { + if (latest === null) { + return revision; + } + + return getTimestamp(revision.start_time) > getTimestamp(latest.start_time) + ? revision + : latest; + }, null) ?? null + ); +}; + +const getLatestBranch = ( + branches: HardwareSelectorBranch[], +): HardwareSelectorBranch | null => { + return ( + branches.reduce((latest, branch) => { + if (latest === null) { + return branch; + } + + return getTimestamp(branch.latest_start_time) > + getTimestamp(latest.latest_start_time) + ? branch + : latest; + }, null) ?? null + ); +}; + +export const encodeBranchValue = ( + gitRepositoryUrl: string, + gitBranch: string, +): string => { + return `${encodeURIComponent(gitRepositoryUrl)}${BRANCH_VALUE_SEPARATOR}${encodeURIComponent(gitBranch)}`; +}; + +export const decodeBranchValue = ( + branchValue: string, +): { gitRepositoryUrl: string; gitBranch: string } | null => { + const [encodedGitRepositoryUrl, encodedGitBranch] = branchValue.split( + BRANCH_VALUE_SEPARATOR, + ); + + if (!encodedGitRepositoryUrl || !encodedGitBranch) { + return null; + } + + return { + gitRepositoryUrl: decodeURIComponent(encodedGitRepositoryUrl), + gitBranch: decodeURIComponent(encodedGitBranch), + }; +}; + +export const getGlobalLatestHardwareSelection = ( + trees: HardwareSelectorTree[], +): { + selection: HardwareRevisionSelection; + revisionStartTime: string; +} | null => { + return ( + trees.reduce<{ + selection: HardwareRevisionSelection; + revisionStartTime: string; + } | null>((latest, tree) => { + const latestBranch = getLatestBranch(tree.branches); + if (latestBranch === null) { + return latest; + } + + const latestRevision = getLatestRevision(latestBranch.revisions); + if (latestRevision === null) { + return latest; + } + + const nextSelection = { + selection: { + treeName: tree.tree_name, + gitRepositoryUrl: latestBranch.git_repository_url, + gitBranch: latestBranch.git_repository_branch, + gitCommitHash: latestRevision.git_commit_hash, + }, + revisionStartTime: latestRevision.start_time, + }; + + if (latest === null) { + return nextSelection; + } + + return getTimestamp(nextSelection.revisionStartTime) > + getTimestamp(latest.revisionStartTime) + ? nextSelection + : latest; + }, null) ?? null + ); +}; + +export const getTreeBySelection = ( + trees: HardwareSelectorTree[], + treeName: string, +): HardwareSelectorTree | null => { + return trees.find(tree => tree.tree_name === treeName) ?? null; +}; + +export const getBranchBySelection = ( + tree: HardwareSelectorTree, + gitRepositoryUrl: string, + gitBranch: string, +): HardwareSelectorBranch | null => { + return ( + tree.branches.find(branch => { + return ( + branch.git_repository_url === gitRepositoryUrl && + branch.git_repository_branch === gitBranch + ); + }) ?? null + ); +}; + +export const getRevisionBySelection = ( + branch: HardwareSelectorBranch, + gitCommitHash: string, +): HardwareSelectorRevision | null => { + return ( + branch.revisions.find( + revision => revision.git_commit_hash === gitCommitHash, + ) ?? null + ); +}; + +export const resolveHardwareSelection = ({ + trees, + selectionFromUrl, + hasSelectionParams, +}: { + trees: HardwareSelectorTree[]; + selectionFromUrl: HardwareRevisionSelection | null; + hasSelectionParams: boolean; +}): ResolvedHardwareSelection => { + if (trees.length === 0) { + return { + selection: null, + revisionStartTime: null, + wasReset: false, + }; + } + + if (selectionFromUrl !== null) { + const selectedTree = getTreeBySelection(trees, selectionFromUrl.treeName); + if (selectedTree !== null) { + const selectedBranch = getBranchBySelection( + selectedTree, + selectionFromUrl.gitRepositoryUrl, + selectionFromUrl.gitBranch, + ); + + if (selectedBranch !== null) { + const selectedRevision = getRevisionBySelection( + selectedBranch, + selectionFromUrl.gitCommitHash, + ); + + if (selectedRevision !== null) { + return { + selection: selectionFromUrl, + revisionStartTime: selectedRevision.start_time, + wasReset: false, + }; + } + } + } + } + + const globalSelection = getGlobalLatestHardwareSelection(trees); + if (globalSelection === null) { + return { + selection: null, + revisionStartTime: null, + wasReset: false, + }; + } + + return { + selection: globalSelection.selection, + revisionStartTime: globalSelection.revisionStartTime, + wasReset: hasSelectionParams, + }; +}; + +export const getSelectionForTreeChange = ({ + trees, + treeName, +}: { + trees: HardwareSelectorTree[]; + treeName: string; +}): HardwareRevisionSelection | null => { + const selectedTree = getTreeBySelection(trees, treeName); + if (selectedTree === null) { + return null; + } + + const selectedBranch = getLatestBranch(selectedTree.branches); + if (selectedBranch === null) { + return null; + } + + const selectedRevision = getLatestRevision(selectedBranch.revisions); + if (selectedRevision === null) { + return null; + } + + return { + treeName: selectedTree.tree_name, + gitRepositoryUrl: selectedBranch.git_repository_url, + gitBranch: selectedBranch.git_repository_branch, + gitCommitHash: selectedRevision.git_commit_hash, + }; +}; + +export const getSelectionForBranchChange = ({ + tree, + gitRepositoryUrl, + gitBranch, +}: { + tree: HardwareSelectorTree; + gitRepositoryUrl: string; + gitBranch: string; +}): HardwareRevisionSelection | null => { + const selectedBranch = getBranchBySelection( + tree, + gitRepositoryUrl, + gitBranch, + ); + if (selectedBranch === null) { + return null; + } + + const selectedRevision = getLatestRevision(selectedBranch.revisions); + if (selectedRevision === null) { + return null; + } + + return { + treeName: tree.tree_name, + gitRepositoryUrl: selectedBranch.git_repository_url, + gitBranch: selectedBranch.git_repository_branch, + gitCommitHash: selectedRevision.git_commit_hash, + }; +}; diff --git a/dashboard/src/pages/Hardware/hardwareTableUtils.ts b/dashboard/src/pages/Hardware/hardwareTableUtils.ts new file mode 100644 index 000000000..56bd4167c --- /dev/null +++ b/dashboard/src/pages/Hardware/hardwareTableUtils.ts @@ -0,0 +1,39 @@ +import type { TFilter } from '@/types/general'; +import type { PossibleTabs } from '@/types/tree/TreeDetails'; + +type ListingSearch = Record; + +export const buildHardwareDetailsSearch = ({ + previousSearch, + currentPageTab, + startTimestampInSeconds, + endTimestampInSeconds, + newDiffFilter, +}: { + previousSearch: ListingSearch; + currentPageTab: PossibleTabs; + startTimestampInSeconds: number; + endTimestampInSeconds: number; + newDiffFilter?: TFilter; +}): ListingSearch => { + const { + treeIndexes: _treeIndexes, + treeCommits: _treeCommits, + treeName: _treeName, + gitRepositoryUrl: _gitRepositoryUrl, + gitBranch: _gitBranch, + gitCommitHash: _gitCommitHash, + ...searchWithoutTreeParams + } = previousSearch; + + const previousDiffFilter = + (searchWithoutTreeParams.diffFilter as Record) ?? {}; + + return { + ...searchWithoutTreeParams, + currentPageTab, + startTimestampInSeconds, + endTimestampInSeconds, + diffFilter: { ...previousDiffFilter, ...newDiffFilter }, + }; +}; diff --git a/dashboard/src/routes/_main/hardware/route.tsx b/dashboard/src/routes/_main/hardware/route.tsx index 2dc80e0ba..9843e5aaf 100644 --- a/dashboard/src/routes/_main/hardware/route.tsx +++ b/dashboard/src/routes/_main/hardware/route.tsx @@ -21,6 +21,10 @@ const zHardwareSchema = z.object({ intervalInDays: makeZIntervalInDays(REDUCED_TIME_SEARCH), hardwareSearch: z.string().catch(''), listingSize: zListingSize, + treeName: z.optional(z.string()), + gitRepositoryUrl: z.optional(z.string()), + gitBranch: z.optional(z.string()), + gitCommitHash: z.optional(z.string()), } satisfies SearchSchema); export const Route = createFileRoute('/_main/hardware')({ diff --git a/dashboard/src/routes/_main/hardware/v2/route.tsx b/dashboard/src/routes/_main/hardware/v2/route.tsx index 825605a1b..996e9e40d 100644 --- a/dashboard/src/routes/_main/hardware/v2/route.tsx +++ b/dashboard/src/routes/_main/hardware/v2/route.tsx @@ -21,6 +21,10 @@ const zHardwareSchema = z.object({ intervalInDays: makeZIntervalInDays(REDUCED_TIME_SEARCH), hardwareSearch: z.string().catch(''), listingSize: zListingSize, + treeName: z.optional(z.string()), + gitRepositoryUrl: z.optional(z.string()), + gitBranch: z.optional(z.string()), + gitCommitHash: z.optional(z.string()), } satisfies SearchSchema); export const Route = createFileRoute('/_main/hardware/v2')({ diff --git a/dashboard/src/types/general.ts b/dashboard/src/types/general.ts index 142cfb12e..cf1ba61c7 100644 --- a/dashboard/src/types/general.ts +++ b/dashboard/src/types/general.ts @@ -255,6 +255,10 @@ export type SearchParamsKeys = | 'treeInfo' | 'treeIndexes' | 'treeCommits' + | 'treeName' + | 'gitRepositoryUrl' + | 'gitBranch' + | 'gitCommitHash' | 'startTimestampInSeconds' | 'endTimestampInSeconds' | 'issueVersion' diff --git a/dashboard/src/types/hardware.ts b/dashboard/src/types/hardware.ts index ec6f403c6..7a19f8d09 100644 --- a/dashboard/src/types/hardware.ts +++ b/dashboard/src/types/hardware.ts @@ -27,3 +27,33 @@ export type HardwareItemV2 = { export interface HardwareListingResponseV2 { hardware: HardwareItemV2[]; } + +export type HardwareSelectorRevision = { + git_commit_hash: string; + git_commit_name?: string | null; + start_time: string; +}; + +export type HardwareSelectorBranch = { + git_repository_url: string; + git_repository_branch: string; + latest_start_time: string; + revisions: HardwareSelectorRevision[]; +}; + +export type HardwareSelectorTree = { + tree_name: string; + latest_start_time: string; + branches: HardwareSelectorBranch[]; +}; + +export interface HardwareSelectorsResponse { + trees: HardwareSelectorTree[]; +} + +export type HardwareRevisionSelection = { + treeName: string; + gitRepositoryUrl: string; + gitBranch: string; + gitCommitHash: string; +}; diff --git a/dashboard/src/utils/search.ts b/dashboard/src/utils/search.ts index 6bcda6ac3..9b8e84f85 100644 --- a/dashboard/src/utils/search.ts +++ b/dashboard/src/utils/search.ts @@ -151,6 +151,10 @@ const generalMinifiedParams: Record = { endTimestampInSeconds: 'et', issueVersion: 'iv', logOpen: 'l', + treeName: 't', + gitRepositoryUrl: 'hgu', + gitBranch: 'gb', + gitCommitHash: 'hch', } as const; const treeInfoMinifiedParams: Record = { From 6c4b62228bc8d39f029db0744e21c5eac0646c6e Mon Sep 17 00:00:00 2001 From: Gustavo Flores Date: Fri, 15 May 2026 14:56:57 -0300 Subject: [PATCH 3/3] refactor: enhance view_cache function to accept custom timeout and update hardware-by-revision path cache duration --- backend/kernelCI_app/urls.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index f57e0f61a..dc9391f9c 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -13,8 +13,8 @@ cache = cache_page(timeout) -def view_cache(view): - return cache(view.as_view()) +def view_cache(view, timeout: int = settings.CACHE_TIMEOUT): + return cache_page(timeout)(view.as_view()) urlpatterns = [ @@ -153,7 +153,7 @@ def view_cache(view): path("hardware/", view_cache(views.HardwareView), name="hardware"), path( "hardware-by-revision/", - view_cache(views.HardwareByRevisionView), + view_cache(views.HardwareByRevisionView, timeout=60 * 60), name="hardwareByRevision", ), path("hardware-v2/", view_cache(views.HardwareViewV2), name="hardware-v2"),