From 32da18ddbe2b1310c05f553ff95550c2c4fd4bc8 Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Tue, 20 Oct 2026 17:41:44 -0300 Subject: [PATCH 1/2] feat(hardware): filter the hardware page by the registry * Filter the listing and the revision listing by architecture, processor, platform vendor, silicon vendor, platform, system module, form factor, and board type * Treat values in one field as OR and different fields as AND * Match a board that is missing from the registry only for a lone platform id * Read hardware_daily_builds and hardware_daily_tests, and offer dropdown values from that same window or checkout * Keep the selection in the URL and document the parameters in the schema Part of #2166 Assisted-by: Cursor Grok 4.7 Signed-off-by: Alan Peixinho --- .../kernelCI_app/constants/localization.py | 6 + backend/kernelCI_app/queries/hardware.py | 100 ++--- .../queries/hardwareRegistryFilters.py | 399 ++++++++++++++++++ .../queries/hardwareRegistryFilters_test.py | 118 ++++++ .../tests/unitTests/url_patterns_test.py | 8 + .../unitTests/views/hardwareView_test.py | 51 ++- .../typeModels/hardwareListing.py | 71 ++++ .../typeModels/hardwareListingByRevision.py | 34 +- backend/kernelCI_app/urls.py | 5 + .../views/hardwareByRevisionView.py | 4 + .../views/hardwareRegistryFiltersView.py | 46 ++ backend/kernelCI_app/views/hardwareView.py | 8 +- backend/schema.yml | 303 +++++++++++++ dashboard/src/api/hardware.ts | 74 +++- dashboard/src/locales/messages/index.ts | 11 + .../pages/Hardware/HardwareListingPage.tsx | 14 +- .../Hardware/HardwareRegistryFilters.tsx | 191 +++++++++ .../pages/Hardware/registryFilters.test.ts | 54 +++ .../src/pages/Hardware/registryFilters.ts | 104 +++++ dashboard/src/routes/_main/hardware/route.tsx | 6 + dashboard/src/types/general.ts | 1 + dashboard/src/types/hardware.ts | 11 + dashboard/src/utils/search.ts | 31 +- 23 files changed, 1583 insertions(+), 67 deletions(-) create mode 100644 backend/kernelCI_app/queries/hardwareRegistryFilters.py create mode 100644 backend/kernelCI_app/tests/unitTests/queries/hardwareRegistryFilters_test.py create mode 100644 backend/kernelCI_app/views/hardwareRegistryFiltersView.py create mode 100644 dashboard/src/pages/Hardware/HardwareRegistryFilters.tsx create mode 100644 dashboard/src/pages/Hardware/registryFilters.test.ts create mode 100644 dashboard/src/pages/Hardware/registryFilters.ts diff --git a/backend/kernelCI_app/constants/localization.py b/backend/kernelCI_app/constants/localization.py index e688c7282..d77626ce8 100644 --- a/backend/kernelCI_app/constants/localization.py +++ b/backend/kernelCI_app/constants/localization.py @@ -117,6 +117,12 @@ class DocStrings: "Optional comma-separated git commit identifiers: full SHA(s) " "and/or tag strings that appear in checkout.git_commit_tags." ) + HARDWARE_REGISTRY_FILTER_DESCRIPTION = ( + "Comma-separated registry values. Values in one field are OR; " + "different fields are AND. A platform that is not in the registry " + "matches only the platform filter, and only when no other registry " + "field is set." + ) LAB_LISTING_ORIGIN_DESCRIPTION = "Origin of the lab results" diff --git a/backend/kernelCI_app/queries/hardware.py b/backend/kernelCI_app/queries/hardware.py index 8f26dd0e2..8798879ee 100644 --- a/backend/kernelCI_app/queries/hardware.py +++ b/backend/kernelCI_app/queries/hardware.py @@ -10,6 +10,10 @@ get_boot_test_duration_clause, get_build_duration_clause, ) +from kernelCI_app.queries.hardwareRegistryFilters import ( + HardwareRegistryFilter, + get_hardware_daily_revision_listing, +) from kernelCI_app.typeModels.hardwareDetails import CommitHead, Tree @@ -177,54 +181,56 @@ def get_hardware_listing_data_by_revision( git_repository_url: str, git_repository_branch: str, git_commit_hash: str, + registry_filter: Optional[HardwareRegistryFilter] = None, ) -> list[dict]: - 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 AS hardware, - 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 - ) - SELECT - relevant_tests.platform, - relevant_tests.hardware, - {count_clauses} - FROM - relevant_tests - GROUP BY - relevant_tests.platform, - relevant_tests.hardware - ORDER BY - relevant_tests.platform ASC - """ - - with connection.cursor() as cursor: - cursor.execute(query, params) - return dict_fetchall(cursor) + rows = get_hardware_daily_revision_listing( + origin=origin, + tree_name=tree_name, + git_repository_url=git_repository_url, + git_repository_branch=git_repository_branch, + git_commit_hash=git_commit_hash, + registry_filter=registry_filter, + ) + return [ + { + "platform": platform, + "hardware": hardware, + "pass_builds": build_pass, + "fail_builds": build_fail, + "null_builds": build_inc, + "error_builds": 0, + "miss_builds": 0, + "done_builds": 0, + "skip_builds": 0, + "pass_boots": boot_pass, + "fail_boots": boot_fail, + "null_boots": boot_inc, + "error_boots": 0, + "miss_boots": 0, + "done_boots": 0, + "skip_boots": 0, + "pass_tests": test_pass, + "fail_tests": test_fail, + "null_tests": test_inc, + "error_tests": 0, + "miss_tests": 0, + "done_tests": 0, + "skip_tests": 0, + } + for ( + platform, + hardware, + build_pass, + build_fail, + build_inc, + boot_pass, + boot_fail, + boot_inc, + test_pass, + test_fail, + test_inc, + ) in rows + ] def get_hardware_listing_data_bulk( diff --git a/backend/kernelCI_app/queries/hardwareRegistryFilters.py b/backend/kernelCI_app/queries/hardwareRegistryFilters.py new file mode 100644 index 000000000..a74bb7573 --- /dev/null +++ b/backend/kernelCI_app/queries/hardwareRegistryFilters.py @@ -0,0 +1,399 @@ +from dataclasses import astuple, dataclass +from datetime import datetime +from typing import Optional + +from django.db import connection + +_CATALOG_COLUMNS = ( + ("architecture", "proc.architecture"), + ("processor", "proc.id"), + ("platform_vendor", "plat.vendor_id"), + ("silicon_vendor", "proc.vendor_id"), + ("system_module", "plat.system_module_id"), + ("form_factor", "plat.form_factor"), + ("board_type", "plat.type"), +) + +_CHECKOUT_JOIN = """ + INNER JOIN checkouts selected + ON selected.id = daily.checkout_id + AND selected.start_time >= %(start_date)s + AND selected.start_time <= %(end_date)s + AND ( + selected.git_commit_hash = ANY(%(commits_list)s) + OR ( + selected.git_commit_tags IS NOT NULL + AND selected.git_commit_tags && %(commits_list)s::text[] + ) + ) +""" + + +def normalize_csv(value: object) -> tuple[str, ...]: + if not isinstance(value, str): + return () + return tuple(part.strip() for part in value.split(",") if part.strip()) + + +@dataclass(frozen=True) +class HardwareRegistryFilter: + architecture: tuple[str, ...] = () + processor: tuple[str, ...] = () + platform_vendor: tuple[str, ...] = () + silicon_vendor: tuple[str, ...] = () + platform: tuple[str, ...] = () + system_module: tuple[str, ...] = () + form_factor: tuple[str, ...] = () + board_type: tuple[str, ...] = () + + def is_empty(self) -> bool: + return not any(astuple(self)) + + +def hardware_registry_filter_from_request(params) -> HardwareRegistryFilter: + return HardwareRegistryFilter( + architecture=normalize_csv(params.get("architecture")), + processor=normalize_csv(params.get("processor")), + platform_vendor=normalize_csv(params.get("platformVendor")), + silicon_vendor=normalize_csv(params.get("siliconVendor")), + platform=normalize_csv(params.get("platform")), + system_module=normalize_csv(params.get("systemModule")), + form_factor=normalize_csv(params.get("formFactor")), + board_type=normalize_csv(params.get("boardType")), + ) + + +def registry_filter_predicate( + registry_filter: HardwareRegistryFilter, platform_sql: str +) -> tuple[Optional[str], dict]: + """Same field is OR (`= ANY`). Different fields are AND. + + A platform missing from the registry matches only when the platform id + is the sole selected field. + """ + if registry_filter.is_empty(): + return None, {} + + platform_only = bool(registry_filter.platform) and all( + not getattr(registry_filter, name) for name, _column in _CATALOG_COLUMNS + ) + if platform_only: + return ( + f"{platform_sql} = ANY(%(platform)s)", + {"platform": list(registry_filter.platform)}, + ) + + params: dict = {} + catalog: list[str] = [] + for name, column in _CATALOG_COLUMNS: + values = getattr(registry_filter, name) + if values: + params[name] = list(values) + catalog.append(f"{column} = ANY(%({name})s)") + if registry_filter.platform: + params["platform"] = list(registry_filter.platform) + catalog.append("plat.id = ANY(%(platform)s)") + + registered = f""" + {platform_sql} IN ( + SELECT plat.id + FROM hardware_registry_platforms plat + INNER JOIN hardware_registry_processors proc + ON proc.id = plat.processor_id + WHERE {" AND ".join(catalog)} + ) + """ + return registered, params + + +_REVISION_JOIN = """ + INNER JOIN checkouts revision + ON revision.id = daily.checkout_id + AND revision.tree_name = %(tree_name)s + AND revision.git_repository_url = %(git_repository_url)s + AND revision.git_repository_branch = %(git_repository_branch)s + AND revision.git_commit_hash = %(git_commit_hash)s +""" + + +def _side_counts( + *, + rows_name: str, + table: str, + origin_column: str, + sums: str, + commits: bool, + revision: bool = False, +) -> str: + if revision: + checkout_join = _REVISION_JOIN + day_filter = "" + else: + checkout_join = _CHECKOUT_JOIN if commits else "" + day_filter = """ + AND daily.checkout_day >= %(start_day)s + AND daily.checkout_day <= %(end_day)s + """ + return f""" + {rows_name}_rows AS ( + SELECT daily.* + FROM {table} daily + {checkout_join} + WHERE daily.{origin_column} = %(origin)s + {day_filter} + ), + {rows_name}_hardware AS ( + SELECT DISTINCT ON (platform) platform, compatibles + FROM {rows_name}_rows + WHERE compatibles IS NOT NULL + ORDER BY platform, CARDINALITY(compatibles) DESC, compatibles + ), + {rows_name}_counts AS ( + SELECT + daily.platform, + hardware.compatibles AS hardware, + {sums} + FROM {rows_name}_rows daily + LEFT JOIN {rows_name}_hardware hardware USING (platform) + GROUP BY daily.platform, hardware.compatibles + ) + """ + + +def _daily_listing_query(*, commits: bool, revision: bool, where: str) -> str: + return f""" + WITH { + _side_counts( + rows_name="build", + table="hardware_daily_builds", + origin_column="build_origin", + sums=''' + SUM(daily.build_pass) AS build_pass, + SUM(daily.build_failed) AS build_failed, + SUM(daily.build_inc) AS build_inc + ''', + commits=commits, + revision=revision, + ) + }, + { + _side_counts( + rows_name="test", + table="hardware_daily_tests", + origin_column="test_origin", + sums=''' + SUM(daily.boot_pass) AS boot_pass, + SUM(daily.boot_failed) AS boot_failed, + SUM(daily.boot_inc) AS boot_inc, + SUM(daily.test_pass) AS test_pass, + SUM(daily.test_failed) AS test_failed, + SUM(daily.test_inc) AS test_inc + ''', + commits=commits, + revision=revision, + ) + } + SELECT + COALESCE(builds.platform, tests.platform) AS platform, + COALESCE(tests.hardware, builds.hardware) AS hardware, + COALESCE(builds.build_pass, 0), + COALESCE(builds.build_failed, 0), + COALESCE(builds.build_inc, 0), + COALESCE(tests.boot_pass, 0), + COALESCE(tests.boot_failed, 0), + COALESCE(tests.boot_inc, 0), + COALESCE(tests.test_pass, 0), + COALESCE(tests.test_failed, 0), + COALESCE(tests.test_inc, 0) + FROM build_counts builds + FULL OUTER JOIN test_counts tests ON tests.platform = builds.platform + {where} + ORDER BY platform + """ + + +def _filter_clause( + registry_filter: Optional[HardwareRegistryFilter], +) -> tuple[str, dict]: + predicate, params = registry_filter_predicate( + registry_filter or HardwareRegistryFilter(), + "COALESCE(builds.platform, tests.platform)", + ) + if not predicate: + return "", params + return f"WHERE {predicate}", params + + +def get_hardware_daily_listing( + *, + origin: str, + start_date: datetime, + end_date: datetime, + commits_list: Optional[list[str]] = None, + registry_filter: Optional[HardwareRegistryFilter] = None, +) -> list[tuple]: + where, filter_params = _filter_clause(registry_filter) + query = _daily_listing_query( + commits=bool(commits_list), revision=False, where=where + ) + with connection.cursor() as cursor: + cursor.execute( + query, + { + "origin": origin, + "start_date": start_date, + "end_date": end_date, + "start_day": start_date.date(), + "end_day": end_date.date(), + "commits_list": commits_list, + **filter_params, + }, + ) + return cursor.fetchall() + + +def get_hardware_daily_revision_listing( + *, + origin: str, + tree_name: str, + git_repository_url: str, + git_repository_branch: str, + git_commit_hash: str, + registry_filter: Optional[HardwareRegistryFilter] = None, +) -> list[tuple]: + """Daily counts for one checkout. Same rows the revision filter options use.""" + where, filter_params = _filter_clause(registry_filter) + query = _daily_listing_query(commits=False, revision=True, where=where) + with connection.cursor() as cursor: + cursor.execute( + query, + { + "origin": origin, + "tree_name": tree_name, + "git_repository_url": git_repository_url, + "git_repository_branch": git_repository_branch, + "git_commit_hash": git_commit_hash, + **filter_params, + }, + ) + return cursor.fetchall() + + +def get_hardware_registry_filter_options( + *, + origin: str, + start_date: datetime, + end_date: datetime, + tree_name: Optional[str] = None, + git_repository_url: Optional[str] = None, + git_repository_branch: Optional[str] = None, + git_commit_hash: Optional[str] = None, +) -> dict[str, list[str]]: + """Distinct registry values for the platforms in the current listing. + + Without a revision, that is the daily window. With one, it is that checkout. + Platform ids include boards that are not in the registry. The other lists + do not: those boards have no architecture, vendor, or board type to offer. + """ + revision = all( + ( + tree_name, + git_repository_url, + git_repository_branch, + git_commit_hash, + ) + ) + if revision: + seen = f""" + SELECT daily.platform + FROM hardware_daily_builds daily + {_REVISION_JOIN} + WHERE daily.build_origin = %(origin)s + UNION + SELECT daily.platform + FROM hardware_daily_tests daily + {_REVISION_JOIN} + WHERE daily.test_origin = %(origin)s + """ + else: + seen = """ + SELECT platform + FROM hardware_daily_builds + WHERE build_origin = %(origin)s + AND checkout_day >= %(start_day)s + AND checkout_day <= %(end_day)s + UNION + SELECT platform + FROM hardware_daily_tests + WHERE test_origin = %(origin)s + AND checkout_day >= %(start_day)s + AND checkout_day <= %(end_day)s + """ + query = f""" + WITH seen AS ( + {seen} + ), + registered AS ( + SELECT + plat.id, + plat.vendor_id AS platform_vendor, + plat.type AS board_type, + plat.form_factor, + plat.system_module_id AS system_module, + proc.id AS processor, + proc.architecture, + proc.vendor_id AS silicon_vendor + FROM seen + INNER JOIN hardware_registry_platforms plat ON plat.id = seen.platform + INNER JOIN hardware_registry_processors proc + ON proc.id = plat.processor_id + ) + SELECT + ARRAY( + SELECT DISTINCT architecture FROM registered + WHERE architecture IS NOT NULL AND architecture <> '' + ORDER BY 1 + ), + ARRAY(SELECT DISTINCT processor FROM registered ORDER BY 1), + ARRAY(SELECT DISTINCT platform_vendor FROM registered ORDER BY 1), + ARRAY(SELECT DISTINCT silicon_vendor FROM registered ORDER BY 1), + ARRAY(SELECT platform FROM seen ORDER BY 1), + ARRAY( + SELECT DISTINCT system_module FROM registered + WHERE system_module IS NOT NULL AND system_module <> '' + ORDER BY 1 + ), + ARRAY( + SELECT DISTINCT form_factor FROM registered + WHERE form_factor IS NOT NULL AND form_factor <> '' + ORDER BY 1 + ), + ARRAY( + SELECT DISTINCT board_type FROM registered + WHERE board_type IS NOT NULL AND board_type <> '' + ORDER BY 1 + ) + """ + params = { + "origin": origin, + "start_day": start_date.date(), + "end_day": end_date.date(), + "tree_name": tree_name, + "git_repository_url": git_repository_url, + "git_repository_branch": git_repository_branch, + "git_commit_hash": git_commit_hash, + } + names = ( + "architecture", + "processor", + "platform_vendor", + "silicon_vendor", + "platform", + "system_module", + "form_factor", + "board_type", + ) + with connection.cursor() as cursor: + cursor.execute(query, params) + row = cursor.fetchone() + return {name: list(row[index] or []) for index, name in enumerate(names)} diff --git a/backend/kernelCI_app/tests/unitTests/queries/hardwareRegistryFilters_test.py b/backend/kernelCI_app/tests/unitTests/queries/hardwareRegistryFilters_test.py new file mode 100644 index 000000000..5ee011cf5 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/queries/hardwareRegistryFilters_test.py @@ -0,0 +1,118 @@ +from datetime import datetime +from unittest.mock import patch + +from django.test import SimpleTestCase + +from kernelCI_app.queries.hardwareRegistryFilters import ( + HardwareRegistryFilter, + get_hardware_daily_listing, + get_hardware_daily_revision_listing, + get_hardware_registry_filter_options, + normalize_csv, + registry_filter_predicate, +) +from kernelCI_app.tests.unitTests.queries.conftest import setup_mock_cursor + + +class TestRegistryFilterPredicate(SimpleTestCase): + def test_empty_filter_adds_no_predicate(self): + predicate, params = registry_filter_predicate( + HardwareRegistryFilter(), "daily.platform" + ) + + self.assertIsNone(predicate) + self.assertEqual(params, {}) + + def test_architecture_excludes_unregistered_platforms(self): + predicate, params = registry_filter_predicate( + HardwareRegistryFilter(architecture=("arm64", "arm")), + "daily.platform", + ) + + self.assertIn("proc.architecture = ANY(%(architecture)s)", predicate) + self.assertNotIn("NOT EXISTS", predicate) + self.assertEqual(params["architecture"], ["arm64", "arm"]) + + def test_platform_id_includes_unregistered_platforms(self): + predicate, params = registry_filter_predicate( + HardwareRegistryFilter(platform=("am62-sk", "unknown-board")), + "daily.platform", + ) + + self.assertEqual(predicate, "daily.platform = ANY(%(platform)s)") + self.assertNotIn("NOT EXISTS", predicate) + self.assertEqual(params["platform"], ["am62-sk", "unknown-board"]) + + def test_platform_id_with_another_field_excludes_unregistered_platforms(self): + predicate, params = registry_filter_predicate( + HardwareRegistryFilter(architecture=("arm64",), platform=("am62-sk",)), + "daily.platform", + ) + + self.assertIn("proc.architecture = ANY(%(architecture)s)", predicate) + self.assertIn("plat.id = ANY(%(platform)s)", predicate) + self.assertNotIn("NOT EXISTS", predicate) + self.assertEqual(params["architecture"], ["arm64"]) + self.assertEqual(params["platform"], ["am62-sk"]) + + @patch("kernelCI_app.queries.hardwareRegistryFilters.connection") + def test_daily_listing_reads_both_tables_and_applies_the_filter( + self, mock_connection + ): + cursor = setup_mock_cursor(mock_connection) + cursor.fetchall.return_value = [] + + get_hardware_daily_listing( + origin="maestro", + start_date=datetime(2026, 1, 1), + end_date=datetime(2026, 1, 2), + registry_filter=HardwareRegistryFilter(architecture=("arm64",)), + ) + + query, params = cursor.execute.call_args[0] + self.assertIn("hardware_daily_builds", query) + self.assertIn("hardware_daily_tests", query) + self.assertIn("proc.architecture = ANY(%(architecture)s)", query) + self.assertNotIn("NOT EXISTS", query) + self.assertEqual(params["architecture"], ["arm64"]) + self.assertEqual(params["origin"], "maestro") + + @patch("kernelCI_app.queries.hardwareRegistryFilters.connection") + def test_revision_listing_and_options_use_the_same_daily_checkout( + self, mock_connection + ): + cursor = setup_mock_cursor(mock_connection) + cursor.fetchall.return_value = [] + cursor.fetchone.return_value = ([], [], [], [], [], [], [], []) + revision = { + "origin": "maestro", + "tree_name": "android", + "git_repository_url": "https://example.com/android", + "git_repository_branch": "main", + "git_commit_hash": "abc", + } + + get_hardware_daily_revision_listing( + **revision, + registry_filter=HardwareRegistryFilter(architecture=("arm64",)), + ) + listing_query = cursor.execute.call_args[0][0] + self.assertIn("hardware_daily_builds", listing_query) + self.assertIn("hardware_daily_tests", listing_query) + self.assertIn("revision.git_commit_hash = %(git_commit_hash)s", listing_query) + self.assertNotIn("environment_misc", listing_query) + + get_hardware_registry_filter_options( + start_date=datetime(2026, 1, 1), + end_date=datetime(2026, 1, 2), + **revision, + ) + options_query = cursor.execute.call_args[0][0] + self.assertIn("hardware_daily_builds", options_query) + self.assertIn("revision.git_commit_hash = %(git_commit_hash)s", options_query) + self.assertNotIn("checkout_day", options_query) + + def test_normalize_csv_drops_blanks(self): + self.assertEqual(normalize_csv(" arm64, ,arm "), ("arm64", "arm")) + self.assertEqual(normalize_csv(None), ()) + self.assertEqual(normalize_csv(""), ()) diff --git a/backend/kernelCI_app/tests/unitTests/url_patterns_test.py b/backend/kernelCI_app/tests/unitTests/url_patterns_test.py index 946a33410..e16513855 100644 --- a/backend/kernelCI_app/tests/unitTests/url_patterns_test.py +++ b/backend/kernelCI_app/tests/unitTests/url_patterns_test.py @@ -141,3 +141,11 @@ def test_hardware_selectors_route(self): assert url == "/api/hardware/selectors/" assert resolved.url_name == "hardwareSelectors" assert "hardware_id" not in resolved.kwargs + + def test_hardware_registry_filters_route(self): + url = reverse("hardwareRegistryFilters") + resolved = resolve(url) + + assert url == "/api/hardware/registry-filters/" + assert resolved.url_name == "hardwareRegistryFilters" + assert "hardware_id" not in resolved.kwargs diff --git a/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py b/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py index 066b4c4b4..7d46e7794 100644 --- a/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py +++ b/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py @@ -5,6 +5,7 @@ from rest_framework.test import APIRequestFactory from kernelCI_app.constants.localization import ClientStrings +from kernelCI_app.queries.hardwareRegistryFilters import HardwareRegistryFilter from kernelCI_app.views.hardwareView import HardwareView @@ -14,9 +15,7 @@ def setUp(self): self.view = HardwareView() self.url = "/hardware" - @patch( - "kernelCI_app.views.hardwareView.get_hardware_listing_data_from_status_table" - ) + @patch("kernelCI_app.views.hardwareView.get_hardware_daily_listing") def test_get_hardware_listing_success(self, mock_get_status_table_data): mock_get_status_table_data.return_value = [ ("platform1", "hardware1", *range(9)), @@ -37,11 +36,10 @@ def test_get_hardware_listing_success(self, mock_get_status_table_data): start_date=ANY, end_date=ANY, commits_list=None, + registry_filter=HardwareRegistryFilter(), ) - @patch( - "kernelCI_app.views.hardwareView.get_hardware_listing_data_from_status_table" - ) + @patch("kernelCI_app.views.hardwareView.get_hardware_daily_listing") def test_get_hardware_listing_passes_commits_list(self, mock_get_status_table_data): mock_get_status_table_data.return_value = [ ("platform1", "hardware1", *range(9)), @@ -66,6 +64,39 @@ def test_get_hardware_listing_passes_commits_list(self, mock_get_status_table_da start_date=ANY, end_date=ANY, commits_list=[h1, h2], + registry_filter=HardwareRegistryFilter(), + ) + + @patch("kernelCI_app.views.hardwareView.get_hardware_daily_listing") + def test_get_hardware_listing_passes_registry_filters( + self, mock_get_status_table_data + ): + mock_get_status_table_data.return_value = [ + ("platform1", "hardware1", *range(9)), + ] + + request = self.factory.get( + self.url, + { + "startTimestampInSeconds": "1741192200", + "endTimestampInSeconds": "1741624200", + "origin": "origin1", + "architecture": "arm64, arm", + "platform": "am62-sk", + }, + ) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + mock_get_status_table_data.assert_called_once_with( + origin="origin1", + start_date=ANY, + end_date=ANY, + commits_list=None, + registry_filter=HardwareRegistryFilter( + architecture=("arm64", "arm"), + platform=("am62-sk",), + ), ) def test_get_hardware_listing_invalid_query_params_returns_bad_request(self): @@ -78,9 +109,7 @@ def test_get_hardware_listing_invalid_query_params_returns_bad_request(self): self.assertIn("start_date", response.data) self.assertIn("end_date", response.data) - @patch( - "kernelCI_app.views.hardwareView.get_hardware_listing_data_from_status_table" - ) + @patch("kernelCI_app.views.hardwareView.get_hardware_daily_listing") def test_get_hardware_listing_no_hardware_found_returns_ok_with_error( self, mock_get_status_table_data ): @@ -98,9 +127,7 @@ def test_get_hardware_listing_no_hardware_found_returns_ok_with_error( self.assertEqual(response.status_code, HTTPStatus.OK) self.assertEqual(response.data, {"error": ClientStrings.NO_HARDWARE_FOUND}) - @patch( - "kernelCI_app.views.hardwareView.get_hardware_listing_data_from_status_table" - ) + @patch("kernelCI_app.views.hardwareView.get_hardware_daily_listing") def test_get_hardware_listing_sanitize_validation_error_returns_internal_server_error( self, mock_get_status_table_data ): diff --git a/backend/kernelCI_app/typeModels/hardwareListing.py b/backend/kernelCI_app/typeModels/hardwareListing.py index 972621a97..c5f0aba2c 100644 --- a/backend/kernelCI_app/typeModels/hardwareListing.py +++ b/backend/kernelCI_app/typeModels/hardwareListing.py @@ -63,6 +63,77 @@ class HardwareQueryParamsDocumentationOnly(BaseModel): default=None, description=DocStrings.HARDWARE_LISTING_COMMITS_LIST_DESCRIPTION, ) + architecture: Optional[str] = Field( + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + processor: Optional[str] = Field( + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + platformVendor: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + siliconVendor: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + platform: Optional[str] = Field( + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + systemModule: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + formFactor: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + boardType: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + + +class HardwareRegistryFilterOptions(BaseModel): + architecture: list[str] + processor: list[str] + platform_vendor: list[str] + silicon_vendor: list[str] + platform: list[str] + system_module: list[str] + form_factor: list[str] + board_type: list[str] + + +class HardwareRegistryFilterOptionsDocumentationOnly(BaseModel): + origin: Annotated[ + str, + Field( + default=DEFAULT_ORIGIN, + description=DocStrings.HARDWARE_LISTING_ORIGIN_DESCRIPTION, + ), + ] + startTimestampInSeconds: str = Field( # noqa: N815 + description=DocStrings.DEFAULT_START_TS_DESCRIPTION + ) + endTimestampInSeconds: str = Field( # noqa: N815 + description=DocStrings.DEFAULT_END_TS_DESCRIPTION + ) + tree_name: Optional[str] = Field( + default=None, description=DocStrings.TREE_NAME_PATH_DESCRIPTION + ) + git_repository_url: Optional[str] = Field( + default=None, description=DocStrings.TREE_QUERY_GIT_URL_DESCRIPTION + ) + git_repository_branch: Optional[str] = Field( + default=None, description=DocStrings.DEFAULT_GIT_BRANCH_DESCRIPTION + ) + git_commit_hash: Optional[str] = Field( + default=None, description=DocStrings.COMMIT_HASH_PATH_DESCRIPTION + ) class HardwareQueryParams(BaseModel): diff --git a/backend/kernelCI_app/typeModels/hardwareListingByRevision.py b/backend/kernelCI_app/typeModels/hardwareListingByRevision.py index 34cecdd15..f9a0780f9 100644 --- a/backend/kernelCI_app/typeModels/hardwareListingByRevision.py +++ b/backend/kernelCI_app/typeModels/hardwareListingByRevision.py @@ -1,4 +1,4 @@ -from typing import Annotated +from typing import Annotated, Optional from pydantic import BaseModel, BeforeValidator, Field @@ -22,6 +22,38 @@ class HardwareListingByRevisionQueryParamsDocumentationOnly(BaseModel): description=DocStrings.DEFAULT_GIT_BRANCH_DESCRIPTION ) git_commit_hash: str = Field(description=DocStrings.COMMIT_HASH_PATH_DESCRIPTION) + architecture: Optional[str] = Field( + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + processor: Optional[str] = Field( + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + platformVendor: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + siliconVendor: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + platform: Optional[str] = Field( + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + systemModule: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + formFactor: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) + boardType: Optional[str] = Field( # noqa: N815 + default=None, + description=DocStrings.HARDWARE_REGISTRY_FILTER_DESCRIPTION, + ) class HardwareListingByRevisionQueryParams(BaseModel): diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index 09584cd25..185d99d77 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -136,6 +136,11 @@ def view_cache(view, timeout: int = settings.CACHE_TIMEOUT): view_cache(views.HardwareSelectorsView), name="hardwareSelectors", ), + path( + "hardware/registry-filters/", + view_cache(views.HardwareRegistryFiltersView), + name="hardwareRegistryFilters", + ), path( "hardware/", view_cache(views.HardwareDetails), diff --git a/backend/kernelCI_app/views/hardwareByRevisionView.py b/backend/kernelCI_app/views/hardwareByRevisionView.py index 931548b70..e417175d8 100644 --- a/backend/kernelCI_app/views/hardwareByRevisionView.py +++ b/backend/kernelCI_app/views/hardwareByRevisionView.py @@ -7,6 +7,9 @@ from rest_framework.views import APIView from kernelCI_app.queries.hardware import get_hardware_listing_data_by_revision +from kernelCI_app.queries.hardwareRegistryFilters import ( + hardware_registry_filter_from_request, +) from kernelCI_app.typeModels.hardwareListing import ( HardwareItem, HardwareListingByRevisionResponse, @@ -79,6 +82,7 @@ def get(self, request: Request): git_repository_url=query_params.git_repository_url, git_repository_branch=query_params.git_repository_branch, git_commit_hash=query_params.git_commit_hash, + registry_filter=hardware_registry_filter_from_request(request.GET), ) try: diff --git a/backend/kernelCI_app/views/hardwareRegistryFiltersView.py b/backend/kernelCI_app/views/hardwareRegistryFiltersView.py new file mode 100644 index 000000000..53daa6dc2 --- /dev/null +++ b/backend/kernelCI_app/views/hardwareRegistryFiltersView.py @@ -0,0 +1,46 @@ +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.hardwareRegistryFilters import ( + get_hardware_registry_filter_options, +) +from kernelCI_app.typeModels.hardwareListing import ( + HardwareQueryParams, + HardwareRegistryFilterOptions, + HardwareRegistryFilterOptionsDocumentationOnly, +) + + +class HardwareRegistryFiltersView(APIView): + @extend_schema( + parameters=[HardwareRegistryFilterOptionsDocumentationOnly], + responses=HardwareRegistryFilterOptions, + ) + def get(self, request: Request): + try: + query_params = HardwareQueryParams( + start_date=request.GET.get("startTimestampInSeconds"), + end_date=request.GET.get("endTimestampInSeconds"), + origin=request.GET.get("origin"), + ) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST) + + options = get_hardware_registry_filter_options( + origin=query_params.origin, + start_date=query_params.start_date, + end_date=query_params.end_date, + 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"), + ) + return Response( + data=HardwareRegistryFilterOptions(**options).model_dump(), + status=HTTPStatus.OK, + ) diff --git a/backend/kernelCI_app/views/hardwareView.py b/backend/kernelCI_app/views/hardwareView.py index f2ab36312..e7cd0ec2c 100644 --- a/backend/kernelCI_app/views/hardwareView.py +++ b/backend/kernelCI_app/views/hardwareView.py @@ -9,7 +9,10 @@ from kernelCI_app.constants.localization import ClientStrings from kernelCI_app.helpers.errorHandling import create_api_error_response -from kernelCI_app.queries.hardware import get_hardware_listing_data_from_status_table +from kernelCI_app.queries.hardwareRegistryFilters import ( + get_hardware_daily_listing, + hardware_registry_filter_from_request, +) from kernelCI_app.typeModels.commonListing import ListingStatusCount from kernelCI_app.typeModels.hardwareListing import ( HardwareListingItem, @@ -68,11 +71,12 @@ def get(self, request: Request): except ValidationError as e: return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST) - hardwares_raw = get_hardware_listing_data_from_status_table( + hardwares_raw = get_hardware_daily_listing( origin=origin, start_date=start_date, end_date=end_date, commits_list=query_params.commits_list, + registry_filter=hardware_registry_filter_from_request(request.GET), ) try: diff --git a/backend/schema.yml b/backend/schema.yml index 1eb84efa4..1df568841 100644 --- a/backend/schema.yml +++ b/backend/schema.yml @@ -77,6 +77,28 @@ paths: get: operationId: hardware_retrieve parameters: + - in: query + name: architecture + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Architecture + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: boardType + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Boardtype + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. - in: query name: commitsList schema: @@ -94,6 +116,17 @@ paths: type: string description: Interval end timestamp in seconds for the results required: true + - in: query + name: formFactor + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Formfactor + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. - in: query name: origin schema: @@ -101,6 +134,50 @@ paths: title: Origin type: string description: Origin of the hardware + - in: query + name: platform + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Platform + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: platformVendor + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Platformvendor + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: processor + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Processor + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: siliconVendor + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Siliconvendor + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. - in: query name: startTimestampInSeconds schema: @@ -108,6 +185,17 @@ paths: type: string description: Interval start timestamp in seconds for the results required: true + - in: query + name: systemModule + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Systemmodule + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. tags: - hardware security: @@ -125,6 +213,39 @@ paths: get: operationId: hardware_by_revision_retrieve parameters: + - in: query + name: architecture + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Architecture + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: boardType + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Boardtype + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: formFactor + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Formfactor + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. - in: query name: git_commit_hash schema: @@ -153,6 +274,61 @@ paths: title: Origin type: string description: Origin of the hardware + - in: query + name: platform + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Platform + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: platformVendor + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Platformvendor + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: processor + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Processor + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: siliconVendor + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Siliconvendor + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. + - in: query + name: systemModule + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Systemmodule + description: Comma-separated registry values. Values in one field are OR; + different fields are AND. A platform that is not in the registry matches + only the platform filter, and only when no other registry field is set. - in: query name: tree_name schema: @@ -399,6 +575,80 @@ paths: schema: $ref: '#/components/schemas/HardwareDetailsTestsResponse' description: '' + /api/hardware/registry-filters/: + get: + operationId: hardware_registry_filters_retrieve + parameters: + - in: query + name: endTimestampInSeconds + schema: + title: Endtimestampinseconds + type: string + description: Interval end timestamp in seconds for the results + required: true + - in: query + name: git_commit_hash + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Git Commit Hash + description: Commit hash of the tree + - in: query + name: git_repository_branch + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Git Repository Branch + description: Git branch name of the tree + - in: query + name: git_repository_url + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Git Repository Url + description: Git repository URL of the tree + - in: query + name: origin + schema: + default: maestro + title: Origin + type: string + description: Origin of the hardware + - in: query + name: startTimestampInSeconds + schema: + title: Starttimestampinseconds + type: string + description: Interval start timestamp in seconds for the results + required: true + - in: query + name: tree_name + schema: + anyOf: + - type: string + - type: 'null' + default: null + title: Tree Name + description: Name of the tree + tags: + - hardware + security: + - cookieAuth: [] + - basicAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/HardwareRegistryFilterOptions' + description: '' /api/hardware/selectors/: get: operationId: hardware_selectors_retrieve @@ -3340,6 +3590,59 @@ components: - hardware title: HardwareListingResponse type: object + HardwareRegistryFilterOptions: + properties: + architecture: + items: + type: string + title: Architecture + type: array + processor: + items: + type: string + title: Processor + type: array + platform_vendor: + items: + type: string + title: Platform Vendor + type: array + silicon_vendor: + items: + type: string + title: Silicon Vendor + type: array + platform: + items: + type: string + title: Platform + type: array + system_module: + items: + type: string + title: System Module + type: array + form_factor: + items: + type: string + title: Form Factor + type: array + board_type: + items: + type: string + title: Board Type + type: array + required: + - architecture + - processor + - platform_vendor + - silicon_vendor + - platform + - system_module + - form_factor + - board_type + title: HardwareRegistryFilterOptions + type: object HardwareSelectorBranch: properties: git_repository_url: diff --git a/dashboard/src/api/hardware.ts b/dashboard/src/api/hardware.ts index bc1961912..feb4f65c0 100644 --- a/dashboard/src/api/hardware.ts +++ b/dashboard/src/api/hardware.ts @@ -5,9 +5,14 @@ import { useSearch } from '@tanstack/react-router'; import type { HardwareListingResponse, + HardwareRegistryFilterOptions, HardwareRevisionSelection, HardwareSelectorsResponse, } from '@/types/hardware'; +import { + registryFilterToParams, + type TRegistryFilter, +} from '@/pages/Hardware/registryFilters'; import type { StatusCount } from '@/types/general'; import { statusCountToShortStatusCount } from '@/utils/status'; @@ -32,6 +37,7 @@ const fetchHardwareListing = async ( startTimestampInSeconds: number, endTimestampInSeconds: number, commitsList?: string[], + registryFilter?: TRegistryFilter, ): Promise => { const data = await RequestData.get( '/api/hardware/', @@ -41,6 +47,7 @@ const fetchHardwareListing = async ( endTimestampInSeconds, origin, ...(commitsList?.length ? { commitsList: commitsList.join(',') } : {}), + ...registryFilterToParams(registryFilter ?? {}), }, }, ); @@ -55,7 +62,7 @@ export const useHardwareListing = ( commitsList?: string[], enabled = true, ): UseQueryResult => { - const { origin } = useSearch({ from: searchFrom }); + const { origin, registryFilter } = useSearch({ from: searchFrom }); const queryKey = [ 'hardwareListing', @@ -63,6 +70,7 @@ export const useHardwareListing = ( endTimestampInSeconds, origin, commitsList ?? null, + registryFilter, ]; return useQuery({ @@ -73,6 +81,7 @@ export const useHardwareListing = ( startTimestampInSeconds, endTimestampInSeconds, commitsList, + registryFilter, ), enabled, refetchOnWindowFocus: false, @@ -94,6 +103,62 @@ const fetchHardwareSelectors = async ( return data; }; +const fetchHardwareRegistryFilterOptions = async ( + origin: string, + startTimestampInSeconds: number, + endTimestampInSeconds: number, + selection: HardwareRevisionSelection | null, +): Promise => + RequestData.get( + '/api/hardware/registry-filters/', + { + params: { + origin, + startTimestampInSeconds, + endTimestampInSeconds, + ...(selection + ? { + tree_name: selection.treeName, + git_repository_url: selection.gitRepositoryUrl, + git_repository_branch: selection.gitBranch, + git_commit_hash: selection.gitCommitHash, + } + : {}), + }, + }, + ); + +export const useHardwareRegistryFilterOptions = ( + startTimestampInSeconds: number, + endTimestampInSeconds: number, + searchFrom: HardwareListingRoutesMap['search'], +): UseQueryResult => { + const { origin, treeName, gitRepositoryUrl, gitBranch, gitCommitHash } = + useSearch({ from: searchFrom }); + const selection: HardwareRevisionSelection | null = + treeName && gitRepositoryUrl && gitBranch && gitCommitHash + ? { treeName, gitRepositoryUrl, gitBranch, gitCommitHash } + : null; + + return useQuery({ + queryKey: [ + 'hardwareRegistryFilterOptions', + origin, + startTimestampInSeconds, + endTimestampInSeconds, + selection, + ], + queryFn: () => + fetchHardwareRegistryFilterOptions( + origin, + startTimestampInSeconds, + endTimestampInSeconds, + selection, + ), + refetchOnWindowFocus: false, + }); +}; + export const useHardwareSelectors = ( searchFrom: HardwareListingRoutesMap['search'], ): UseQueryResult => { @@ -109,6 +174,7 @@ export const useHardwareSelectors = ( const fetchHardwareListingByRevision = async ( selection: HardwareRevisionSelection, origin: string, + registryFilter: TRegistryFilter, ): Promise => { const data = await RequestData.get( '/api/hardware-by-revision/', @@ -119,6 +185,7 @@ const fetchHardwareListingByRevision = async ( git_repository_url: selection.gitRepositoryUrl, git_repository_branch: selection.gitBranch, git_commit_hash: selection.gitCommitHash, + ...registryFilterToParams(registryFilter), }, }, ); @@ -144,7 +211,7 @@ export const useHardwareListingByRevision = ( selection: HardwareRevisionSelection | null, searchFrom: HardwareListingRoutesMap['search'], ): UseQueryResult => { - const { origin } = useSearch({ from: searchFrom }); + const { origin, registryFilter } = useSearch({ from: searchFrom }); const queryKey = [ 'hardwareListingByRevision', @@ -154,6 +221,7 @@ export const useHardwareListingByRevision = ( selection?.gitBranch, selection?.gitCommitHash, selection, + registryFilter, ]; return useQuery({ @@ -162,7 +230,7 @@ export const useHardwareListingByRevision = ( if (selection === null) { return { hardware: [] }; } - return fetchHardwareListingByRevision(selection, origin); + return fetchHardwareListingByRevision(selection, origin, registryFilter); }, enabled: Boolean( selection?.treeName && diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index 7804ebcfd..5cf797783 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -245,6 +245,17 @@ export const messages = { 'hardwareListing.branchSelectorSearchPlaceholder': 'Search branch...', 'hardwareListing.clearSelection': 'Clear selection', 'hardwareListing.description': 'List of hardware from kernel tests', + 'hardwareListing.filterArchitecture': 'Architecture', + 'hardwareListing.filterBoardType': 'Board type', + 'hardwareListing.filterEmpty': 'No matches.', + 'hardwareListing.filterFormFactor': 'Form factor', + 'hardwareListing.filterPlatform': 'Platform', + 'hardwareListing.filterPlatformVendor': 'Platform vendor', + 'hardwareListing.filterProcessor': 'Processor', + 'hardwareListing.filterRemove': 'Remove {label} {value}', + 'hardwareListing.filterSearch': 'Search...', + 'hardwareListing.filterSiliconVendor': 'Silicon vendor', + 'hardwareListing.filterSystemModule': 'System module', 'hardwareListing.notFound': 'No hardware information available', 'hardwareListing.revisionEmpty': 'The selected revision has no hardware rows yet. Data ingestion may still be in progress.', diff --git a/dashboard/src/pages/Hardware/HardwareListingPage.tsx b/dashboard/src/pages/Hardware/HardwareListingPage.tsx index 12fd3fb26..2e2f79b17 100644 --- a/dashboard/src/pages/Hardware/HardwareListingPage.tsx +++ b/dashboard/src/pages/Hardware/HardwareListingPage.tsx @@ -30,6 +30,8 @@ import { REDUCED_TIME_SEARCH } from '@/utils/constants/general'; import type { HardwareListingRoutesMap } from '@/utils/constants/hardwareListing'; import type { SearchIntent } from '@/lib/intent'; +import HardwareRegistryFilters from './HardwareRegistryFilters'; +import { hasRegistryFilter } from './registryFilters'; import { HardwareTable } from './HardwareTable'; import { decodeBranchValue, @@ -60,6 +62,7 @@ const HardwareListingPage = ({ gitRepositoryUrl, gitBranch, gitCommitHash, + registryFilter, } = useSearch({ from: urlFromMap.search }); const inputFilter = intent.search; const intentCommits = @@ -307,7 +310,10 @@ const HardwareListingPage = ({ activeListing.data.hardware.length > 0, ); const tableEmptyMessageId = - hasSelection && !hasListingRows && inputFilter.length === 0 + hasSelection && + !hasListingRows && + inputFilter.length === 0 && + !hasRegistryFilter(registryFilter) ? 'hardwareListing.revisionEmpty' : 'hardwareListing.notFound'; @@ -325,6 +331,12 @@ const HardwareListingPage = ({ {kcidevComponent} + + void; +}): JSX.Element => { + const intl = useIntl(); + const [open, setOpen] = useState(false); + const searchPlaceholder = intl.formatMessage({ + id: 'hardwareListing.filterSearch', + }); + const emptyMessage = intl.formatMessage({ + id: 'hardwareListing.filterEmpty', + }); + + return ( + + + + + + + + + {emptyMessage} + + {options.map(option => ( + onToggle(option)} + value={option} + > + {option} + + + ))} + + + + + + ); +}; + +const HardwareRegistryFilters = ({ + startTimestampInSeconds, + endTimestampInSeconds, + urlFromMap, +}: { + startTimestampInSeconds: number; + endTimestampInSeconds: number; + urlFromMap: HardwareListingRoutesMap; +}): JSX.Element => { + const intl = useIntl(); + const navigate = useNavigate({ from: urlFromMap.navigate }); + const { registryFilter } = useSearch({ from: urlFromMap.search }); + const optionsQuery = useHardwareRegistryFilterOptions( + startTimestampInSeconds, + endTimestampInSeconds, + urlFromMap.search, + ); + const options = optionsQuery.data; + + const setField = (key: RegistryFilterKey, values: string[]): void => { + navigate({ + search: previousSearch => ({ + ...previousSearch, + registryFilter: cleanRegistryFilter({ + ...registryFilter, + [key]: values, + }), + }), + state: s => s, + }); + }; + + const toggle = (key: RegistryFilterKey, value: string): void => { + const current = registryFilter[key] ?? []; + setField( + key, + current.includes(value) + ? current.filter(item => item !== value) + : [...current, value], + ); + }; + + const chips = REGISTRY_FILTER_FIELDS.flatMap(({ key, label }) => + (registryFilter[key] ?? []).map(value => ({ key, label, value })), + ); + + return ( +
+
+ {REGISTRY_FILTER_FIELDS.map(field => ( + toggle(field.key, value)} + options={options?.[field.optionKey] ?? []} + selected={registryFilter[field.key] ?? []} + /> + ))} +
+ {chips.length > 0 && ( +
+ {chips.map(chip => { + const label = intl.formatMessage({ id: chip.label }); + return ( + + {label}: {chip.value} + + + ); + })} +
+ )} +
+ ); +}; + +export default HardwareRegistryFilters; diff --git a/dashboard/src/pages/Hardware/registryFilters.test.ts b/dashboard/src/pages/Hardware/registryFilters.test.ts new file mode 100644 index 000000000..ea4200659 --- /dev/null +++ b/dashboard/src/pages/Hardware/registryFilters.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { parseSearch, stringifySearch } from '@/utils/search'; + +import { + cleanRegistryFilter, + registryFilterToParams, + type TRegistryFilter, +} from './registryFilters'; + +describe('registryFilterToParams', () => { + it('omits empty fields and joins selected values', () => { + const filter: TRegistryFilter = { + architecture: ['arm64', 'arm'], + processor: [], + platform: ['am62-sk'], + }; + + expect(registryFilterToParams(filter)).toEqual({ + architecture: 'arm64,arm', + platform: 'am62-sk', + }); + }); +}); + +describe('cleanRegistryFilter', () => { + it('drops blank and empty lists', () => { + expect( + cleanRegistryFilter({ + architecture: ['arm64', ''], + boardType: [], + }), + ).toEqual({ architecture: ['arm64'] }); + }); +}); + +describe('registry filter url', () => { + it('round-trips every field', () => { + const registryFilter: TRegistryFilter = { + architecture: ['arm64', 'arm'], + processor: ['am625'], + platformVendor: ['ti'], + siliconVendor: ['ti'], + platform: ['am62-sk'], + systemModule: ['am62-som'], + formFactor: ['sbc'], + boardType: ['evaluation_board'], + }; + + expect(parseSearch(stringifySearch({ registryFilter }))).toMatchObject({ + registryFilter, + }); + }); +}); diff --git a/dashboard/src/pages/Hardware/registryFilters.ts b/dashboard/src/pages/Hardware/registryFilters.ts new file mode 100644 index 000000000..15f9af078 --- /dev/null +++ b/dashboard/src/pages/Hardware/registryFilters.ts @@ -0,0 +1,104 @@ +import { z } from 'zod'; + +import type { MessagesKey } from '@/locales/messages'; +import type { HardwareRegistryFilterOptions } from '@/types/hardware'; + +export const REGISTRY_FILTER_FIELDS = [ + { + key: 'architecture', + optionKey: 'architecture', + label: 'hardwareListing.filterArchitecture', + }, + { + key: 'processor', + optionKey: 'processor', + label: 'hardwareListing.filterProcessor', + }, + { + key: 'platformVendor', + optionKey: 'platform_vendor', + label: 'hardwareListing.filterPlatformVendor', + }, + { + key: 'siliconVendor', + optionKey: 'silicon_vendor', + label: 'hardwareListing.filterSiliconVendor', + }, + { + key: 'platform', + optionKey: 'platform', + label: 'hardwareListing.filterPlatform', + }, + { + key: 'systemModule', + optionKey: 'system_module', + label: 'hardwareListing.filterSystemModule', + }, + { + key: 'formFactor', + optionKey: 'form_factor', + label: 'hardwareListing.filterFormFactor', + }, + { + key: 'boardType', + optionKey: 'board_type', + label: 'hardwareListing.filterBoardType', + }, +] as const satisfies ReadonlyArray<{ + key: string; + optionKey: keyof HardwareRegistryFilterOptions; + label: MessagesKey; +}>; + +export type RegistryFilterKey = (typeof REGISTRY_FILTER_FIELDS)[number]['key']; + +export type TRegistryFilter = Partial>; + +export const DEFAULT_REGISTRY_FILTER: TRegistryFilter = {}; + +const zStringList = z.preprocess(value => { + if (value === null || value === undefined || value === '') { + return undefined; + } + const values = Array.isArray(value) ? value : [value]; + return values.map(item => String(item)).filter(item => item !== ''); +}, z.array(z.string()).optional()); + +export const zRegistryFilter = z + .object({ + architecture: zStringList, + processor: zStringList, + platformVendor: zStringList, + siliconVendor: zStringList, + platform: zStringList, + systemModule: zStringList, + formFactor: zStringList, + boardType: zStringList, + } satisfies Record) + .catch(DEFAULT_REGISTRY_FILTER); + +export const cleanRegistryFilter = ( + filter: TRegistryFilter, +): TRegistryFilter => { + const cleaned: TRegistryFilter = {}; + for (const { key } of REGISTRY_FILTER_FIELDS) { + const values = filter[key]?.filter(value => value !== ''); + if (values && values.length > 0) { + cleaned[key] = values; + } + } + return cleaned; +}; + +export const registryFilterToParams = ( + filter: TRegistryFilter, +): Record => + Object.fromEntries( + REGISTRY_FILTER_FIELDS.flatMap(({ key }) => { + const values = filter[key]; + return values && values.length > 0 ? [[key, values.join(',')]] : []; + }), + ); + +export const hasRegistryFilter = (filter: TRegistryFilter): boolean => + REGISTRY_FILTER_FIELDS.some(({ key }) => (filter[key]?.length ?? 0) > 0); diff --git a/dashboard/src/routes/_main/hardware/route.tsx b/dashboard/src/routes/_main/hardware/route.tsx index 9843e5aaf..42fc66fb8 100644 --- a/dashboard/src/routes/_main/hardware/route.tsx +++ b/dashboard/src/routes/_main/hardware/route.tsx @@ -1,6 +1,10 @@ import { createFileRoute, stripSearchParams } from '@tanstack/react-router'; import { z } from 'zod'; +import { + DEFAULT_REGISTRY_FILTER, + zRegistryFilter, +} from '@/pages/Hardware/registryFilters'; import { makeZIntervalInDays, zListingSize, @@ -15,12 +19,14 @@ const defaultValues = { intervalInDays: REDUCED_TIME_SEARCH, hardwareSearch: '', listingSize: DEFAULT_LISTING_ITEMS, + registryFilter: DEFAULT_REGISTRY_FILTER, }; const zHardwareSchema = z.object({ intervalInDays: makeZIntervalInDays(REDUCED_TIME_SEARCH), hardwareSearch: z.string().catch(''), listingSize: zListingSize, + registryFilter: zRegistryFilter, treeName: z.optional(z.string()), gitRepositoryUrl: z.optional(z.string()), gitBranch: z.optional(z.string()), diff --git a/dashboard/src/types/general.ts b/dashboard/src/types/general.ts index 43d36e805..637a35ba9 100644 --- a/dashboard/src/types/general.ts +++ b/dashboard/src/types/general.ts @@ -301,6 +301,7 @@ export type SearchParamsKeys = | 'gitRepositoryUrl' | 'gitBranch' | 'gitCommitHash' + | 'registryFilter' | 'startTimestampInSeconds' | 'endTimestampInSeconds' | 'issueVersion' diff --git a/dashboard/src/types/hardware.ts b/dashboard/src/types/hardware.ts index 6da6b0bd7..de03c6616 100644 --- a/dashboard/src/types/hardware.ts +++ b/dashboard/src/types/hardware.ts @@ -20,6 +20,17 @@ export interface HardwareListingResponse { hardware: HardwareListingApiItem[]; } +export type HardwareRegistryFilterOptions = { + architecture: string[]; + processor: string[]; + platform_vendor: string[]; + silicon_vendor: string[]; + platform: string[]; + system_module: string[]; + form_factor: string[]; + board_type: string[]; +}; + export type HardwareSelectorRevision = { git_commit_hash: string; git_commit_name?: string | null; diff --git a/dashboard/src/utils/search.ts b/dashboard/src/utils/search.ts index d4f9ba865..2602f9323 100644 --- a/dashboard/src/utils/search.ts +++ b/dashboard/src/utils/search.ts @@ -55,6 +55,7 @@ export const parseSearch = (searchStr: string): AnySchema => { et: 'number', iv: 'number', ...diffFilterDurationQueryNumberTypes, + ...registryFilterQueryArrayTypes, }, }); @@ -156,8 +157,27 @@ const generalMinifiedParams: Record = { gitRepositoryUrl: 'gu', gitBranch: 'gb', gitCommitHash: 'ch', + registryFilter: 'rf', } as const; +const registryFilterMinifiedParams = { + architecture: 'a', + processor: 'p', + platformVendor: 'pv', + siliconVendor: 'sv', + platform: 'pl', + systemModule: 'sm', + formFactor: 'ff', + boardType: 'bt', +} as const; + +const registryFilterQueryArrayTypes = Object.fromEntries( + Object.values(registryFilterMinifiedParams).map(key => [ + `rf${KEY_FLAT_CHAR}${key}`, + 'string[]', + ]), +) as Record; + const treeInfoMinifiedParams: Record = { gitBranch: 'gb', gitUrl: 'gu', @@ -210,7 +230,11 @@ const diffFilterDurationQueryNumberTypes = Object.fromEntries( ) as Record; type MinifiedParams = Record< - SearchParamsKeys | TFilterKeys | keyof TableFilter | keyof TTreeInformation, + | SearchParamsKeys + | TFilterKeys + | keyof TableFilter + | keyof TTreeInformation + | keyof typeof registryFilterMinifiedParams, string >; type MinifiedParamsKeys = keyof MinifiedParams; @@ -219,6 +243,7 @@ const minifiedParams: MinifiedParams = { ...treeInfoMinifiedParams, ...tableFilterMinifiedParams, ...diffFilterMinifiedParams, + ...registryFilterMinifiedParams, } as const; type MinifiedValues = Record< @@ -280,6 +305,9 @@ const tableFilterMinifiedParamsArray = Object.entries( const diffFilterMinifiedParamsArray = Object.entries( diffFilterMinifiedParams, ).map(([key, value]) => [value, key]); +const registryFilterMinifiedParamsArray = Object.entries( + registryFilterMinifiedParams, +).map(([key, value]) => [value, key]); const minifiedValuesArray = Object.entries(minifiedValues).map( ([key, value]) => [value, key], ); @@ -291,6 +319,7 @@ const groupedMinifiedParams = { // Multi-table sort keys reuse the tableFilter abbreviations (b, bt, t). s: Object.fromEntries(tableFilterMinifiedParamsArray), df: Object.fromEntries(diffFilterMinifiedParamsArray), + rf: Object.fromEntries(registryFilterMinifiedParamsArray), value: Object.fromEntries(minifiedValuesArray), } as const; type GroupedMinifiedKeys = keyof typeof groupedMinifiedParams; From 93d652875ebeee5571beecb8b047f7658c697d6f Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Tue, 20 Oct 2026 18:44:50 -0300 Subject: [PATCH 2/2] feat(filters): tighten the shared filters drawer * Show the hardware registry filters in the shared filters drawer * Use compact checkbox chips, and a searchable multi-select once a section has more than 8 options * Clear a multi-select with one control * Drop the repeated select prompts and put duration min and max on one line Part of #2161 Part of #2166 Assisted-by: Cursor Grok 4.7 Signed-off-by: Alan Peixinho --- .../src/components/Checkbox/Checkbox.tsx | 4 +- .../src/components/Filter/CheckboxSection.tsx | 205 +++++++++++++++--- dashboard/src/components/Filter/Drawer.tsx | 4 +- .../components/Filter/TimeRangeSection.tsx | 60 +++-- dashboard/src/components/Tabs/Filters.tsx | 5 - dashboard/src/locales/messages/index.ts | 24 +- .../pages/Hardware/HardwareListingPage.tsx | 15 +- .../Hardware/HardwareRegistryFilters.tsx | 202 +++++------------ .../pages/IssueListing/IssueListingFilter.tsx | 4 - .../pages/TreeDetails/TreeDetailsFilter.tsx | 14 -- .../hardwareDetails/HardwareDetailsFilter.tsx | 11 - 11 files changed, 272 insertions(+), 276 deletions(-) diff --git a/dashboard/src/components/Checkbox/Checkbox.tsx b/dashboard/src/components/Checkbox/Checkbox.tsx index 394c2bfa5..0709c5cd2 100644 --- a/dashboard/src/components/Checkbox/Checkbox.tsx +++ b/dashboard/src/components/Checkbox/Checkbox.tsx @@ -19,7 +19,7 @@ interface ICheckbox { } const containerClass = - 'min-w-[300px] p-4 border-[2px] border-dark-gray rounded-sm cursor-pointer text-dim-gray'; + 'px-2 py-1 border border-dark-gray rounded-sm cursor-pointer text-dim-gray text-sm'; const maxCheckboxLength = 29; @@ -46,7 +46,7 @@ const Checkbox = ({ })} > void; type TItems = { [key: string]: boolean }; @@ -37,7 +57,6 @@ export interface ICheckboxSection { items?: TItems; title: string; isGlobal?: boolean; - subtitle?: string; subsections?: ICheckboxSubsection[]; onClickItem: TOnClickItem; className?: string; @@ -46,20 +65,21 @@ export interface ICheckboxSection { export interface ISectionItem { title: MessageDescriptor['id']; - subtitle: MessageDescriptor['id']; sectionKey: TFilterObjectsKeys; isGlobal?: boolean; } -type SplitFilterString = `${string}__${string}`; - -const extractCheckboxValue = (value: SplitFilterString | undefined): string => { - if (!value) { - return ''; +const checkboxLabel = ( + value: string, + formatMessage: IntlShape['formatMessage'], +): string => { + for (const page of Object.values(OptionFilters)) { + if (value in page) { + return formatMessage({ id: page[value].displayTextId }); + } } - const valueSplit = value.split('__'); - return valueSplit[0]; + return value.split('__')[0] ?? ''; }; const CheckboxSectionItem = ({ @@ -67,36 +87,149 @@ const CheckboxSectionItem = ({ onClickItem, isSelected, }: ICheckboxSectionItem): JSX.Element => { + const { formatMessage } = useIntl(); const handleOnToggle = useCallback( () => onClickItem(value), [value, onClickItem], ); + return ( + + ); +}; + +const FilterMultiCombobox = ({ + items, + onClickItem, +}: ICheckboxList): JSX.Element => { const { formatMessage } = useIntl(); + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + const keys = Object.keys(items); + const selected = keys.filter(key => items[key]); + const label = (value: string): string => checkboxLabel(value, formatMessage); + + useEffect(() => { + if (!open) { + return; + } - // Checks if the value to be shown is different than the key used in diffFilter - // This happens for filters that are manual options instead of values from received data - const shownValue = useMemo(() => { - Object.values(OptionFilters).forEach(page => { - if (value in page) { - const valueId = page[value].displayTextId; - return formatMessage({ id: valueId }); + const onPointerDown = (event: PointerEvent): void => { + if (!rootRef.current?.contains(event.target as Node)) { + setOpen(false); } - }); + }; - return value; - }, [formatMessage, value]); + document.addEventListener('pointerdown', onPointerDown); + return (): void => + document.removeEventListener('pointerdown', onPointerDown); + }, [open]); return ( - +
+
setOpen(current => !current)} + onKeyDown={event => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setOpen(current => !current); + } + }} + role="combobox" + tabIndex={0} + > + + {selected.length === 0 ? ( + + {formatMessage({ id: 'filter.select' })} + + ) : ( + selected.map(value => ( + + {label(value)} + + + )) + )} + + {selected.length > 0 && ( + + )} + +
+ {open && ( + + + + + {formatMessage({ id: 'filter.noMatches' })} + + + {keys.map(key => ( + onClickItem(key)} + value={key} + > + + {label(key)} + + ))} + + + + )} +
); }; -const CheckboxList = ({ items, onClickItem }: ICheckboxList): JSX.Element => { +const CheckboxChips = ({ items, onClickItem }: ICheckboxList): JSX.Element => { const itemComponents = useMemo( () => Object.keys(items).map(key => ( @@ -110,11 +243,15 @@ const CheckboxList = ({ items, onClickItem }: ICheckboxList): JSX.Element => { [items, onClickItem], ); - return ( -
- {itemComponents} -
- ); + return
{itemComponents}
; +}; + +const CheckboxList = ({ items, onClickItem }: ICheckboxList): JSX.Element => { + if (Object.keys(items).length > CHIP_LIST_LIMIT) { + return ; + } + + return ; }; const CheckboxSubsection = ({ @@ -133,7 +270,6 @@ const CheckboxSubsection = ({ const CheckboxSection = ({ items, title, - subtitle, onClickItem, subsections, isGlobal = false, @@ -155,11 +291,10 @@ const CheckboxSection = ({ return (
-

+

{showIcon && } {title}

-

{subtitle}

{items && } {subsectionComponents}
diff --git a/dashboard/src/components/Filter/Drawer.tsx b/dashboard/src/components/Filter/Drawer.tsx index 57f719815..518320197 100644 --- a/dashboard/src/components/Filter/Drawer.tsx +++ b/dashboard/src/components/Filter/Drawer.tsx @@ -85,7 +85,7 @@ export const DrawerSection = ({ return ( <> {!hideSeparator && } -
{children}
+
{children}
); }; @@ -122,7 +122,7 @@ export const FilterTypeIcon = ({ type }: FilterTypeIconProps): JSX.Element => { const Legend = (): JSX.Element => { return ( -
+
diff --git a/dashboard/src/components/Filter/TimeRangeSection.tsx b/dashboard/src/components/Filter/TimeRangeSection.tsx index 9537e42b1..9afab2355 100644 --- a/dashboard/src/components/Filter/TimeRangeSection.tsx +++ b/dashboard/src/components/Filter/TimeRangeSection.tsx @@ -8,7 +8,6 @@ import { FilterTypeIcon } from './Drawer'; interface TimeRangeSection { title: string; - subtitle: string; min: number | undefined; max: number | undefined; isGlobal?: boolean; @@ -16,12 +15,10 @@ interface TimeRangeSection { onMaxChange: (e: React.FormEvent) => void; } -const inputContainerClass = 'flex gap-x-4 items-center'; -const inputClass = 'max-w-20'; +const inputClass = 'h-8 max-w-20'; const TimeRangeSection = ({ title, - subtitle, min, max, isGlobal = false, @@ -29,37 +26,36 @@ const TimeRangeSection = ({ onMaxChange, }: TimeRangeSection): JSX.Element => { return ( -
-

+
+

{title}

-

{subtitle}

-
- - : - - -
- -
- - : - - - +
+ +
); diff --git a/dashboard/src/components/Tabs/Filters.tsx b/dashboard/src/components/Tabs/Filters.tsx index 0c4f8ab1c..9409696a1 100644 --- a/dashboard/src/components/Tabs/Filters.tsx +++ b/dashboard/src/components/Tabs/Filters.tsx @@ -170,7 +170,6 @@ const CheckboxSection = ({ () => sections.map(section => ({ title: intl.formatMessage({ id: section.title }), - subtitle: intl.formatMessage({ id: section.subtitle }), items: parsedFilter[section.sectionKey], isGlobal: section.isGlobal, showIcon: showAllIcons, @@ -228,7 +227,6 @@ const TimeRangeSection = ({ () => [ { title: intl.formatMessage({ id: 'filter.buildDuration' }), - subtitle: intl.formatMessage({ id: 'filter.durationSubtitle' }), min: diffFilter.buildDurationMin, max: diffFilter.buildDurationMax, onMaxChange: e => timeChangeHandler(e, 'buildDurationMax'), @@ -236,7 +234,6 @@ const TimeRangeSection = ({ }, { title: intl.formatMessage({ id: 'filter.bootDuration' }), - subtitle: intl.formatMessage({ id: 'filter.durationSubtitle' }), min: diffFilter.bootDurationMin, max: diffFilter.bootDurationMax, onMaxChange: e => timeChangeHandler(e, 'bootDurationMax'), @@ -244,7 +241,6 @@ const TimeRangeSection = ({ }, { title: intl.formatMessage({ id: 'filter.testDuration' }), - subtitle: intl.formatMessage({ id: 'filter.durationSubtitle' }), min: diffFilter.testDurationMin, max: diffFilter.testDurationMax, onMaxChange: e => timeChangeHandler(e, 'testDurationMax'), @@ -306,7 +302,6 @@ const TreeSelectSection = ({ title={intl.formatMessage({ id: 'global.trees' })} items={filterItems} isGlobal - subtitle={intl.formatMessage({ id: 'filter.treeSubtitle' })} onClickItem={handleSelectTree} showIcon={showIcon} /> diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index 5cf797783..0f494df22 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -56,7 +56,6 @@ export const messages = { 'dateRange.endBeforeStart': 'End date cannot be before the start date', 'dateRange.startAfterEnd': 'Start date cannot be after the end date', 'dateRange.startTooEarly': 'Start date cannot be before 2024-01-01', - 'filter.architectureSubtitle': 'Please select one or more Architectures:', 'filter.bootDuration': 'Boot duration', 'filter.bootIssue': 'Boot issue', 'filter.bootOrigin': 'Boot origin', @@ -66,44 +65,34 @@ export const messages = { 'filter.buildIssue': 'Build Issue', 'filter.buildOrigin': 'Build origin', 'filter.buildStatus': 'Build Status', - 'filter.compatiblesSubtitle': 'Please select one or more compatibles:', - 'filter.compilersSubtitle': 'Please select one or more compilers:', - 'filter.configsSubtitle': 'Please select one or more configs:', - 'filter.durationSubtitle': 'Please select the duration range:', + 'filter.clear': 'Clear', 'filter.filtering': 'Filtering', 'filter.globalFilter': 'Global filters', 'filter.globalFilterAllTabs': 'Global filters affects all the tabs', 'filter.hardware': 'Hardware', - 'filter.hardwareSubtitle': 'Please select one or more hardware:', 'filter.invalid': 'Please provide a time limit, in days, greater than 0', 'filter.issueCategories': 'Issue Categories', - 'filter.issueCategoriesSubtitle': - 'Please select one or more issue Categories:', 'filter.issueCulprit': 'Issue Culprit', - 'filter.issueCulpritSubtitle': 'Select one or more issue culprits:', 'filter.issueHasIncident': 'Has incident', - 'filter.issueSubtitle': 'Please select one or more issues:', 'filter.labs': 'Labs', - 'filter.labsSubtitle': 'Please select one or more labs:', 'filter.latestCheckoutFilterLabel': 'Showing latest checkout for trees updated in the last {days} days', 'filter.max': 'Max', 'filter.min': 'Min', + 'filter.noMatches': 'No matches.', 'filter.onlySpecificTab': 'Only affects a specific tab', 'filter.options': 'Options', - 'filter.optionsSubtitle': 'Select one or more options:', 'filter.origins': 'Origins', - 'filter.originsSubtitle': 'Please select one or more origins:', 'filter.perTabFilter': 'Per tab filters', - 'filter.platformSubtitle': 'Please select one or more platforms:', - 'filter.statusSubtitle': 'Please select one or more Status:', + 'filter.remove': 'Remove {value}', + 'filter.search': 'Search...', + 'filter.select': 'Select', 'filter.tableFilter': 'Status filters:', 'filter.testDuration': 'Test duration', 'filter.testIssue': 'Test issue', 'filter.testOrigin': 'Test origin', 'filter.testPlatform': 'Test Platforms', 'filter.testStatus': 'Test Status', - 'filter.treeSubtitle': 'Please select one or more Trees:', 'filter.treeURL': 'Tree URL', 'footer.cliCommand': 'CLI command', 'footer.commandDocumentation': 'Command documentation', @@ -247,13 +236,10 @@ export const messages = { 'hardwareListing.description': 'List of hardware from kernel tests', 'hardwareListing.filterArchitecture': 'Architecture', 'hardwareListing.filterBoardType': 'Board type', - 'hardwareListing.filterEmpty': 'No matches.', 'hardwareListing.filterFormFactor': 'Form factor', 'hardwareListing.filterPlatform': 'Platform', 'hardwareListing.filterPlatformVendor': 'Platform vendor', 'hardwareListing.filterProcessor': 'Processor', - 'hardwareListing.filterRemove': 'Remove {label} {value}', - 'hardwareListing.filterSearch': 'Search...', 'hardwareListing.filterSiliconVendor': 'Silicon vendor', 'hardwareListing.filterSystemModule': 'System module', 'hardwareListing.notFound': 'No hardware information available', diff --git a/dashboard/src/pages/Hardware/HardwareListingPage.tsx b/dashboard/src/pages/Hardware/HardwareListingPage.tsx index 2e2f79b17..dd1e6849b 100644 --- a/dashboard/src/pages/Hardware/HardwareListingPage.tsx +++ b/dashboard/src/pages/Hardware/HardwareListingPage.tsx @@ -328,15 +328,16 @@ const HardwareListingPage = ({ values={{ br:
}} /> - {kcidevComponent} +
+ + {kcidevComponent} +
- - void; -}): JSX.Element => { - const intl = useIntl(); - const [open, setOpen] = useState(false); - const searchPlaceholder = intl.formatMessage({ - id: 'hardwareListing.filterSearch', - }); - const emptyMessage = intl.formatMessage({ - id: 'hardwareListing.filterEmpty', - }); - - return ( - - - - - - - - - {emptyMessage} - - {options.map(option => ( - onToggle(option)} - value={option} - > - {option} - - - ))} - - - - - - ); -}; - const HardwareRegistryFilters = ({ startTimestampInSeconds, endTimestampInSeconds, @@ -118,73 +32,71 @@ const HardwareRegistryFilters = ({ endTimestampInSeconds, urlFromMap.search, ); - const options = optionsQuery.data; + const [draft, setDraft] = useState(registryFilter); - const setField = (key: RegistryFilterKey, values: string[]): void => { + const handleOpenChange = useCallback(() => { + setDraft(registryFilter); + }, [registryFilter]); + + const onFilter = useCallback(() => { navigate({ search: previousSearch => ({ ...previousSearch, - registryFilter: cleanRegistryFilter({ - ...registryFilter, - [key]: values, - }), + registryFilter: cleanRegistryFilter(draft), }), state: s => s, }); - }; - - const toggle = (key: RegistryFilterKey, value: string): void => { - const current = registryFilter[key] ?? []; - setField( - key, - current.includes(value) - ? current.filter(item => item !== value) - : [...current, value], - ); - }; + }, [draft, navigate]); - const chips = REGISTRY_FILTER_FIELDS.flatMap(({ key, label }) => - (registryFilter[key] ?? []).map(value => ({ key, label, value })), + const sections = useMemo( + () => + REGISTRY_FILTER_FIELDS.flatMap(field => { + const choices = optionsQuery.data?.[field.optionKey] ?? []; + if (choices.length === 0) { + return []; + } + const selected = new Set(draft[field.key] ?? []); + return [ + { + key: field.key, + title: intl.formatMessage({ id: field.label }), + items: Object.fromEntries( + choices.map(choice => [choice, selected.has(choice)]), + ), + }, + ]; + }), + [draft, intl, optionsQuery.data], ); + const toggle = useCallback((key: RegistryFilterKey, value: string): void => { + setDraft(current => { + const values = current[key] ?? []; + const nextValues = values.includes(value) + ? values.filter(item => item !== value) + : [...values, value]; + return cleanRegistryFilter({ ...current, [key]: nextValues }); + }); + }, []); + return ( -
-
- {REGISTRY_FILTER_FIELDS.map(field => ( - toggle(field.key, value)} - options={options?.[field.optionKey] ?? []} - selected={registryFilter[field.key] ?? []} + + {sections.map((section, index) => ( + + toggle(section.key, value)} + showIcon={false} + title={section.title} /> - ))} -
- {chips.length > 0 && ( -
- {chips.map(chip => { - const label = intl.formatMessage({ id: chip.label }); - return ( - - {label}: {chip.value} - - - ); - })} -
- )} -
+ + ))} + ); }; diff --git a/dashboard/src/pages/IssueListing/IssueListingFilter.tsx b/dashboard/src/pages/IssueListing/IssueListingFilter.tsx index 510b5f266..39655731f 100644 --- a/dashboard/src/pages/IssueListing/IssueListingFilter.tsx +++ b/dashboard/src/pages/IssueListing/IssueListingFilter.tsx @@ -65,23 +65,19 @@ export const createFilter = ( const sectionTrees: ISectionItem[] = [ { title: 'filter.origins', - subtitle: 'filter.originsSubtitle', sectionKey: 'origins', }, { title: 'filter.issueCategories', - subtitle: 'filter.issueCategoriesSubtitle', sectionKey: 'issueCategories', }, //// These sections will be available to the user once the data is stable in kcidb // { // title: 'filter.issueCulprit', - // subtitle: 'filter.issueCulpritSubtitle', // sectionKey: 'issueCulprits', // }, // { // title: 'filter.options', - // subtitle: 'filter.optionsSubtitle', // sectionKey: 'issueOptions', // }, ]; diff --git a/dashboard/src/pages/TreeDetails/TreeDetailsFilter.tsx b/dashboard/src/pages/TreeDetails/TreeDetailsFilter.tsx index f8039c401..fe65d7fb0 100644 --- a/dashboard/src/pages/TreeDetails/TreeDetailsFilter.tsx +++ b/dashboard/src/pages/TreeDetails/TreeDetailsFilter.tsx @@ -132,79 +132,65 @@ export const createFilter = (data: TreeDetailsSummary): TFilter => { const sectionTrees: ISectionItem[] = [ { title: 'filter.buildStatus', - subtitle: 'filter.statusSubtitle', sectionKey: 'buildStatus', }, { title: 'filter.bootStatus', - subtitle: 'filter.statusSubtitle', sectionKey: 'bootStatus', }, { title: 'filter.testStatus', - subtitle: 'filter.statusSubtitle', sectionKey: 'testStatus', }, { title: 'filter.buildIssue', - subtitle: 'filter.issueSubtitle', sectionKey: 'buildIssue', }, { title: 'filter.bootIssue', - subtitle: 'filter.issueSubtitle', sectionKey: 'bootIssue', }, { title: 'filter.testIssue', - subtitle: 'filter.issueSubtitle', sectionKey: 'testIssue', }, { title: 'filter.hardware', - subtitle: 'filter.hardwareSubtitle', sectionKey: 'hardware', isGlobal: true, }, { title: 'global.configs', - subtitle: 'filter.configsSubtitle', sectionKey: 'configs', isGlobal: true, }, { title: 'global.architecture', - subtitle: 'filter.architectureSubtitle', sectionKey: 'archs', isGlobal: true, }, { title: 'global.compilers', - subtitle: 'filter.compilersSubtitle', sectionKey: 'compilers', isGlobal: true, }, { title: 'filter.labs', - subtitle: 'filter.labsSubtitle', sectionKey: 'labs', isGlobal: true, }, { title: 'filter.buildOrigin', - subtitle: 'filter.originsSubtitle', sectionKey: 'buildOrigin', isGlobal: false, }, { title: 'filter.bootOrigin', - subtitle: 'filter.originsSubtitle', sectionKey: 'bootOrigin', isGlobal: false, }, { title: 'filter.testOrigin', - subtitle: 'filter.originsSubtitle', sectionKey: 'testOrigin', isGlobal: false, }, diff --git a/dashboard/src/pages/hardwareDetails/HardwareDetailsFilter.tsx b/dashboard/src/pages/hardwareDetails/HardwareDetailsFilter.tsx index 821d64dd7..3412d1f4c 100644 --- a/dashboard/src/pages/hardwareDetails/HardwareDetailsFilter.tsx +++ b/dashboard/src/pages/hardwareDetails/HardwareDetailsFilter.tsx @@ -144,61 +144,50 @@ export const createFilter = ( const sectionHardware: ISectionItem[] = [ { title: 'filter.buildStatus', - subtitle: 'filter.statusSubtitle', sectionKey: 'buildStatus', }, { title: 'filter.bootStatus', - subtitle: 'filter.statusSubtitle', sectionKey: 'bootStatus', }, { title: 'filter.testStatus', - subtitle: 'filter.statusSubtitle', sectionKey: 'testStatus', }, { title: 'filter.buildIssue', - subtitle: 'filter.issueSubtitle', sectionKey: 'buildIssue', }, { title: 'filter.bootIssue', - subtitle: 'filter.issueSubtitle', sectionKey: 'bootIssue', }, { title: 'filter.testIssue', - subtitle: 'filter.issueSubtitle', sectionKey: 'testIssue', }, { title: 'global.configs', - subtitle: 'filter.configsSubtitle', sectionKey: 'configs', isGlobal: true, }, { title: 'global.architecture', - subtitle: 'filter.architectureSubtitle', sectionKey: 'archs', isGlobal: true, }, { title: 'global.compilers', - subtitle: 'filter.compilersSubtitle', sectionKey: 'compilers', isGlobal: true, }, { title: 'global.compatibles', - subtitle: 'filter.compatiblesSubtitle', sectionKey: 'hardware', isGlobal: true, }, { title: 'filter.labs', - subtitle: 'filter.labsSubtitle', sectionKey: 'labs', isGlobal: true, },