From fb284df606d7ea39c5179019502ce42ff4a338ba Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Tue, 11 Aug 2026 15:27:19 -0300 Subject: [PATCH 1/3] feat(hardware): mock hardware registry enrichment UI Preview registry-backed platform info on listing, hardware details, and test details without backend wiring (#1886). - Add sortable processor column and expandable registry details on listing - Show registry strip on hardware details and hardware-info card on tests Signed-off-by: Alan Peixinho --- .../src/components/Cards/DetailsInfoCard.tsx | 12 +- .../HardwareRegistry/HardwareRegistry.tsx | 213 ++++++++++++++++++ .../components/LinkWithIcon/LinkWithIcon.tsx | 8 +- .../components/TestDetails/TestDetails.tsx | 14 ++ dashboard/src/lib/hardwareRegistryMock.ts | 53 +++++ dashboard/src/locales/messages/index.ts | 12 + .../src/pages/Hardware/HardwareTable.tsx | 136 +++++++++-- .../pages/hardwareDetails/HardwareDetails.tsx | 10 + 8 files changed, 426 insertions(+), 32 deletions(-) create mode 100644 dashboard/src/components/HardwareRegistry/HardwareRegistry.tsx create mode 100644 dashboard/src/lib/hardwareRegistryMock.ts diff --git a/dashboard/src/components/Cards/DetailsInfoCard.tsx b/dashboard/src/components/Cards/DetailsInfoCard.tsx index 265dea8ad..6305793b1 100644 --- a/dashboard/src/components/Cards/DetailsInfoCard.tsx +++ b/dashboard/src/components/Cards/DetailsInfoCard.tsx @@ -1,4 +1,4 @@ -import type { JSX } from 'react'; +import type { JSX, ReactNode } from 'react'; import { useMemo } from 'react'; import type { ColumnDef } from '@tanstack/react-table'; import { @@ -41,15 +41,17 @@ const columns: ColumnDef[] = [ export const DetailsInfoCard = ({ cardTitle, + title, data, }: { - cardTitle: MessagesKey; + cardTitle?: MessagesKey; + title?: ReactNode; data: ILinkWithIcon[]; }): JSX.Element => { const sanitizedData: DetailRow[] = useMemo( () => - data.map(({ title, ...value }) => ({ - title, + data.map(({ title: fieldTitle, ...value }) => ({ + title: fieldTitle, value: { ...value }, })), [data], @@ -93,7 +95,7 @@ export const DetailsInfoCard = ({ return ( } + title={title ?? (cardTitle ? : null)} className="mb-0 gap-0" > diff --git a/dashboard/src/components/HardwareRegistry/HardwareRegistry.tsx b/dashboard/src/components/HardwareRegistry/HardwareRegistry.tsx new file mode 100644 index 000000000..69ac126fc --- /dev/null +++ b/dashboard/src/components/HardwareRegistry/HardwareRegistry.tsx @@ -0,0 +1,213 @@ +import type { JSX, ReactNode } from 'react'; + +import { FormattedMessage } from 'react-intl'; + +import { MdDeveloperBoard } from 'react-icons/md'; + +import { valueOrEmpty } from '@/lib/string'; +import type { MessagesKey } from '@/locales/messages'; +import type { HardwareRegistryInfo } from '@/lib/hardwareRegistryMock'; + +import BaseCard from '@/components/Cards/BaseCard'; +import { DetailsInfoCard } from '@/components/Cards/DetailsInfoCard'; +import LinkWithIcon, { + type ILinkWithIcon, +} from '@/components/LinkWithIcon/LinkWithIcon'; +import { LinkIcon } from '@/components/Icons/Link'; + +const humanize = (text?: string): string | undefined => + text?.replace(/_/g, ' '); + +const processorFields = (info: HardwareRegistryInfo): ILinkWithIcon[] => { + const clock = info.processor?.maxClockSpeedMhz; + return [ + { + title: 'global.soc', + linkText: valueOrEmpty(info.processor?.id), + link: info.processor?.url, + }, + { + title: 'global.architecture', + linkText: valueOrEmpty(info.processor?.architecture), + }, + { + title: 'global.cores', + linkText: valueOrEmpty(info.processor?.cores?.toString()), + }, + { + title: 'global.maxClockSpeed', + linkText: valueOrEmpty(clock ? `${clock} MHz` : undefined), + }, + { + title: 'global.siliconVendor', + linkText: valueOrEmpty(info.siliconVendor?.id), + link: info.siliconVendor?.url, + }, + ]; +}; + +const boardFields = (info: HardwareRegistryInfo): ILinkWithIcon[] => [ + { + title: 'global.boardType', + linkText: valueOrEmpty(humanize(info.boardType)), + }, + { + title: 'global.formFactor', + linkText: valueOrEmpty(humanize(info.formFactor)), + }, + ...(info.systemModule + ? [ + { + title: 'global.systemModule' as MessagesKey, + linkText: valueOrEmpty(info.systemModule.id), + link: info.systemModule.url, + }, + ] + : []), + { + title: 'global.vendor', + linkText: valueOrEmpty(info.vendor?.id), + link: info.vendor?.url, + }, +]; + +const fieldByTitle = ( + fields: ILinkWithIcon[], + title: MessagesKey, +): ILinkWithIcon | undefined => fields.find(field => field.title === title); + +const listingFields = (info: HardwareRegistryInfo): ILinkWithIcon[] => { + const processor = processorFields(info); + const board = boardFields(info); + return [ + { + title: 'global.platform', + linkText: valueOrEmpty(info.platformId), + link: info.url, + }, + fieldByTitle(processor, 'global.soc'), + fieldByTitle(processor, 'global.architecture'), + fieldByTitle(board, 'global.vendor'), + fieldByTitle(board, 'global.boardType'), + fieldByTitle(board, 'global.formFactor'), + ].filter((field): field is ILinkWithIcon => field !== undefined); +}; + +const SpecGroup = ({ + label, + children, +}: { + label: MessagesKey; + children: ReactNode; +}): JSX.Element => ( +
+ + + +
{children}
+
+); + +const specs = (fields: ILinkWithIcon[]): JSX.Element[] => + fields.map(field => ( + + )); + +const RegistryTitle = ({ + info, +}: { + info: HardwareRegistryInfo; +}): JSX.Element => ( +
+
+ + +
+ {info.description && ( + + {info.description} + + )} +
+); + +export const HardwareRegistryListingDetails = ({ + info, +}: { + info: HardwareRegistryInfo; +}): JSX.Element => ( +
+ {info.description && ( + {info.description} + )} +
+ {specs(listingFields(info))} +
+
+); + +export const HardwareRegistryStrip = ({ + info, + className, +}: { + info?: HardwareRegistryInfo; + className?: string; +}): JSX.Element | null => { + if (!info) { + return null; + } + + return ( + }> +
+ + {specs(processorFields(info))} + +
+ {specs(boardFields(info))} +
+ + ); +}; + +export const HardwareRegistryCard = ({ + info, +}: { + info?: HardwareRegistryInfo; +}): JSX.Element | null => { + if (!info) { + return null; + } + + return ( + + + +
+ } + data={[ + { + title: 'global.platform' as MessagesKey, + linkText: valueOrEmpty(info.platformId), + link: info.url, + }, + { + title: 'global.description' as MessagesKey, + linkText: valueOrEmpty(info.description), + }, + ...processorFields(info), + ...boardFields(info), + ].map(field => + field.link + ? { ...field, icon: } + : field, + )} + /> + ); +}; diff --git a/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx b/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx index c699d53de..f8f1852fe 100644 --- a/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx +++ b/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx @@ -15,6 +15,7 @@ export interface ILinkWithIcon { unformattedTitle?: string; titleIcon?: JSX.Element; className?: string; + titleClassName?: string; } const LinkWithIcon = ({ @@ -27,6 +28,7 @@ const LinkWithIcon = ({ unformattedTitle, titleIcon, className, + titleClassName, }: ILinkWithIcon): JSX.Element => { const WrapperLink = link ? 'a' : 'div'; @@ -44,8 +46,10 @@ const LinkWithIcon = ({ className={cn('flex flex-col items-start gap-1 text-[16px]', className)} > {(titleText || titleIcon) && ( -
- {titleText && {titleText}} +
+ {titleText && ( + {titleText} + )} {titleIcon}
)} diff --git a/dashboard/src/components/TestDetails/TestDetails.tsx b/dashboard/src/components/TestDetails/TestDetails.tsx index 06a372a1a..fa418e81c 100644 --- a/dashboard/src/components/TestDetails/TestDetails.tsx +++ b/dashboard/src/components/TestDetails/TestDetails.tsx @@ -79,6 +79,10 @@ import { DetailsInfoCard } from '@/components/Cards/DetailsInfoCard'; import CopyButton from '@/components/Button/CopyButton'; +import { getMockHardwareRegistryInfo } from '@/lib/hardwareRegistryMock'; + +import { HardwareRegistryCard } from '@/components/HardwareRegistry/HardwareRegistry'; + import { StatusHistoryItem } from './StatusHistoryItem'; const TestDetailsSections = ({ @@ -199,6 +203,14 @@ const TestDetailsSections = ({ endTimestampInSeconds, ]); + const registryInfo = useMemo(() => { + const platform = + typeof test.environment_misc?.['platform'] === 'string' + ? test.environment_misc['platform'] + : undefined; + return getMockHardwareRegistryInfo(platform); + }, [test.environment_misc]); + const setSheetToLog = useCallback( (): void => setSheetType('log'), [setSheetType], @@ -462,6 +474,7 @@ const TestDetailsSections = ({ }, ]} /> +
), }, @@ -476,6 +489,7 @@ const TestDetailsSections = ({ hardwareDetailsLink, buildDetailsLink, compatiblesLink, + registryInfo, ]); const miscSection: ISection | undefined = useMemo((): diff --git a/dashboard/src/lib/hardwareRegistryMock.ts b/dashboard/src/lib/hardwareRegistryMock.ts new file mode 100644 index 000000000..10e46ce53 --- /dev/null +++ b/dashboard/src/lib/hardwareRegistryMock.ts @@ -0,0 +1,53 @@ +// MOCK ONLY — frontend preview. Real API later. + +export interface HardwareRegistryInfo { + platformId: string; + boardType?: string; + formFactor?: string; + description?: string; + url?: string; + vendor?: { id: string; url?: string }; + siliconVendor?: { id: string; url?: string }; + systemModule?: { id: string; formFactor?: string; url?: string }; + processor?: { + id: string; + architecture?: string; + cores?: number; + maxClockSpeedMhz?: number; + url?: string; + description?: string; + }; +} + +const MOCK: HardwareRegistryInfo = { + platformId: 'am335x-bone-black', + boardType: 'single_board_computer', + formFactor: 'board', + description: 'BeagleBone Black open-source single-board computer', + url: 'https://beagleboard.org/black', + vendor: { id: 'beagleboard', url: 'https://beagleboard.org' }, + siliconVendor: { id: 'ti', url: 'https://www.ti.com' }, + systemModule: { + id: 'osd335x', + formFactor: 'system-on-module', + url: 'https://octavosystems.com/octavo_products/osd335x/', + }, + processor: { + id: 'am3358', + architecture: 'arm', + cores: 1, + maxClockSpeedMhz: 800, + url: 'https://www.ti.com/product/AM3358', + description: 'Arm Cortex-A8, 3D graphics, PRU-ICSS, CAN', + }, +}; + +export const getMockHardwareRegistryInfo = ( + _platform?: string, +): HardwareRegistryInfo => MOCK; + +export const getMockHardwareRegistryListingInfo = ( + platform: string, + index: number, +): HardwareRegistryInfo | undefined => + index === 0 ? { ...MOCK, platformId: platform } : undefined; diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index b246f45df..0cfd88b14 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -121,6 +121,8 @@ export const messages = { 'global.arrowRight': 'Right Arrow', 'global.arrowUp': 'Up Arrow', 'global.backToHome': 'Go back to Home', + 'global.board': 'Board', + 'global.boardType': 'Board Type', 'global.boots': 'Boots', 'global.buildErrors': 'Build errors', 'global.buildTime': 'Build Time', @@ -136,8 +138,10 @@ export const messages = { 'global.compilers': 'Compilers', 'global.config': 'Config', 'global.configs': 'Configs', + 'global.cores': 'Cores', 'global.date': 'Date', 'global.days': 'Days', + 'global.description': 'Description', 'global.details': 'Details', 'global.documentation': 'Documentation', 'global.duration': 'Duration', @@ -151,6 +155,7 @@ export const messages = { 'global.filter': 'Filter', 'global.filters': 'Filters', 'global.first': 'First', + 'global.formFactor': 'Form Factor', 'global.fullLogs': 'Full logs', 'global.gitHubIssue': 'GitHub Issue', 'global.hardware': 'Hardware', @@ -169,6 +174,7 @@ export const messages = { 'global.loading': 'Loading...', 'global.logExcerpt': 'Log Excerpt', 'global.logs': 'Logs', + 'global.maxClockSpeed': 'Max Clock Speed', 'global.name': 'Name', 'global.new': 'New', 'global.newer': 'Newer', @@ -183,6 +189,7 @@ export const messages = { 'global.platform': 'Platform', 'global.prev': 'Prev', 'global.privacy': 'Privacy Policy', + 'global.processor': 'Processor', 'global.projectUnderDevelopment': 'This is an ongoing project.{br}' + `Please report bugs and suggestions to ${FEEDBACK_EMAIL_TO}.`, @@ -192,12 +199,15 @@ export const messages = { 'global.search': 'Search', 'global.seconds': 'sec', 'global.showMoreDetails': 'Show more details', + 'global.siliconVendor': 'Silicon Vendor', + 'global.soc': 'SoC / Processor', 'global.somethingWrong': 'Sorry... something went wrong', 'global.startTime': 'Start Time', 'global.status': 'Status', 'global.success': 'Success', 'global.successCount': 'Success: {count}', 'global.summary': 'Summary', + 'global.systemModule': 'System Module', 'global.tests': 'Tests', 'global.timeAgo': '{time} ago', 'global.tree': 'Tree', @@ -211,6 +221,7 @@ export const messages = { 'global.unknown': 'Unknown', 'global.unknownArchitecture': 'Unknown architecture', 'global.url': 'URL', + 'global.vendor': 'Vendor', 'global.viewJson': 'View Json', 'global.viewLog': 'View Log Excerpt', 'global.warning': 'Warning', @@ -370,6 +381,7 @@ export const messages = { 'Inconclusive groups tests with ERROR, MISS, SKIP, DONE, and unknown statuses defined by KCIDB.', 'testDetails.buildInfo': 'Build Info', 'testDetails.cannotFetchHistory': 'No tracking information available', + 'testDetails.hardwareInfo': 'Hardware Info', 'testDetails.notFound': 'Test not found', 'testDetails.regressionTooltip.fixed': 'Test was failing but passed in the last iterations', diff --git a/dashboard/src/pages/Hardware/HardwareTable.tsx b/dashboard/src/pages/Hardware/HardwareTable.tsx index e586cc8ce..103b01746 100644 --- a/dashboard/src/pages/Hardware/HardwareTable.tsx +++ b/dashboard/src/pages/Hardware/HardwareTable.tsx @@ -1,6 +1,7 @@ import type { ColumnDef, ColumnFiltersState, + ExpandedState, Row, SortingState, } from '@tanstack/react-table'; @@ -8,19 +9,22 @@ import type { import { flexRender, getCoreRowModel, + getExpandedRowModel, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { useCallback, useMemo, useState, type JSX } from 'react'; +import { Fragment, useCallback, useMemo, useState, type JSX } from 'react'; import type { UseQueryResult } from '@tanstack/react-query'; import { FormattedMessage } from 'react-intl'; import { useNavigate, useSearch, type LinkProps } from '@tanstack/react-router'; +import { MdChevronRight, MdDeveloperBoard } from 'react-icons/md'; + import BaseTable, { TableHead } from '@/components/Table/BaseTable'; import type { MessagesKey } from '@/locales/messages'; @@ -65,6 +69,11 @@ import { MemoizedSectionError } from '@/components/DetailsPages/SectionError'; import { LoadingCircle } from '@/components/ui/loading-circle'; import { FilterLabel } from '@/components/FilterLabel/FilterLabel'; +import { HardwareRegistryListingDetails } from '@/components/HardwareRegistry/HardwareRegistry'; +import { + getMockHardwareRegistryListingInfo, + type HardwareRegistryInfo, +} from '@/lib/hardwareRegistryMock'; import { buildHardwareDetailsSearch } from './hardwareTableUtils'; import { HardwareRevisionSelectors } from './HardwareRevisionSelectors'; @@ -91,9 +100,12 @@ interface IHardwareTable { } type HardwareListingRoutes = '/hardware'; +type HardwareListingRow = HardwareItem & { + registry?: HardwareRegistryInfo; +}; const getLinkProps = ( - row: Row, + row: Row, startTimestampInSeconds: number, endTimestampInSeconds: number, navigateFrom: HardwareListingRoutes, @@ -131,8 +143,30 @@ const getColumns = ( startTimestampInSeconds: number, endTimestampInSeconds: number, navigateFrom: HardwareListingRoutes, -): ColumnDef[] => { +): ColumnDef[] => { return [ + { + id: 'registry_expander', + header: () => null, + enableSorting: false, + cell: ({ row }): JSX.Element | null => + row.getCanExpand() ? ( + + ) : null, + }, { accessorKey: 'platform', header: ({ column }): JSX.Element => ( @@ -142,6 +176,29 @@ const getColumns = ( tabTarget: 'global.builds', }, }, + { + id: 'processor', + accessorFn: row => row.registry?.processor?.id ?? '', + header: ({ column }): JSX.Element => ( + + ), + cell: ({ row }): JSX.Element => { + const processorId = row.original.registry?.processor?.id; + if (!processorId) { + return <>{EMPTY_VALUE}; + } + + return ( + + + {processorId} + + ); + }, + meta: { + tabTarget: 'global.builds', + }, + }, { accessorKey: 'hardware', accessorFn: ({ hardware }): number => { @@ -406,11 +463,19 @@ export function HardwareTable({ defaultSorting: DEFAULT_HARDWARE_SORTING, }); const [columnFilters, setColumnFilters] = useState([]); + const [expanded, setExpanded] = useState({}); const { pagination, paginationUpdater } = usePaginationState( 'hardwareListing', listingSize, ); + const data = useMemo(() => { + return treeTableRows.map((row, index) => ({ + ...row, + registry: getMockHardwareRegistryListingInfo(row.platform, index), + })); + }, [treeTableRows]); + const columns = useMemo( () => getColumns(startTimestampInSeconds, endTimestampInSeconds, navigateFrom), @@ -418,12 +483,15 @@ export function HardwareTable({ ); const table = useReactTable({ - data: treeTableRows, + data, columns, enableSortingRemoval: false, onSortingChange: handleSortingChange, onColumnFiltersChange: setColumnFilters, + onExpandedChange: setExpanded, getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getRowCanExpand: row => row.original.registry !== undefined, getPaginationRowModel: getPaginationRowModel(), onPaginationChange: paginationUpdater, getSortedRowModel: getSortedRowModel(), @@ -432,6 +500,7 @@ export function HardwareTable({ sorting, columnFilters, pagination, + expanded, }, }); @@ -458,27 +527,44 @@ export function HardwareTable({ const tableBody = useMemo((): JSX.Element[] | JSX.Element => { return modelRows?.length ? ( modelRows.map(row => ( - - {row.getVisibleCells().map(cell => { - const tabTarget = ( - cell.column.columnDef.meta as ListingTableColumnMeta - ).tabTarget; - return ( - - ); - })} - + + + {row.getVisibleCells().map(cell => { + if (cell.column.id === 'registry_expander') { + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + } + + const tabTarget = ( + cell.column.columnDef.meta as ListingTableColumnMeta + ).tabTarget; + return ( + + ); + })} + + {row.getIsExpanded() && row.original.registry && ( + + + + + + )} + )) ) : ( diff --git a/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx b/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx index 178f39cba..5e45ed5aa 100644 --- a/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx +++ b/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx @@ -69,6 +69,10 @@ import { isEmptyObject } from '@/utils/utils'; import { LoadingCircle } from '@/components/ui/loading-circle'; +import { HardwareRegistryStrip } from '@/components/HardwareRegistry/HardwareRegistry'; + +import { getMockHardwareRegistryInfo } from '@/lib/hardwareRegistryMock'; + import { HardwareHeader } from './HardwareDetailsHeaderTable'; import HardwareDetailsTabs from './Tabs/HardwareDetailsTabs'; import HardwareDetailsFilter from './HardwareDetailsFilter'; @@ -491,6 +495,11 @@ function HardwareDetails(): JSX.Element { ); }, [formatMessage, hardwareId]); + const registryInfo = useMemo( + () => getMockHardwareRegistryInfo(hardwareId), + [hardwareId], + ); + const filterButtonHeaderExtra = useMemo(() => { if (!hasSelectedTrees) { return undefined; @@ -582,6 +591,7 @@ function HardwareDetails(): JSX.Element {

+ {!!treeData && ( <> Date: Wed, 23 Sep 2026 16:17:16 -0300 Subject: [PATCH 2/3] feat(hardware): wire registry enrichment to API Replace the frontend mock with registry rows from listing, details, and test endpoints so boards without entries stay unenriched (#2049). Signed-off-by: Alan Peixinho --- .../kernelCI_app/helpers/hardwareRegistry.py | 80 +++++++++++ .../helpers/hardwareRegistry_test.py | 90 ++++++++++++ .../views/hardwareDetailsSummaryView_test.py | 9 +- .../unitTests/views/hardwareView_test.py | 36 +++++ .../typeModels/hardwareDetails.py | 2 + .../typeModels/hardwareListing.py | 3 + .../typeModels/hardwareRegistry.py | 30 ++++ .../kernelCI_app/typeModels/testDetails.py | 2 + .../views/hardwareByRevisionView.py | 5 + .../views/hardwareDetailsSummaryView.py | 7 +- .../kernelCI_app/views/hardwareDetailsView.py | 4 + backend/kernelCI_app/views/hardwareView.py | 5 + backend/kernelCI_app/views/testDetailsView.py | 8 ++ backend/schema.yml | 133 ++++++++++++++++++ dashboard/src/api/hardware.ts | 3 + .../HardwareRegistry/HardwareRegistry.tsx | 30 ++-- .../components/LinkWithIcon/LinkWithIcon.tsx | 4 +- .../components/TestDetails/TestDetails.tsx | 10 +- dashboard/src/lib/hardwareRegistryMock.ts | 53 ------- dashboard/src/lib/string.ts | 2 +- .../src/pages/Hardware/HardwareTable.tsx | 22 +-- .../pages/hardwareDetails/HardwareDetails.tsx | 12 +- dashboard/src/types/hardware.ts | 36 +++-- .../src/types/hardware/hardwareDetails.ts | 2 + dashboard/src/types/tree/TestDetails.tsx | 2 + 25 files changed, 474 insertions(+), 116 deletions(-) create mode 100644 backend/kernelCI_app/helpers/hardwareRegistry.py create mode 100644 backend/kernelCI_app/tests/unitTests/helpers/hardwareRegistry_test.py create mode 100644 backend/kernelCI_app/typeModels/hardwareRegistry.py delete mode 100644 dashboard/src/lib/hardwareRegistryMock.ts diff --git a/backend/kernelCI_app/helpers/hardwareRegistry.py b/backend/kernelCI_app/helpers/hardwareRegistry.py new file mode 100644 index 000000000..4c4affe67 --- /dev/null +++ b/backend/kernelCI_app/helpers/hardwareRegistry.py @@ -0,0 +1,80 @@ +from typing import Iterable, Optional + +from kernelCI_app.models import HardwareRegistryPlatform +from kernelCI_app.typeModels.hardwareRegistry import ( + HardwareRegistryInfo, + HardwareRegistryNamedLink, + HardwareRegistryProcessorInfo, +) + + +def serialize_hardware_registry_platform( + platform: HardwareRegistryPlatform, +) -> HardwareRegistryInfo: + processor = platform.processor + system_module = platform.system_module + + return HardwareRegistryInfo( + platform_id=platform.id, + board_type=platform.type, + form_factor=platform.form_factor, + description=platform.details, + url=platform.url, + vendor=HardwareRegistryNamedLink( + id=platform.vendor.id, url=platform.vendor.url + ), + silicon_vendor=HardwareRegistryNamedLink( + id=processor.vendor.id, url=processor.vendor.url + ), + system_module=( + HardwareRegistryNamedLink( + id=system_module.id, + url=system_module.url, + form_factor=system_module.form_factor, + ) + if system_module + else None + ), + processor=HardwareRegistryProcessorInfo( + id=processor.id, + architecture=processor.architecture, + cores=processor.cores, + max_clock_speed_mhz=processor.max_clock_speed_mhz, + url=processor.url, + description=processor.details, + ), + ) + + +def _platform_ids(values: Iterable[object]) -> list[str]: + """Unique, non-empty platform ids. environment_misc values can be anything.""" + return list(dict.fromkeys(v for v in values if isinstance(v, str) and v)) + + +def get_hardware_registry_by_ids( + platform_ids: Iterable[object], +) -> dict[str, HardwareRegistryInfo]: + ids = _platform_ids(platform_ids) + if not ids: + return {} + + platforms = HardwareRegistryPlatform.objects.select_related( + "vendor", + "processor", + "processor__vendor", + "system_module", + ).filter(id__in=ids) + + return { + platform.id: serialize_hardware_registry_platform(platform) + for platform in platforms + } + + +def get_first_hardware_registry( + platform_ids: Iterable[object], +) -> Optional[HardwareRegistryInfo]: + """Registry info for the first id that the registry knows about.""" + ids = _platform_ids(platform_ids) + by_id = get_hardware_registry_by_ids(ids) + return next((by_id[pid] for pid in ids if pid in by_id), None) diff --git a/backend/kernelCI_app/tests/unitTests/helpers/hardwareRegistry_test.py b/backend/kernelCI_app/tests/unitTests/helpers/hardwareRegistry_test.py new file mode 100644 index 000000000..14509c204 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/helpers/hardwareRegistry_test.py @@ -0,0 +1,90 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from kernelCI_app.helpers.hardwareRegistry import ( + get_first_hardware_registry, + get_hardware_registry_by_ids, + serialize_hardware_registry_platform, +) + + +def _platform(**overrides): + silicon = SimpleNamespace(id="ti", url="https://www.ti.com") + processor = SimpleNamespace( + id="am3358", + architecture="arm", + cores=1, + max_clock_speed_mhz=800, + url="https://www.ti.com/product/AM3358", + details="Arm Cortex-A8", + vendor=silicon, + ) + values = { + "id": "am335x-bone-black", + "type": "single_board_computer", + "form_factor": "board", + "details": "BeagleBone Black", + "url": "https://beagleboard.org/black", + "vendor": SimpleNamespace(id="beagleboard", url="https://beagleboard.org"), + "processor": processor, + "system_module": SimpleNamespace( + id="osd335x", + url="https://octavosystems.com", + form_factor="system-on-module", + ), + } + values.update(overrides) + return SimpleNamespace(**values) + + +class TestSerializeHardwareRegistryPlatform: + def test_maps_related_fields(self): + info = serialize_hardware_registry_platform(_platform()) + + assert info.platform_id == "am335x-bone-black" + assert info.board_type == "single_board_computer" + assert info.processor.id == "am3358" + assert info.processor.max_clock_speed_mhz == 800 + assert info.silicon_vendor.id == "ti" + assert info.vendor.id == "beagleboard" + assert info.system_module.id == "osd335x" + + def test_keeps_optional_relations_absent(self): + info = serialize_hardware_registry_platform(_platform(system_module=None)) + + assert info.system_module is None + + +class TestGetHardwareRegistryByIds: + def test_skips_query_when_no_usable_ids(self): + with patch( + "kernelCI_app.helpers.hardwareRegistry.HardwareRegistryPlatform.objects" + ) as mock_objects: + assert get_hardware_registry_by_ids([None, "", 1, {"a": 1}]) == {} + mock_objects.select_related.assert_not_called() + + def test_indexes_serialized_platforms(self): + platform = _platform() + with patch( + "kernelCI_app.helpers.hardwareRegistry.HardwareRegistryPlatform.objects" + ) as mock_objects: + mock_objects.select_related.return_value.filter.return_value = [platform] + result = get_hardware_registry_by_ids(["am335x-bone-black"]) + + assert list(result) == ["am335x-bone-black"] + assert result["am335x-bone-black"].processor.id == "am3358" + + +class TestGetFirstHardwareRegistry: + def test_returns_first_matching_id_in_order(self): + platform = _platform(id="second") + with patch( + "kernelCI_app.helpers.hardwareRegistry.get_hardware_registry_by_ids" + ) as mock_by_ids: + mock_by_ids.return_value = { + "second": serialize_hardware_registry_platform(platform) + } + info = get_first_hardware_registry([None, "missing", "second", "third"]) + + assert info is not None + assert info.platform_id == "second" diff --git a/backend/kernelCI_app/tests/unitTests/views/hardwareDetailsSummaryView_test.py b/backend/kernelCI_app/tests/unitTests/views/hardwareDetailsSummaryView_test.py index c4d5caa7c..0a1cdf2df 100644 --- a/backend/kernelCI_app/tests/unitTests/views/hardwareDetailsSummaryView_test.py +++ b/backend/kernelCI_app/tests/unitTests/views/hardwareDetailsSummaryView_test.py @@ -64,7 +64,14 @@ def _body(self, **overrides): return body def _assert_query_count(self, body, n): - with patch(HEADS_PATCH) as mock_heads, patch(QUERY_PATCH) as mock_query: + with ( + patch(HEADS_PATCH) as mock_heads, + patch(QUERY_PATCH) as mock_query, + patch( + "kernelCI_app.views.hardwareDetailsSummaryView.get_first_hardware_registry", + return_value=None, + ), + ): mock_heads.return_value = self.heads mock_query.return_value = [SUMMARY_ROW] response = self._post(body) diff --git a/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py b/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py index 066b4c4b4..e168e2466 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.typeModels.hardwareRegistry import HardwareRegistryInfo from kernelCI_app.views.hardwareView import HardwareView @@ -13,6 +14,12 @@ def setUp(self): self.factory = APIRequestFactory() self.view = HardwareView() self.url = "/hardware" + registry_patcher = patch( + "kernelCI_app.views.hardwareView.get_hardware_registry_by_ids", + return_value={}, + ) + self.addCleanup(registry_patcher.stop) + registry_patcher.start() @patch( "kernelCI_app.views.hardwareView.get_hardware_listing_data_from_status_table" @@ -119,3 +126,32 @@ def test_get_hardware_listing_sanitize_validation_error_returns_internal_server_ self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) self.assertIn("platform", response.data) + + @patch( + "kernelCI_app.views.hardwareView.get_hardware_listing_data_from_status_table" + ) + def test_get_hardware_listing_includes_registry(self, mock_get_status_table_data): + mock_get_status_table_data.return_value = [ + ("am335x-bone-black", "beaglebone", *range(9)), + ] + registry = HardwareRegistryInfo(platform_id="am335x-bone-black") + + request = self.factory.get( + self.url, + { + "startTimestampInSeconds": "1741192200", + "endTimestampInSeconds": "1741624200", + "origin": "origin1", + }, + ) + with patch( + "kernelCI_app.views.hardwareView.get_hardware_registry_by_ids", + return_value={"am335x-bone-black": registry}, + ): + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual( + response.data["hardware"][0]["registry"]["platform_id"], + "am335x-bone-black", + ) diff --git a/backend/kernelCI_app/typeModels/hardwareDetails.py b/backend/kernelCI_app/typeModels/hardwareDetails.py index 2cd459d2f..7f190d793 100644 --- a/backend/kernelCI_app/typeModels/hardwareDetails.py +++ b/backend/kernelCI_app/typeModels/hardwareDetails.py @@ -22,6 +22,7 @@ Origin, StatusValues, ) +from kernelCI_app.typeModels.hardwareRegistry import HardwareRegistryInfo class HardwareDetailsQueryParameters(BaseModel): @@ -94,6 +95,7 @@ class Tree(BaseModel): class HardwareCommon(BaseModel): trees: List[Tree] compatibles: List[str] + registry: Optional[HardwareRegistryInfo] = None class HardwareTestLocalFilters(LocalFilters): diff --git a/backend/kernelCI_app/typeModels/hardwareListing.py b/backend/kernelCI_app/typeModels/hardwareListing.py index 972621a97..3e830089c 100644 --- a/backend/kernelCI_app/typeModels/hardwareListing.py +++ b/backend/kernelCI_app/typeModels/hardwareListing.py @@ -7,6 +7,7 @@ from kernelCI_app.constants.localization import DocStrings from kernelCI_app.typeModels.common import StatusCount from kernelCI_app.typeModels.commonListing import ListingStatusCount +from kernelCI_app.typeModels.hardwareRegistry import HardwareRegistryInfo def _normalize_commits_list(value: object) -> Optional[list[str]]: @@ -24,6 +25,7 @@ class HardwareItem(BaseModel): test_status_summary: StatusCount boot_status_summary: StatusCount build_status_summary: StatusCount + registry: Optional[HardwareRegistryInfo] = None class HardwareListingItem(BaseModel): @@ -32,6 +34,7 @@ class HardwareListingItem(BaseModel): test_status_summary: ListingStatusCount boot_status_summary: ListingStatusCount build_status_summary: ListingStatusCount + registry: Optional[HardwareRegistryInfo] = None class HardwareListingResponse(BaseModel): diff --git a/backend/kernelCI_app/typeModels/hardwareRegistry.py b/backend/kernelCI_app/typeModels/hardwareRegistry.py new file mode 100644 index 000000000..92f00d85c --- /dev/null +++ b/backend/kernelCI_app/typeModels/hardwareRegistry.py @@ -0,0 +1,30 @@ +from typing import Optional + +from pydantic import BaseModel + + +class HardwareRegistryNamedLink(BaseModel): + id: str + url: Optional[str] = None + form_factor: Optional[str] = None + + +class HardwareRegistryProcessorInfo(BaseModel): + id: str + architecture: Optional[str] = None + cores: Optional[int] = None + max_clock_speed_mhz: Optional[int] = None + url: Optional[str] = None + description: Optional[str] = None + + +class HardwareRegistryInfo(BaseModel): + platform_id: str + board_type: Optional[str] = None + form_factor: Optional[str] = None + description: Optional[str] = None + url: Optional[str] = None + vendor: Optional[HardwareRegistryNamedLink] = None + silicon_vendor: Optional[HardwareRegistryNamedLink] = None + system_module: Optional[HardwareRegistryNamedLink] = None + processor: Optional[HardwareRegistryProcessorInfo] = None diff --git a/backend/kernelCI_app/typeModels/testDetails.py b/backend/kernelCI_app/typeModels/testDetails.py index 0ea86f9cd..1cacd8e66 100644 --- a/backend/kernelCI_app/typeModels/testDetails.py +++ b/backend/kernelCI_app/typeModels/testDetails.py @@ -31,6 +31,7 @@ Test__StartTime, Timestamp, ) +from kernelCI_app.typeModels.hardwareRegistry import HardwareRegistryInfo from kernelCI_app.utils import validate_str_to_dict @@ -63,6 +64,7 @@ class TestDetailsResponse(BaseModel): tree_name: Checkout__TreeName origin: Optional[Origin] test_origin: Origin + registry: Optional[HardwareRegistryInfo] = None type PossibleRegressionType = Literal["regression", "fixed", "unstable", "pass", "fail"] diff --git a/backend/kernelCI_app/views/hardwareByRevisionView.py b/backend/kernelCI_app/views/hardwareByRevisionView.py index 931548b70..339f11c22 100644 --- a/backend/kernelCI_app/views/hardwareByRevisionView.py +++ b/backend/kernelCI_app/views/hardwareByRevisionView.py @@ -6,6 +6,7 @@ from rest_framework.response import Response from rest_framework.views import APIView +from kernelCI_app.helpers.hardwareRegistry import get_hardware_registry_by_ids from kernelCI_app.queries.hardware import get_hardware_listing_data_by_revision from kernelCI_app.typeModels.hardwareListing import ( HardwareItem, @@ -19,6 +20,9 @@ class HardwareByRevisionView(APIView): def _sanitize_records(self, hardwares_raw: list[dict]) -> list[HardwareItem]: + registry_by_id = get_hardware_registry_by_ids( + hardware["platform"] for hardware in hardwares_raw + ) hardwares = [] for hardware in hardwares_raw: hardwares.append( @@ -52,6 +56,7 @@ def _sanitize_records(self, hardwares_raw: list[dict]) -> list[HardwareItem]: "DONE": hardware["done_tests"], "SKIP": hardware["skip_tests"], }, + registry=registry_by_id.get(hardware["platform"]), ) ) diff --git a/backend/kernelCI_app/views/hardwareDetailsSummaryView.py b/backend/kernelCI_app/views/hardwareDetailsSummaryView.py index 71afccf36..4f7d8fac2 100644 --- a/backend/kernelCI_app/views/hardwareDetailsSummaryView.py +++ b/backend/kernelCI_app/views/hardwareDetailsSummaryView.py @@ -25,6 +25,7 @@ generate_test_summary_typed, unstable_parse_post_body, ) +from kernelCI_app.helpers.hardwareRegistry import get_first_hardware_registry from kernelCI_app.helpers.issueExtras import parse_issue from kernelCI_app.queries.hardware import ( get_hardware_details_summary, @@ -614,7 +615,11 @@ def post(self, request, hardware_id) -> Response: summary = Summary( builds=builds_summary, boots=boots_summary, tests=tests_summary ) - commons = HardwareCommon(trees=all_trees, compatibles=all_compatibles) + commons = HardwareCommon( + trees=all_trees, + compatibles=all_compatibles, + registry=get_first_hardware_registry([hardware_id, *all_compatibles]), + ) filters = HardwareDetailsFilters( all=all_filters, builds=builds_filters, diff --git a/backend/kernelCI_app/views/hardwareDetailsView.py b/backend/kernelCI_app/views/hardwareDetailsView.py index a7aa17809..7fa743e5e 100644 --- a/backend/kernelCI_app/views/hardwareDetailsView.py +++ b/backend/kernelCI_app/views/hardwareDetailsView.py @@ -40,6 +40,7 @@ set_trees_status_summary, unstable_parse_post_body, ) +from kernelCI_app.helpers.hardwareRegistry import get_first_hardware_registry from kernelCI_app.queries.hardware import ( get_hardware_details_data, get_hardware_trees_data, @@ -405,6 +406,9 @@ def post(self, request, hardware_id) -> Response: common=HardwareCommon( trees=trees, compatibles=list(self.processed_compatibles - {hardware_id}), + registry=get_first_hardware_registry( + [hardware_id, *self.processed_compatibles] + ), ), ) except ValidationError as e: diff --git a/backend/kernelCI_app/views/hardwareView.py b/backend/kernelCI_app/views/hardwareView.py index f2ab36312..bb13943cc 100644 --- a/backend/kernelCI_app/views/hardwareView.py +++ b/backend/kernelCI_app/views/hardwareView.py @@ -9,6 +9,7 @@ from kernelCI_app.constants.localization import ClientStrings from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.helpers.hardwareRegistry import get_hardware_registry_by_ids from kernelCI_app.queries.hardware import get_hardware_listing_data_from_status_table from kernelCI_app.typeModels.commonListing import ListingStatusCount from kernelCI_app.typeModels.hardwareListing import ( @@ -23,6 +24,9 @@ class HardwareView(APIView): def _sanitize_records( self, hardwares_raw: list[tuple] ) -> list[HardwareListingItem]: + registry_by_id = get_hardware_registry_by_ids( + hardware[0] for hardware in hardwares_raw + ) hardwares = [] for hardware in hardwares_raw: hardwares.append( @@ -44,6 +48,7 @@ def _sanitize_records( FAIL=hardware[9], INCONCLUSIVE=hardware[10], ), + registry=registry_by_id.get(hardware[0]), ) ) diff --git a/backend/kernelCI_app/views/testDetailsView.py b/backend/kernelCI_app/views/testDetailsView.py index 05d903e1c..cb017dcda 100644 --- a/backend/kernelCI_app/views/testDetailsView.py +++ b/backend/kernelCI_app/views/testDetailsView.py @@ -7,6 +7,7 @@ from kernelCI_app.constants.localization import ClientStrings from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.helpers.hardwareRegistry import get_first_hardware_registry from kernelCI_app.queries.test import get_test_details_data from kernelCI_app.typeModels.commonOpenApiParameters import TEST_ID_PATH_PARAM from kernelCI_app.typeModels.testDetails import ( @@ -29,6 +30,13 @@ def get(self, _request, test_id: str) -> Response: try: valid_response = TestDetailsResponse(**response[0]) + environment_misc = valid_response.environment_misc or {} + valid_response.registry = get_first_hardware_registry( + [ + environment_misc.get("platform"), + *(valid_response.environment_compatible or []), + ] + ) except ValidationError as e: return Response(data=e.json(), status=HTTPStatus.INTERNAL_SERVER_ERROR) diff --git a/backend/schema.yml b/backend/schema.yml index 6269c5c28..0edc532a1 100644 --- a/backend/schema.yml +++ b/backend/schema.yml @@ -3096,6 +3096,11 @@ components: type: string title: Compatibles type: array + registry: + anyOf: + - $ref: '#/components/schemas/HardwareRegistryInfo' + - type: 'null' + default: null required: - trees - compatibles @@ -3256,6 +3261,11 @@ components: $ref: '#/components/schemas/StatusCount' build_status_summary: $ref: '#/components/schemas/StatusCount' + registry: + anyOf: + - $ref: '#/components/schemas/HardwareRegistryInfo' + - type: 'null' + default: null required: - hardware - platform @@ -3295,6 +3305,11 @@ components: $ref: '#/components/schemas/ListingStatusCount' build_status_summary: $ref: '#/components/schemas/ListingStatusCount' + registry: + anyOf: + - $ref: '#/components/schemas/HardwareRegistryInfo' + - type: 'null' + default: null required: - hardware - platform @@ -3314,6 +3329,119 @@ components: - hardware title: HardwareListingResponse type: object + HardwareRegistryInfo: + properties: + platform_id: + title: Platform Id + type: string + board_type: + anyOf: + - type: string + - type: 'null' + default: null + title: Board Type + form_factor: + anyOf: + - type: string + - type: 'null' + default: null + title: Form Factor + description: + anyOf: + - type: string + - type: 'null' + default: null + title: Description + url: + anyOf: + - type: string + - type: 'null' + default: null + title: Url + vendor: + anyOf: + - $ref: '#/components/schemas/HardwareRegistryNamedLink' + - type: 'null' + default: null + silicon_vendor: + anyOf: + - $ref: '#/components/schemas/HardwareRegistryNamedLink' + - type: 'null' + default: null + system_module: + anyOf: + - $ref: '#/components/schemas/HardwareRegistryNamedLink' + - type: 'null' + default: null + processor: + anyOf: + - $ref: '#/components/schemas/HardwareRegistryProcessorInfo' + - type: 'null' + default: null + required: + - platform_id + title: HardwareRegistryInfo + type: object + HardwareRegistryNamedLink: + properties: + id: + title: Id + type: string + url: + anyOf: + - type: string + - type: 'null' + default: null + title: Url + form_factor: + anyOf: + - type: string + - type: 'null' + default: null + title: Form Factor + required: + - id + title: HardwareRegistryNamedLink + type: object + HardwareRegistryProcessorInfo: + properties: + id: + title: Id + type: string + architecture: + anyOf: + - type: string + - type: 'null' + default: null + title: Architecture + cores: + anyOf: + - type: integer + - type: 'null' + default: null + title: Cores + max_clock_speed_mhz: + anyOf: + - type: integer + - type: 'null' + default: null + title: Max Clock Speed Mhz + url: + anyOf: + - type: string + - type: 'null' + default: null + title: Url + description: + anyOf: + - type: string + - type: 'null' + default: null + title: Description + required: + - id + title: HardwareRegistryProcessorInfo + type: object HardwareSelectorBranch: properties: git_repository_url: @@ -4417,6 +4545,11 @@ components: - type: 'null' test_origin: $ref: '#/components/schemas/Origin' + registry: + anyOf: + - $ref: '#/components/schemas/HardwareRegistryInfo' + - type: 'null' + default: null required: - field_timestamp - id diff --git a/dashboard/src/api/hardware.ts b/dashboard/src/api/hardware.ts index bc1961912..fc7731fb0 100644 --- a/dashboard/src/api/hardware.ts +++ b/dashboard/src/api/hardware.ts @@ -5,6 +5,7 @@ import { useSearch } from '@tanstack/react-router'; import type { HardwareListingResponse, + HardwareRegistryInfo, HardwareRevisionSelection, HardwareSelectorsResponse, } from '@/types/hardware'; @@ -21,6 +22,7 @@ type HardwareListingByRevisionApiItem = { build_status_summary: StatusCount; test_status_summary: StatusCount; boot_status_summary: StatusCount; + registry?: HardwareRegistryInfo | null; }; type HardwareListingByRevisionApiResponse = { @@ -136,6 +138,7 @@ const fetchHardwareListingByRevision = async ( boot_status_summary: statusCountToShortStatusCount( item.boot_status_summary, ), + registry: item.registry, })), }; }; diff --git a/dashboard/src/components/HardwareRegistry/HardwareRegistry.tsx b/dashboard/src/components/HardwareRegistry/HardwareRegistry.tsx index 69ac126fc..ffecfe7b5 100644 --- a/dashboard/src/components/HardwareRegistry/HardwareRegistry.tsx +++ b/dashboard/src/components/HardwareRegistry/HardwareRegistry.tsx @@ -6,7 +6,7 @@ import { MdDeveloperBoard } from 'react-icons/md'; import { valueOrEmpty } from '@/lib/string'; import type { MessagesKey } from '@/locales/messages'; -import type { HardwareRegistryInfo } from '@/lib/hardwareRegistryMock'; +import type { HardwareRegistryInfo } from '@/types/hardware'; import BaseCard from '@/components/Cards/BaseCard'; import { DetailsInfoCard } from '@/components/Cards/DetailsInfoCard'; @@ -15,11 +15,11 @@ import LinkWithIcon, { } from '@/components/LinkWithIcon/LinkWithIcon'; import { LinkIcon } from '@/components/Icons/Link'; -const humanize = (text?: string): string | undefined => +const humanize = (text?: string | null): string | undefined => text?.replace(/_/g, ' '); const processorFields = (info: HardwareRegistryInfo): ILinkWithIcon[] => { - const clock = info.processor?.maxClockSpeedMhz; + const clock = info.processor?.max_clock_speed_mhz; return [ { title: 'global.soc', @@ -40,8 +40,8 @@ const processorFields = (info: HardwareRegistryInfo): ILinkWithIcon[] => { }, { title: 'global.siliconVendor', - linkText: valueOrEmpty(info.siliconVendor?.id), - link: info.siliconVendor?.url, + linkText: valueOrEmpty(info.silicon_vendor?.id), + link: info.silicon_vendor?.url, }, ]; }; @@ -49,18 +49,18 @@ const processorFields = (info: HardwareRegistryInfo): ILinkWithIcon[] => { const boardFields = (info: HardwareRegistryInfo): ILinkWithIcon[] => [ { title: 'global.boardType', - linkText: valueOrEmpty(humanize(info.boardType)), + linkText: valueOrEmpty(humanize(info.board_type)), }, { title: 'global.formFactor', - linkText: valueOrEmpty(humanize(info.formFactor)), + linkText: valueOrEmpty(humanize(info.form_factor)), }, - ...(info.systemModule + ...(info.system_module ? [ { title: 'global.systemModule' as MessagesKey, - linkText: valueOrEmpty(info.systemModule.id), - link: info.systemModule.url, + linkText: valueOrEmpty(info.system_module.id), + link: info.system_module.url, }, ] : []), @@ -82,7 +82,7 @@ const listingFields = (info: HardwareRegistryInfo): ILinkWithIcon[] => { return [ { title: 'global.platform', - linkText: valueOrEmpty(info.platformId), + linkText: valueOrEmpty(info.platform_id), link: info.url, }, fieldByTitle(processor, 'global.soc'), @@ -125,7 +125,7 @@ const RegistryTitle = ({
- +
{info.description && ( @@ -154,7 +154,7 @@ export const HardwareRegistryStrip = ({ info, className, }: { - info?: HardwareRegistryInfo; + info?: HardwareRegistryInfo | null; className?: string; }): JSX.Element | null => { if (!info) { @@ -177,7 +177,7 @@ export const HardwareRegistryStrip = ({ export const HardwareRegistryCard = ({ info, }: { - info?: HardwareRegistryInfo; + info?: HardwareRegistryInfo | null; }): JSX.Element | null => { if (!info) { return null; @@ -194,7 +194,7 @@ export const HardwareRegistryCard = ({ data={[ { title: 'global.platform' as MessagesKey, - linkText: valueOrEmpty(info.platformId), + linkText: valueOrEmpty(info.platform_id), link: info.url, }, { diff --git a/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx b/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx index f8f1852fe..51df66905 100644 --- a/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx +++ b/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx @@ -8,7 +8,7 @@ import { cn } from '@/lib/utils'; export interface ILinkWithIcon { title?: MessagesKey; linkText?: string | ReactElement; - link?: string; + link?: string | null; icon?: ReactElement; linkComponent?: ReactElement; onClick?: () => void; @@ -58,7 +58,7 @@ const LinkWithIcon = ({ className={cn('flex flex-row items-center gap-1', { 'underline hover:text-gray-900': onClick || link, })} - href={link} + href={link ?? undefined} target="_blank" rel="noreferrer" onClick={onClick} diff --git a/dashboard/src/components/TestDetails/TestDetails.tsx b/dashboard/src/components/TestDetails/TestDetails.tsx index fa418e81c..3892ed264 100644 --- a/dashboard/src/components/TestDetails/TestDetails.tsx +++ b/dashboard/src/components/TestDetails/TestDetails.tsx @@ -79,8 +79,6 @@ import { DetailsInfoCard } from '@/components/Cards/DetailsInfoCard'; import CopyButton from '@/components/Button/CopyButton'; -import { getMockHardwareRegistryInfo } from '@/lib/hardwareRegistryMock'; - import { HardwareRegistryCard } from '@/components/HardwareRegistry/HardwareRegistry'; import { StatusHistoryItem } from './StatusHistoryItem'; @@ -203,13 +201,7 @@ const TestDetailsSections = ({ endTimestampInSeconds, ]); - const registryInfo = useMemo(() => { - const platform = - typeof test.environment_misc?.['platform'] === 'string' - ? test.environment_misc['platform'] - : undefined; - return getMockHardwareRegistryInfo(platform); - }, [test.environment_misc]); + const registryInfo = test.registry; const setSheetToLog = useCallback( (): void => setSheetType('log'), diff --git a/dashboard/src/lib/hardwareRegistryMock.ts b/dashboard/src/lib/hardwareRegistryMock.ts deleted file mode 100644 index 10e46ce53..000000000 --- a/dashboard/src/lib/hardwareRegistryMock.ts +++ /dev/null @@ -1,53 +0,0 @@ -// MOCK ONLY — frontend preview. Real API later. - -export interface HardwareRegistryInfo { - platformId: string; - boardType?: string; - formFactor?: string; - description?: string; - url?: string; - vendor?: { id: string; url?: string }; - siliconVendor?: { id: string; url?: string }; - systemModule?: { id: string; formFactor?: string; url?: string }; - processor?: { - id: string; - architecture?: string; - cores?: number; - maxClockSpeedMhz?: number; - url?: string; - description?: string; - }; -} - -const MOCK: HardwareRegistryInfo = { - platformId: 'am335x-bone-black', - boardType: 'single_board_computer', - formFactor: 'board', - description: 'BeagleBone Black open-source single-board computer', - url: 'https://beagleboard.org/black', - vendor: { id: 'beagleboard', url: 'https://beagleboard.org' }, - siliconVendor: { id: 'ti', url: 'https://www.ti.com' }, - systemModule: { - id: 'osd335x', - formFactor: 'system-on-module', - url: 'https://octavosystems.com/octavo_products/osd335x/', - }, - processor: { - id: 'am3358', - architecture: 'arm', - cores: 1, - maxClockSpeedMhz: 800, - url: 'https://www.ti.com/product/AM3358', - description: 'Arm Cortex-A8, 3D graphics, PRU-ICSS, CAN', - }, -}; - -export const getMockHardwareRegistryInfo = ( - _platform?: string, -): HardwareRegistryInfo => MOCK; - -export const getMockHardwareRegistryListingInfo = ( - platform: string, - index: number, -): HardwareRegistryInfo | undefined => - index === 0 ? { ...MOCK, platformId: platform } : undefined; diff --git a/dashboard/src/lib/string.ts b/dashboard/src/lib/string.ts index 9a0e7e4d8..d071a83dd 100644 --- a/dashboard/src/lib/string.ts +++ b/dashboard/src/lib/string.ts @@ -1,7 +1,7 @@ export const EMPTY_VALUE = '-'; export const valueOrEmpty = ( - value: string | undefined, + value: string | null | undefined, emptyValue = EMPTY_VALUE, ): string => value || emptyValue; diff --git a/dashboard/src/pages/Hardware/HardwareTable.tsx b/dashboard/src/pages/Hardware/HardwareTable.tsx index 103b01746..5ae18a684 100644 --- a/dashboard/src/pages/Hardware/HardwareTable.tsx +++ b/dashboard/src/pages/Hardware/HardwareTable.tsx @@ -70,10 +70,6 @@ import { LoadingCircle } from '@/components/ui/loading-circle'; import { FilterLabel } from '@/components/FilterLabel/FilterLabel'; import { HardwareRegistryListingDetails } from '@/components/HardwareRegistry/HardwareRegistry'; -import { - getMockHardwareRegistryListingInfo, - type HardwareRegistryInfo, -} from '@/lib/hardwareRegistryMock'; import { buildHardwareDetailsSearch } from './hardwareTableUtils'; import { HardwareRevisionSelectors } from './HardwareRevisionSelectors'; @@ -100,12 +96,9 @@ interface IHardwareTable { } type HardwareListingRoutes = '/hardware'; -type HardwareListingRow = HardwareItem & { - registry?: HardwareRegistryInfo; -}; const getLinkProps = ( - row: Row, + row: Row, startTimestampInSeconds: number, endTimestampInSeconds: number, navigateFrom: HardwareListingRoutes, @@ -143,7 +136,7 @@ const getColumns = ( startTimestampInSeconds: number, endTimestampInSeconds: number, navigateFrom: HardwareListingRoutes, -): ColumnDef[] => { +): ColumnDef[] => { return [ { id: 'registry_expander', @@ -469,13 +462,6 @@ export function HardwareTable({ listingSize, ); - const data = useMemo(() => { - return treeTableRows.map((row, index) => ({ - ...row, - registry: getMockHardwareRegistryListingInfo(row.platform, index), - })); - }, [treeTableRows]); - const columns = useMemo( () => getColumns(startTimestampInSeconds, endTimestampInSeconds, navigateFrom), @@ -483,7 +469,7 @@ export function HardwareTable({ ); const table = useReactTable({ - data, + data: treeTableRows, columns, enableSortingRemoval: false, onSortingChange: handleSortingChange, @@ -491,7 +477,7 @@ export function HardwareTable({ onExpandedChange: setExpanded, getCoreRowModel: getCoreRowModel(), getExpandedRowModel: getExpandedRowModel(), - getRowCanExpand: row => row.original.registry !== undefined, + getRowCanExpand: row => Boolean(row.original.registry), getPaginationRowModel: getPaginationRowModel(), onPaginationChange: paginationUpdater, getSortedRowModel: getSortedRowModel(), diff --git a/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx b/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx index 5e45ed5aa..79988f538 100644 --- a/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx +++ b/dashboard/src/pages/hardwareDetails/HardwareDetails.tsx @@ -71,8 +71,6 @@ import { LoadingCircle } from '@/components/ui/loading-circle'; import { HardwareRegistryStrip } from '@/components/HardwareRegistry/HardwareRegistry'; -import { getMockHardwareRegistryInfo } from '@/lib/hardwareRegistryMock'; - import { HardwareHeader } from './HardwareDetailsHeaderTable'; import HardwareDetailsTabs from './Tabs/HardwareDetailsTabs'; import HardwareDetailsFilter from './HardwareDetailsFilter'; @@ -495,11 +493,6 @@ function HardwareDetails(): JSX.Element { ); }, [formatMessage, hardwareId]); - const registryInfo = useMemo( - () => getMockHardwareRegistryInfo(hardwareId), - [hardwareId], - ); - const filterButtonHeaderExtra = useMemo(() => { if (!hasSelectedTrees) { return undefined; @@ -591,7 +584,10 @@ function HardwareDetails(): JSX.Element {

- + {!!treeData && ( <> Date: Wed, 23 Sep 2026 18:02:26 -0300 Subject: [PATCH 3/3] feat(hardware): add registry filter drawer on listing * Wire listing rows to API registry; fix row mapping that dropped registry * Client-side registryFilter modal and URL param (listing only) * Drop registryFilter when opening hardware details * Generalize checkbox filter keys for listing drawer * Link expander column to details when row has no registry chevron * Trim registry tests to platform-id guard and first-match lookup Signed-off-by: Alan Peixinho --- .../helpers/hardwareRegistry_test.py | 63 +++------- .../unitTests/views/hardwareView_test.py | 30 ----- .../src/components/Filter/CheckboxSection.tsx | 4 +- dashboard/src/components/Tabs/Filters.tsx | 111 ++++++++--------- dashboard/src/locales/messages/index.ts | 13 ++ .../pages/Hardware/HardwareListingFilter.tsx | 69 +++++++++++ .../pages/Hardware/HardwareListingPage.tsx | 45 +++---- .../src/pages/Hardware/HardwareTable.tsx | 19 +++ .../Hardware/hardwareListingFilters.test.ts | 50 ++++++++ .../pages/Hardware/hardwareListingFilters.ts | 114 ++++++++++++++++++ .../src/pages/Hardware/hardwareTableUtils.ts | 1 + dashboard/src/routes/_main/hardware/route.tsx | 6 + dashboard/src/types/general.ts | 1 + dashboard/src/utils/search.ts | 1 + 14 files changed, 368 insertions(+), 159 deletions(-) create mode 100644 dashboard/src/pages/Hardware/HardwareListingFilter.tsx create mode 100644 dashboard/src/pages/Hardware/hardwareListingFilters.test.ts create mode 100644 dashboard/src/pages/Hardware/hardwareListingFilters.ts diff --git a/backend/kernelCI_app/tests/unitTests/helpers/hardwareRegistry_test.py b/backend/kernelCI_app/tests/unitTests/helpers/hardwareRegistry_test.py index 14509c204..4ad0648d7 100644 --- a/backend/kernelCI_app/tests/unitTests/helpers/hardwareRegistry_test.py +++ b/backend/kernelCI_app/tests/unitTests/helpers/hardwareRegistry_test.py @@ -8,51 +8,27 @@ ) -def _platform(**overrides): +def _minimal_platform(platform_id: str): silicon = SimpleNamespace(id="ti", url="https://www.ti.com") processor = SimpleNamespace( id="am3358", architecture="arm", cores=1, max_clock_speed_mhz=800, - url="https://www.ti.com/product/AM3358", - details="Arm Cortex-A8", + url=None, + details=None, vendor=silicon, ) - values = { - "id": "am335x-bone-black", - "type": "single_board_computer", - "form_factor": "board", - "details": "BeagleBone Black", - "url": "https://beagleboard.org/black", - "vendor": SimpleNamespace(id="beagleboard", url="https://beagleboard.org"), - "processor": processor, - "system_module": SimpleNamespace( - id="osd335x", - url="https://octavosystems.com", - form_factor="system-on-module", - ), - } - values.update(overrides) - return SimpleNamespace(**values) - - -class TestSerializeHardwareRegistryPlatform: - def test_maps_related_fields(self): - info = serialize_hardware_registry_platform(_platform()) - - assert info.platform_id == "am335x-bone-black" - assert info.board_type == "single_board_computer" - assert info.processor.id == "am3358" - assert info.processor.max_clock_speed_mhz == 800 - assert info.silicon_vendor.id == "ti" - assert info.vendor.id == "beagleboard" - assert info.system_module.id == "osd335x" - - def test_keeps_optional_relations_absent(self): - info = serialize_hardware_registry_platform(_platform(system_module=None)) - - assert info.system_module is None + return SimpleNamespace( + id=platform_id, + type="board", + form_factor=None, + details=None, + url=None, + vendor=SimpleNamespace(id="ti", url=None), + processor=processor, + system_module=None, + ) class TestGetHardwareRegistryByIds: @@ -63,21 +39,10 @@ def test_skips_query_when_no_usable_ids(self): assert get_hardware_registry_by_ids([None, "", 1, {"a": 1}]) == {} mock_objects.select_related.assert_not_called() - def test_indexes_serialized_platforms(self): - platform = _platform() - with patch( - "kernelCI_app.helpers.hardwareRegistry.HardwareRegistryPlatform.objects" - ) as mock_objects: - mock_objects.select_related.return_value.filter.return_value = [platform] - result = get_hardware_registry_by_ids(["am335x-bone-black"]) - - assert list(result) == ["am335x-bone-black"] - assert result["am335x-bone-black"].processor.id == "am3358" - class TestGetFirstHardwareRegistry: def test_returns_first_matching_id_in_order(self): - platform = _platform(id="second") + platform = _minimal_platform("second") with patch( "kernelCI_app.helpers.hardwareRegistry.get_hardware_registry_by_ids" ) as mock_by_ids: diff --git a/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py b/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py index e168e2466..8a686121b 100644 --- a/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py +++ b/backend/kernelCI_app/tests/unitTests/views/hardwareView_test.py @@ -5,7 +5,6 @@ from rest_framework.test import APIRequestFactory from kernelCI_app.constants.localization import ClientStrings -from kernelCI_app.typeModels.hardwareRegistry import HardwareRegistryInfo from kernelCI_app.views.hardwareView import HardwareView @@ -126,32 +125,3 @@ def test_get_hardware_listing_sanitize_validation_error_returns_internal_server_ self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) self.assertIn("platform", response.data) - - @patch( - "kernelCI_app.views.hardwareView.get_hardware_listing_data_from_status_table" - ) - def test_get_hardware_listing_includes_registry(self, mock_get_status_table_data): - mock_get_status_table_data.return_value = [ - ("am335x-bone-black", "beaglebone", *range(9)), - ] - registry = HardwareRegistryInfo(platform_id="am335x-bone-black") - - request = self.factory.get( - self.url, - { - "startTimestampInSeconds": "1741192200", - "endTimestampInSeconds": "1741624200", - "origin": "origin1", - }, - ) - with patch( - "kernelCI_app.views.hardwareView.get_hardware_registry_by_ids", - return_value={"am335x-bone-black": registry}, - ): - response = self.view.get(request) - - self.assertEqual(response.status_code, HTTPStatus.OK) - self.assertEqual( - response.data["hardware"][0]["registry"]["platform_id"], - "am335x-bone-black", - ) diff --git a/dashboard/src/components/Filter/CheckboxSection.tsx b/dashboard/src/components/Filter/CheckboxSection.tsx index 24841322b..5ae423348 100644 --- a/dashboard/src/components/Filter/CheckboxSection.tsx +++ b/dashboard/src/components/Filter/CheckboxSection.tsx @@ -4,8 +4,6 @@ import { useCallback, useMemo, type JSX } from 'react'; import { useIntl, type MessageDescriptor } from 'react-intl'; -import type { TFilterObjectsKeys } from '@/types/general'; - import Checkbox from '@/components/Checkbox/Checkbox'; import { OptionFilters } from '@/types/filters'; @@ -47,7 +45,7 @@ export interface ICheckboxSection { export interface ISectionItem { title: MessageDescriptor['id']; subtitle: MessageDescriptor['id']; - sectionKey: TFilterObjectsKeys; + sectionKey: string; isGlobal?: boolean; } diff --git a/dashboard/src/components/Tabs/Filters.tsx b/dashboard/src/components/Tabs/Filters.tsx index 53ba502ea..bd5d76faf 100644 --- a/dashboard/src/components/Tabs/Filters.tsx +++ b/dashboard/src/components/Tabs/Filters.tsx @@ -12,13 +12,8 @@ import FilterTimeRangeSection from '@/components/Filter/TimeRangeSection'; import { DrawerSection } from '@/components/Filter/Drawer'; -import type { - TFilterKeys, - TFilter, - TFilterObjectsKeys, - TFilterNumberKeys, -} from '@/types/general'; -import { filterFieldMap, zFilterObjectsKeys } from '@/types/general'; +import type { TFilterKeys, TFilter, TFilterNumberKeys } from '@/types/general'; +import { filterFieldMap } from '@/types/general'; import { UNCATEGORIZED_STRING } from '@/utils/constants/backend'; import { version_prefix } from '@/utils/utils'; @@ -78,50 +73,49 @@ export const mapFilterToReq = (filter: TFilter): TFilter => { return filterMapped; }; -const parseCheckboxFilter = ( - filter: TFilter, - diffFilter: TFilter, - isTFilterObjectKeys: (key: string) => boolean, -): TFilter => { - const result: TFilter = structuredClone(filter); +const isBoolRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); - Object.keys(result).forEach(key => { - // key is always returned as string in Object.keys function, but he is a TFilterObjectKeys type. - const validateKey = zFilterObjectsKeys.catch('buildStatus').parse(key); - const currentFilterSection = result[validateKey]; +const parseCheckboxFilter = >( + filter: T, + diffFilter: T, + isObjectKey: (key: string) => boolean, +): T => { + const result = structuredClone(filter); - if (!currentFilterSection || !isTFilterObjectKeys(validateKey)) { + Object.keys(result).forEach(key => { + if (!isObjectKey(key)) { return; } - const diffFilterSection = diffFilter[validateKey]; - - if (diffFilterSection) { - Object.keys(diffFilterSection).forEach(filterSectionKey => { - currentFilterSection[filterSectionKey] = - diffFilterSection[filterSectionKey]; - }); + const currentFilterSection = result[key]; + const diffFilterSection = diffFilter[key]; + if ( + !isBoolRecord(currentFilterSection) || + !isBoolRecord(diffFilterSection) + ) { + return; } + + Object.keys(diffFilterSection).forEach(filterSectionKey => { + currentFilterSection[filterSectionKey] = + diffFilterSection[filterSectionKey]; + }); }); return result; }; -const changeCheckboxFilterValue = ( - filter: TFilter, - filterField: TFilterObjectsKeys, +const changeCheckboxFilterValue = >( + filter: T, + filterField: string, value: string, -): TFilter => { - const newFilter = JSON.parse(JSON.stringify(filter ?? {})); - if (!newFilter[filterField]) { - newFilter[filterField] = {}; - } - - const filterSection = newFilter[filterField]; - const filterValue = filterSection[value] ?? false; - filterSection[value] = !filterValue; +): T => { + const current = filter[filterField]; + const section = { ...(isBoolRecord(current) ? current : {}) }; + section[value] = !section[value]; - return newFilter; + return { ...filter, [filterField]: section }; }; type SectionsProps = { @@ -129,8 +123,10 @@ type SectionsProps = { setDiffFilter: Dispatch>; }; -interface ICheckboxSectionProps extends SectionsProps { - filter: TFilter; +interface ICheckboxSectionProps> { + diffFilter: T; + setDiffFilter: Dispatch>; + filter: T; isTFilterObjectKeys: (key: string) => boolean; sections: ISectionItem[]; showAllIcons?: boolean; @@ -144,14 +140,14 @@ interface ITreeSectionProps { } // TODO: Remove useState for this forms, use something like react hook forms or tanstack forms (when it gets released) -const CheckboxSection = ({ +const CheckboxSection = >({ diffFilter, setDiffFilter, filter, isTFilterObjectKeys, sections, showAllIcons = true, -}: ICheckboxSectionProps): JSX.Element => { +}: ICheckboxSectionProps): JSX.Element => { const intl = useIntl(); const parsedFilter = useMemo( @@ -161,18 +157,21 @@ const CheckboxSection = ({ const checkboxSectionsProps: ICheckboxSection[] = useMemo( () => - sections.map(section => ({ - title: intl.formatMessage({ id: section.title }), - subtitle: intl.formatMessage({ id: section.subtitle }), - items: parsedFilter[section.sectionKey], - isGlobal: section.isGlobal, - showIcon: showAllIcons, - onClickItem: (value: string): void => { - setDiffFilter(old => - changeCheckboxFilterValue(old, section.sectionKey, value), - ); - }, - })), + sections.map(section => { + const items = parsedFilter[section.sectionKey]; + return { + title: intl.formatMessage({ id: section.title }), + subtitle: intl.formatMessage({ id: section.subtitle }), + items: isBoolRecord(items) ? items : undefined, + isGlobal: section.isGlobal, + showIcon: showAllIcons, + onClickItem: (value: string): void => { + setDiffFilter(old => + changeCheckboxFilterValue(old, section.sectionKey, value), + ); + }, + }; + }), [intl, parsedFilter, sections, setDiffFilter, showAllIcons], ); @@ -190,7 +189,9 @@ const CheckboxSection = ({ ); }; -export const MemoizedCheckboxSection = memo(CheckboxSection); +export const MemoizedCheckboxSection = memo( + CheckboxSection, +) as typeof CheckboxSection; const TimeRangeSection = ({ diffFilter, diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index 0cfd88b14..4357aa5e4 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -95,7 +95,20 @@ export const messages = { 'filter.origins': 'Origins', 'filter.originsSubtitle': 'Please select one or more origins:', 'filter.perTabFilter': 'Per tab filters', + 'filter.platformBoardTypes': 'Board Type', + 'filter.platformBoardTypesSubtitle': + 'Please select one or more board types:', 'filter.platformSubtitle': 'Please select one or more platforms:', + 'filter.platformVendors': 'Platform Vendor', + 'filter.platformVendorsSubtitle': + 'Please select one or more platform vendors:', + 'filter.processorArchs': 'Architecture', + 'filter.processorArchsSubtitle': 'Please select one or more architectures:', + 'filter.processorIds': 'Processor', + 'filter.processorIdsSubtitle': 'Please select one or more processors:', + 'filter.processorVendors': 'Processor Vendor', + 'filter.processorVendorsSubtitle': + 'Please select one or more processor vendors:', 'filter.statusSubtitle': 'Please select one or more Status:', 'filter.tableFilter': 'Status filters:', 'filter.testDuration': 'Test duration', diff --git a/dashboard/src/pages/Hardware/HardwareListingFilter.tsx b/dashboard/src/pages/Hardware/HardwareListingFilter.tsx new file mode 100644 index 000000000..c5f2144f7 --- /dev/null +++ b/dashboard/src/pages/Hardware/HardwareListingFilter.tsx @@ -0,0 +1,69 @@ +import { useCallback, useMemo, useState, type JSX } from 'react'; + +import { useNavigate } from '@tanstack/react-router'; + +import FilterDrawer from '@/components/Filter/Drawer'; + +import { MemoizedCheckboxSection } from '@/components/Tabs/Filters'; + +import type { HardwareItem } from '@/types/hardware'; +import type { HardwareListingRoutesMap } from '@/utils/constants/hardwareListing'; + +import { + cleanRegistryFilter, + createRegistryFilter, + isRegistryFilterKey, + registryFilterSections, + type TRegistryFilter, +} from './hardwareListingFilters'; + +const HardwareListingFilter = ({ + paramFilter, + items, + urlFromMap, +}: { + paramFilter: TRegistryFilter; + items: HardwareItem[]; + urlFromMap: HardwareListingRoutesMap; +}): JSX.Element => { + const navigate = useNavigate({ from: urlFromMap.navigate }); + + const filter = useMemo(() => createRegistryFilter(items), [items]); + + const [diffFilter, setDiffFilter] = useState(paramFilter); + + const onClickFilterHandle = useCallback(() => { + navigate({ + search: previousSearch => ({ + ...previousSearch, + registryFilter: cleanRegistryFilter(diffFilter), + }), + state: s => s, + }); + }, [diffFilter, navigate]); + + const resetToParamFilter = useCallback( + () => setDiffFilter(paramFilter), + [paramFilter], + ); + + return ( + + + + ); +}; + +export default HardwareListingFilter; diff --git a/dashboard/src/pages/Hardware/HardwareListingPage.tsx b/dashboard/src/pages/Hardware/HardwareListingPage.tsx index 9e50e814c..d67a9da9a 100644 --- a/dashboard/src/pages/Hardware/HardwareListingPage.tsx +++ b/dashboard/src/pages/Hardware/HardwareListingPage.tsx @@ -30,6 +30,8 @@ import type { HardwareListingRoutesMap } from '@/utils/constants/hardwareListing import type { SearchIntent } from '@/lib/intent'; import { HardwareTable } from './HardwareTable'; +import HardwareListingFilter from './HardwareListingFilter'; +import { matchesRegistryFilter } from './hardwareListingFilters'; import { decodeBranchValue, findSelectionByCommitTokens, @@ -59,6 +61,7 @@ const HardwareListingPage = ({ gitRepositoryUrl, gitBranch, gitCommitHash, + registryFilter, } = useSearch({ from: urlFromMap.search }); const inputFilter = intent.search; const intentCommits = @@ -185,23 +188,14 @@ const HardwareListingPage = ({ return []; } - return listingData.hardware - .filter(hardware => { - return ( - matchesRegexOrIncludes(hardware.platform, inputFilter) || - includesInAnStringOrStringArray(hardware.hardware ?? '', inputFilter) - ); - }) - .map((hardware): HardwareItem => { - return { - hardware: hardware.hardware, - platform: hardware.platform, - build_status_summary: hardware.build_status_summary, - test_status_summary: hardware.test_status_summary, - boot_status_summary: hardware.boot_status_summary, - }; - }); - }, [activeListing.data, activeListing.error, inputFilter]); + return listingData.hardware.filter(hardware => { + const matchesSearch = + matchesRegexOrIncludes(hardware.platform, inputFilter) || + includesInAnStringOrStringArray(hardware.hardware ?? '', inputFilter); + + return matchesSearch && matchesRegistryFilter(hardware, registryFilter); + }); + }, [activeListing.data, activeListing.error, inputFilter, registryFilter]); const selectedRevision = hasSelection && gitCommitHash @@ -313,12 +307,19 @@ const HardwareListingPage = ({ <>
- - }} +
+ + }} + /> + + - +
{row.getVisibleCells().map(cell => { if (cell.column.id === 'registry_expander') { + const expanderLinkProps = getLinkProps( + row, + startTimestampInSeconds, + endTimestampInSeconds, + navigateFrom, + 'global.builds', + ); + + if (!row.getCanExpand()) { + return ( + + ); + } + return ( {flexRender(cell.column.columnDef.cell, cell.getContext())} diff --git a/dashboard/src/pages/Hardware/hardwareListingFilters.test.ts b/dashboard/src/pages/Hardware/hardwareListingFilters.test.ts new file mode 100644 index 000000000..0d78e6a61 --- /dev/null +++ b/dashboard/src/pages/Hardware/hardwareListingFilters.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; + +import type { HardwareItem } from '@/types/hardware'; + +import { matchesRegistryFilter } from './hardwareListingFilters'; + +const noStatus = { PASS: 0, FAIL: 0, INCONCLUSIVE: 0 }; + +const withRegistry: HardwareItem = { + platform: 'j721e-evm', + build_status_summary: noStatus, + boot_status_summary: noStatus, + test_status_summary: noStatus, + registry: { + platform_id: 'j721e-evm', + vendor: { id: 'ti' }, + processor: { id: 'tda4vm', architecture: 'arm64' }, + }, +}; + +const withoutRegistry: HardwareItem = { + platform: 'qemu-x86', + build_status_summary: noStatus, + boot_status_summary: noStatus, + test_status_summary: noStatus, +}; + +describe('matchesRegistryFilter', () => { + it('requires registry match when a section is active; ANDs sections, ORs values in one', () => { + const vendorTiOrBeagle = { + platformVendors: { ti: true, beagleboard: true }, + }; + + expect(matchesRegistryFilter(withoutRegistry, vendorTiOrBeagle)).toBe( + false, + ); + expect( + matchesRegistryFilter(withRegistry, { + ...vendorTiOrBeagle, + processorArchs: { arm: true }, + }), + ).toBe(false); + expect( + matchesRegistryFilter(withRegistry, { + ...vendorTiOrBeagle, + processorArchs: { arm64: true }, + }), + ).toBe(true); + }); +}); diff --git a/dashboard/src/pages/Hardware/hardwareListingFilters.ts b/dashboard/src/pages/Hardware/hardwareListingFilters.ts new file mode 100644 index 000000000..af65a3554 --- /dev/null +++ b/dashboard/src/pages/Hardware/hardwareListingFilters.ts @@ -0,0 +1,114 @@ +import { z } from 'zod'; + +import type { ISectionItem } from '@/components/Filter/CheckboxSection'; +import type { MessagesKey } from '@/locales/messages'; +import type { HardwareItem, HardwareRegistryInfo } from '@/types/hardware'; + +type RegistryFilterField = { + key: string; + title: MessagesKey; + subtitle: MessagesKey; + value: (registry?: HardwareRegistryInfo | null) => string | null | undefined; +}; + +const REGISTRY_FILTER_FIELDS = [ + { + key: 'platformVendors', + title: 'filter.platformVendors', + subtitle: 'filter.platformVendorsSubtitle', + value: (r): string | null | undefined => r?.vendor?.id, + }, + { + key: 'platformBoardTypes', + title: 'filter.platformBoardTypes', + subtitle: 'filter.platformBoardTypesSubtitle', + value: (r): string | null | undefined => r?.board_type, + }, + { + key: 'processorIds', + title: 'filter.processorIds', + subtitle: 'filter.processorIdsSubtitle', + value: (r): string | null | undefined => r?.processor?.id, + }, + { + key: 'processorVendors', + title: 'filter.processorVendors', + subtitle: 'filter.processorVendorsSubtitle', + value: (r): string | null | undefined => r?.silicon_vendor?.id, + }, + { + key: 'processorArchs', + title: 'filter.processorArchs', + subtitle: 'filter.processorArchsSubtitle', + value: (r): string | null | undefined => r?.processor?.architecture, + }, +] as const satisfies ReadonlyArray; + +type RegistryFilterKey = (typeof REGISTRY_FILTER_FIELDS)[number]['key']; + +export type TRegistryFilter = Partial< + Record> +>; + +const registryFilterKeys = REGISTRY_FILTER_FIELDS.map(field => field.key) as [ + RegistryFilterKey, + ...RegistryFilterKey[], +]; + +export const DEFAULT_REGISTRY_FILTER: TRegistryFilter = {}; + +export const zRegistryFilter = z + .record(z.enum(registryFilterKeys), z.record(z.boolean())) + .default(DEFAULT_REGISTRY_FILTER) + .catch(DEFAULT_REGISTRY_FILTER); + +export const isRegistryFilterKey = (key: string): boolean => + registryFilterKeys.includes(key as RegistryFilterKey); + +export const registryFilterSections: ISectionItem[] = + REGISTRY_FILTER_FIELDS.map(({ key, title, subtitle }) => ({ + title, + subtitle, + sectionKey: key, + })); + +const selectedIn = (section?: Record): string[] => + Object.keys(section ?? {}).filter(value => section?.[value]); + +export const createRegistryFilter = ( + items: HardwareItem[], +): TRegistryFilter => { + const filter: TRegistryFilter = {}; + + for (const item of items) { + for (const { key, value } of REGISTRY_FILTER_FIELDS) { + const option = value(item.registry); + if (option) { + (filter[key] ??= {})[option] = false; + } + } + } + + return filter; +}; + +export const matchesRegistryFilter = ( + item: HardwareItem, + filter: TRegistryFilter, +): boolean => + REGISTRY_FILTER_FIELDS.every(({ key, value }) => { + const selected = selectedIn(filter[key]); + return ( + selected.length === 0 || selected.includes(value(item.registry) ?? '') + ); + }); + +export const cleanRegistryFilter = (filter: TRegistryFilter): TRegistryFilter => + Object.fromEntries( + REGISTRY_FILTER_FIELDS.map(({ key }) => [key, selectedIn(filter[key])]) + .filter(([, selected]) => selected.length > 0) + .map(([key, selected]) => [ + key, + Object.fromEntries((selected as string[]).map(value => [value, true])), + ]), + ); diff --git a/dashboard/src/pages/Hardware/hardwareTableUtils.ts b/dashboard/src/pages/Hardware/hardwareTableUtils.ts index db1d315a8..3626c6a78 100644 --- a/dashboard/src/pages/Hardware/hardwareTableUtils.ts +++ b/dashboard/src/pages/Hardware/hardwareTableUtils.ts @@ -24,6 +24,7 @@ export const buildHardwareDetailsSearch = ({ gitBranch: _gitBranch, gitCommitHash: _gitCommitHash, tableSort: _tableSort, + registryFilter: _registryFilter, ...searchWithoutTreeParams } = previousSearch; diff --git a/dashboard/src/routes/_main/hardware/route.tsx b/dashboard/src/routes/_main/hardware/route.tsx index 9843e5aaf..4d06d3f1e 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/hardwareListingFilters'; 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 ed1ce7a52..5cdb697b1 100644 --- a/dashboard/src/types/general.ts +++ b/dashboard/src/types/general.ts @@ -272,6 +272,7 @@ export type SearchParamsKeys = | 'gitRepositoryUrl' | 'gitBranch' | 'gitCommitHash' + | 'registryFilter' | 'startTimestampInSeconds' | 'endTimestampInSeconds' | 'issueVersion' diff --git a/dashboard/src/utils/search.ts b/dashboard/src/utils/search.ts index f3aacc4e5..d776d92b4 100644 --- a/dashboard/src/utils/search.ts +++ b/dashboard/src/utils/search.ts @@ -157,6 +157,7 @@ const generalMinifiedParams: Record = { gitRepositoryUrl: 'gu', gitBranch: 'gb', gitCommitHash: 'ch', + registryFilter: 'rf', } as const; const treeInfoMinifiedParams: Record = {