Skip to content

Commit 7a8bb20

Browse files
feat(GAT-9424): Implement call to nightly dataset test within admin panel for results
1 parent afa7ad5 commit 7a8bb20

7 files changed

Lines changed: 337 additions & 3 deletions

File tree

src/app/[locale]/(logged-out)/dataset/[datasetId]/components/DatasetMindMap/DatasetMindMap.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,9 +78,10 @@ const DatasetMindMap = ({
7878
const safeTitle = encodeURIComponent(title);
7979

8080
if (node.id === "node-synthetic") {
81+
// Loki - For Calum. Tweak per GAT-9374
8182
href =
8283
data.metadata.metadata?.structuralMetadata
83-
?.syntheticDataWebLink[0];
84+
?.syntheticDataWebLink?.[0] ?? null;
8485

8586
if (!href) {
8687
empty.push(node.id);
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
"use client";
2+
3+
import { ReactNode, useMemo, useState } from "react";
4+
import { useTranslations } from "next-intl";
5+
import { ColumnDef } from "@tanstack/react-table";
6+
import { Alert, Avatar, Divider, Skeleton } from "@mui/material";
7+
import PercentIcon from "@mui/icons-material/Percent";
8+
import Box from "@/components/Box";
9+
import Paper from "@/components/Paper";
10+
import Typography from "@/components/Typography";
11+
import Chip from "@/components/Chip";
12+
import Link from "@/components/Link";
13+
import SortIcon from "@/components/SortIcon";
14+
import Table from "@/components/Table";
15+
import useGet from "@/hooks/useGet";
16+
import apis from "@/config/apis";
17+
import { RouteName } from "@/consts/routeName";
18+
import {
19+
CheckCircleIcon,
20+
ErrorIcon,
21+
SearchRoundedIcon,
22+
} from "@/consts/icons";
23+
import { formatDate } from "@/utils/date";
24+
import { FailedDatasetTest, NightlyDatasetTestResponse } from "@/interfaces/NightlyDatasetTest";
25+
26+
interface Sort {
27+
key: string;
28+
direction: string;
29+
}
30+
31+
type StatColor = "info" | "success" | "error";
32+
33+
const TRANSLATION_PATH = "pages.account.profile.searchAdmin";
34+
35+
const HTTP_STATUS_MESSAGES: { [statusCode: number]: string } = {
36+
400: "Bad Request",
37+
401: "Unauthorized",
38+
403: "Forbidden",
39+
404: "Not Found",
40+
408: "Request Timeout",
41+
429: "Too Many Requests",
42+
500: "Internal Server Error",
43+
502: "Bad Gateway",
44+
503: "Service Unavailable",
45+
504: "Gateway Timeout",
46+
};
47+
48+
const statusLabel = (statusCode: number | null) => {
49+
if (statusCode === null) return "No response";
50+
const message = HTTP_STATUS_MESSAGES[statusCode];
51+
return message ? `${statusCode} ${message}` : `${statusCode}`;
52+
};
53+
54+
const getColumns = (
55+
sort: Sort,
56+
setSort: (sort: Sort) => void
57+
): ColumnDef<FailedDatasetTest>[] => [
58+
{
59+
id: "datasetId",
60+
header: "Dataset ID",
61+
cell: ({ row: { original } }) => (
62+
<Link href={`/${RouteName.DATASET_ITEM}/${original.datasetId}`}>
63+
{original.datasetId}
64+
</Link>
65+
),
66+
},
67+
{
68+
id: "statusCode",
69+
header: () => (
70+
<Box
71+
sx={{
72+
p: 0,
73+
display: "flex",
74+
alignItems: "center",
75+
}}>
76+
Status code
77+
<SortIcon
78+
sort={sort}
79+
setSort={setSort}
80+
sortKey="statusCode"
81+
ariaLabel="Sort by status code"
82+
/>
83+
</Box>
84+
),
85+
cell: ({ row: { original } }) => (
86+
<Chip
87+
size="small"
88+
color="error"
89+
label={statusLabel(original.statusCode)}
90+
/>
91+
),
92+
},
93+
{
94+
id: "checkedAt",
95+
header: "Last checked",
96+
cell: ({ row: { original } }) =>
97+
formatDate(original.checkedAt, "DD/MM/YYYY HH:mm"),
98+
},
99+
];
100+
101+
const StatTile = ({
102+
icon,
103+
color,
104+
label,
105+
value,
106+
children,
107+
}: {
108+
icon: ReactNode;
109+
color: StatColor;
110+
label: string;
111+
value: ReactNode;
112+
children?: ReactNode;
113+
}) => (
114+
<Paper
115+
variant="outlined"
116+
sx={{
117+
p: 2,
118+
flex: 1,
119+
minWidth: 200,
120+
borderTop: 3,
121+
borderColor: `${color}.main`,
122+
}}>
123+
<Box sx={{ p: 0, display: "flex", alignItems: "center", gap: 1.5 }}>
124+
<Avatar
125+
sx={{
126+
bgcolor: `${color}.main`,
127+
width: 36,
128+
height: 36,
129+
}}>
130+
{icon}
131+
</Avatar>
132+
<Box sx={{ p: 0 }}>
133+
<Typography variant="body2" color="text.secondary">
134+
{label}
135+
</Typography>
136+
<Typography variant="h3" sx={{ lineHeight: 1.2 }}>
137+
{value}
138+
</Typography>
139+
</Box>
140+
</Box>
141+
{children}
142+
</Paper>
143+
);
144+
145+
export default function NightlyDatasetTestsTab() {
146+
const t = useTranslations(TRANSLATION_PATH);
147+
const { data, isLoading } = useGet<NightlyDatasetTestResponse>(
148+
apis.nightlyDatasetTestsV2Url
149+
);
150+
151+
const [sort, setSort] = useState<Sort>({
152+
key: "statusCode",
153+
direction: "asc",
154+
});
155+
156+
const failedDatasets = useMemo(() => {
157+
const list = data?.failedDatasets ?? [];
158+
159+
if (sort.key !== "statusCode") return list;
160+
161+
return [...list].sort((a, b) => {
162+
const diff = (a.statusCode ?? 0) - (b.statusCode ?? 0);
163+
return sort.direction === "asc" ? diff : -diff;
164+
});
165+
}, [data, sort]);
166+
167+
const columns = useMemo(() => getColumns(sort, setSort), [sort]);
168+
169+
const failureBreakdown = useMemo(() => {
170+
const counts = new Map<number | null, number>();
171+
172+
(data?.failedDatasets ?? []).forEach(({ statusCode }) => {
173+
counts.set(statusCode, (counts.get(statusCode) ?? 0) + 1);
174+
});
175+
176+
return [...counts.entries()]
177+
.sort(([, a], [, b]) => b - a)
178+
.map(([statusCode, count]) => ({ statusCode, count }));
179+
}, [data]);
180+
181+
if (isLoading) {
182+
return (
183+
<Paper variant="outlined" sx={{ p: 2 }}>
184+
<Skeleton variant="rounded" height={40} sx={{ mb: 1 }} />
185+
<Skeleton variant="rounded" height={40} />
186+
</Paper>
187+
);
188+
}
189+
190+
return (
191+
<Box sx={{ p: 0 }}>
192+
<Alert severity="info" sx={{ mb: 3 }}>
193+
{t("nightlyTestsDisclaimer")}
194+
</Alert>
195+
196+
<Box
197+
sx={{
198+
display: "flex",
199+
gap: 2,
200+
mb: 3,
201+
flexWrap: "wrap",
202+
}}>
203+
<StatTile
204+
icon={<SearchRoundedIcon fontSize="small" />}
205+
color="info"
206+
label={t("nightlyTestsTotalChecked")}
207+
value={data?.summary.totalChecked ?? 0}
208+
/>
209+
<StatTile
210+
icon={<CheckCircleIcon fontSize="small" />}
211+
color="success"
212+
label={t("nightlyTestsTotalSuccessful")}
213+
value={data?.summary.totalSuccessful ?? 0}
214+
/>
215+
<StatTile
216+
icon={<ErrorIcon fontSize="small" />}
217+
color="error"
218+
label={t("nightlyTestsTotalFailed")}
219+
value={data?.summary.totalFailed ?? 0}>
220+
{failureBreakdown.length > 0 && (
221+
<>
222+
<Divider sx={{ my: 1.5 }} />
223+
<Box sx={{ p: 0 }}>
224+
{failureBreakdown.map(
225+
({ statusCode, count }, index) => (
226+
<Box
227+
key={statusCode ?? "null"}
228+
sx={{
229+
p: 0,
230+
display: "flex",
231+
justifyContent:
232+
"space-between",
233+
gap: 1,
234+
mt: index === 0 ? 0 : 0.5,
235+
}}>
236+
<Typography
237+
variant="body2"
238+
color="text.secondary"
239+
sx={{
240+
overflow: "hidden",
241+
textOverflow: "ellipsis",
242+
whiteSpace: "nowrap",
243+
}}>
244+
{statusLabel(statusCode)}
245+
</Typography>
246+
<Typography
247+
variant="body2"
248+
sx={{ fontWeight: 600 }}>
249+
{count}
250+
</Typography>
251+
</Box>
252+
)
253+
)}
254+
</Box>
255+
</>
256+
)}
257+
</StatTile>
258+
<StatTile
259+
icon={<PercentIcon fontSize="small" />}
260+
color="error"
261+
label={t("nightlyTestsPercentageFailed")}
262+
value={`${data?.summary.percentageFailed ?? 0}%`}
263+
/>
264+
</Box>
265+
266+
<Typography variant="h3" sx={{ mb: 2 }}>
267+
{t("nightlyTestsFailedTitle")}
268+
</Typography>
269+
270+
{failedDatasets.length === 0 ? (
271+
<Paper variant="outlined" sx={{ p: 2 }}>
272+
<Typography variant="body2" color="text.secondary">
273+
{t("nightlyTestsNoFailures")}
274+
</Typography>
275+
</Paper>
276+
) : (
277+
<div style={{ marginBlock: 10 }}>
278+
<Table<FailedDatasetTest>
279+
columns={columns}
280+
rows={failedDatasets}
281+
style={{
282+
width: "100%",
283+
borderCollapse: "collapse",
284+
tableLayout: "auto",
285+
}}
286+
/>
287+
</div>
288+
)}
289+
</Box>
290+
);
291+
}

src/app/[locale]/account/profile/search-admin/SearchAdminPanel.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { AdminSearchStatusResponse } from "@/interfaces/AdminSearch";
1111
import FeatureFlagsTable from "./FeatureFlagsTable";
1212
import SearchEntitiesTab from "./SearchEntitiesTab";
1313
import DataCustodianNetworksTab from "./DataCustodianNetworksTab";
14+
import NightlyDatasetTestsTab from "./NightlyDatasetTestsTab";
1415

1516
const TRANSLATION_PATH = "pages.account.profile.searchAdmin";
1617

@@ -61,6 +62,15 @@ export default function SearchAdminPanel() {
6162
</Box>
6263
),
6364
},
65+
{
66+
label: t("nightlyDatasetTestsTab"),
67+
value: "nightlyDatasetTests",
68+
content: (
69+
<Box sx={{ p: 0, pt: 2 }}>
70+
<NightlyDatasetTestsTab />
71+
</Box>
72+
),
73+
},
6474
]}
6575
/>
6676
</Box>

src/config/apis.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ const apis = {
107107
widgetsV1UrlIP: `${apiV1IPUrl}/widgets`,
108108
workgroupsV1Url: `${apiV1Url}/workgroups`,
109109
metricsV2Url: `${apiV2Url}/metrics`,
110+
nightlyDatasetTestsV2Url: `${apiV2IPUrl}/nightly_dataset_tests`,
110111
};
111112

112113
export default apis;

src/config/messages/en.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -581,10 +581,18 @@
581581
},
582582
"searchAdmin": {
583583
"title": "Admin Panel",
584-
"text": "Monitor and manage the Typesense search indexes and application feature flags.",
584+
"text": "Administration of Gateway configuration and tools.",
585585
"featureFlagsTab": "Feature flags",
586586
"searchEntitiesTab": "Search entities",
587587
"dataCustodianNetworksTab": "Data Custodian Networks",
588+
"nightlyDatasetTestsTab": "Nightly Dataset Tests",
589+
"nightlyTestsTotalChecked": "Total checked",
590+
"nightlyTestsTotalSuccessful": "Total successful",
591+
"nightlyTestsTotalFailed": "Total failed",
592+
"nightlyTestsPercentageFailed": "Percentage failed",
593+
"nightlyTestsDisclaimer": "These results reflect a single point-in-time check from the last nightly run. A 500 error is often transient (a slow dependency, a momentary outage) and may no longer apply — revisit the dataset before assuming it's still broken. A 404 is more likely to indicate a genuinely missing or misconfigured page and is worth investigating directly.",
594+
"nightlyTestsFailedTitle": "Failed datasets",
595+
"nightlyTestsNoFailures": "No failed datasets in the last nightly check.",
588596
"enableEditing": "Enable editing",
589597
"noFeatureFlags": "No feature flags found.",
590598
"reindexInfo": "Reindexing drops and recreates a collection from scratch. Small entities finish in seconds, but larger ones (datasets, data use register, publications) can take a minute or two to fully re-import — this page won't update automatically, so use Refresh status to check progress.",

src/interfaces/Dataset.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface StructuralMetadata {
2626

2727
interface StructuralMetadataPublicSchema {
2828
tables: StructuralMetadata[];
29-
syntheticDataWebLink: string[];
29+
syntheticDataWebLink?: string[];
3030
}
3131

3232
interface DemographicGeneric {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
interface NightlyDatasetTestSummary {
2+
totalChecked: number;
3+
totalSuccessful: number;
4+
totalFailed: number;
5+
percentageFailed: number;
6+
}
7+
8+
interface FailedDatasetTest {
9+
datasetId: number;
10+
statusCode: number | null;
11+
checkedAt: string;
12+
}
13+
14+
interface NightlyDatasetTestResponse {
15+
summary: NightlyDatasetTestSummary;
16+
failedDatasets: FailedDatasetTest[];
17+
}
18+
19+
export type {
20+
NightlyDatasetTestSummary,
21+
FailedDatasetTest,
22+
NightlyDatasetTestResponse,
23+
};

0 commit comments

Comments
 (0)