Skip to content

Commit 017dd23

Browse files
feat(dashboard): add labs listing page
Closes #1966 Signed-off-by: Felipe Bergamin <felipebergamin@profusion.mobi>
1 parent 9dc0425 commit 017dd23

17 files changed

Lines changed: 570 additions & 7 deletions

File tree

dashboard/src/api/labs.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { UseQueryResult } from '@tanstack/react-query';
2+
import { useQuery } from '@tanstack/react-query';
3+
4+
import { useSearch } from '@tanstack/react-router';
5+
6+
import type { LabListingResponse } from '@/types/lab';
7+
8+
import type { LabsListingRoutesMap } from '@/utils/constants/labsListing';
9+
10+
import { RequestData } from './commonRequest';
11+
12+
const fetchLabsListing = async (
13+
origin: string,
14+
startTimestampInSeconds: number,
15+
endTimestampInSeconds: number,
16+
): Promise<LabListingResponse> => {
17+
return RequestData.get<LabListingResponse>('/api/labs/', {
18+
params: {
19+
startTimestampInSeconds,
20+
endTimestampInSeconds,
21+
origin,
22+
},
23+
});
24+
};
25+
26+
export const useLabsListing = (
27+
startTimestampInSeconds: number,
28+
endTimestampInSeconds: number,
29+
searchFrom: LabsListingRoutesMap['search'],
30+
): UseQueryResult<LabListingResponse> => {
31+
const { origin } = useSearch({ from: searchFrom });
32+
33+
return useQuery({
34+
queryKey: [
35+
'labsListing',
36+
startTimestampInSeconds,
37+
endTimestampInSeconds,
38+
origin,
39+
],
40+
queryFn: () =>
41+
fetchLabsListing(origin, startTimestampInSeconds, endTimestampInSeconds),
42+
refetchOnWindowFocus: false,
43+
});
44+
};

dashboard/src/components/OpenGraphTags/ListingOGTags.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ const ListingOGTags = ({
3030
case '/issues':
3131
descriptionId = 'issueListing.description';
3232
break;
33+
case '/labs':
34+
descriptionId = 'labsListing.description';
35+
break;
3336
}
3437
return (
3538
formatMessage({ id: descriptionId }) +
@@ -47,6 +50,8 @@ const ListingOGTags = ({
4750
return formatMessage({ id: 'hardwareListing.title' });
4851
case '/issues':
4952
return formatMessage({ id: 'issueListing.title' });
53+
case '/labs':
54+
return formatMessage({ id: 'labsListing.title' });
5055
}
5156
}, [formatMessage, monitor]);
5257

dashboard/src/components/SearchBoxNavigate/SearchBoxNavigate.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ import DebounceInput from '@/components/DebounceInput/DebounceInput';
88
import { CustomDialog } from '@/components/Dialog/CustomDialog';
99
import { treeListingCleanFullPaths } from '@/utils/constants/treeListing';
1010
import { hwListingCleanFullPaths } from '@/utils/constants/hardwareListing';
11+
import { labsListingCleanFullPaths } from '@/utils/constants/labsListing';
1112

1213
// Relates the type of listing to the corresponding search key
1314
const forwardFields: Record<string, string> = {
1415
tree: 'treeSearch',
1516
hardware: 'hardwareSearch',
1617
issue: 'issueSearch',
18+
labs: 'labsSearch',
1719
};
1820

1921
interface ISearchData {
@@ -40,11 +42,15 @@ export const SearchBoxNavigate = (): JSX.Element => {
4042
return 'issue';
4143
}
4244

45+
if (labsListingCleanFullPaths.includes(cleanFullPath)) {
46+
return 'labs';
47+
}
48+
4349
return 'unknown';
4450
}, [matches]);
4551
const { formatMessage } = useIntl();
4652

47-
const { treeSearch, hardwareSearch, issueSearch } = useSearch({
53+
const { treeSearch, hardwareSearch, issueSearch, labsSearch } = useSearch({
4854
strict: false,
4955
});
5056
const searchData = useMemo((): ISearchData => {
@@ -71,14 +77,29 @@ export const SearchBoxNavigate = (): JSX.Element => {
7177
}),
7278
navigateTarget: 'issueSearch',
7379
};
80+
case 'labs':
81+
return {
82+
currentSearch: labsSearch,
83+
searchPlaceholder: formatMessage({
84+
id: 'labs.searchPlaceholder',
85+
}),
86+
navigateTarget: 'labsSearch',
87+
};
7488
default:
7589
return {
7690
currentSearch: '',
7791
searchPlaceholder: '',
7892
navigateTarget: '',
7993
};
8094
}
81-
}, [routeInfo, treeSearch, formatMessage, hardwareSearch, issueSearch]);
95+
}, [
96+
routeInfo,
97+
treeSearch,
98+
formatMessage,
99+
hardwareSearch,
100+
issueSearch,
101+
labsSearch,
102+
]);
82103

83104
const navigate = useNavigate();
84105

dashboard/src/components/SideMenu/menuItems.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import type { JSX } from 'react';
22

3-
import { MdOutlineBarChart, MdOutlineMonitorHeart } from 'react-icons/md';
3+
import {
4+
MdOutlineBarChart,
5+
MdOutlineMonitorHeart,
6+
MdOutlineScience,
7+
} from 'react-icons/md';
48
import { RxRadiobutton } from 'react-icons/rx';
59
import { ImTree } from 'react-icons/im';
610
import { HiOutlineDocumentSearch } from 'react-icons/hi';
@@ -29,6 +33,7 @@ export type LinkStringItems = {
2933

3034
const TreeIcon = <ImTree className="size-5" />;
3135
const MonitorHeartIcon = <MdOutlineMonitorHeart className="size-5" />;
36+
const LabsIcon = <MdOutlineScience className="size-5" />;
3237
const RadioButtonIcon = <RxRadiobutton className="size-5" />;
3338
const MetricsIcon = <MdOutlineBarChart className="size-5" />;
3439
const DocumentSearchIcon = <HiOutlineDocumentSearch />;
@@ -46,6 +51,12 @@ export const routeItems: RouteMenuItems[] = [
4651
icon: MonitorHeartIcon,
4752
selected: false,
4853
},
54+
{
55+
navigateTo: '/labs',
56+
idIntl: 'routes.labsMonitor',
57+
icon: LabsIcon,
58+
selected: false,
59+
},
4960
{
5061
navigateTo: '/issues',
5162
idIntl: 'routes.issueMonitor',

dashboard/src/components/TopBar/TopBar.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import MobileSideMenu from '@/components/SideMenu/MobileSideMenu';
1515
import { SearchBoxNavigate } from '@/components/SearchBoxNavigate';
1616
import { treeListingCleanFullPaths } from '@/utils/constants/treeListing';
1717
import { hwListingCleanFullPaths } from '@/utils/constants/hardwareListing';
18+
import { labsListingCleanFullPaths } from '@/utils/constants/labsListing';
1819

1920
const OriginSelect = ({
2021
isHardwarePath,
@@ -109,6 +110,8 @@ const TitleName = ({ basePath }: { basePath: string }): JSX.Element => {
109110
return <FormattedMessage id="routes.issueDetails" />;
110111
case 'metrics':
111112
return <FormattedMessage id="routes.metricsMonitor" />;
113+
case 'labs':
114+
return <FormattedMessage id="routes.labsMonitor" />;
112115
default:
113116
return <FormattedMessage id="routes.unknown" />;
114117
}
@@ -124,13 +127,18 @@ const TopBar = (): JSX.Element => {
124127
const cleanFullPath = lastMatch?.fullPath.replace(/\//g, '') ?? '';
125128
const isTreeListing = treeListingCleanFullPaths.includes(cleanFullPath);
126129
const isHardwareListing = hwListingCleanFullPaths.includes(cleanFullPath);
130+
const isLabsListing = labsListingCleanFullPaths.includes(cleanFullPath);
127131
const isListingPage =
128-
isTreeListing || isHardwareListing || cleanFullPath.includes('issues');
132+
isTreeListing ||
133+
isHardwareListing ||
134+
isLabsListing ||
135+
cleanFullPath.includes('issues');
129136

130137
return {
131138
firstUrlLocation,
132139
isTreeListing: isTreeListing,
133140
isHardwarePage: cleanFullPath.includes('hardware'),
141+
isLabsPage: isLabsListing,
134142
isListingPage: isListingPage,
135143
};
136144
}, [matches]);
@@ -152,8 +160,14 @@ const TopBar = (): JSX.Element => {
152160
<span className="mr-2 text-2xl sm:mr-10">
153161
<TitleName basePath={routeInfo.firstUrlLocation} />
154162
</span>
155-
{(routeInfo.isTreeListing || routeInfo.isHardwarePage) && (
156-
<OriginSelect isHardwarePath={routeInfo.isHardwarePage} />
163+
{(routeInfo.isTreeListing ||
164+
routeInfo.isHardwarePage ||
165+
routeInfo.isLabsPage) && (
166+
<OriginSelect
167+
isHardwarePath={
168+
routeInfo.isHardwarePage || routeInfo.isLabsPage
169+
}
170+
/>
157171
)}
158172
<span className="ml-0 flex w-full px-6 lg:ml-14">
159173
{routeInfo.isListingPage && <SearchBoxNavigate />}

dashboard/src/locales/messages/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ export const messages = {
156156
'global.issues': 'Issues',
157157
'global.kcidev': 'kci-dev',
158158
'global.lab': 'Lab',
159+
'global.labs': 'Labs',
159160
'global.last': 'Last',
160161
'global.legend': 'Legend',
161162
'global.loading': 'Loading...',
@@ -280,6 +281,11 @@ export const messages = {
280281
'issueListing.treeBranchTooltip':
281282
'The tree name and git repository branch of the first incident\nClick a cell to see details of that checkout',
282283
'jsonSheet.title': 'JSON Viewer',
284+
'labs.searchPlaceholder': 'Search by lab name with a regex',
285+
'labsListing.description':
286+
'List of labs with aggregated build, boot and test status',
287+
'labsListing.notFound': 'No lab information available',
288+
'labsListing.title': 'Lab Listing ― KCI Dashboard',
283289
'logSheet.downloadLog': 'You can download the full log here: {link}',
284290
'logSheet.fileName': 'File Name',
285291
'logSheet.fileSize': 'File Size',
@@ -309,6 +315,7 @@ export const messages = {
309315
'routes.hardwareNewMonitor': 'Hardware New',
310316
'routes.issueDetails': 'Issue',
311317
'routes.issueMonitor': 'Issues',
318+
'routes.labsMonitor': 'Labs',
312319
'routes.metricsMonitor': 'Metrics',
313320
'routes.testDetails': 'Test',
314321
'routes.treeMonitor': 'Trees',

dashboard/src/pages/Labs/Labs.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { JSX } from 'react';
2+
3+
import { useSearch } from '@tanstack/react-router';
4+
5+
import { MemoizedListingOGTags } from '@/components/OpenGraphTags/ListingOGTags';
6+
import type { LabsListingRoutesMap } from '@/utils/constants/labsListing';
7+
8+
import { LabsPage } from './LabsPage';
9+
10+
const Labs = ({
11+
urlFromMap,
12+
}: {
13+
urlFromMap: LabsListingRoutesMap;
14+
}): JSX.Element => {
15+
const { labsSearch } = useSearch({
16+
from: urlFromMap.search,
17+
});
18+
19+
return (
20+
<>
21+
<MemoizedListingOGTags monitor="/labs" search={labsSearch} />
22+
<div className="bg-light-gray w-full py-4">
23+
<LabsPage inputFilter={labsSearch} urlFromMap={urlFromMap} />
24+
</div>
25+
</>
26+
);
27+
};
28+
29+
export default Labs;
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { useMemo, type JSX } from 'react';
2+
3+
import { roundToNearestMinutes } from 'date-fns';
4+
5+
import { useSearch } from '@tanstack/react-router';
6+
7+
import type { LabListingItem } from '@/types/lab';
8+
9+
import { useLabsListing } from '@/api/labs';
10+
11+
import { Toaster } from '@/components/ui/toaster';
12+
13+
import { matchesRegexOrIncludes } from '@/lib/string';
14+
15+
import type { LabsListingRoutesMap } from '@/utils/constants/labsListing';
16+
17+
import { dateObjectToTimestampInSeconds, daysToSeconds } from '@/utils/date';
18+
import { REDUCED_TIME_SEARCH } from '@/utils/constants/general';
19+
20+
import { LabsTable } from './LabsTable';
21+
22+
export function LabsPage({
23+
inputFilter,
24+
urlFromMap,
25+
}: {
26+
inputFilter: string;
27+
urlFromMap: LabsListingRoutesMap;
28+
}): JSX.Element {
29+
const { intervalInDays = REDUCED_TIME_SEARCH } = useSearch({
30+
from: urlFromMap.search,
31+
});
32+
33+
const { startTimestampInSeconds, endTimestampInSeconds } = useMemo(() => {
34+
const end = dateObjectToTimestampInSeconds(
35+
roundToNearestMinutes(new Date(), { nearestTo: 30 }),
36+
);
37+
return {
38+
startTimestampInSeconds: end - daysToSeconds(intervalInDays),
39+
endTimestampInSeconds: end,
40+
};
41+
}, [intervalInDays]);
42+
43+
const { data, error, status, isLoading } = useLabsListing(
44+
startTimestampInSeconds,
45+
endTimestampInSeconds,
46+
urlFromMap.search,
47+
);
48+
49+
const listItems: LabListingItem[] = useMemo(() => {
50+
if (!data) {
51+
return [];
52+
}
53+
54+
return data.labs
55+
.filter(lab => matchesRegexOrIncludes(lab.lab_name, inputFilter))
56+
.sort((a, b) => a.lab_name.localeCompare(b.lab_name));
57+
}, [data, inputFilter]);
58+
59+
return (
60+
<>
61+
<Toaster />
62+
<div className="flex flex-col gap-6">
63+
<LabsTable
64+
labTableRows={listItems}
65+
status={status}
66+
queryData={data}
67+
error={error}
68+
isLoading={isLoading}
69+
urlFromMap={urlFromMap}
70+
/>
71+
</div>
72+
</>
73+
);
74+
}

0 commit comments

Comments
 (0)