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 => (
+
+);
+
+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 = {