Skip to content

Commit c52b27f

Browse files
committed
fix: add skeletons to show loading phase for scatter charts & time series charts
1 parent 02c99cc commit c52b27f

10 files changed

Lines changed: 359 additions & 89 deletions

File tree

carbonserver/carbonserver/api/infra/repositories/repository_experiments.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,14 +155,14 @@ def get_project_detailed_sums_by_experiment(
155155
)
156156
.join(
157157
SqlModelEmission,
158-
SqlModelRun.id == SqlModelEmission.run_id,
158+
and_(
159+
SqlModelRun.id == SqlModelEmission.run_id,
160+
SqlModelEmission.timestamp >= start_date,
161+
SqlModelEmission.timestamp <= end_date,
162+
),
159163
isouter=True,
160164
)
161165
.filter(SqlModelExperiment.project_id == project_id)
162-
.filter(
163-
and_(SqlModelEmission.timestamp >= start_date),
164-
(SqlModelEmission.timestamp <= end_date),
165-
)
166166
.group_by(
167167
SqlModelExperiment.id,
168168
SqlModelExperiment.timestamp,

carbonserver/carbonserver/api/infra/repositories/repository_runs.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -159,27 +159,21 @@ def get_experiment_detailed_sums_by_run(
159159
)
160160
.join(
161161
SqlModelEmission,
162-
SqlModelRun.id == SqlModelEmission.run_id,
162+
and_(
163+
SqlModelRun.id == SqlModelEmission.run_id,
164+
SqlModelEmission.timestamp >= start_date,
165+
SqlModelEmission.timestamp <= end_date,
166+
),
163167
isouter=True,
164168
)
165169
.filter(SqlModelRun.experiment_id == experiment_id)
166-
.filter(
167-
and_(SqlModelEmission.timestamp >= start_date),
168-
(SqlModelEmission.timestamp <= end_date),
169-
)
170170
.group_by(
171171
SqlModelRun.id,
172172
SqlModelRun.timestamp,
173173
)
174174
.all()
175175
)
176-
# TODO: Remove this log XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
177-
logger.debug(f"get_experiment_detailed_sums_by_run {res=}")
178-
if res is None:
179-
return []
180-
# Ca à l'air d'être le return qui n'est plus accepter car PyDantic refuse de
181-
# faire rentrer res dans RunReport
182-
return res
176+
return res or []
183177

184178
def get_project_last_run(self, project_id, start_date, end_date) -> Union[Run]:
185179
"""Find the last run of a project in database between two dates and return it

carbonserver/carbonserver/api/usecases/experiment/project_sum_by_experiment.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,8 @@ def compute_detailed_sum(
1414
self, project_id: str, start_date, end_date, user=None
1515
) -> List[ExperimentReport]:
1616
# TODO: check permissions
17-
sums = self._experiment_repository.get_project_detailed_sums_by_experiment(
17+
return self._experiment_repository.get_project_detailed_sums_by_experiment(
1818
project_id,
1919
start_date,
2020
end_date,
2121
)
22-
print(sums)
23-
return sums

webapp/e2e/mock-service.spec.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { test, expect, Page } from "@playwright/test";
2+
3+
const ORG_ID = "mock-org-1";
4+
const PROJECT_ID = "mock-project-1";
5+
6+
async function logIn(page: Page) {
7+
await page.goto("/");
8+
await page.getByTestId("mock-login").click();
9+
// The mock login bounces to /home, which then redirects to /<org-id>
10+
// as soon as the mock organizations call resolves. Wait for either.
11+
await page.waitForURL(/\/(home|mock-org-1)/);
12+
}
13+
14+
function collectConsoleErrors(page: Page): string[] {
15+
const errors: string[] = [];
16+
page.on("console", (msg) => {
17+
if (msg.type() === "error") errors.push(msg.text());
18+
});
19+
page.on("pageerror", (err) => errors.push(err.message));
20+
return errors;
21+
}
22+
23+
test.describe("Mock service end-to-end", () => {
24+
test("HomePage redirects to the mock org dashboard", async ({ page }) => {
25+
const errors = collectConsoleErrors(page);
26+
await logIn(page);
27+
28+
// HomePage reads /organizations, picks the first, and navigates.
29+
await expect(page).toHaveURL(new RegExp(`/${ORG_ID}$`));
30+
expect(errors).toEqual([]);
31+
});
32+
33+
test("Projects list shows both mock projects", async ({ page }) => {
34+
const errors = collectConsoleErrors(page);
35+
await logIn(page);
36+
await page.goto(`/${ORG_ID}/projects`);
37+
38+
await expect(
39+
page.getByRole("heading", { name: /^projects$/i }),
40+
).toBeVisible();
41+
await expect(page.getByText("ML Training Pipeline")).toBeVisible();
42+
await expect(page.getByText("Inference Service")).toBeVisible();
43+
expect(errors).toEqual([]);
44+
});
45+
46+
test("Project dashboard loads experiments, charts, and metrics", async ({
47+
page,
48+
}) => {
49+
const errors = collectConsoleErrors(page);
50+
await logIn(page);
51+
await page.goto(`/${ORG_ID}/projects/${PROJECT_ID}`);
52+
53+
// The page title shows the project name — proves getOneProject
54+
// parsed the snake_case wire shape into the camelCase app shape.
55+
await expect(
56+
page.getByRole("heading", {
57+
name: /^project ml training pipeline$/i,
58+
}),
59+
).toBeVisible();
60+
61+
// Experiments table is populated from /projects/:id/experiments.
62+
await expect(page.getByText("Baseline run")).toBeVisible();
63+
await expect(page.getByText("Optimized model")).toBeVisible();
64+
65+
// The two charts must NOT show their "No data available" fallback —
66+
// that would mean the mock fetch interceptor missed the request.
67+
await expect(page.getByText(/no data available/i)).toHaveCount(0);
68+
69+
// Bar chart card title appears (rendered, not skeleton).
70+
await expect(page.getByText(/project experiment runs/i)).toBeVisible();
71+
await expect(
72+
page.getByText(/scatter chart - emissions by run id/i),
73+
).toBeVisible();
74+
75+
expect(errors).toEqual([]);
76+
});
77+
78+
test("Project settings page shows the API tokens table", async ({
79+
page,
80+
}) => {
81+
const errors = collectConsoleErrors(page);
82+
await logIn(page);
83+
await page.goto(`/${ORG_ID}/projects/${PROJECT_ID}/settings`);
84+
85+
// From MOCK.token.byProjectId:
86+
await expect(page.getByText("Local dev token")).toBeVisible();
87+
expect(errors).toEqual([]);
88+
});
89+
90+
test("Org dashboard renders without ErrorMessage (single-org fetch works)", async ({
91+
page,
92+
}) => {
93+
const errors = collectConsoleErrors(page);
94+
await logIn(page);
95+
await page.goto(`/${ORG_ID}`);
96+
97+
// OrgDashboardPage hits /organizations/<id> and /organizations/<id>/sums.
98+
// If either is missing from the mock, the page renders <ErrorMessage />.
99+
await expect(page.getByText(/an error occurred/i)).toHaveCount(0);
100+
101+
// The radial cards contain unit labels — at least one should be visible.
102+
await expect(page.getByText("kg eq CO2").first()).toBeVisible();
103+
expect(errors).toEqual([]);
104+
});
105+
106+
test("Members page lists organization users", async ({ page }) => {
107+
const errors = collectConsoleErrors(page);
108+
await logIn(page);
109+
await page.goto(`/${ORG_ID}/members`);
110+
111+
// MembersPage fetches /organizations/<id>/users — must not be the
112+
// ErrorMessage fallback.
113+
await expect(page.getByText(/an error occurred/i)).toHaveCount(0);
114+
115+
await expect(page.getByText("Mock Admin")).toBeVisible();
116+
await expect(page.getByText("Mock Member")).toBeVisible();
117+
expect(errors).toEqual([]);
118+
});
119+
});

webapp/src/components/chart-skeleton.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@ import { Skeleton } from "@/components/ui/skeleton";
22
import { Card, CardContent, CardHeader } from "@/components/ui/card";
33

44
interface ChartSkeletonProps {
5-
title?: string;
65
className?: string;
76
height?: number;
7+
bars?: number;
88
}
99

10+
// Pre-computed so the bars don't rejiggle on every render.
11+
const BAR_HEIGHTS = Array.from(
12+
{ length: 24 },
13+
() => Math.floor(Math.random() * 60) + 20,
14+
);
15+
1016
export default function ChartSkeleton({
11-
title = "Loading...",
1217
className = "",
1318
height = 250,
19+
bars = 12,
1420
}: ChartSkeletonProps) {
1521
return (
1622
<Card className={className}>
@@ -19,7 +25,22 @@ export default function ChartSkeleton({
1925
<Skeleton className="h-4 w-4/5" />
2026
</CardHeader>
2127
<CardContent>
22-
<Skeleton className="w-full" style={{ height }} />
28+
<div
29+
className="flex items-end justify-around gap-1 px-2"
30+
style={{ height }}
31+
aria-hidden
32+
>
33+
{Array.from({ length: bars }).map((_, i) => (
34+
<Skeleton
35+
key={i}
36+
className="w-full rounded-t"
37+
style={{
38+
height: `${BAR_HEIGHTS[i % BAR_HEIGHTS.length]}%`,
39+
animationDelay: `${i * 0.05}s`,
40+
}}
41+
/>
42+
))}
43+
</div>
2344
</CardContent>
2445
</Card>
2546
);

webapp/src/components/emissions-time-series.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import * as React from "react";
1616
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
1717

1818
import { ExportCsvButton } from "@/components/export-csv-button";
19+
import ChartSkeleton from "@/components/chart-skeleton";
1920
import { getEmissionsTimeSeries } from "@/api/runs";
2021
import { exportEmissionsTimeSeriesCsv } from "@/utils/export";
22+
import { pickTimeFormat } from "@/helpers/time-axis";
2123
import { format } from "date-fns";
2224
import { Cpu, HardDrive, Server } from "lucide-react";
2325

@@ -75,13 +77,26 @@ export default function EmissionsTimeSeriesChart({
7577
}
7678

7779
if (isLoading) {
78-
return <div>Loading...</div>;
80+
return <ChartSkeleton height={300} />;
7981
}
8082

8183
if (!emissionTimeSeries || !emissionTimeSeries.metadata) {
8284
return <div>No data available</div>;
8385
}
8486

87+
// Recharts category axis distributes ticks evenly by index, so 200 samples
88+
// taken in the same minute produce 200 identical-looking labels. Re-key on
89+
// a numeric timestamp and a time scale so the axis spaces ticks by *when*
90+
// points happened, not by how many of them there are.
91+
const points = emissionTimeSeries.emissions.map((e) => ({
92+
...e,
93+
ts: new Date(e.timestamp).getTime(),
94+
}));
95+
const spanMs = points.length
96+
? points[points.length - 1].ts - points[0].ts
97+
: 0;
98+
const tickFmt = pickTimeFormat(spanMs);
99+
85100
return (
86101
<div className="grid gap-4 md:grid-cols-2">
87102
<Card>
@@ -189,21 +204,24 @@ export default function EmissionsTimeSeriesChart({
189204
className="aspect-auto h-[250px] w-full"
190205
>
191206
<LineChart
192-
data={emissionTimeSeries.emissions}
207+
data={points}
193208
margin={{
194209
left: 12,
195210
right: 12,
196211
}}
197212
>
198213
<CartesianGrid vertical={false} />
199214
<XAxis
200-
dataKey="timestamp"
215+
dataKey="ts"
216+
type="number"
217+
scale="time"
218+
domain={["dataMin", "dataMax"]}
201219
tickLine={false}
202220
axisLine={false}
203221
tickMargin={8}
204-
minTickGap={32}
222+
minTickGap={48}
205223
tickFormatter={(value) =>
206-
format(new Date(value), "MMM d, HH:mm")
224+
format(new Date(value), tickFmt)
207225
}
208226
/>
209227
<YAxis
@@ -214,11 +232,11 @@ export default function EmissionsTimeSeriesChart({
214232
<ChartTooltip
215233
content={
216234
<ChartTooltipContent
217-
className="w-[150px]"
235+
className="w-[180px]"
218236
labelFormatter={(value) =>
219237
format(
220-
new Date(value),
221-
"MMM d, yyyy HH:mm",
238+
new Date(value as number),
239+
"MMM d, yyyy HH:mm:ss",
222240
)
223241
}
224242
/>

0 commit comments

Comments
 (0)