Skip to content

Commit 12445d0

Browse files
Add user metrics page (#286)
* Add user metrics page * add tests * update runs endpoint * accesibility update
1 parent 0a8c9e5 commit 12445d0

File tree

6 files changed

+314
-0
lines changed

6 files changed

+314
-0
lines changed

cypress/e2e/user_metrics.cy.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
describe("user metrics view", () => {
2+
const expid = "a6zi";
3+
4+
beforeEach(() => {
5+
cy.byPassAuth();
6+
7+
cy.intercept(
8+
"GET",
9+
Cypress.env("EXTERNAL_API") + `/v4/experiments/${expid}/user-metrics-runs`,
10+
{
11+
fixture: "api/v4/experiments/runs/minimal_run_list.json",
12+
}
13+
).as("dummy_runs_response");
14+
15+
cy.intercept(
16+
"GET",
17+
Cypress.env("EXTERNAL_API") + `/v4/experiments/${expid}/runs/3/user-metrics`,
18+
{
19+
fixture: "api/v4/experiments/runs/user-metrics/metrics.json",
20+
}
21+
).as("dummy_user_metrics_response");
22+
23+
cy.intercept(
24+
"GET",
25+
Cypress.env("EXTERNAL_API") + `/v4/experiments/${expid}/runs/1/user-metrics`,
26+
{
27+
body: {
28+
run_id: 1,
29+
metrics: []
30+
},
31+
}
32+
).as("dummy_config_response");
33+
34+
})
35+
36+
it("user metrics view", () => {
37+
cy.visit(`/experiment/${expid}/user-metrics`);
38+
cy.wait(500);
39+
cy.contains("Run 3")
40+
41+
cy.contains("a6zi_SIM")
42+
43+
// Change the selected run to 1
44+
cy.contains("Run 3").parent().select("Run 1")
45+
cy.wait(250);
46+
47+
cy.contains("Run 1")
48+
cy.contains("No metrics available")
49+
})
50+
51+
it("user metrics view with filter", () => {
52+
cy.visit(`/experiment/${expid}/user-metrics?run_id=1`);
53+
cy.wait(500);
54+
55+
cy.contains("Run 1")
56+
cy.contains("No metrics available")
57+
})
58+
59+
it("cannot get runs", () => {
60+
cy.intercept(
61+
"GET",
62+
Cypress.env("EXTERNAL_API") + `/v4/experiments/${expid}/user-metrics-runs`,
63+
{
64+
body: {
65+
error: "Cannot get runs",
66+
},
67+
statusCode: 500,
68+
}
69+
).as("dummy_runs_response");
70+
71+
cy.visit(`/experiment/${expid}/user-metrics`);
72+
cy.wait(500);
73+
74+
// Check if the page contains "No metrics available"
75+
cy.contains("Error fetching the experiment runs")
76+
})
77+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"run_id": 3,
3+
"metrics": [
4+
{
5+
"job_name": "a6zi_SETUP",
6+
"metric_name": "metric",
7+
"metric_value": "lorem ipsum dolor sit amet consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua, lorem ipsum dolor sit amet consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua, lorem ipsum dolor sit amet consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua, lorem ipsum dolor sit amet consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
8+
"modified": "2025-04-02T12:54:47+00:00"
9+
},
10+
{
11+
"job_name": "a6zi_SIM",
12+
"metric_name": "foo_metric",
13+
"metric_value": "300",
14+
"modified": "2025-04-02T12:54:47+00:00"
15+
},
16+
{
17+
"job_name": "a6zi_SIM",
18+
"metric_name": "bar_metric",
19+
"metric_value": "ICON-model",
20+
"modified": "2025-04-02T12:54:47+00:00"
21+
},
22+
{
23+
"job_name": "a6zi_POST",
24+
"metric_name": "qux_metric",
25+
"metric_value": "2000321401340134902340324913123412423",
26+
"modified": "2025-04-02T12:54:47+00:00"
27+
}
28+
]
29+
}

src/layout/ExperimentWrapper.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ const EXPERIMENT_MENU_ITEMS = [
4545
iconClass: "fa-solid fa-stopwatch",
4646
route: "/performance"
4747
},
48+
{
49+
name: "USER METRICS",
50+
iconClass: "fa-solid fa-magnifying-glass-chart",
51+
route: "/user-metrics"
52+
}
4853
]
4954

5055
const ExperimentMenuItems = ({ showLabels = true }) => {

src/layout/Router.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { appActions } from "../store/appSlice";
2727
import { useEffect } from "react";
2828
import Footer from "./Footer";
2929
import TopAnnouncement from "./TopAnnouncement";
30+
import UserMetricsPage from "../pages/UserMetricsPage";
3031

3132
const router = createBrowserRouter(
3233
[
@@ -102,6 +103,10 @@ const router = createBrowserRouter(
102103
path: "/experiment/:expid/performance",
103104
element: <ExperimentPerformance />,
104105
},
106+
{
107+
path: "/experiment/:expid/user-metrics",
108+
element: <UserMetricsPage />,
109+
},
105110
],
106111
},
107112
],

src/pages/UserMetricsPage.jsx

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
import { useMemo } from "react";
2+
import useASTitle from "../hooks/useASTitle";
3+
import useBreadcrumb from "../hooks/useBreadcrumb";
4+
import { useParams, useSearchParams } from "react-router-dom";
5+
import { autosubmitApiV4 } from "../services/autosubmitApiV4";
6+
import {
7+
Table,
8+
TableHead,
9+
TableRow,
10+
TableHeader,
11+
TableBody,
12+
TableCell,
13+
} from "../common/Table";
14+
import { cn } from "../services/utils";
15+
16+
const UserMetricsTable = ({ metrics }) => {
17+
const groupedMetrics = useMemo(() => {
18+
return metrics?.reduce((acc, metric) => {
19+
const { job_name, metric_name, metric_value } = metric;
20+
if (!acc[job_name]) {
21+
acc[job_name] = [];
22+
}
23+
acc[job_name].push({ metric_name, metric_value });
24+
return acc;
25+
}, {});
26+
}, [metrics]);
27+
28+
return (
29+
<div className="overflow-auto custom-scrollbar">
30+
<Table>
31+
<TableHead className="bg-dark text-white">
32+
<TableRow>
33+
<TableHeader className="w-1/4">Job Name</TableHeader>
34+
<TableHeader className="w-1/4">Metric</TableHeader>
35+
<TableHeader className="w-1/2">Value</TableHeader>
36+
</TableRow>
37+
</TableHead>
38+
<TableBody>
39+
{groupedMetrics &&
40+
Object.entries(groupedMetrics).map(([jobName, metrics]) => {
41+
return metrics.map((metric, index) => (
42+
<TableRow key={`${jobName}-${metric.metric_name}`}>
43+
{index === 0 && (
44+
<TableCell
45+
rowSpan={metrics.length}
46+
className="font-semibold"
47+
>
48+
{jobName}
49+
</TableCell>
50+
)}
51+
<TableCell className="font-semibold">
52+
{metric.metric_name}
53+
</TableCell>
54+
<TableCell>{metric.metric_value}</TableCell>
55+
</TableRow>
56+
));
57+
})}
58+
{groupedMetrics && Object.keys(groupedMetrics).length === 0 && (
59+
<TableRow>
60+
<TableCell colSpan={3} className="text-center opacity-50 py-6">
61+
No metrics available
62+
</TableCell>
63+
</TableRow>
64+
)}
65+
</TableBody>
66+
</Table>
67+
</div>
68+
);
69+
};
70+
71+
const UserMetricsSelection = ({ expid, runs }) => {
72+
const [searchParams, setSearchParams] = useSearchParams();
73+
74+
const runId = useMemo(() => {
75+
return searchParams.get("run_id") || runs[0].run_id;
76+
}, [searchParams]);
77+
78+
const { data, isFetching } =
79+
autosubmitApiV4.endpoints.getUserMetricsByRun.useQuery(
80+
{
81+
expid: expid,
82+
run_id: runId,
83+
},
84+
{
85+
skip: !runId,
86+
}
87+
);
88+
89+
return (
90+
<div className="flex flex-col gap-4">
91+
<div className="flex items-center gap-4 mx-8">
92+
<label htmlFor="select-run-user-metrics" className="font-semibold">
93+
Select Run:
94+
</label>
95+
<select
96+
id="select-run-user-metrics"
97+
className="border rounded-xl px-2 py-3 bg-white text-black min-w-[25%] max-w-full"
98+
value={runId}
99+
onChange={(e) => setSearchParams({ run_id: e.target.value })}
100+
>
101+
<option value="" disabled>
102+
Select a run
103+
</option>
104+
{runs.map((run) => (
105+
<option key={run.run_id} value={run.run_id}>
106+
Run {run.run_id}
107+
</option>
108+
))}
109+
</select>
110+
</div>
111+
<div className="flex flex-col">
112+
{isFetching ? (
113+
<div className="w-full h-full flex items-center justify-center">
114+
<div className="spinner-border" role="status"></div>
115+
</div>
116+
) : (
117+
<UserMetricsTable metrics={data?.metrics} />
118+
)}
119+
</div>
120+
</div>
121+
);
122+
};
123+
124+
const UserMetricsPage = () => {
125+
const routeParams = useParams();
126+
useASTitle(`Experiment ${routeParams.expid} user-defined metrics`);
127+
useBreadcrumb([
128+
{
129+
name: `Experiment ${routeParams.expid}`,
130+
route: `/experiment/${routeParams.expid}`,
131+
},
132+
{
133+
name: `User-defined metrics`,
134+
route: `/experiment/${routeParams.expid}/user-metrics`,
135+
},
136+
]);
137+
138+
const {
139+
data: runsData,
140+
isFetching: runsIsFetching,
141+
isError: runsIsError,
142+
} = autosubmitApiV4.endpoints.getUserMetricsRuns.useQuery({
143+
expid: routeParams.expid,
144+
});
145+
146+
return (
147+
<div className="w-full h-full flex flex-col min-w-0">
148+
{runsIsFetching ? (
149+
<div className="w-full h-full flex items-center justify-center">
150+
<div className="spinner-border" role="status"></div>
151+
</div>
152+
) : (
153+
<>
154+
{runsIsError ||
155+
!Array.isArray(runsData?.runs) ||
156+
runsData.runs.length === 0 ? (
157+
<div
158+
className={cn(
159+
"w-full grow flex flex-col gap-8 items-center justify-center",
160+
runsData?.runs?.length === 0 ? "opacity-50" : "text-danger"
161+
)}
162+
>
163+
<i className="fa-solid fa-triangle-exclamation text-9xl"></i>
164+
<div className="text-2xl">
165+
{runsData?.runs?.length === 0
166+
? "No runs with user metrics found"
167+
: "Error fetching the experiment runs"}
168+
</div>
169+
</div>
170+
) : (
171+
<UserMetricsSelection
172+
expid={routeParams.expid}
173+
runs={[...runsData.runs].sort((a, b) => b.run_id - a.run_id)}
174+
/>
175+
)}
176+
</>
177+
)}
178+
</div>
179+
);
180+
};
181+
182+
export default UserMetricsPage;

src/services/autosubmitApiV4.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,22 @@ export const autosubmitApiV4 = createApi({
7777
}
7878
}
7979
}),
80+
getUserMetricsRuns: builder.query({
81+
query: ({ expid }) => {
82+
return {
83+
url: `experiments/${expid}/user-metrics-runs`,
84+
method: "GET"
85+
}
86+
}
87+
}),
88+
getUserMetricsByRun: builder.query({
89+
query: ({ expid, run_id }) => {
90+
return {
91+
url: `experiments/${expid}/runs/${run_id}/user-metrics`,
92+
method: "GET"
93+
}
94+
}
95+
}),
8096
login: builder.mutation({
8197
query: ({ provider, ticket, service, code, redirect_uri }) => {
8298
if (provider === "oidc") {

0 commit comments

Comments
 (0)