diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index 7804ebcfd..5ebf9f401 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -305,6 +305,30 @@ export const messages = { 'issueListing.treeBranchTooltip': 'The tree name and git repository branch of the first incident\nClick a cell to see details of that checkout', 'jsonSheet.title': 'JSON Viewer', + 'labDetails.attentionProblem': '{test} failed on {platform}.', + 'labDetails.back': 'Back to labs', + 'labDetails.bootsPerDay': 'Boots per day', + 'labDetails.buildDuration': 'Build duration', + 'labDetails.buildsPerDay': 'Builds per day', + 'labDetails.checkoutsBooted': 'Checkouts booted', + 'labDetails.checkoutsBuilt': 'Checkouts built', + 'labDetails.checkoutsTested': 'Checkouts tested', + 'labDetails.distribution': 'Distribution', + 'labDetails.distributionNote': + 'Counts only values that were sent. Config, compiler, and architecture come from the build each result ran on. Platform comes from the lab.', + 'labDetails.duration': 'Boot / test duration', + 'labDetails.fillOf': '{filled} of {total}', + 'labDetails.fullLog': 'Full log', + 'labDetails.logExcerpt': 'Log excerpt', + 'labDetails.other': 'Other', + 'labDetails.passedIn': 'Passed in', + 'labDetails.selectTreeMessage': + 'Please select one or more trees from the table above to view detailed information about builds, boots, and tests for this lab.', + 'labDetails.started': 'Started', + 'labDetails.submissionFill': 'Submission fill', + 'labDetails.testsPerDay': 'Tests per day', + 'labDetails.title': '{lab} ― KCI Dashboard', + 'labDetails.window': '{origin}, last {days} days', 'labs.searchPlaceholder': 'Search by lab name with a regex', 'labsListing.description': 'List of labs with aggregated build, boot and test status', @@ -401,6 +425,7 @@ export const messages = { 'testStatus.error': 'Error', 'testStatus.fail': 'Fail', 'testStatus.miss': 'Miss', + 'testStatus.null': 'Null', 'testStatus.pass': 'Pass', 'testStatus.skip': 'Skip', 'testsTab.hardwareTested': 'Hardware tested', diff --git a/dashboard/src/pages/Labs/LabDetails.tsx b/dashboard/src/pages/Labs/LabDetails.tsx new file mode 100644 index 000000000..513675d9c --- /dev/null +++ b/dashboard/src/pages/Labs/LabDetails.tsx @@ -0,0 +1,714 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type JSX, + type ReactNode, +} from 'react'; + +import { format, subDays } from 'date-fns'; +import { MdWarningAmber } from 'react-icons/md'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { + Link, + useNavigate, + useParams, + useSearch, + type LinkProps, +} from '@tanstack/react-router'; + +import { ChevronRightAnimate } from '@/components/AnimatedIcons/Chevron'; +import { BaseCard } from '@/components/Cards/BaseCard'; +import { LineChart } from '@/components/LineChart'; +import { Colors } from '@/components/StatusChart/StatusCharts'; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/Breadcrumb/Breadcrumb'; +import MemoizedIssuesList from '@/components/Cards/IssuesList'; +import { IndeterminateCheckbox } from '@/components/Checkbox/IndeterminateCheckbox'; +import { MemoizedSectionError } from '@/components/DetailsPages/SectionError'; +import PageWithTitle from '@/components/PageWithTitle'; +import { GroupedTestStatus } from '@/components/Status/Status'; +import BaseTable, { TableHead } from '@/components/Table/BaseTable'; +import { MemoizedStatusCard } from '@/components/Tabs/StatusCard'; +import Tabs from '@/components/Tabs/Tabs'; +import { TestsTable } from '@/components/TestsTable/TestsTable'; +import { CommitTagTooltip } from '@/components/Tooltip/CommitTagTooltip'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '@/components/ui/collapsible'; +import { Separator } from '@/components/ui/separator'; +import { TableBody, TableCell, TableRow } from '@/components/ui/table'; + +import type { MessagesKey } from '@/locales/messages'; +import { DEFAULT_DIFF_FILTER, type RequiredStatusCount } from '@/types/general'; +import { zPossibleTabValidator } from '@/types/tree/TreeDetails'; +import { + defaultTableStatusSelection, + toggleTableStatus, + type TableStatusOption, + type TableStatusSelection, +} from '@/utils/tableStatusFilter'; + +import LabProvided from './LabProvided'; +import { + aggregateLabTrees, + dailyStatus, + labAttentionItems, + labDetailsMock, + sumStatusCounts, + toTestHistory, + type LabDailyKind, + type LabTestRow, + type LabTreeMock, +} from './labDetailsMock'; + +const dayLabels = (days: number): string[] => + Array.from({ length: days }, (_, index) => + format(subDays(new Date(), days - 1 - index), 'MMM d'), + ); + +const LabDailyGraph = ({ + treeCount, + days, + kind, + titleId, +}: { + treeCount: number; + days: number; + kind: LabDailyKind; + titleId: MessagesKey; +}): JSX.Element => { + const { formatMessage } = useIntl(); + const counts = dailyStatus(kind, days, treeCount); + + return ( + + } + /> + ); +}; + +const LabTestsTable = ({ + rows, + labName, +}: { + rows: LabTestRow[]; + labName: string; +}): JSX.Element => { + const [filter, setFilter] = useState(() => [ + ...defaultTableStatusSelection, + ]); + const [pathFilter, setPathFilter] = useState(''); + const testHistory = useMemo( + () => toTestHistory(rows, labName), + [labName, rows], + ); + const onToggleFilter = useCallback((option: TableStatusOption) => { + setFilter(current => toggleTableStatus(current, option)); + }, []); + const getRowLink = useCallback( + (testId: string): LinkProps => ({ + to: '/test/$testId', + params: { testId }, + search: previous => ({ origin: previous.origin }), + }), + [], + ); + + return ( + + ); +}; + +const statusCounts = (summary: RequiredStatusCount): JSX.Element => ( + +); + +const MockTable = ({ + headers, + rows, +}: { + headers: MessagesKey[]; + rows: { key: string; cells: ReactNode[] }[]; +}): JSX.Element => ( + ( + + + + ))} + > + + {rows.map(row => ( + + {row.cells.map((cell, index) => ( + {cell} + ))} + + ))} + + +); + +const LabDetails = (): JSX.Element => { + const { labName } = useParams({ from: '/_main/labs/$labName' }); + const { origin, intervalInDays, currentPageTab, labDetailsPreview } = + useSearch({ from: '/_main/labs/$labName' }); + const navigate = useNavigate({ from: '/labs/$labName' }); + const { formatMessage } = useIntl(); + const trees = useMemo(() => labDetailsMock(labName), [labName]); + const [selectedKeys, setSelectedKeys] = useState(null); + const showEmpty = labDetailsPreview === 'empty'; + const selection = selectedKeys ?? trees.map(tree => tree.key); + const selectedTrees = trees.filter(tree => selection.includes(tree.key)); + const summary = aggregateLabTrees(selectedTrees); + const allSelected = selection.length === trees.length; + const noopFilter = useCallback(() => undefined, []); + + useEffect(() => { + setSelectedKeys(null); + }, [labName]); + + const onTabChange = useCallback( + (value: string) => { + navigate({ + search: previous => ({ + ...previous, + currentPageTab: zPossibleTabValidator.parse(value), + }), + state: s => s, + }); + }, + [navigate], + ); + + const toggleTree = (key: string): void => { + const next = new Set(selection); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + setSelectedKeys([...next]); + }; + + return ( + +
+
+ + + + ({ + origin: previous.origin, + intervalInDays: previous.intervalInDays, + labsSearch: previous.labsSearch, + listingSize: previous.listingSize, + })} + state={s => s} + > + + + + + + {labName} + + + +

+ +

+
+ + {showEmpty ? ( +
+ +
+ +
+
+ ) : ( +
+ + + setSelectedKeys(allSelected ? [] : trees.map(tree => tree.key)) + } + onToggleTree={toggleTree} + /> + {selectedTrees.length === 0 ? ( +
+
+

+ +

+

+ +

+
+
+ ) : ( + +
+
+ + } + statusCounts={summary.builds} + filterStatusKey="buildStatus" + toggleFilterBySection={noopFilter} + /> + } + issues={summary.buildIssues} + diffFilter={DEFAULT_DIFF_FILTER} + issueFilterSection="buildIssue" + /> +
+ +
+ ({ + key: `${row.tree}-${row.config}-${row.arch}-${row.started}`, + cells: [ + `${row.tree} / ${row.branch}`, + row.config, + row.arch, + row.compiler, + row.status, + row.started, + ], + }))} + /> +
+ ), + }, + { + name: 'global.boots', + rightElement: statusCounts(summary.boots), + content: ( + ({ + key: `${row.tree}-${row.platform}-${row.started}`, + cells: [ + `${row.tree} / ${row.branch}`, + row.platform, + row.status, + row.started, + ], + }))} + rowHeaders={[ + 'global.treeBranch', + 'global.platform', + 'global.status', + 'labDetails.started', + ]} + titleId="global.boots" + noopFilter={noopFilter} + treeCount={selectedTrees.length} + intervalInDays={intervalInDays} + /> + ), + }, + { + name: 'global.tests', + rightElement: statusCounts(summary.tests), + content: ( + + } + noopFilter={noopFilter} + treeCount={selectedTrees.length} + intervalInDays={intervalInDays} + /> + ), + }, + ]} + value={currentPageTab} + onValueChange={onTabChange} + /> + )} + + tree.buildRows)} + boots={trees.flatMap(tree => tree.bootRows)} + tests={trees.flatMap(tree => tree.testRows)} + buildCounts={sumStatusCounts(trees.map(tree => tree.builds))} + bootCounts={sumStatusCounts(trees.map(tree => tree.boots))} + testCounts={sumStatusCounts(trees.map(tree => tree.tests))} + checkoutTotal={trees.length} + checkoutsBuilt={ + trees.filter(tree => tree.buildRows.length > 0).length + } + checkoutsBooted={ + trees.filter(tree => tree.bootRows.length > 0).length + } + checkoutsTested={ + trees.filter(tree => tree.testRows.length > 0).length + } + /> +
+ )} + +
+ ); +}; + +const LabAttention = ({ labName }: { labName: string }): JSX.Element | null => { + const items = labAttentionItems(labName); + if (items.length === 0) { + return null; + } + + return ( + + + + + {items.map((item, index) => ( + + {index > 0 && ' / '} + + + ))} + + + + + {items.map(item => ( +
+
+ {( + [ + ['global.platform', item.platform], + ['global.treeBranch', `${item.tree} / ${item.branch}`], + [ + 'globalTable.commitTag', + , + ], + ['global.config', item.config], + ['global.architecture', item.arch], + ['global.compiler', item.compiler], + ['global.date', item.date], + ] as [MessagesKey, ReactNode][] + ).map(([labelId, value]) => ( +
+
+ +
+
{value}
+
+ ))} +
+
+ + + + {item.passedLabs.map(lab => ( + + {lab} + + ))} +
+
+ ))} +
+
+ ); +}; + +const TreeTable = ({ + trees, + selection, + allSelected, + onToggleAll, + onToggleTree, +}: { + trees: LabTreeMock[]; + selection: string[]; + allSelected: boolean; + onToggleAll: () => void; + onToggleTree: (key: string) => void; +}): JSX.Element => ( + + 0 && !allSelected} + onChange={onToggleAll} + /> + , + + + , + + + , + + + , + + + , + + + , + + + , + ]} + > + + {trees.map(tree => ( + + + onToggleTree(tree.key)} + /> + + {tree.tree} + {tree.branch} + + + + {statusCounts(tree.builds)} + {statusCounts(tree.boots)} + {statusCounts(tree.tests)} + + ))} + + +); + +const ResultTable = ({ + titleId, + headers, + rows, +}: { + titleId: MessagesKey; + headers: MessagesKey[]; + rows: { key: string; cells: ReactNode[] }[]; +}): JSX.Element => ( +
+
+ +
+ +
+); + +const BootOrTestTab = ({ + kind, + statusTitleId, + statusCountsValue, + issues, + issueFilterSection, + rows, + rowHeaders, + titleId, + results, + noopFilter, + treeCount, + intervalInDays, +}: { + kind: 'boots' | 'tests'; + statusTitleId: MessagesKey; + statusCountsValue: RequiredStatusCount; + issues: LabTreeMock['bootIssues']; + issueFilterSection: 'bootIssue' | 'testIssue'; + rows?: { key: string; cells: ReactNode[] }[]; + rowHeaders?: MessagesKey[]; + titleId?: MessagesKey; + results?: ReactNode; + noopFilter: () => undefined; + treeCount: number; + intervalInDays: number; +}): JSX.Element => ( +
+
+
+ } + statusCounts={statusCountsValue} + filterStatusKey={kind === 'boots' ? 'bootStatus' : 'testStatus'} + toggleFilterBySection={noopFilter} + /> + } + issues={issues} + diffFilter={DEFAULT_DIFF_FILTER} + issueFilterSection={issueFilterSection} + /> +
+ +
+ {results ?? + (titleId && rowHeaders && rows ? ( + + ) : null)} +
+); + +export default LabDetails; diff --git a/dashboard/src/pages/Labs/LabProvided.tsx b/dashboard/src/pages/Labs/LabProvided.tsx new file mode 100644 index 000000000..601753bae --- /dev/null +++ b/dashboard/src/pages/Labs/LabProvided.tsx @@ -0,0 +1,360 @@ +import type { JSX } from 'react'; + +import { PieChart } from '@mui/x-charts/PieChart'; +import { FormattedMessage, useIntl } from 'react-intl'; + +import { BaseCard } from '@/components/Cards/BaseCard'; +import { Colors } from '@/components/StatusChart/StatusCharts'; + +import type { MessagesKey } from '@/locales/messages'; +import type { Status } from '@/types/database'; +import type { RequiredStatusCount } from '@/types/general'; + +import { + fillCount, + fillTone, + frequencySlices, + type FillTone, + type FrequencySlice, + type LabBootRow, + type LabBuildRow, + type LabTestRow, +} from './labDetailsMock'; + +const SLICE_COLORS = [ + '#4C78A8', + '#F58518', + '#54A24B', + '#E45756', + '#72B7B2', + '#EECA3B', + '#B279A2', + '#FF9DA6', +]; + +const UNKNOWN_COLOR = '#EAEAEA'; +const OTHER_COLOR = '#9CA3AF'; +const PERCENT = 100; + +const sliceColor = (slice: FrequencySlice, index: number): string => { + if (slice.kind === 'unknown') { + return UNKNOWN_COLOR; + } + if (slice.kind === 'other') { + return OTHER_COLOR; + } + return SLICE_COLORS[index % SLICE_COLORS.length] ?? SLICE_COLORS[0]; +}; + +const sliceLabel = ( + slice: FrequencySlice, + formatMessage: (descriptor: { id: MessagesKey }) => string, +): string => { + if (slice.kind === 'unknown') { + return formatMessage({ id: 'global.unknown' }); + } + if (slice.kind === 'other') { + return formatMessage({ id: 'labDetails.other' }); + } + return slice.name; +}; + +const TONE_CLASS: Record = { + good: 'text-green', + mid: 'text-[#EAB839]', + bad: 'text-red', +}; + +const FillStat = ({ + labelId, + filled, + total, +}: { + labelId: MessagesKey; + filled: number; + total: number; +}): JSX.Element => { + const percent = total === 0 ? 0 : Math.round((PERCENT * filled) / total); + const tone = fillTone(percent); + + return ( +
+
+ +
+
+ {percent}% +
+
+ +
+
+ ); +}; + +type DonutSlice = { + label: string; + value: number; + color: string; +}; + +const Donut = ({ + titleId, + slices, + testId, +}: { + titleId: MessagesKey; + slices: DonutSlice[]; + testId: string; +}): JSX.Element => { + const total = slices.reduce((sum, slice) => sum + slice.value, 0); + const data = slices.map((slice, index) => ({ id: index, ...slice })); + + return ( +
+
+ +
+ {total === 0 ? ( +

+ +

+ ) : ( +
+
+ +
+ {total} +
+
+
    + {data.map(item => ( +
  • + + {item.label} + + {item.value} + +
  • + ))} +
+
+ )} +
+ ); +}; + +const FrequencyChart = ({ + titleId, + labels, +}: { + titleId: MessagesKey; + labels: readonly string[]; +}): JSX.Element => { + const { formatMessage } = useIntl(); + const slices = frequencySlices(labels).map((slice, index) => ({ + value: slice.value, + label: sliceLabel(slice, formatMessage), + color: sliceColor(slice, index), + })); + + return ( + + ); +}; + +const STATUS_ORDER = [ + 'PASS', + 'FAIL', + 'ERROR', + 'MISS', + 'SKIP', + 'DONE', + 'NULL', +] as const satisfies readonly Status[]; + +const STATUS_COLOR: Record = { + PASS: Colors.Green, + FAIL: Colors.Red, + ERROR: '#F58518', + MISS: '#B279A2', + SKIP: '#72B7B2', + DONE: '#4C78A8', + NULL: Colors.Gray, +}; + +const STATUS_LABEL: Record = { + PASS: 'testStatus.pass', + FAIL: 'testStatus.fail', + ERROR: 'testStatus.error', + MISS: 'testStatus.miss', + SKIP: 'testStatus.skip', + DONE: 'testStatus.done', + NULL: 'testStatus.null', +}; + +const StatusChart = ({ + titleId, + counts, +}: { + titleId: MessagesKey; + counts: RequiredStatusCount; +}): JSX.Element => { + const { formatMessage } = useIntl(); + const slices = STATUS_ORDER.flatMap(status => { + const value = counts[status]; + if (value === 0) { + return []; + } + return [ + { + label: formatMessage({ id: STATUS_LABEL[status] }), + value, + color: STATUS_COLOR[status], + }, + ]; + }); + + return ; +}; + +const sentLabels = (labels: readonly string[]): string[] => + labels.filter(label => label.trim() !== ''); + +const LabProvided = ({ + builds, + boots, + tests, + buildCounts, + bootCounts, + testCounts, + checkoutTotal, + checkoutsBuilt, + checkoutsBooted, + checkoutsTested, +}: { + builds: LabBuildRow[]; + boots: LabBootRow[]; + tests: LabTestRow[]; + buildCounts: RequiredStatusCount; + bootCounts: RequiredStatusCount; + testCounts: RequiredStatusCount; + checkoutTotal: number; + checkoutsBuilt: number; + checkoutsBooted: number; + checkoutsTested: number; +}): JSX.Element => { + const runs = [...boots, ...tests]; + const duration = fillCount(runs.map(row => row.hasDuration)); + const fullLog = fillCount(runs.map(row => row.hasLogUrl)); + const excerpt = fillCount(runs.map(row => row.hasLogExcerpt)); + const buildDuration = fillCount(builds.map(row => row.hasDuration)); + const platform = fillCount(runs.map(row => row.platform.trim() !== '')); + + return ( +
+ } + content={ +
+

+ +

+
+ row.platform))} + /> + row.config))} + /> + row.compiler))} + /> + row.arch))} + /> + {builds.length > 0 ? ( + + ) : null} + + +
+
+ } + /> + } + content={ +
+ + + + + {builds.length > 0 ? ( + + ) : null} + + + +
+ } + /> +
+ ); +}; + +export default LabProvided; diff --git a/dashboard/src/pages/Labs/LabsTable.tsx b/dashboard/src/pages/Labs/LabsTable.tsx index 82884c2d7..7766a01d3 100644 --- a/dashboard/src/pages/Labs/LabsTable.tsx +++ b/dashboard/src/pages/Labs/LabsTable.tsx @@ -20,7 +20,12 @@ import { FormattedMessage } from 'react-intl'; import { useNavigate, useSearch } from '@tanstack/react-router'; import BaseTable, { TableHead } from '@/components/Table/BaseTable'; -import { TableBody, TableCell, TableRow } from '@/components/ui/table'; +import { + TableBody, + TableCell, + TableCellWithLink, + TableRow, +} from '@/components/ui/table'; import { GroupedTestStatus } from '@/components/Status/Status'; import { TableHeader } from '@/components/Table/TableHeader'; import { @@ -165,9 +170,18 @@ export function LabsTable({ modelRows.map(row => ( {row.getVisibleCells().map(cell => ( - + previous, + state: s => s, + }} + > {flexRender(cell.column.columnDef.cell, cell.getContext())} - + ))} )) diff --git a/dashboard/src/pages/Labs/labDetailsMock.test.ts b/dashboard/src/pages/Labs/labDetailsMock.test.ts new file mode 100644 index 000000000..abeec387d --- /dev/null +++ b/dashboard/src/pages/Labs/labDetailsMock.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { + aggregateLabTrees, + dailyStatus, + FILL_GREEN_AT, + FILL_YELLOW_AT, + fillCount, + fillTone, + frequencySlices, + labAttentionItems, + labDetailsMock, + sumStatusCounts, + toTestHistory, +} from './labDetailsMock'; + +describe('labDetailsMock', () => { + it('keeps card totals equal to the sample rows', () => { + const trees = labDetailsMock('lab-baylibre'); + const passRows = trees.reduce( + (total, tree) => + total + tree.buildRows.filter(row => row.status === 'PASS').length, + 0, + ); + + expect(sumStatusCounts(trees.map(tree => tree.builds)).PASS).toBe(passRows); + expect(trees[0]?.bootRows[0]?.platform).toBe('meson-g12b-odroid-n2'); + expect(aggregateLabTrees(trees.slice(0, 1)).buildRows).toHaveLength( + trees[0]?.buildRows.length ?? 0, + ); + const days = trees[0]?.buildRows.length ?? 0; + const counts = dailyStatus('builds', days, trees.length); + expect(days).toBeGreaterThan(0); + expect(counts.pass).toHaveLength(days); + expect(counts.fail).toHaveLength(days); + expect(counts.inconclusive).toHaveLength(days); + expect( + Math.max(...counts.pass) - Math.min(...counts.pass), + ).toBeLessThanOrEqual(trees.length); + const history = toTestHistory(trees[0]?.testRows ?? [], 'lab-baylibre'); + expect(history).toHaveLength(trees[0]?.testRows.length ?? 0); + expect(history[0]?.path).toBe(trees[0]?.testRows[0]?.path); + expect(history[0]?.lab).toBe('lab-baylibre'); + expect(Number.isNaN(Date.parse(history[0]?.start_time ?? ''))).toBe(false); + }); + + it('keeps unknown out of the named slices and folds the tail into other', () => { + const slices = frequencySlices(['a', 'a', 'a', 'b', 'b', 'c', ' '], 2); + + expect(slices).toEqual([ + { name: 'a', value: 3, kind: 'named' }, + { name: 'b', value: 2, kind: 'named' }, + { name: '', value: 1, kind: 'other' }, + { name: '', value: 1, kind: 'unknown' }, + ]); + expect(fillCount([true, false, true])).toEqual({ filled: 2, total: 3 }); + expect(fillTone(FILL_GREEN_AT)).toBe('good'); + expect(fillTone(FILL_YELLOW_AT)).toBe('mid'); + expect(fillTone(FILL_YELLOW_AT - 1)).toBe('bad'); + }); + + it('shows the boot and kselftest failures on the same lab page', () => { + const items = labAttentionItems('lava-broonie'); + + expect(items.map(item => item.test)).toEqual(['boot', 'kselftest']); + expect(labAttentionItems('pull-labs-demo')).toBe(items); + expect(labAttentionItems('lab-baylibre')).toEqual([]); + }); +}); diff --git a/dashboard/src/pages/Labs/labDetailsMock.ts b/dashboard/src/pages/Labs/labDetailsMock.ts new file mode 100644 index 000000000..b54825f24 --- /dev/null +++ b/dashboard/src/pages/Labs/labDetailsMock.ts @@ -0,0 +1,608 @@ +import type { Status } from '@/types/database'; +import type { RequiredStatusCount, TestHistory } from '@/types/general'; +import type { TIssue } from '@/types/issues'; + +export type LabBuildRow = { + tree: string; + branch: string; + config: string; + arch: string; + compiler: string; + status: Status; + started: string; + hasDuration: boolean; +}; + +export type LabBootRow = { + tree: string; + branch: string; + platform: string; + config: string; + arch: string; + compiler: string; + status: Status; + started: string; + hasDuration: boolean; + hasLogUrl: boolean; + hasLogExcerpt: boolean; +}; + +export type LabTestRow = LabBootRow & { + path: string; +}; + +export type LabTreeMock = { + key: string; + tree: string; + branch: string; + commitName: string; + commitHash: string; + builds: RequiredStatusCount; + boots: RequiredStatusCount; + tests: RequiredStatusCount; + buildIssues: TIssue[]; + bootIssues: TIssue[]; + testIssues: TIssue[]; + buildRows: LabBuildRow[]; + bootRows: LabBootRow[]; + testRows: LabTestRow[]; +}; + +const blankCount = (): RequiredStatusCount => ({ + PASS: 0, + FAIL: 0, + ERROR: 0, + MISS: 0, + SKIP: 0, + DONE: 0, + NULL: 0, +}); + +const countStatus = ( + counts: RequiredStatusCount, + status: Status, +): RequiredStatusCount => ({ + ...counts, + [status]: counts[status] + 1, +}); + +export const sumStatusCounts = ( + counts: RequiredStatusCount[], +): RequiredStatusCount => + counts.reduce( + (total, item) => ({ + PASS: total.PASS + item.PASS, + FAIL: total.FAIL + item.FAIL, + ERROR: total.ERROR + item.ERROR, + MISS: total.MISS + item.MISS, + SKIP: total.SKIP + item.SKIP, + DONE: total.DONE + item.DONE, + NULL: total.NULL + item.NULL, + }), + blankCount(), + ); + +const countRows = (statuses: Status[]): RequiredStatusCount => + statuses.reduce(countStatus, blankCount()); + +const issueFor = ( + id: string, + comment: string, + counts: RequiredStatusCount, +): TIssue[] => { + if (counts.FAIL + counts.ERROR + counts.MISS === 0) { + return []; + } + return [{ id, version: 1, comment, incidents_info: counts }]; +}; + +const platformsFor = (labName: string): [string, string] => { + if (labName.includes('renesas')) { + return ['r8a774b1-hihope-rzg2n-ex', 'r9a07g044l2-smarc-rzg2l']; + } + if (labName.includes('qualcomm')) { + return ['qcs6490-rb3gen2', 'sm8550-hdk']; + } + if (labName.includes('baylibre')) { + return ['meson-g12b-odroid-n2', 'amlogic-sm1']; + } + if (labName.includes('broonie')) { + return ['imx8mm-evk', 'bcm2711-rpi-4-b']; + } + return ['qemu-arm64', 'rk3399-rock-pi-4b']; +}; + +const treeKey = (tree: string, branch: string): string => `${tree}/${branch}`; + +export const toTestHistory = ( + rows: LabTestRow[], + labName: string, +): TestHistory[] => + rows.map((row, index) => { + const [hours, minutes] = row.started.split(':'); + const start = new Date(); + start.setHours(Number(hours), Number(minutes), 0, 0); + return { + id: `sample-${index}`, + status: row.status, + path: row.path, + start_time: start.toISOString(), + tree_name: row.tree, + git_repository_branch: row.branch, + lab: labName, + environment_misc: { platform: row.platform }, + }; + }); + +export type LabDailyKind = 'builds' | 'boots' | 'tests'; + +type DailySplit = { + pass: number[]; + fail: number[]; + inconclusive: number[]; +}; + +// Per-tree sample counts, oldest to newest. The chart sums selected trees. +// Longer windows repeat the pattern. +/* eslint-disable no-magic-numbers -- proposal chart sample, not derived from rows */ +const dailyPerTree: Record = { + builds: { + pass: [3, 3, 3, 4, 3, 3, 3, 3, 4, 3, 3, 3, 3, 4], + fail: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + inconclusive: [0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0], + }, + boots: { + pass: [6, 6, 6, 7, 6, 6, 6, 7, 6, 6, 6, 6, 7, 6], + fail: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + inconclusive: [0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1], + }, + tests: { + pass: [14, 14, 15, 14, 14, 14, 15, 14, 14, 15, 14, 14, 14, 15], + fail: [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2], + inconclusive: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], + }, +}; +/* eslint-enable no-magic-numbers */ + +const scaleWindow = ( + pattern: number[], + days: number, + treeCount: number, +): number[] => { + const window = + days <= pattern.length + ? pattern.slice(pattern.length - days) + : Array.from( + { length: days }, + (_, index) => pattern[index % pattern.length] ?? 0, + ); + return window.map(value => value * treeCount); +}; + +export const dailyStatus = ( + kind: LabDailyKind, + days: number, + treeCount: number, +): DailySplit => ({ + pass: scaleWindow(dailyPerTree[kind].pass, days, treeCount), + fail: scaleWindow(dailyPerTree[kind].fail, days, treeCount), + inconclusive: scaleWindow(dailyPerTree[kind].inconclusive, days, treeCount), +}); + +type BuildInput = Omit; + +type BootInput = Omit< + LabBootRow, + 'tree' | 'branch' | 'config' | 'hasDuration' | 'hasLogUrl' | 'hasLogExcerpt' +> & { config?: string }; + +type TestInput = BootInput & Pick; + +const stampBuilds = ( + rows: BuildInput[], + tree: string, + branch: string, +): LabBuildRow[] => + rows.map((row, index) => ({ + ...row, + tree, + branch, + hasDuration: index === 0 && rows.length > 1, + })); + +/* eslint-disable no-magic-numbers -- sample gaps so the fill rates are not all full */ +const stampRuns = ( + rows: T[], + tree: string, + branch: string, +): (Omit & LabBootRow)[] => + rows.map((row, index) => ({ + ...row, + config: row.config ?? 'defconfig', + tree, + branch, + hasDuration: index % 4 !== 3, + hasLogUrl: index % 3 !== 2, + hasLogExcerpt: index % 5 !== 4, + })); +/* eslint-enable no-magic-numbers */ + +export type SliceKind = 'named' | 'unknown' | 'other'; + +export type FrequencySlice = { + name: string; + value: number; + kind: SliceKind; +}; + +const FREQUENCY_TOP = 8; + +export const frequencySlices = ( + labels: readonly string[], + top = FREQUENCY_TOP, +): FrequencySlice[] => { + const counts = new Map(); + let unknown = 0; + labels.forEach(label => { + const name = label.trim(); + if (name === '') { + unknown += 1; + return; + } + counts.set(name, (counts.get(name) ?? 0) + 1); + }); + const ranked = [...counts.entries()].sort( + (left, right) => right[1] - left[1] || left[0].localeCompare(right[0]), + ); + const named = ranked + .slice(0, top) + .map(([name, value]): FrequencySlice => ({ name, value, kind: 'named' })); + const other = ranked + .slice(top) + .reduce((total, [, value]) => total + value, 0); + if (other > 0) { + named.push({ name: '', value: other, kind: 'other' }); + } + if (unknown > 0) { + named.push({ name: '', value: unknown, kind: 'unknown' }); + } + return named; +}; + +export const fillCount = ( + flags: readonly boolean[], +): { filled: number; total: number } => ({ + filled: flags.filter(Boolean).length, + total: flags.length, +}); + +export type FillTone = 'good' | 'mid' | 'bad'; + +export const FILL_GREEN_AT = 80; +export const FILL_YELLOW_AT = 50; + +export const fillTone = (percent: number): FillTone => { + if (percent >= FILL_GREEN_AT) { + return 'good'; + } + if (percent >= FILL_YELLOW_AT) { + return 'mid'; + } + return 'bad'; +}; + +const makeTree = ({ + tree, + branch, + commitName, + commitHash, + buildRows, + bootRows, + testRows, +}: { + tree: string; + branch: string; + commitName: string; + commitHash: string; + buildRows: BuildInput[]; + bootRows: BootInput[]; + testRows: TestInput[]; +}): LabTreeMock => { + const builds = stampBuilds(buildRows, tree, branch); + const boots = stampRuns(bootRows, tree, branch); + const tests = stampRuns(testRows, tree, branch); + const buildCounts = countRows(builds.map(row => row.status)); + const bootCounts = countRows(boots.map(row => row.status)); + const testCounts = countRows(tests.map(row => row.status)); + + return { + key: treeKey(tree, branch), + tree, + branch, + commitName, + commitHash, + builds: buildCounts, + boots: bootCounts, + tests: testCounts, + buildIssues: issueFor( + `sample:${tree}:build`, + `${tree} build failures`, + buildCounts, + ), + bootIssues: issueFor( + `sample:${tree}:boot`, + `${tree} boot failures`, + bootCounts, + ), + testIssues: issueFor( + `sample:${tree}:test`, + `${tree} test failures`, + testCounts, + ), + buildRows: builds, + bootRows: boots, + testRows: tests, + }; +}; + +const SPREAD_PLATFORMS = [ + '', + 'qemu-arm64', + 'rk3399-rock-pi-4b', + 'imx8mm-evk', + 'bcm2711-rpi-4-b', + 'meson-g12b-odroid-n2', + 'amlogic-sm1', + 'qcs6490-rb3gen2', + 'sm8550-hdk', + 'jh7110-visionfive-2', +]; + +const SPREAD_CONFIGS = [ + 'defconfig', + 'allmodconfig', + 'tinyconfig', + 'allyesconfig', + 'defconfig+thinlto', + 'kvm_guest.config', + 'arm64_defconfig', + 'x86_64_defconfig', + 'riscv_defconfig', +]; + +const SPREAD_COMPILERS = ['gcc-13', 'gcc-14', 'clang-18', 'clang-19']; +const SPREAD_ARCHS = ['arm64', 'arm', 'x86_64', 'riscv']; +const SPREAD_STATUS: Status[] = ['PASS', 'FAIL', 'MISS', 'ERROR', 'PASS']; + +const spreadRuns = ( + primaryPlatform: string, + secondaryPlatform: string, +): BootInput[] => + [...SPREAD_PLATFORMS, primaryPlatform, secondaryPlatform].map( + (platform, index) => ({ + platform, + config: SPREAD_CONFIGS[index % SPREAD_CONFIGS.length] ?? 'defconfig', + arch: SPREAD_ARCHS[index % SPREAD_ARCHS.length] ?? 'arm64', + compiler: SPREAD_COMPILERS[index % SPREAD_COMPILERS.length] ?? 'gcc-13', + status: SPREAD_STATUS[index % SPREAD_STATUS.length] ?? 'PASS', + started: '12:00', + }), + ); + +export const labDetailsMock = (labName: string): LabTreeMock[] => { + const [primaryPlatform, secondaryPlatform] = platformsFor(labName); + const spread = spreadRuns(primaryPlatform, secondaryPlatform); + + return [ + makeTree({ + tree: 'mainline', + branch: 'master', + commitName: 'v6.17-rc2', + commitHash: 'aaaa1111bbbb2222cccc3333dddd4444eeee5555', + buildRows: [ + { + config: 'defconfig', + arch: 'arm64', + compiler: 'gcc-13', + status: 'PASS', + started: '09:12', + }, + { + config: 'allmodconfig', + arch: 'arm64', + compiler: 'gcc-13', + status: 'FAIL', + started: '08:40', + }, + { + config: 'defconfig', + arch: 'x86_64', + compiler: 'gcc-13', + status: 'PASS', + started: '08:10', + }, + ], + bootRows: [ + { + platform: primaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'PASS', + started: '09:40', + }, + { + platform: secondaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'FAIL', + started: '09:18', + }, + { + platform: primaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'MISS', + started: '09:05', + }, + ...spread, + ], + testRows: [ + { + path: 'v4l2.compliance', + platform: primaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'PASS', + started: '10:02', + }, + { + path: 'kselftest', + platform: secondaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'FAIL', + started: '09:51', + }, + { + path: 'kunit', + platform: primaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'SKIP', + started: '09:33', + }, + ...spread.map((row, index) => ({ + ...row, + path: index % 2 === 0 ? 'kselftest' : 'v4l2.compliance', + })), + ], + }), + makeTree({ + tree: 'next', + branch: 'master', + commitName: 'next-20261012', + commitHash: 'ffff6666eeee5555dddd4444cccc3333bbbb2222', + buildRows: [ + { + config: 'defconfig', + arch: 'arm64', + compiler: 'clang-18', + status: 'ERROR', + started: '07:15', + }, + { + config: 'defconfig', + arch: 'arm64', + compiler: 'gcc-13', + status: 'PASS', + started: '07:02', + }, + ], + bootRows: [ + { + platform: primaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'FAIL', + started: '08:05', + }, + ], + testRows: [ + { + path: 'kunit', + platform: primaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'FAIL', + started: '08:22', + }, + ], + }), + makeTree({ + tree: 'stable', + branch: 'linux-6.12.y', + commitName: 'v6.12.48', + commitHash: '1111aaaa2222bbbb3333cccc4444dddd5555eeee', + buildRows: [], + bootRows: [ + { + platform: primaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'PASS', + started: '06:44', + }, + ], + testRows: [ + { + path: 'kselftest', + platform: primaryPlatform, + arch: 'arm64', + compiler: 'gcc-13', + status: 'PASS', + started: '07:03', + }, + ], + }), + ]; +}; + +const labAttention = [ + { + test: 'boot', + platform: 'cd8180-orion-o6', + tree: 'next', + branch: 'pending-fixes', + commitName: 'v7.3-rc6-241-gdcb935319e0eb', + commitHash: 'dcb935319e0ebfac02fcaec00ce465055bc61b4e', + config: 'defconfig', + arch: 'arm64', + compiler: 'gcc-15', + date: '5 Oct 2026', + passedLabs: ['lava-broonie', 'lava-cip'], + }, + { + test: 'kselftest', + platform: 'bcm2711-rpi-4-b', + tree: 'mainline', + branch: 'master', + commitName: 'v7.3-1138-g14c8977403882', + commitHash: '14c897740388216acd59477a382de327d5e27ba4', + config: 'defconfig+lab-setup+kselftest', + arch: 'arm64', + compiler: 'gcc-14', + date: '21 Oct 2026', + passedLabs: ['lava-collabora', 'lava-cip', 'lava-renesas'], + }, +]; + +export const labAttentionItems = (labName: string): typeof labAttention => { + const name = labName.toLowerCase(); + return name.includes('pull-labs-demo') || name.includes('broonie') + ? labAttention + : []; +}; + +export const aggregateLabTrees = ( + trees: LabTreeMock[], +): { + builds: RequiredStatusCount; + boots: RequiredStatusCount; + tests: RequiredStatusCount; + buildIssues: TIssue[]; + bootIssues: TIssue[]; + testIssues: TIssue[]; + buildRows: LabBuildRow[]; + bootRows: LabBootRow[]; + testRows: LabTestRow[]; +} => ({ + builds: sumStatusCounts(trees.map(tree => tree.builds)), + boots: sumStatusCounts(trees.map(tree => tree.boots)), + tests: sumStatusCounts(trees.map(tree => tree.tests)), + buildIssues: trees.flatMap(tree => tree.buildIssues), + bootIssues: trees.flatMap(tree => tree.bootIssues), + testIssues: trees.flatMap(tree => tree.testIssues), + buildRows: trees.flatMap(tree => tree.buildRows), + bootRows: trees.flatMap(tree => tree.bootRows), + testRows: trees.flatMap(tree => tree.testRows), +}); diff --git a/dashboard/src/routeTree.gen.ts b/dashboard/src/routeTree.gen.ts index b1a6d152b..387ba51f9 100644 --- a/dashboard/src/routeTree.gen.ts +++ b/dashboard/src/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as MainHardwareHardwareIdRouteRouteImport } from './routes/_main/ import { Route as MainIssueIssueIdRouteRouteImport } from './routes/_main/issue/$issueId/route' import { Route as MainIssuesIndexRouteImport } from './routes/_main/issues/index' import { Route as MainLabsIndexRouteImport } from './routes/_main/labs/index' +import { Route as MainLabsLabNameRouteImport } from './routes/_main/labs/$labName' import { Route as MainMetricsIndexRouteImport } from './routes/_main/metrics/index' import { Route as MainPrivacyIndexRouteImport } from './routes/_main/privacy/index' import { Route as MainTestTestIdRouteRouteImport } from './routes/_main/test/$testId/route' @@ -140,6 +141,11 @@ const MainLabsIndexRoute = MainLabsIndexRouteImport.update({ path: '/', getParentRoute: () => MainLabsRouteRoute, } as any) +const MainLabsLabNameRoute = MainLabsLabNameRouteImport.update({ + id: '/$labName', + path: '/$labName', + getParentRoute: () => MainLabsRouteRoute, +} as any) const MainMetricsIndexRoute = MainMetricsIndexRouteImport.update({ id: '/', path: '/', @@ -350,6 +356,7 @@ export interface FileRoutesByFullPath { '/issue/$issueId': typeof MainIssueIssueIdRouteRouteWithChildren '/test/$testId': typeof MainTestTestIdRouteRouteWithChildren '/tree/$treeId': typeof MainTreeTreeIdRouteRouteWithChildren + '/labs/$labName': typeof MainLabsLabNameRoute '/hardware/': typeof MainHardwareIndexRoute '/issues/': typeof MainIssuesIndexRoute '/labs/': typeof MainLabsIndexRoute @@ -390,6 +397,7 @@ export interface FileRoutesByTo { '/$': typeof SplatRoute '/log-viewer': typeof LogViewerRoute '/': typeof MainIndexRoute + '/labs/$labName': typeof MainLabsLabNameRoute '/hardware': typeof MainHardwareIndexRoute '/issues': typeof MainIssuesIndexRoute '/labs': typeof MainLabsIndexRoute @@ -438,6 +446,7 @@ export interface FileRoutesById { '/_main/issue/$issueId': typeof MainIssueIssueIdRouteRouteWithChildren '/_main/test/$testId': typeof MainTestTestIdRouteRouteWithChildren '/_main/tree/$treeId': typeof MainTreeTreeIdRouteRouteWithChildren + '/_main/labs/$labName': typeof MainLabsLabNameRoute '/_main/hardware/': typeof MainHardwareIndexRoute '/_main/issues/': typeof MainIssuesIndexRoute '/_main/labs/': typeof MainLabsIndexRoute @@ -491,6 +500,7 @@ export interface FileRouteTypes { | '/issue/$issueId' | '/test/$testId' | '/tree/$treeId' + | '/labs/$labName' | '/hardware/' | '/issues/' | '/labs/' @@ -531,6 +541,7 @@ export interface FileRouteTypes { | '/$' | '/log-viewer' | '/' + | '/labs/$labName' | '/hardware' | '/issues' | '/labs' @@ -578,6 +589,7 @@ export interface FileRouteTypes { | '/_main/issue/$issueId' | '/_main/test/$testId' | '/_main/tree/$treeId' + | '/_main/labs/$labName' | '/_main/hardware/' | '/_main/issues/' | '/_main/labs/' @@ -735,6 +747,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainLabsIndexRouteImport parentRoute: typeof MainLabsRouteRoute } + '/_main/labs/$labName': { + id: '/_main/labs/$labName' + path: '/$labName' + fullPath: '/labs/$labName' + preLoaderRoute: typeof MainLabsLabNameRouteImport + parentRoute: typeof MainLabsRouteRoute + } '/_main/metrics/': { id: '/_main/metrics/' path: '/' @@ -1033,10 +1052,12 @@ const MainIssuesRouteRouteWithChildren = MainIssuesRouteRoute._addFileChildren( ) interface MainLabsRouteRouteChildren { + MainLabsLabNameRoute: typeof MainLabsLabNameRoute MainLabsIndexRoute: typeof MainLabsIndexRoute } const MainLabsRouteRouteChildren: MainLabsRouteRouteChildren = { + MainLabsLabNameRoute: MainLabsLabNameRoute, MainLabsIndexRoute: MainLabsIndexRoute, } diff --git a/dashboard/src/routes/_main/labs/$labName.tsx b/dashboard/src/routes/_main/labs/$labName.tsx new file mode 100644 index 000000000..9f05a152d --- /dev/null +++ b/dashboard/src/routes/_main/labs/$labName.tsx @@ -0,0 +1,27 @@ +import { createFileRoute, stripSearchParams } from '@tanstack/react-router'; + +import { z } from 'zod'; + +import LabDetails from '@/pages/Labs/LabDetails'; + +import { + defaultValidadorValues, + zPossibleTabValidator, +} from '@/types/tree/TreeDetails'; +import type { SearchSchema } from '@/types/general'; + +const defaultValues = { + currentPageTab: defaultValidadorValues.tab, + labDetailsPreview: undefined, +}; + +const labDetailsSearchSchema = z.object({ + currentPageTab: zPossibleTabValidator, + labDetailsPreview: z.literal('empty').optional().catch(undefined), +} satisfies SearchSchema); + +export const Route = createFileRoute('/_main/labs/$labName')({ + validateSearch: labDetailsSearchSchema, + search: { middlewares: [stripSearchParams(defaultValues)] }, + component: LabDetails, +}); diff --git a/dashboard/src/types/general.ts b/dashboard/src/types/general.ts index 43d36e805..83ef6b53e 100644 --- a/dashboard/src/types/general.ts +++ b/dashboard/src/types/general.ts @@ -293,6 +293,7 @@ export type SearchParamsKeys = | 'listingSize' | 'hardwareSearch' | 'labsSearch' + | 'labDetailsPreview' | 'issueSearch' | 'treeInfo' | 'treeIndexes' diff --git a/dashboard/src/utils/search.ts b/dashboard/src/utils/search.ts index b7960b300..c23f72274 100644 --- a/dashboard/src/utils/search.ts +++ b/dashboard/src/utils/search.ts @@ -144,6 +144,7 @@ const generalMinifiedParams: Record = { listingSize: 'ls', hardwareSearch: 'hs', labsSearch: 'lsr', + labDetailsPreview: 'ldp', issueSearch: 'is', treeInfo: 'tri', treeIndexes: 'x',