diff --git a/backend/kernelCI_app/constants/localization.py b/backend/kernelCI_app/constants/localization.py index 9af0ea4cf..190ce2dd2 100644 --- a/backend/kernelCI_app/constants/localization.py +++ b/backend/kernelCI_app/constants/localization.py @@ -42,6 +42,7 @@ class ClientStrings: ISSUE_TESTS_NOT_FOUND = "No tests found for this issue" ISSUE_BUILDS_NOT_FOUND = "No builds found for this issue" NO_HARDWARE_FOUND = "No hardware found" + NO_LABS_FOUND = "No labs found" HARDWARE_NOT_FOUND = "Hardware not found" HARDWARE_NO_COMMITS = "This hardware isn't associated with any commit" HARDWARE_TEST_NOT_FOUND = "No tests found for this hardware" @@ -107,6 +108,8 @@ class DocStrings: "and/or tag strings that appear in checkout.git_commit_tags." ) + LAB_LISTING_ORIGIN_DESCRIPTION = "Origin of the lab results" + ISSUE_DETAILS_VERSION_DESCRIPTION = "Issue version" ISSUE_EXTRA_ID_LIST_DESCRIPTION = "List of issue ids" diff --git a/backend/kernelCI_app/queries/labs.py b/backend/kernelCI_app/queries/labs.py new file mode 100644 index 000000000..644abe50d --- /dev/null +++ b/backend/kernelCI_app/queries/labs.py @@ -0,0 +1,91 @@ +from django.db import connection + + +def get_lab_listing_data( + *, + origin: str, + interval_in_days: int, +) -> list[tuple]: + params = { + "origin": origin, + "interval": f"{interval_in_days} days", + } + + query = """ + WITH status_counts AS ( + SELECT + COALESCE(bl.name, b.misc->>'lab') AS lab_name, + 'build' AS item_type, + CASE + WHEN b.status IN ('PASS', 'FAIL') THEN b.status + ELSE 'INCONCLUSIVE' + END AS item_status, + COUNT(*) AS item_count + FROM builds b + LEFT JOIN labs bl ON b.lab_id = bl.id + WHERE + b.origin = %(origin)s + AND b.start_time >= NOW() - INTERVAL %(interval)s + AND b.id NOT LIKE 'maestro:dummy_%%' + AND COALESCE(bl.name, b.misc->>'lab') IS NOT NULL + GROUP BY 1, 2, 3 + + UNION ALL + + SELECT + COALESCE(tl.name, t.misc->>'runtime') AS lab_name, + CASE + WHEN t.path = 'boot' OR t.path LIKE 'boot.%%' THEN 'boot' + ELSE 'test' + END AS item_type, + CASE + WHEN t.status IN ('PASS', 'FAIL') THEN t.status + ELSE 'INCONCLUSIVE' + END AS item_status, + COUNT(*) AS item_count + FROM tests t + LEFT JOIN labs tl ON t.lab_id = tl.id + WHERE + t.origin = %(origin)s + AND t.start_time >= NOW() - INTERVAL %(interval)s + AND t.path IS NOT NULL + AND COALESCE(tl.name, t.misc->>'runtime') IS NOT NULL + GROUP BY 1, 2, 3 + ) + SELECT + lab_name, + COALESCE(SUM(item_count) FILTER ( + WHERE item_type = 'build' AND item_status = 'PASS' + ), 0) AS build_pass, + COALESCE(SUM(item_count) FILTER ( + WHERE item_type = 'build' AND item_status = 'FAIL' + ), 0) AS build_fail, + COALESCE(SUM(item_count) FILTER ( + WHERE item_type = 'build' AND item_status = 'INCONCLUSIVE' + ), 0) AS build_inc, + COALESCE(SUM(item_count) FILTER ( + WHERE item_type = 'boot' AND item_status = 'PASS' + ), 0) AS boot_pass, + COALESCE(SUM(item_count) FILTER ( + WHERE item_type = 'boot' AND item_status = 'FAIL' + ), 0) AS boot_fail, + COALESCE(SUM(item_count) FILTER ( + WHERE item_type = 'boot' AND item_status = 'INCONCLUSIVE' + ), 0) AS boot_inc, + COALESCE(SUM(item_count) FILTER ( + WHERE item_type = 'test' AND item_status = 'PASS' + ), 0) AS test_pass, + COALESCE(SUM(item_count) FILTER ( + WHERE item_type = 'test' AND item_status = 'FAIL' + ), 0) AS test_fail, + COALESCE(SUM(item_count) FILTER ( + WHERE item_type = 'test' AND item_status = 'INCONCLUSIVE' + ), 0) AS test_inc + FROM status_counts + GROUP BY lab_name + ORDER BY lab_name + """ + + with connection.cursor() as cursor: + cursor.execute(query, params) + return cursor.fetchall() diff --git a/backend/kernelCI_app/tests/integrationTests/labs_test.py b/backend/kernelCI_app/tests/integrationTests/labs_test.py new file mode 100644 index 000000000..e8664e791 --- /dev/null +++ b/backend/kernelCI_app/tests/integrationTests/labs_test.py @@ -0,0 +1,75 @@ +from http import HTTPStatus + +import pytest +from django.urls import reverse + +import requests +from kernelCI_app.tests.utils.asserts import ( + assert_has_fields_in_response_content, + assert_status_code_and_error_response, +) +from kernelCI_app.tests.utils.client.baseClient import BaseClient +from kernelCI_app.utils import string_to_json + +LAB_FIELDS = [ + "lab_name", + "build_status_summary", + "boot_status_summary", + "test_status_summary", +] +STATUS_FIELDS = ["PASS", "FAIL", "INCONCLUSIVE"] + + +@pytest.mark.parametrize( + "query, status_code, has_error_body", + [ + ( + {"origin": "maestro", "interval_in_days": "7"}, + HTTPStatus.OK, + False, + ), + ( + {"origin": "invalid", "interval_in_days": "7"}, + HTTPStatus.OK, + True, + ), + ( + {"origin": "maestro", "interval_in_days": "-1"}, + HTTPStatus.BAD_REQUEST, + True, + ), + ], +) +def test_lab_listing( + query: dict, + status_code: HTTPStatus, + has_error_body: bool, +) -> None: + url = BaseClient().get_endpoint(path=reverse("labs"), query=query) + response = requests.get(url) + content = string_to_json(response.content.decode()) + + assert_status_code_and_error_response( + response=response, + content=content, + status_code=status_code, + should_error=has_error_body, + ) + + if has_error_body: + return + + lab = content["labs"][0] + assert_has_fields_in_response_content( + fields=LAB_FIELDS, + response_content=lab, + ) + for summary in ( + lab["build_status_summary"], + lab["boot_status_summary"], + lab["test_status_summary"], + ): + assert_has_fields_in_response_content( + fields=STATUS_FIELDS, + response_content=summary, + ) diff --git a/backend/kernelCI_app/tests/unitTests/views/labView_test.py b/backend/kernelCI_app/tests/unitTests/views/labView_test.py new file mode 100644 index 000000000..387b67363 --- /dev/null +++ b/backend/kernelCI_app/tests/unitTests/views/labView_test.py @@ -0,0 +1,106 @@ +from http import HTTPStatus +from unittest.mock import patch + +from django.test.testcases import SimpleTestCase +from rest_framework.test import APIRequestFactory + +from kernelCI_app.constants.localization import ClientStrings +from kernelCI_app.views.labView import LabView + + +class TestLabView(SimpleTestCase): + def setUp(self): + self.factory = APIRequestFactory() + self.view = LabView() + self.url = "/labs" + + @patch("kernelCI_app.views.labView.get_lab_listing_data") + def test_get_lab_listing_success(self, mock_get_lab_listing_data): + mock_get_lab_listing_data.return_value = [ + ("lab-collabora", *range(9)), + ] + + query_params = { + "interval_in_days": "7", + "origin": "maestro", + } + + request = self.factory.get(self.url, query_params) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + mock_get_lab_listing_data.assert_called_once_with( + origin="maestro", + interval_in_days=7, + ) + self.assertEqual( + response.data, + { + "labs": [ + { + "lab_name": "lab-collabora", + "build_status_summary": { + "PASS": 0, + "FAIL": 1, + "INCONCLUSIVE": 2, + }, + "boot_status_summary": { + "PASS": 3, + "FAIL": 4, + "INCONCLUSIVE": 5, + }, + "test_status_summary": { + "PASS": 6, + "FAIL": 7, + "INCONCLUSIVE": 8, + }, + } + ] + }, + ) + + def test_get_lab_listing_invalid_query_params_returns_bad_request(self): + request = self.factory.get( + self.url, + {"origin": "maestro", "interval_in_days": "-1"}, + ) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) + self.assertIn("interval_in_days", response.data) + + @patch("kernelCI_app.views.labView.get_lab_listing_data") + def test_get_lab_listing_no_labs_found_returns_ok_with_error( + self, mock_get_lab_listing_data + ): + mock_get_lab_listing_data.return_value = [] + + query_params = { + "interval_in_days": "7", + "origin": "maestro", + } + + request = self.factory.get(self.url, query_params) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.OK) + self.assertEqual(response.data, {"error": ClientStrings.NO_LABS_FOUND}) + + @patch("kernelCI_app.views.labView.get_lab_listing_data") + def test_get_lab_listing_sanitize_validation_error_returns_internal_server_error( + self, mock_get_lab_listing_data + ): + mock_get_lab_listing_data.return_value = [ + (None, *range(9)), + ] + + query_params = { + "interval_in_days": "7", + "origin": "maestro", + } + + request = self.factory.get(self.url, query_params) + response = self.view.get(request) + + self.assertEqual(response.status_code, HTTPStatus.INTERNAL_SERVER_ERROR) + self.assertIn("lab_name", response.data["error"]) diff --git a/backend/kernelCI_app/typeModels/labListing.py b/backend/kernelCI_app/typeModels/labListing.py new file mode 100644 index 000000000..143d8167e --- /dev/null +++ b/backend/kernelCI_app/typeModels/labListing.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + +from kernelCI_app.typeModels.commonListing import ListingStatusCount + + +class LabListingItem(BaseModel): + lab_name: str + build_status_summary: ListingStatusCount + boot_status_summary: ListingStatusCount + test_status_summary: ListingStatusCount + + +class LabListingResponse(BaseModel): + labs: list[LabListingItem] diff --git a/backend/kernelCI_app/urls.py b/backend/kernelCI_app/urls.py index a89890862..0237bb72e 100644 --- a/backend/kernelCI_app/urls.py +++ b/backend/kernelCI_app/urls.py @@ -146,6 +146,7 @@ def view_cache(view, timeout: int = settings.CACHE_TIMEOUT): name="hardwareDetailsTests", ), path("hardware/", view_cache(views.HardwareView), name="hardware"), + path("labs/", view_cache(views.LabView), name="labs"), path( "hardware-by-revision/", view_cache(views.HardwareByRevisionView), diff --git a/backend/kernelCI_app/views/labView.py b/backend/kernelCI_app/views/labView.py new file mode 100644 index 000000000..c0db892d9 --- /dev/null +++ b/backend/kernelCI_app/views/labView.py @@ -0,0 +1,82 @@ +from http import HTTPStatus + +from drf_spectacular.utils import extend_schema +from pydantic import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from kernelCI_app.constants.localization import ClientStrings +from kernelCI_app.helpers.errorHandling import create_api_error_response +from kernelCI_app.queries.labs import get_lab_listing_data +from kernelCI_app.typeModels.commonListing import ( + ListingQueryParameters, + ListingStatusCount, +) +from kernelCI_app.typeModels.labListing import ( + LabListingItem, + LabListingResponse, +) + + +class LabView(APIView): + def _sanitize_records(self, labs_raw: list[tuple]) -> list[LabListingItem]: + labs = [] + for lab in labs_raw: + labs.append( + LabListingItem( + lab_name=lab[0], + build_status_summary=ListingStatusCount( + PASS=lab[1], + FAIL=lab[2], + INCONCLUSIVE=lab[3], + ), + boot_status_summary=ListingStatusCount( + PASS=lab[4], + FAIL=lab[5], + INCONCLUSIVE=lab[6], + ), + test_status_summary=ListingStatusCount( + PASS=lab[7], + FAIL=lab[8], + INCONCLUSIVE=lab[9], + ), + ) + ) + + return labs + + @extend_schema( + parameters=[ListingQueryParameters], + responses=LabListingResponse, + ) + def get(self, request: Request): + try: + query_params = ListingQueryParameters( + origin=request.GET.get("origin"), + interval_in_days=request.GET.get("interval_in_days"), + ) + except ValidationError as e: + return Response(data=e.json(), status=HTTPStatus.BAD_REQUEST) + + labs_raw = get_lab_listing_data( + origin=query_params.origin, + interval_in_days=query_params.interval_in_days, + ) + + try: + sanitized_records = self._sanitize_records(labs_raw=labs_raw) + result = LabListingResponse(labs=sanitized_records) + + if len(result.labs) < 1: + return create_api_error_response( + error_message=ClientStrings.NO_LABS_FOUND, + status_code=HTTPStatus.OK, + ) + except ValidationError as e: + return create_api_error_response( + error_message=e.json(), + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + return Response(data=result.model_dump(), status=HTTPStatus.OK) diff --git a/dashboard/src/api/labs.ts b/dashboard/src/api/labs.ts new file mode 100644 index 000000000..3f9aec5a6 --- /dev/null +++ b/dashboard/src/api/labs.ts @@ -0,0 +1,34 @@ +import type { UseQueryResult } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; + +import { useSearch } from '@tanstack/react-router'; + +import type { LabListingResponse } from '@/types/lab'; + +import type { LabsListingRoutesMap } from '@/utils/constants/labsListing'; + +import { RequestData } from './commonRequest'; + +const fetchLabsListing = async ( + origin: string, + intervalInDays: number, +): Promise => { + return RequestData.get('/api/labs/', { + params: { + origin, + interval_in_days: intervalInDays, + }, + }); +}; + +export const useLabsListing = ( + searchFrom: LabsListingRoutesMap['search'], +): UseQueryResult => { + const { origin, intervalInDays } = useSearch({ from: searchFrom }); + + return useQuery({ + queryKey: ['labsListing', origin, intervalInDays], + queryFn: () => fetchLabsListing(origin, intervalInDays), + refetchOnWindowFocus: false, + }); +}; diff --git a/dashboard/src/components/OpenGraphTags/ListingOGTags.tsx b/dashboard/src/components/OpenGraphTags/ListingOGTags.tsx index f5d125a07..6b5e87fd3 100644 --- a/dashboard/src/components/OpenGraphTags/ListingOGTags.tsx +++ b/dashboard/src/components/OpenGraphTags/ListingOGTags.tsx @@ -30,6 +30,9 @@ const ListingOGTags = ({ case '/issues': descriptionId = 'issueListing.description'; break; + case '/labs': + descriptionId = 'labsListing.description'; + break; } return ( formatMessage({ id: descriptionId }) + @@ -47,6 +50,8 @@ const ListingOGTags = ({ return formatMessage({ id: 'hardwareListing.title' }); case '/issues': return formatMessage({ id: 'issueListing.title' }); + case '/labs': + return formatMessage({ id: 'labsListing.title' }); } }, [formatMessage, monitor]); diff --git a/dashboard/src/components/SearchBoxNavigate/SearchBoxNavigate.tsx b/dashboard/src/components/SearchBoxNavigate/SearchBoxNavigate.tsx index c8cfcece0..6fb8abda7 100644 --- a/dashboard/src/components/SearchBoxNavigate/SearchBoxNavigate.tsx +++ b/dashboard/src/components/SearchBoxNavigate/SearchBoxNavigate.tsx @@ -8,12 +8,14 @@ import DebounceInput from '@/components/DebounceInput/DebounceInput'; import { CustomDialog } from '@/components/Dialog/CustomDialog'; import { treeListingCleanFullPaths } from '@/utils/constants/treeListing'; import { hwListingCleanFullPaths } from '@/utils/constants/hardwareListing'; +import { labsListingCleanFullPaths } from '@/utils/constants/labsListing'; // Relates the type of listing to the corresponding search key const forwardFields: Record = { tree: 'treeSearch', hardware: 'hardwareSearch', issue: 'issueSearch', + labs: 'labsSearch', }; interface ISearchData { @@ -40,11 +42,15 @@ export const SearchBoxNavigate = (): JSX.Element => { return 'issue'; } + if (labsListingCleanFullPaths.includes(cleanFullPath)) { + return 'labs'; + } + return 'unknown'; }, [matches]); const { formatMessage } = useIntl(); - const { treeSearch, hardwareSearch, issueSearch } = useSearch({ + const { treeSearch, hardwareSearch, issueSearch, labsSearch } = useSearch({ strict: false, }); const searchData = useMemo((): ISearchData => { @@ -71,6 +77,14 @@ export const SearchBoxNavigate = (): JSX.Element => { }), navigateTarget: 'issueSearch', }; + case 'labs': + return { + currentSearch: labsSearch, + searchPlaceholder: formatMessage({ + id: 'labs.searchPlaceholder', + }), + navigateTarget: 'labsSearch', + }; default: return { currentSearch: '', @@ -78,7 +92,14 @@ export const SearchBoxNavigate = (): JSX.Element => { navigateTarget: '', }; } - }, [routeInfo, treeSearch, formatMessage, hardwareSearch, issueSearch]); + }, [ + routeInfo, + treeSearch, + formatMessage, + hardwareSearch, + issueSearch, + labsSearch, + ]); const navigate = useNavigate(); diff --git a/dashboard/src/components/SideMenu/menuItems.tsx b/dashboard/src/components/SideMenu/menuItems.tsx index 112b743b1..157945234 100644 --- a/dashboard/src/components/SideMenu/menuItems.tsx +++ b/dashboard/src/components/SideMenu/menuItems.tsx @@ -1,6 +1,10 @@ import type { JSX } from 'react'; -import { MdOutlineBarChart, MdOutlineMonitorHeart } from 'react-icons/md'; +import { + MdOutlineBarChart, + MdOutlineMonitorHeart, + MdOutlineScience, +} from 'react-icons/md'; import { RxRadiobutton } from 'react-icons/rx'; import { ImTree } from 'react-icons/im'; import { HiOutlineDocumentSearch } from 'react-icons/hi'; @@ -29,6 +33,7 @@ export type LinkStringItems = { const TreeIcon = ; const MonitorHeartIcon = ; +const LabsIcon = ; const RadioButtonIcon = ; const MetricsIcon = ; const DocumentSearchIcon = ; @@ -46,6 +51,12 @@ export const routeItems: RouteMenuItems[] = [ icon: MonitorHeartIcon, selected: false, }, + { + navigateTo: '/labs', + idIntl: 'routes.labsMonitor', + icon: LabsIcon, + selected: false, + }, { navigateTo: '/issues', idIntl: 'routes.issueMonitor', diff --git a/dashboard/src/components/TopBar/TopBar.tsx b/dashboard/src/components/TopBar/TopBar.tsx index b72b000f5..3c87235b3 100644 --- a/dashboard/src/components/TopBar/TopBar.tsx +++ b/dashboard/src/components/TopBar/TopBar.tsx @@ -15,6 +15,7 @@ import MobileSideMenu from '@/components/SideMenu/MobileSideMenu'; import { SearchBoxNavigate } from '@/components/SearchBoxNavigate'; import { treeListingCleanFullPaths } from '@/utils/constants/treeListing'; import { hwListingCleanFullPaths } from '@/utils/constants/hardwareListing'; +import { labsListingCleanFullPaths } from '@/utils/constants/labsListing'; const OriginSelect = ({ isHardwarePath, @@ -109,6 +110,8 @@ const TitleName = ({ basePath }: { basePath: string }): JSX.Element => { return ; case 'metrics': return ; + case 'labs': + return ; default: return ; } @@ -124,13 +127,18 @@ const TopBar = (): JSX.Element => { const cleanFullPath = lastMatch?.fullPath.replace(/\//g, '') ?? ''; const isTreeListing = treeListingCleanFullPaths.includes(cleanFullPath); const isHardwareListing = hwListingCleanFullPaths.includes(cleanFullPath); + const isLabsListing = labsListingCleanFullPaths.includes(cleanFullPath); const isListingPage = - isTreeListing || isHardwareListing || cleanFullPath.includes('issues'); + isTreeListing || + isHardwareListing || + isLabsListing || + cleanFullPath.includes('issues'); return { firstUrlLocation, isTreeListing: isTreeListing, isHardwarePage: cleanFullPath.includes('hardware'), + isLabsPage: isLabsListing, isListingPage: isListingPage, }; }, [matches]); @@ -152,8 +160,14 @@ const TopBar = (): JSX.Element => { - {(routeInfo.isTreeListing || routeInfo.isHardwarePage) && ( - + {(routeInfo.isTreeListing || + routeInfo.isHardwarePage || + routeInfo.isLabsPage) && ( + )} {routeInfo.isListingPage && } diff --git a/dashboard/src/locales/messages/index.ts b/dashboard/src/locales/messages/index.ts index e1e111107..05fce6c1d 100644 --- a/dashboard/src/locales/messages/index.ts +++ b/dashboard/src/locales/messages/index.ts @@ -156,6 +156,7 @@ export const messages = { 'global.issues': 'Issues', 'global.kcidev': 'kci-dev', 'global.lab': 'Lab', + 'global.labs': 'Labs', 'global.last': 'Last', 'global.legend': 'Legend', 'global.loading': 'Loading...', @@ -280,6 +281,11 @@ 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', + 'labs.searchPlaceholder': 'Search by lab name with a regex', + 'labsListing.description': + 'List of labs with aggregated build, boot and test status', + 'labsListing.notFound': 'No lab information available', + 'labsListing.title': 'Lab Listing ― KCI Dashboard', 'logSheet.downloadLog': 'You can download the full log here: {link}', 'logSheet.fileName': 'File Name', 'logSheet.fileSize': 'File Size', @@ -309,6 +315,7 @@ export const messages = { 'routes.hardwareNewMonitor': 'Hardware New', 'routes.issueDetails': 'Issue', 'routes.issueMonitor': 'Issues', + 'routes.labsMonitor': 'Labs', 'routes.metricsMonitor': 'Metrics', 'routes.testDetails': 'Test', 'routes.treeMonitor': 'Trees', diff --git a/dashboard/src/pages/Labs/Labs.tsx b/dashboard/src/pages/Labs/Labs.tsx new file mode 100644 index 000000000..39084e0d0 --- /dev/null +++ b/dashboard/src/pages/Labs/Labs.tsx @@ -0,0 +1,29 @@ +import type { JSX } from 'react'; + +import { useSearch } from '@tanstack/react-router'; + +import { MemoizedListingOGTags } from '@/components/OpenGraphTags/ListingOGTags'; +import type { LabsListingRoutesMap } from '@/utils/constants/labsListing'; + +import { LabsPage } from './LabsPage'; + +const Labs = ({ + urlFromMap, +}: { + urlFromMap: LabsListingRoutesMap; +}): JSX.Element => { + const { labsSearch } = useSearch({ + from: urlFromMap.search, + }); + + return ( + <> + +
+ +
+ + ); +}; + +export default Labs; diff --git a/dashboard/src/pages/Labs/LabsPage.tsx b/dashboard/src/pages/Labs/LabsPage.tsx new file mode 100644 index 000000000..e0df26231 --- /dev/null +++ b/dashboard/src/pages/Labs/LabsPage.tsx @@ -0,0 +1,49 @@ +import { useMemo, type JSX } from 'react'; + +import type { LabListingItem } from '@/types/lab'; + +import { useLabsListing } from '@/api/labs'; + +import { Toaster } from '@/components/ui/toaster'; + +import { matchesRegexOrIncludes } from '@/lib/string'; + +import type { LabsListingRoutesMap } from '@/utils/constants/labsListing'; + +import { LabsTable } from './LabsTable'; + +export function LabsPage({ + inputFilter, + urlFromMap, +}: { + inputFilter: string; + urlFromMap: LabsListingRoutesMap; +}): JSX.Element { + const { data, error, status, isLoading } = useLabsListing(urlFromMap.search); + + const listItems: LabListingItem[] = useMemo(() => { + if (!data) { + return []; + } + + return data.labs + .filter(lab => matchesRegexOrIncludes(lab.lab_name, inputFilter)) + .sort((a, b) => a.lab_name.localeCompare(b.lab_name)); + }, [data, inputFilter]); + + return ( + <> + +
+ +
+ + ); +} diff --git a/dashboard/src/pages/Labs/LabsTable.tsx b/dashboard/src/pages/Labs/LabsTable.tsx new file mode 100644 index 000000000..82884c2d7 --- /dev/null +++ b/dashboard/src/pages/Labs/LabsTable.tsx @@ -0,0 +1,237 @@ +import type { + ColumnDef, + ColumnFiltersState, + SortingState, +} from '@tanstack/react-table'; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; + +import { useCallback, useMemo, useState, type JSX } from 'react'; +import type { UseQueryResult } from '@tanstack/react-query'; + +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 { GroupedTestStatus } from '@/components/Status/Status'; +import { TableHeader } from '@/components/Table/TableHeader'; +import { + ItemsPerPageSelector, + ListingCount, + PaginationButtons, + PaginationInfo, +} from '@/components/Table/PaginationInfo'; + +import type { LabListingItem } from '@/types/lab'; +import type { ShortStatusCount } from '@/types/general'; + +import { sumStatus } from '@/utils/status'; + +import { usePaginationState } from '@/hooks/usePaginationState'; + +import QuerySwitcher from '@/components/QuerySwitcher/QuerySwitcher'; +import { MemoizedSectionError } from '@/components/DetailsPages/SectionError'; + +import type { LabsListingRoutesMap } from '@/utils/constants/labsListing'; + +const statusCell = (summary: ShortStatusCount): JSX.Element => ( + +); + +const columns: ColumnDef[] = [ + { + accessorKey: 'lab_name', + header: ({ column }): JSX.Element => ( + + ), + }, + { + accessorKey: 'build_status_summary', + accessorFn: ({ build_status_summary: buildCount }): number => + sumStatus(buildCount), + header: ({ column }): JSX.Element => ( + + ), + cell: ({ row }): JSX.Element => + statusCell(row.original.build_status_summary), + }, + { + accessorKey: 'boot_status_summary', + accessorFn: ({ boot_status_summary: bootCount }): number => + sumStatus(bootCount), + header: ({ column }): JSX.Element => ( + + ), + cell: ({ row }): JSX.Element => + statusCell(row.original.boot_status_summary), + }, + { + accessorKey: 'test_status_summary', + accessorFn: ({ test_status_summary: testCount }): number => + sumStatus(testCount), + header: ({ column }): JSX.Element => ( + + ), + cell: ({ row }): JSX.Element => + statusCell(row.original.test_status_summary), + }, +]; + +export function LabsTable({ + labTableRows, + status, + queryData, + error, + isLoading, + urlFromMap, +}: { + labTableRows: LabListingItem[]; + status?: UseQueryResult['status']; + queryData?: unknown; + error?: Error | null; + isLoading?: boolean; + urlFromMap: LabsListingRoutesMap; +}): JSX.Element { + const { listingSize } = useSearch({ from: urlFromMap.search }); + const navigate = useNavigate({ from: urlFromMap.navigate }); + + const [sorting, setSorting] = useState([]); + const [columnFilters, setColumnFilters] = useState([]); + const { pagination, paginationUpdater } = usePaginationState( + 'labsListing', + listingSize, + ); + + const table = useReactTable({ + data: labTableRows, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onPaginationChange: paginationUpdater, + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + state: { + sorting, + columnFilters, + pagination, + }, + }); + + const groupHeaders = table.getHeaderGroups()[0].headers; + const tableHeaders = useMemo((): JSX.Element[] => { + return groupHeaders.map(header => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, { + ...header.getContext(), + sorting, + })} + + )); + }, [groupHeaders, sorting]); + + const modelRows = table.getRowModel().rows; + const tableBody = useMemo((): JSX.Element[] | JSX.Element => { + return modelRows?.length ? ( + modelRows.map(row => ( + + {row.getVisibleCells().map(cell => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + + + + ); + }, [modelRows]); + + const navigateWithPageSize = useCallback( + (pageSize: number) => { + navigate({ + search: prev => ({ ...prev, listingSize: pageSize }), + state: s => s, + }); + }, + [navigate], + ); + + return ( +
+
+ + }} + /> + +
+ +
+
+ + +
+
+ + } + > + + {tableBody} + + + +
+ ); +} diff --git a/dashboard/src/routeTree.gen.ts b/dashboard/src/routeTree.gen.ts index c92bbaed9..9156efc4c 100644 --- a/dashboard/src/routeTree.gen.ts +++ b/dashboard/src/routeTree.gen.ts @@ -15,10 +15,12 @@ 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 MainLabsRouteRouteImport } from './routes/_main/labs/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 MainLabsIndexRouteImport } from './routes/_main/labs/index' import { Route as MainIssuesIndexRouteImport } from './routes/_main/issues/index' import { Route as MainHardwareIndexRouteImport } from './routes/_main/hardware/index' import { Route as MainTreeTreeIdRouteRouteImport } from './routes/_main/tree/$treeId/route' @@ -83,6 +85,11 @@ const MainMetricsRouteRoute = MainMetricsRouteRouteImport.update({ path: '/metrics', getParentRoute: () => MainRouteRoute, } as any) +const MainLabsRouteRoute = MainLabsRouteRouteImport.update({ + id: '/labs', + path: '/labs', + getParentRoute: () => MainRouteRoute, +} as any) const MainIssuesRouteRoute = MainIssuesRouteRouteImport.update({ id: '/issues', path: '/issues', @@ -103,6 +110,11 @@ const MainMetricsIndexRoute = MainMetricsIndexRouteImport.update({ path: '/', getParentRoute: () => MainMetricsRouteRoute, } as any) +const MainLabsIndexRoute = MainLabsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => MainLabsRouteRoute, +} as any) const MainIssuesIndexRoute = MainIssuesIndexRouteImport.update({ id: '/', path: '/', @@ -303,6 +315,7 @@ export interface FileRoutesByFullPath { '/log-viewer': typeof LogViewerRoute '/hardware': typeof MainHardwareRouteRouteWithChildren '/issues': typeof MainIssuesRouteRouteWithChildren + '/labs': typeof MainLabsRouteRouteWithChildren '/metrics': typeof MainMetricsRouteRouteWithChildren '/tree': typeof MainTreeRouteRouteWithChildren '/build/$buildId': typeof MainBuildBuildIdRouteRouteWithChildren @@ -312,6 +325,7 @@ export interface FileRoutesByFullPath { '/tree/$treeId': typeof MainTreeTreeIdRouteRouteWithChildren '/hardware/': typeof MainHardwareIndexRoute '/issues/': typeof MainIssuesIndexRoute + '/labs/': typeof MainLabsIndexRoute '/metrics/': typeof MainMetricsIndexRoute '/tree/': typeof MainTreeIndexRoute '/b/$buildId': typeof MainalternativesBBuildIdRouteRouteWithChildren @@ -348,6 +362,7 @@ export interface FileRoutesByTo { '/': typeof MainIndexRoute '/hardware': typeof MainHardwareIndexRoute '/issues': typeof MainIssuesIndexRoute + '/labs': typeof MainLabsIndexRoute '/metrics': typeof MainMetricsIndexRoute '/tree': typeof MainTreeIndexRoute '/i': typeof MainalternativesIIndexRoute @@ -381,6 +396,7 @@ export interface FileRoutesById { '/log-viewer': typeof LogViewerRoute '/_main/hardware': typeof MainHardwareRouteRouteWithChildren '/_main/issues': typeof MainIssuesRouteRouteWithChildren + '/_main/labs': typeof MainLabsRouteRouteWithChildren '/_main/metrics': typeof MainMetricsRouteRouteWithChildren '/_main/tree': typeof MainTreeRouteRouteWithChildren '/_main/': typeof MainIndexRoute @@ -391,6 +407,7 @@ export interface FileRoutesById { '/_main/tree/$treeId': typeof MainTreeTreeIdRouteRouteWithChildren '/_main/hardware/': typeof MainHardwareIndexRoute '/_main/issues/': typeof MainIssuesIndexRoute + '/_main/labs/': typeof MainLabsIndexRoute '/_main/metrics/': typeof MainMetricsIndexRoute '/_main/tree/': typeof MainTreeIndexRoute '/_main/(alternatives)/b/$buildId': typeof MainalternativesBBuildIdRouteRouteWithChildren @@ -429,6 +446,7 @@ export interface FileRouteTypes { | '/log-viewer' | '/hardware' | '/issues' + | '/labs' | '/metrics' | '/tree' | '/build/$buildId' @@ -438,6 +456,7 @@ export interface FileRouteTypes { | '/tree/$treeId' | '/hardware/' | '/issues/' + | '/labs/' | '/metrics/' | '/tree/' | '/b/$buildId' @@ -474,6 +493,7 @@ export interface FileRouteTypes { | '/' | '/hardware' | '/issues' + | '/labs' | '/metrics' | '/tree' | '/i' @@ -506,6 +526,7 @@ export interface FileRouteTypes { | '/log-viewer' | '/_main/hardware' | '/_main/issues' + | '/_main/labs' | '/_main/metrics' | '/_main/tree' | '/_main/' @@ -516,6 +537,7 @@ export interface FileRouteTypes { | '/_main/tree/$treeId' | '/_main/hardware/' | '/_main/issues/' + | '/_main/labs/' | '/_main/metrics/' | '/_main/tree/' | '/_main/(alternatives)/b/$buildId' @@ -597,6 +619,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainMetricsRouteRouteImport parentRoute: typeof MainRouteRoute } + '/_main/labs': { + id: '/_main/labs' + path: '/labs' + fullPath: '/labs' + preLoaderRoute: typeof MainLabsRouteRouteImport + parentRoute: typeof MainRouteRoute + } '/_main/issues': { id: '/_main/issues' path: '/issues' @@ -625,6 +654,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MainMetricsIndexRouteImport parentRoute: typeof MainMetricsRouteRoute } + '/_main/labs/': { + id: '/_main/labs/' + path: '/' + fullPath: '/labs/' + preLoaderRoute: typeof MainLabsIndexRouteImport + parentRoute: typeof MainLabsRouteRoute + } '/_main/issues/': { id: '/_main/issues/' path: '/' @@ -922,6 +958,18 @@ const MainIssuesRouteRouteWithChildren = MainIssuesRouteRoute._addFileChildren( MainIssuesRouteRouteChildren, ) +interface MainLabsRouteRouteChildren { + MainLabsIndexRoute: typeof MainLabsIndexRoute +} + +const MainLabsRouteRouteChildren: MainLabsRouteRouteChildren = { + MainLabsIndexRoute: MainLabsIndexRoute, +} + +const MainLabsRouteRouteWithChildren = MainLabsRouteRoute._addFileChildren( + MainLabsRouteRouteChildren, +) + interface MainMetricsRouteRouteChildren { MainMetricsIndexRoute: typeof MainMetricsIndexRoute } @@ -1063,6 +1111,7 @@ const MainalternativesTTestIdRouteRouteWithChildren = interface MainRouteRouteChildren { MainHardwareRouteRoute: typeof MainHardwareRouteRouteWithChildren MainIssuesRouteRoute: typeof MainIssuesRouteRouteWithChildren + MainLabsRouteRoute: typeof MainLabsRouteRouteWithChildren MainMetricsRouteRoute: typeof MainMetricsRouteRouteWithChildren MainTreeRouteRoute: typeof MainTreeRouteRouteWithChildren MainIndexRoute: typeof MainIndexRoute @@ -1082,6 +1131,7 @@ interface MainRouteRouteChildren { const MainRouteRouteChildren: MainRouteRouteChildren = { MainHardwareRouteRoute: MainHardwareRouteRouteWithChildren, MainIssuesRouteRoute: MainIssuesRouteRouteWithChildren, + MainLabsRouteRoute: MainLabsRouteRouteWithChildren, MainMetricsRouteRoute: MainMetricsRouteRouteWithChildren, MainTreeRouteRoute: MainTreeRouteRouteWithChildren, MainIndexRoute: MainIndexRoute, diff --git a/dashboard/src/routes/_main/labs/index.tsx b/dashboard/src/routes/_main/labs/index.tsx new file mode 100644 index 000000000..798ac2c98 --- /dev/null +++ b/dashboard/src/routes/_main/labs/index.tsx @@ -0,0 +1,13 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import type { JSX } from 'react'; + +import Labs from '@/pages/Labs/Labs'; + +const LabsComponent = (): JSX.Element => { + return ; +}; + +export const Route = createFileRoute('/_main/labs/')({ + component: LabsComponent, +}); diff --git a/dashboard/src/routes/_main/labs/route.tsx b/dashboard/src/routes/_main/labs/route.tsx new file mode 100644 index 000000000..03b863126 --- /dev/null +++ b/dashboard/src/routes/_main/labs/route.tsx @@ -0,0 +1,29 @@ +import { createFileRoute, stripSearchParams } from '@tanstack/react-router'; +import { z } from 'zod'; + +import { + makeZIntervalInDays, + zListingSize, + type SearchSchema, +} from '@/types/general'; +import { + DEFAULT_LISTING_ITEMS, + REDUCED_TIME_SEARCH, +} from '@/utils/constants/general'; + +const defaultValues = { + intervalInDays: REDUCED_TIME_SEARCH, + labsSearch: '', + listingSize: DEFAULT_LISTING_ITEMS, +}; + +const zLabsSchema = z.object({ + intervalInDays: makeZIntervalInDays(REDUCED_TIME_SEARCH), + labsSearch: z.string().catch(''), + listingSize: zListingSize, +} satisfies SearchSchema); + +export const Route = createFileRoute('/_main/labs')({ + validateSearch: zLabsSchema, + search: { middlewares: [stripSearchParams(defaultValues)] }, +}); diff --git a/dashboard/src/types/general.ts b/dashboard/src/types/general.ts index 3fc5d9cba..e1656d7b3 100644 --- a/dashboard/src/types/general.ts +++ b/dashboard/src/types/general.ts @@ -251,6 +251,7 @@ export type SearchParamsKeys = | 'treeSearch' | 'listingSize' | 'hardwareSearch' + | 'labsSearch' | 'issueSearch' | 'treeInfo' | 'treeIndexes' @@ -398,7 +399,7 @@ export enum RedirectFrom { Issues = 'issues', } -export type ListingPaths = '/tree' | '/hardware' | '/issues'; +export type ListingPaths = '/tree' | '/hardware' | '/issues' | '/labs'; export type PossibleMonitorPath = ListingPaths | '/metrics'; diff --git a/dashboard/src/types/lab.ts b/dashboard/src/types/lab.ts new file mode 100644 index 000000000..9d7a50f58 --- /dev/null +++ b/dashboard/src/types/lab.ts @@ -0,0 +1,12 @@ +import type { ShortStatusCount } from './general'; + +export type LabListingItem = { + lab_name: string; + build_status_summary: ShortStatusCount; + boot_status_summary: ShortStatusCount; + test_status_summary: ShortStatusCount; +}; + +export interface LabListingResponse { + labs: LabListingItem[]; +} diff --git a/dashboard/src/utils/constants/labsListing.ts b/dashboard/src/utils/constants/labsListing.ts new file mode 100644 index 000000000..397faee7d --- /dev/null +++ b/dashboard/src/utils/constants/labsListing.ts @@ -0,0 +1,11 @@ +import type { FileRoutesById, FileRoutesByTo } from '@/routeTree.gen'; + +type ValidLabsNavigates = T; +type ValidLabsFroms = T; + +export type LabsListingRoutesMap = { + navigate: ValidLabsNavigates<'/labs'>; + search: ValidLabsFroms<'/_main/labs'>; +}; + +export const labsListingCleanFullPaths = ['labs']; diff --git a/dashboard/src/utils/constants/tables.ts b/dashboard/src/utils/constants/tables.ts index dad2ea481..e3498147f 100644 --- a/dashboard/src/utils/constants/tables.ts +++ b/dashboard/src/utils/constants/tables.ts @@ -4,6 +4,7 @@ export type TableKeys = | 'treeDetailsBoots' | 'treeDetailsTests' | 'hardwareListing' + | 'labsListing' | 'hardwareDetailsBuilds' | 'hardwareDetailsBoots' | 'hardwareDetailsTests' diff --git a/dashboard/src/utils/search.ts b/dashboard/src/utils/search.ts index b77751d9e..eaa009377 100644 --- a/dashboard/src/utils/search.ts +++ b/dashboard/src/utils/search.ts @@ -143,6 +143,7 @@ const generalMinifiedParams: Record = { treeSearch: 'ts', listingSize: 'ls', hardwareSearch: 'hs', + labsSearch: 'lsr', issueSearch: 'is', treeInfo: 'tri', treeIndexes: 'x',