From d14c43df7f7a649b7c55394fef5ea8ea63ed7060 Mon Sep 17 00:00:00 2001 From: Alan Peixinho Date: Tue, 11 Aug 2026 15:27:19 -0300 Subject: [PATCH] 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 | 132 +++++++++-- .../pages/hardwareDetails/HardwareDetails.tsx | 10 + 8 files changed, 422 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 5b5d754fc..629ae361f 100644 --- a/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx +++ b/dashboard/src/components/LinkWithIcon/LinkWithIcon.tsx @@ -14,6 +14,7 @@ export interface ILinkWithIcon { onClick?: () => void; unformattedTitle?: string; titleIcon?: JSX.Element; + titleClassName?: string; } const LinkWithIcon = ({ @@ -25,6 +26,7 @@ const LinkWithIcon = ({ onClick, unformattedTitle, titleIcon, + titleClassName, }: ILinkWithIcon): JSX.Element => { const WrapperLink = link ? 'a' : 'div'; @@ -40,8 +42,10 @@ const LinkWithIcon = ({ return (
{(titleText || titleIcon) && ( -
- {titleText && {titleText}} +
+ {titleText && ( + {titleText} + )} {titleIcon}
)} diff --git a/dashboard/src/components/TestDetails/TestDetails.tsx b/dashboard/src/components/TestDetails/TestDetails.tsx index 95fba2f76..8ede7aa2e 100644 --- a/dashboard/src/components/TestDetails/TestDetails.tsx +++ b/dashboard/src/components/TestDetails/TestDetails.tsx @@ -78,6 +78,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 = ({ @@ -198,6 +202,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], @@ -443,6 +455,7 @@ const TestDetailsSections = ({ }, ]} /> +
), }, @@ -457,6 +470,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 e1e111107..d38807906 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -115,6 +115,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', @@ -130,8 +132,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', @@ -145,6 +149,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', @@ -161,6 +166,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', @@ -174,6 +180,7 @@ export const messages = { 'global.path': 'Path', 'global.platform': 'Platform', 'global.prev': 'Prev', + 'global.processor': 'Processor', 'global.projectUnderDevelopment': 'This is an ongoing project.{br}' + `Please report bugs and suggestions to ${FEEDBACK_EMAIL_TO}.`, @@ -182,12 +189,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', @@ -198,6 +208,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', @@ -339,6 +350,7 @@ export const messages = { 'Inconclusive - test concluded with inconclusive results such as infrastructure errors.{br}{br}' + 'Inconclusive groups tests with ERROR, MISS, SKIP, DONE, and unknown statuses defined by KCIDB.', 'testDetails.buildInfo': 'Build Info', + '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 bf01e877e..f2e20541f 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'; @@ -64,6 +68,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'; @@ -90,9 +99,12 @@ interface IHardwareTable { } type HardwareListingRoutes = '/hardware'; +type HardwareListingRow = HardwareItem & { + registry?: HardwareRegistryInfo; +}; const getLinkProps = ( - row: Row, + row: Row, startTimestampInSeconds: number, endTimestampInSeconds: number, navigateFrom: HardwareListingRoutes, @@ -130,8 +142,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 => ( @@ -141,6 +175,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 => { @@ -399,13 +456,17 @@ export function HardwareTable({ const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); + const [expanded, setExpanded] = useState({}); const { pagination, paginationUpdater } = usePaginationState( 'hardwareListing', listingSize, ); const data = useMemo(() => { - return treeTableRows; + return treeTableRows.map((row, index) => ({ + ...row, + registry: getMockHardwareRegistryListingInfo(row.platform, index), + })); }, [treeTableRows]); const columns = useMemo( @@ -419,7 +480,10 @@ export function HardwareTable({ columns, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, + onExpandedChange: setExpanded, getCoreRowModel: getCoreRowModel(), + getExpandedRowModel: getExpandedRowModel(), + getRowCanExpand: row => row.original.registry !== undefined, getPaginationRowModel: getPaginationRowModel(), onPaginationChange: paginationUpdater, getSortedRowModel: getSortedRowModel(), @@ -428,6 +492,7 @@ export function HardwareTable({ sorting, columnFilters, pagination, + expanded, }, }); @@ -454,27 +519,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 && ( <>