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 def39dac3..fc9fa8854 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',
@@ -162,6 +167,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',
@@ -175,6 +181,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}.`,
@@ -183,12 +190,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',
@@ -199,6 +209,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',
@@ -357,6 +368,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 && (
<>