diff --git a/backend/kernelCI_app/queries/notifications.py b/backend/kernelCI_app/queries/notifications.py index b3e815e97..af0773e7b 100644 --- a/backend/kernelCI_app/queries/notifications.py +++ b/backend/kernelCI_app/queries/notifications.py @@ -790,6 +790,42 @@ def get_metrics_data( WHERE r.ranked <= 3 AND n.total_incidents > 0 """ + new_build_issues_query = """ + WITH time_rank AS ( + SELECT + _timestamp, + origin, + issue_id, + ROW_NUMBER() OVER (PARTITION BY issue_id ORDER BY _timestamp) AS rn + FROM incidents + WHERE build_id IS NOT NULL + ), + new_issues AS ( + SELECT issue_id, origin + FROM time_rank + WHERE rn = 1 + AND _timestamp BETWEEN + NOW() - INTERVAL %(start_days_ago)s + AND NOW() - INTERVAL %(end_days_ago)s + ) + SELECT + inc.origin, + inc.issue_id, + inc.issue_version, + i.comment, + COUNT(inc.*) AS total + FROM incidents inc + JOIN issues i ON inc.issue_id = i.id AND inc.issue_version = i.version + JOIN new_issues ni ON inc.issue_id = ni.issue_id AND inc.origin = ni.origin + WHERE + inc.build_id IS NOT NULL + AND inc._timestamp BETWEEN + NOW() - INTERVAL %(start_days_ago)s + AND NOW() - INTERVAL %(end_days_ago)s + GROUP BY inc.origin, inc.issue_id, inc.issue_version, i.comment + ORDER BY inc.origin, total DESC + """ + lab_summary_query = """ -- get count of tests of each lab and how many builds are related to those tests SELECT @@ -806,7 +842,7 @@ def get_metrics_data( GROUP BY lab """ - with ThreadPoolExecutor(max_workers=5) as executor: + with ThreadPoolExecutor(max_workers=6) as executor: total_objects_result = executor.submit( query_fetchone_work, query=total_objects_query, params=params ) @@ -816,6 +852,9 @@ def get_metrics_data( build_incidents_result = executor.submit( query_fetchall_work, query=build_incidents_query, params=params ) + new_build_issues_result = executor.submit( + query_fetchall_work, query=new_build_issues_query, params=params + ) lab_summary_results = executor.submit( query_fetchall_work, query=lab_summary_query, params=params ) @@ -826,12 +865,14 @@ def get_metrics_data( total_objects_result = total_objects_result.result() prev_total_objects_result = prev_total_objects_result.result() build_incidents_result = build_incidents_result.result() + new_build_issues_result = new_build_issues_result.result() lab_summary_results = lab_summary_results.result() prev_lab_summary_results = prev_lab_summary_results.result() try: build_incidents_by_origin: dict[str, BuildIncidentsCount] = {} top_issues_by_origin: dict[str, dict[tuple[str, int], TopIssue]] = {} + new_issues_by_origin: dict[str, dict[tuple[str, int], TopIssue]] = {} for row in build_incidents_result: origin = row[0] issue_id = row[4] @@ -851,6 +892,19 @@ def get_metrics_data( total_incidents=row[7], ) + for row in new_build_issues_result: + origin = row[0] + issue_id = row[1] + issue_version = row[2] + if new_issues_by_origin.get(origin) is None: + new_issues_by_origin[origin] = {} + new_issues_by_origin[origin][(issue_id, issue_version)] = TopIssue( + id=issue_id, + version=issue_version, + comment=row[3], + total_incidents=row[4], + ) + data = MetricsReportData( n_trees=total_objects_result[0], n_checkouts=total_objects_result[1], @@ -860,6 +914,7 @@ def get_metrics_data( n_incidents=total_objects_result[5], build_incidents_by_origin=build_incidents_by_origin, top_issues_by_origin=top_issues_by_origin, + new_issues_by_origin=new_issues_by_origin, lab_maps={ row[0]: LabMetricsData( builds=row[1], diff --git a/backend/kernelCI_app/tests/integrationTests/metrics_test.py b/backend/kernelCI_app/tests/integrationTests/metrics_test.py index 61111bb97..e0b6a483b 100644 --- a/backend/kernelCI_app/tests/integrationTests/metrics_test.py +++ b/backend/kernelCI_app/tests/integrationTests/metrics_test.py @@ -21,6 +21,7 @@ "n_incidents", "build_incidents_by_origin", "top_issues_by_origin", + "new_issues_by_origin", "lab_maps", "prev_n_trees", "prev_n_checkouts", diff --git a/backend/kernelCI_app/tests/unitTests/commands/metrics_notifications_test.py b/backend/kernelCI_app/tests/unitTests/commands/metrics_notifications_test.py index 443e5498d..5611ff991 100644 --- a/backend/kernelCI_app/tests/unitTests/commands/metrics_notifications_test.py +++ b/backend/kernelCI_app/tests/unitTests/commands/metrics_notifications_test.py @@ -62,6 +62,16 @@ def make_metrics_data(**overrides) -> MetricsReportData: ), }, }, + new_issues_by_origin={ + "maestro": { + ("issue-new", 1): TopIssue( + id="issue-new", + version=1, + comment="New regression issue", + total_incidents=10, + ), + }, + }, lab_maps={ "lava-collabora": LabMetricsData( builds=0, boots=50000, tests=450000, origin="maestro" diff --git a/backend/kernelCI_app/typeModels/metrics.py b/backend/kernelCI_app/typeModels/metrics.py index bf74d7e23..74388d82a 100644 --- a/backend/kernelCI_app/typeModels/metrics.py +++ b/backend/kernelCI_app/typeModels/metrics.py @@ -34,6 +34,7 @@ class MetricsResponse(BaseModel): n_incidents: int build_incidents_by_origin: dict[str, BuildIncidentsCount] top_issues_by_origin: dict[str, list[TopIssue]] + new_issues_by_origin: dict[str, list[TopIssue]] lab_maps: dict[str, LabMetricsData] prev_n_trees: int prev_n_checkouts: int @@ -55,6 +56,10 @@ def metrics_report_data_to_response(data: MetricsReportData) -> MetricsResponse: origin: list(issues.values()) for origin, issues in data.top_issues_by_origin.items() }, + new_issues_by_origin={ + origin: list(issues.values()) + for origin, issues in data.new_issues_by_origin.items() + }, lab_maps=data.lab_maps, prev_n_trees=data.prev_n_trees, prev_n_checkouts=data.prev_n_checkouts, diff --git a/backend/kernelCI_app/typeModels/metrics_notifications.py b/backend/kernelCI_app/typeModels/metrics_notifications.py index b7cd9884f..a4f953267 100644 --- a/backend/kernelCI_app/typeModels/metrics_notifications.py +++ b/backend/kernelCI_app/typeModels/metrics_notifications.py @@ -34,6 +34,8 @@ class MetricsReportData(BaseModel): build_incidents_by_origin: dict[str, BuildIncidentsCount] # top_issues = origin -> (issue_id, version) -> TopIssue top_issues_by_origin: dict[str, dict[tuple[str, int], TopIssue]] + # new_issues = origin -> (issue_id, version) -> TopIssue (first build incident in period) + new_issues_by_origin: dict[str, dict[tuple[str, int], TopIssue]] lab_maps: dict[str, LabMetricsData] # Previous interval (for comparison) prev_n_trees: int diff --git a/dashboard/src/api/metrics.ts b/dashboard/src/api/metrics.ts new file mode 100644 index 000000000..7c7aef298 --- /dev/null +++ b/dashboard/src/api/metrics.ts @@ -0,0 +1,39 @@ +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; + +import type { MetricsResponse } from '@/types/metrics'; + +import { RequestData } from './commonRequest'; + +type FetchMetricsParams = { + startDaysAgo: number; + endDaysAgo: number; +}; + +export const fetchMetrics = async ({ + startDaysAgo, + endDaysAgo, +}: FetchMetricsParams): Promise => { + const data = await RequestData.get('/api/metrics/', { + params: { + start_days_ago: startDaysAgo, + end_days_ago: endDaysAgo, + }, + }); + + return data; +}; + +export const useMetrics = ({ + intervalInDays, +}: { + intervalInDays: number; +}): UseQueryResult => { + return useQuery({ + queryKey: ['metrics', intervalInDays], + queryFn: () => + fetchMetrics({ + startDaysAgo: intervalInDays, + endDaysAgo: 0, + }), + }); +}; diff --git a/dashboard/src/components/SideMenu/menuItems.tsx b/dashboard/src/components/SideMenu/menuItems.tsx index 0a1677e8a..112b743b1 100644 --- a/dashboard/src/components/SideMenu/menuItems.tsx +++ b/dashboard/src/components/SideMenu/menuItems.tsx @@ -1,6 +1,6 @@ import type { JSX } from 'react'; -import { MdOutlineMonitorHeart } from 'react-icons/md'; +import { MdOutlineBarChart, MdOutlineMonitorHeart } from 'react-icons/md'; import { RxRadiobutton } from 'react-icons/rx'; import { ImTree } from 'react-icons/im'; import { HiOutlineDocumentSearch } from 'react-icons/hi'; @@ -30,6 +30,7 @@ export type LinkStringItems = { const TreeIcon = ; const MonitorHeartIcon = ; const RadioButtonIcon = ; +const MetricsIcon = ; const DocumentSearchIcon = ; export const routeItems: RouteMenuItems[] = [ @@ -51,6 +52,12 @@ export const routeItems: RouteMenuItems[] = [ icon: RadioButtonIcon, selected: false, }, + { + navigateTo: '/metrics', + idIntl: 'routes.metricsMonitor', + icon: MetricsIcon, + selected: false, + }, ]; export const linkItems: LinkMenuItems[] = [ diff --git a/dashboard/src/components/TopBar/TopBar.tsx b/dashboard/src/components/TopBar/TopBar.tsx index 602cc1387..d3ad3f8c0 100644 --- a/dashboard/src/components/TopBar/TopBar.tsx +++ b/dashboard/src/components/TopBar/TopBar.tsx @@ -108,6 +108,8 @@ const TitleName = ({ basePath }: { basePath: string }): JSX.Element => { return ; case 'issue': return ; + case 'metrics': + return ; default: return ; } diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index d08269b67..d29278f05 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -278,6 +278,7 @@ export const messages = { 'routes.hardwareNewMonitor': 'Hardware New', 'routes.issueDetails': 'Issue', 'routes.issueMonitor': 'Issues', + 'routes.metricsMonitor': 'Metrics', 'routes.testDetails': 'Test', 'routes.treeMonitor': 'Trees', 'routes.unknown': 'Unknown', diff --git a/dashboard/src/pages/Metrics/MetricsPage.tsx b/dashboard/src/pages/Metrics/MetricsPage.tsx new file mode 100644 index 000000000..054bfea00 --- /dev/null +++ b/dashboard/src/pages/Metrics/MetricsPage.tsx @@ -0,0 +1,580 @@ +import { Fragment, useState, type JSX } from 'react'; + +import { Link, useNavigate, useSearch } from '@tanstack/react-router'; + +import { ArrowRightIcon } from 'lucide-react'; + +import { ChevronRightAnimate } from '@/components/AnimatedIcons/Chevron'; + +import { useMetrics } from '@/api/metrics'; +import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; +import type { MetricsResponse } from '@/types/metrics'; + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +import { cn } from '@/lib/utils'; + +type PeriodOption = { + label: string; + days: number; +}; + +const PERIOD_OPTIONS: PeriodOption[] = [ + { label: '7 days', days: 7 }, + { label: '14 days', days: 14 }, + { label: '30 days', days: 30 }, +]; + +type CoverageMetric = { + label: string; + current: number; + previous: number; +}; + +type IssueDetail = { + id: string; + version: number; + comment: string; + count: number; +}; + +type BuildIncident = { + origin: string; + existingIssues: number; + newIssues: number; + totalIncidents: number; + topIssues: IssueDetail[]; + newIssueDetails: IssueDetail[]; +}; + +type LabData = { + name: string; + builds: number; + boots: number; + tests: number; + prevTests: number; + isNew: boolean; + isExtinct: boolean; +}; + +function formatNumber(n: number): string { + return n.toLocaleString(); +} + +const PERCENTAGE_BASE = 100; +const DEFAULT_INTERVAL_DAYS = 7; + +const getCoverageMetrics = (data: MetricsResponse): CoverageMetric[] => { + return [ + { label: 'Trees', current: data.n_trees, previous: data.prev_n_trees }, + { + label: 'Checkouts', + current: data.n_checkouts, + previous: data.prev_n_checkouts, + }, + { label: 'Builds', current: data.n_builds, previous: data.prev_n_builds }, + { label: 'Tests', current: data.n_tests, previous: data.prev_n_tests }, + ]; +}; + +const getBuildIncidents = (data: MetricsResponse): BuildIncident[] => { + return Object.entries(data.build_incidents_by_origin).map( + ([origin, incidents]) => ({ + origin, + existingIssues: incidents.n_existing_issues, + newIssues: incidents.n_new_issues, + totalIncidents: incidents.total_incidents, + topIssues: (data.top_issues_by_origin[origin] ?? []).map(issue => ({ + id: issue.id, + version: issue.version, + comment: issue.comment, + count: issue.total_incidents, + })), + newIssueDetails: (data.new_issues_by_origin[origin] ?? []).map(issue => ({ + id: issue.id, + version: issue.version, + comment: issue.comment, + count: issue.total_incidents, + })), + }), + ); +}; + +function IssueDetailRow({ issue }: { issue: IssueDetail }): JSX.Element { + return ( +
+
+ {issue.comment} + + {formatNumber(issue.count)} incidents + +
+ ({ + origin: s.origin, + issueVersion: issue.version, + })} + className="flex shrink-0 items-center gap-1 text-sm text-blue-600" + > + View + + +
+ ); +} + +const getLabActivity = (data: MetricsResponse): LabData[] => { + const allLabNames = new Set([ + ...Object.keys(data.lab_maps), + ...Object.keys(data.prev_lab_maps), + ]); + + return [...allLabNames] + .map(name => { + const current = data.lab_maps[name]; + const previous = data.prev_lab_maps[name]; + + return { + name, + builds: current?.builds ?? 0, + boots: current?.boots ?? 0, + tests: current?.tests ?? 0, + prevTests: previous?.tests ?? 0, + isNew: current !== undefined && previous === undefined, + isExtinct: current === undefined && previous !== undefined, + }; + }) + .sort((left, right) => { + if (left.isExtinct !== right.isExtinct) { + return left.isExtinct ? 1 : -1; + } + + return left.name.localeCompare(right.name); + }); +}; + +function formatDelta(current: number, previous: number): string { + const diff = current - previous; + if (diff === 0) { + return 'unchanged'; + } + const pct = + previous !== 0 ? Math.round((diff / previous) * PERCENTAGE_BASE) : 0; + const sign = diff > 0 ? '+' : ''; + const pctStr = previous !== 0 ? ` (${sign}${pct}%)` : ''; + return `${sign}${formatNumber(diff)}${pctStr}`; +} + +function deltaColor( + current: number, + previous: number, + moreIsGood = true, +): string { + const diff = current - previous; + if (diff === 0) { + return 'text-gray-500'; + } + if (moreIsGood) { + return diff > 0 ? 'text-green-700' : 'text-red-700'; + } + return diff > 0 ? 'text-red-700' : 'text-green-700'; +} + +function PeriodSelector({ + activeDays, + onChange, +}: { + activeDays: number; + onChange: (days: number) => void; +}): JSX.Element { + return ( +
+ Period: + {PERIOD_OPTIONS.map(opt => ( + + ))} +
+ ); +} + +function CoverageSection({ + metrics, +}: { + metrics: CoverageMetric[]; +}): JSX.Element { + return ( +
+

Coverage

+
+ {metrics.map(metric => ( +
+
+ {metric.label} +
+
+ {formatNumber(metric.current)} +
+
+ {formatDelta(metric.current, metric.previous)} +
+
+ ))} +
+
+ ); +} + +function RegressionsSection({ + regressions, +}: { + regressions: BuildIncident[]; +}): JSX.Element { + const [expandedOrigins, setExpandedOrigins] = useState>( + () => new Set(), + ); + + const toggleOrigin = (origin: string): void => { + setExpandedOrigins(previous => { + const next = new Set(previous); + if (next.has(origin)) { + next.delete(origin); + } else { + next.add(origin); + } + return next; + }); + }; + + const totalIncidents = regressions.reduce( + (sum, r) => sum + r.totalIncidents, + 0, + ); + const totalExisting = regressions.reduce( + (sum, r) => sum + r.existingIssues, + 0, + ); + const totalNew = regressions.reduce((sum, r) => sum + r.newIssues, 0); + + return ( +
+

+ Build Regressions +

+

+ A regression is a reported problem affecting one or more builds. New + issues are those whose first build incident occurred in this period. +

+ {regressions.length === 0 ? ( +

+ No build regressions in this period. +

+ ) : ( +
+ + + + Origin + + Issues (known + new) + + + Affected Builds + + + + + {regressions.map(row => { + const isExpandable = row.newIssues > 0; + const isExpanded = expandedOrigins.has(row.origin); + + return ( + + + + {isExpandable ? ( + + ) : ( + row.origin + )} + + + {row.existingIssues} + {row.newIssues} ={' '} + {row.existingIssues + row.newIssues} + {row.newIssues > 0 && ( + + {row.newIssues} new + + )} + + {formatNumber(row.totalIncidents)} + + {isExpandable && isExpanded && ( + + + {row.newIssueDetails.length > 0 ? ( +
+

+ New regressions +

+ {row.newIssueDetails.map(issue => ( + + ))} +
+ ) : ( +

+ New issue details unavailable. +

+ )} +
+
+ )} +
+ ); + })} + + Total + + {totalExisting} + {totalNew} = {totalExisting + totalNew} + + {formatNumber(totalIncidents)} + +
+
+
+ )} +
+ ); +} + +function TopRegressionsSection({ + regressions, +}: { + regressions: BuildIncident[]; +}): JSX.Element { + const hasIssues = regressions.some(r => r.topIssues.length > 0); + + if (!hasIssues) { + return ( +
+

+ Top Regressions +

+

+ No regression details in this period. +

+
+ ); + } + + return ( +
+

+ Top Regressions +

+
+ {regressions.map(row => ( +
+

+ {row.origin} +

+
+ {row.topIssues.map((issue, idx) => ( +
+ + {idx + 1} + +
+ + {issue.comment} + + + {formatNumber(issue.count)} incidents + +
+ ({ + origin: s.origin, + issueVersion: issue.version, + })} + className="flex shrink-0 items-center gap-1 text-sm text-blue-600" + > + View + + +
+ ))} +
+
+ ))} +
+
+ ); +} + +function LabsSection({ labs }: { labs: LabData[] }): JSX.Element { + const activeLabs = labs.filter(l => !l.isExtinct); + const extinctLabs = labs.filter(l => l.isExtinct); + const totalTests = activeLabs.reduce((sum, l) => sum + l.tests, 0); + const totalPrevTests = activeLabs.reduce((sum, l) => sum + l.prevTests, 0); + const totalBoots = activeLabs.reduce((sum, l) => sum + l.boots, 0); + + return ( +
+
+

+ Test Labs Activity +

+ + {activeLabs.length} lab{activeLabs.length !== 1 ? 's' : ''} reported + results + +
+
+ + + + Lab + Builds + Boots + Tests + + Change (tests) + + + + + {activeLabs.map(lab => ( + + {lab.name} + {formatNumber(lab.builds)} + {formatNumber(lab.boots)} + {formatNumber(lab.tests)} + + {formatDelta(lab.tests, lab.prevTests)} + + + ))} + {extinctLabs.map(lab => ( + + + {lab.name} + (inactive) + + 0 + 0 + 0 + + {formatDelta(0, lab.prevTests)} + + + ))} + + Total + + {formatNumber(activeLabs.reduce((sum, l) => sum + l.builds, 0))} + + {formatNumber(totalBoots)} + {formatNumber(totalTests)} + + {formatDelta(totalTests, totalPrevTests)} + + + +
+
+
+ ); +} + +export const MetricsPage = (): JSX.Element => { + const { intervalInDays } = useSearch({ from: '/_main/metrics' }); + const navigate = useNavigate(); + + const activeDays = intervalInDays ?? DEFAULT_INTERVAL_DAYS; + + const { status, data, error } = useMetrics({ intervalInDays: activeDays }); + + const coverageMetrics = data ? getCoverageMetrics(data) : []; + const regressions = data ? getBuildIncidents(data) : []; + const labs = data ? getLabActivity(data) : []; + + return ( +
+
+ { + navigate({ + to: '.', + search: previousSearch => ({ + ...previousSearch, + intervalInDays: days, + }), + }); + }} + /> +
+ + + + + + + +
+ ); +}; diff --git a/dashboard/src/routeTree.gen.ts b/dashboard/src/routeTree.gen.ts index 80cef6454..98bd4a772 100644 --- a/dashboard/src/routeTree.gen.ts +++ b/dashboard/src/routeTree.gen.ts @@ -14,9 +14,11 @@ import { Route as SplatRouteImport } from './routes/$' import { Route as MainRouteRouteImport } from './routes/_main/route' import { Route as MainIndexRouteImport } from './routes/_main/index' import { Route as MainTreeRouteRouteImport } from './routes/_main/tree/route' +import { Route as MainMetricsRouteRouteImport } from './routes/_main/metrics/route' import { Route as MainIssuesRouteRouteImport } from './routes/_main/issues/route' import { Route as MainHardwareRouteRouteImport } from './routes/_main/hardware/route' import { Route as MainTreeIndexRouteImport } from './routes/_main/tree/index' +import { Route as MainMetricsIndexRouteImport } from './routes/_main/metrics/index' import { Route as MainIssuesIndexRouteImport } from './routes/_main/issues/index' import { Route as MainHardwareIndexRouteImport } from './routes/_main/hardware/index' import { Route as MainTreeV2RouteRouteImport } from './routes/_main/tree/v2/route' @@ -84,6 +86,11 @@ const MainTreeRouteRoute = MainTreeRouteRouteImport.update({ path: '/tree', getParentRoute: () => MainRouteRoute, } as any) +const MainMetricsRouteRoute = MainMetricsRouteRouteImport.update({ + id: '/metrics', + path: '/metrics', + getParentRoute: () => MainRouteRoute, +} as any) const MainIssuesRouteRoute = MainIssuesRouteRouteImport.update({ id: '/issues', path: '/issues', @@ -99,6 +106,11 @@ const MainTreeIndexRoute = MainTreeIndexRouteImport.update({ path: '/', getParentRoute: () => MainTreeRouteRoute, } as any) +const MainMetricsIndexRoute = MainMetricsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => MainMetricsRouteRoute, +} as any) const MainIssuesIndexRoute = MainIssuesIndexRouteImport.update({ id: '/', path: '/', @@ -339,6 +351,7 @@ export interface FileRoutesByFullPath { '/log-viewer': typeof LogViewerRoute '/hardware': typeof MainHardwareRouteRouteWithChildren '/issues': typeof MainIssuesRouteRouteWithChildren + '/metrics': typeof MainMetricsRouteRouteWithChildren '/tree': typeof MainTreeRouteRouteWithChildren '/build/$buildId': typeof MainBuildBuildIdRouteRouteWithChildren '/hardware/$hardwareId': typeof MainHardwareHardwareIdRouteRouteWithChildren @@ -351,6 +364,7 @@ export interface FileRoutesByFullPath { '/tree/v2': typeof MainTreeV2RouteRouteWithChildren '/hardware/': typeof MainHardwareIndexRoute '/issues/': typeof MainIssuesIndexRoute + '/metrics/': typeof MainMetricsIndexRoute '/tree/': typeof MainTreeIndexRoute '/b/$buildId': typeof MainalternativesBBuildIdRouteRouteWithChildren '/i/$issueId': typeof MainalternativesIIssueIdRouteRouteWithChildren @@ -390,6 +404,7 @@ export interface FileRoutesByTo { '/': typeof MainIndexRoute '/hardware': typeof MainHardwareIndexRoute '/issues': typeof MainIssuesIndexRoute + '/metrics': typeof MainMetricsIndexRoute '/tree': typeof MainTreeIndexRoute '/i': typeof MainalternativesIIndexRoute '/build/$buildId': typeof MainBuildBuildIdIndexRoute @@ -426,6 +441,7 @@ export interface FileRoutesById { '/log-viewer': typeof LogViewerRoute '/_main/hardware': typeof MainHardwareRouteRouteWithChildren '/_main/issues': typeof MainIssuesRouteRouteWithChildren + '/_main/metrics': typeof MainMetricsRouteRouteWithChildren '/_main/tree': typeof MainTreeRouteRouteWithChildren '/_main/': typeof MainIndexRoute '/_main/build/$buildId': typeof MainBuildBuildIdRouteRouteWithChildren @@ -439,6 +455,7 @@ export interface FileRoutesById { '/_main/tree/v2': typeof MainTreeV2RouteRouteWithChildren '/_main/hardware/': typeof MainHardwareIndexRoute '/_main/issues/': typeof MainIssuesIndexRoute + '/_main/metrics/': typeof MainMetricsIndexRoute '/_main/tree/': typeof MainTreeIndexRoute '/_main/(alternatives)/b/$buildId': typeof MainalternativesBBuildIdRouteRouteWithChildren '/_main/(alternatives)/i/$issueId': typeof MainalternativesIIssueIdRouteRouteWithChildren @@ -480,6 +497,7 @@ export interface FileRouteTypes { | '/log-viewer' | '/hardware' | '/issues' + | '/metrics' | '/tree' | '/build/$buildId' | '/hardware/$hardwareId' @@ -492,6 +510,7 @@ export interface FileRouteTypes { | '/tree/v2' | '/hardware/' | '/issues/' + | '/metrics/' | '/tree/' | '/b/$buildId' | '/i/$issueId' @@ -531,6 +550,7 @@ export interface FileRouteTypes { | '/' | '/hardware' | '/issues' + | '/metrics' | '/tree' | '/i' | '/build/$buildId' @@ -566,6 +586,7 @@ export interface FileRouteTypes { | '/log-viewer' | '/_main/hardware' | '/_main/issues' + | '/_main/metrics' | '/_main/tree' | '/_main/' | '/_main/build/$buildId' @@ -579,6 +600,7 @@ export interface FileRouteTypes { | '/_main/tree/v2' | '/_main/hardware/' | '/_main/issues/' + | '/_main/metrics/' | '/_main/tree/' | '/_main/(alternatives)/b/$buildId' | '/_main/(alternatives)/i/$issueId' @@ -656,6 +678,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainTreeRouteRouteImport parentRoute: typeof MainRouteRoute } + '/_main/metrics': { + id: '/_main/metrics' + path: '/metrics' + fullPath: '/metrics' + preLoaderRoute: typeof MainMetricsRouteRouteImport + parentRoute: typeof MainRouteRoute + } '/_main/issues': { id: '/_main/issues' path: '/issues' @@ -677,6 +706,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainTreeIndexRouteImport parentRoute: typeof MainTreeRouteRoute } + '/_main/metrics/': { + id: '/_main/metrics/' + path: '/' + fullPath: '/metrics/' + preLoaderRoute: typeof MainMetricsIndexRouteImport + parentRoute: typeof MainMetricsRouteRoute + } '/_main/issues/': { id: '/_main/issues/' path: '/' @@ -1056,6 +1092,17 @@ const MainIssuesRouteRouteWithChildren = MainIssuesRouteRoute._addFileChildren( MainIssuesRouteRouteChildren, ) +interface MainMetricsRouteRouteChildren { + MainMetricsIndexRoute: typeof MainMetricsIndexRoute +} + +const MainMetricsRouteRouteChildren: MainMetricsRouteRouteChildren = { + MainMetricsIndexRoute: MainMetricsIndexRoute, +} + +const MainMetricsRouteRouteWithChildren = + MainMetricsRouteRoute._addFileChildren(MainMetricsRouteRouteChildren) + interface MainTreeTreeIdRouteRouteChildren { MainTreeTreeIdIndexRoute: typeof MainTreeTreeIdIndexRoute MainTreeTreeIdBuildBuildIdIndexRoute: typeof MainTreeTreeIdBuildBuildIdIndexRoute @@ -1214,6 +1261,7 @@ const MainalternativesTTestIdRouteRouteWithChildren = interface MainRouteRouteChildren { MainHardwareRouteRoute: typeof MainHardwareRouteRouteWithChildren MainIssuesRouteRoute: typeof MainIssuesRouteRouteWithChildren + MainMetricsRouteRoute: typeof MainMetricsRouteRouteWithChildren MainTreeRouteRoute: typeof MainTreeRouteRouteWithChildren MainIndexRoute: typeof MainIndexRoute MainBuildBuildIdRouteRoute: typeof MainBuildBuildIdRouteRouteWithChildren @@ -1232,6 +1280,7 @@ interface MainRouteRouteChildren { const MainRouteRouteChildren: MainRouteRouteChildren = { MainHardwareRouteRoute: MainHardwareRouteRouteWithChildren, MainIssuesRouteRoute: MainIssuesRouteRouteWithChildren, + MainMetricsRouteRoute: MainMetricsRouteRouteWithChildren, MainTreeRouteRoute: MainTreeRouteRouteWithChildren, MainIndexRoute: MainIndexRoute, MainBuildBuildIdRouteRoute: MainBuildBuildIdRouteRouteWithChildren, diff --git a/dashboard/src/routes/_main/metrics/index.tsx b/dashboard/src/routes/_main/metrics/index.tsx new file mode 100644 index 000000000..579b36427 --- /dev/null +++ b/dashboard/src/routes/_main/metrics/index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import type { JSX } from 'react'; + +import { MetricsPage } from '@/pages/Metrics/MetricsPage'; + +const MetricsComponent = (): JSX.Element => { + return ; +}; + +export const Route = createFileRoute('/_main/metrics/')({ + component: MetricsComponent, +}); diff --git a/dashboard/src/routes/_main/metrics/route.tsx b/dashboard/src/routes/_main/metrics/route.tsx new file mode 100644 index 000000000..e1a32e955 --- /dev/null +++ b/dashboard/src/routes/_main/metrics/route.tsx @@ -0,0 +1,20 @@ +import { createFileRoute, stripSearchParams } from '@tanstack/react-router'; + +import { z } from 'zod'; + +import { makeZIntervalInDays, type SearchSchema } from '@/types/general'; + +const DEFAULT_METRICS_INTERVAL = 7; + +const defaultValues = { + intervalInDays: DEFAULT_METRICS_INTERVAL, +}; + +const metricsSearchSchema = z.object({ + intervalInDays: makeZIntervalInDays(DEFAULT_METRICS_INTERVAL), +} satisfies SearchSchema); + +export const Route = createFileRoute('/_main/metrics')({ + validateSearch: metricsSearchSchema, + search: { middlewares: [stripSearchParams(defaultValues)] }, +}); diff --git a/dashboard/src/types/general.ts b/dashboard/src/types/general.ts index 142cfb12e..b17a7a80b 100644 --- a/dashboard/src/types/general.ts +++ b/dashboard/src/types/general.ts @@ -402,6 +402,7 @@ export type PossibleMonitorPath = | '/hardware/v1' | '/hardware/v2' | '/tree/v1' - | '/tree/v2'; + | '/tree/v2' + | '/metrics'; export type TreeEntityTypes = 'builds' | 'boots' | 'tests'; diff --git a/dashboard/src/types/metrics.ts b/dashboard/src/types/metrics.ts new file mode 100644 index 000000000..6575e49d1 --- /dev/null +++ b/dashboard/src/types/metrics.ts @@ -0,0 +1,37 @@ +export type BuildIncidentsCount = { + total_incidents: number; + n_new_issues: number; + n_existing_issues: number; + n_total_issues: number; +}; + +export type TopIssue = { + id: string; + version: number; + comment: string; + total_incidents: number; +}; + +export type LabMetricsData = { + builds: number; + boots: number; + tests: number; +}; + +export type MetricsResponse = { + n_trees: number; + n_checkouts: number; + n_builds: number; + n_tests: number; + n_issues: number; + n_incidents: number; + build_incidents_by_origin: Record; + top_issues_by_origin: Record; + new_issues_by_origin: Record; + lab_maps: Record; + prev_n_trees: number; + prev_n_checkouts: number; + prev_n_builds: number; + prev_n_tests: number; + prev_lab_maps: Record; +};