Skip to content

Commit 9cafda3

Browse files
authored
fix(front): fix dashboard public sharing and front ci (#1212)
* fix(front): add public project encryption key & ci update * fix: add skeletons to show loading phase for scatter charts & time series charts * fix: fix front end CD * fix: update mocks for tests
1 parent 05d7b6c commit 9cafda3

39 files changed

Lines changed: 1099 additions & 625 deletions

.github/workflows/deploy.yml

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,14 +41,9 @@ jobs:
4141
./clever-tools-latest_linux/clever link $CLEVER_APP_ID
4242
./clever-tools-latest_linux/clever deploy -f
4343
44-
- name: Deploy frontend to Clever Cloud (PROD)
44+
- name: Deploy frontend (static SPA) to Clever Cloud (PROD)
4545
env:
4646
CLEVER_APP_ID: ${{ secrets.FE_CLEVER_APP_ID_PROD }}
47-
APP_NAME: cc_dashboard_prod
4847
run: |
49-
echo $CLEVER_APP_ID
50-
echo $APP_NAME
5148
./clever-tools-latest_linux/clever link $CLEVER_APP_ID
52-
# As the clever tools CLI aliasing is escaping _ character, a temporary hard-coded value is needed
53-
# waiting for a fix from Clever
54-
./clever-tools-latest_linux/clever deploy -f -a ccdashboardprod --quiet
49+
./clever-tools-latest_linux/clever deploy -f

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/.env.development

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Local dev defaults. Loaded automatically by Vite on `npm run dev`.
2+
# Override per-developer with `.env.development.local` (gitignored).
3+
4+
# Empty VITE_API_URL → the mock fetch interceptor catches every
5+
# same-origin /api request and serves from src/api/mock/data.ts.
6+
# Set this to a real API base (e.g. http://localhost:8008) and unset
7+
# VITE_USE_MOCK_DATA to talk to a local carbonserver instead.
8+
VITE_API_URL=
9+
VITE_BASE_URL=http://localhost:3000
10+
11+
VITE_USE_MOCK_DATA=true
12+
13+
# Used by share-project-button to produce shareable public links.
14+
# Any 32-byte (256-bit) hex string works for dev.
15+
VITE_PROJECT_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef

webapp/.env.example

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Example webapp env. Copy to `.env.development.local` (gitignored) to
2+
# override local dev defaults from `.env.development`, or to
3+
# `.env.production.local` for production builds.
4+
5+
# Base URL of the carbonserver API. Leave empty in mock mode.
6+
VITE_API_URL=http://localhost:8008
7+
8+
# Public origin of this webapp. Used to build OAuth redirect URLs and
9+
# share links.
10+
VITE_BASE_URL=http://localhost:3000
11+
12+
# Toggle the in-browser mock service (src/api/mock/). When "true", every
13+
# same-origin fetch is resolved from synthetic data and no carbonserver
14+
# is required.
15+
VITE_USE_MOCK_DATA=false
16+
17+
# AES key used to encrypt project ids in shareable public links. Must be
18+
# 64 hex chars (256 bits). Keep stable across the fleet — rotating it
19+
# invalidates existing links.
20+
VITE_PROJECT_ENCRYPTION_KEY=
21+
22+
# Fief profile base URL — surfaces a "Profile" item in the navbar that
23+
# links to the IdP's account page. Leave empty to hide it.
24+
VITE_FIEF_BASE_URL=

webapp/e2e/landing.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ test.describe("Landing page (mock mode)", () => {
1111
).toBeVisible();
1212

1313
// In mock mode the real-login button is hidden — there is no real
14-
// OAuth backend in this build.
14+
// OAuth backend in this build. Only the mock button is rendered.
1515
await expect(page.getByTestId("real-login")).toHaveCount(0);
1616
await expect(page.getByTestId("mock-login")).toBeVisible();
1717
});

webapp/e2e/mock-service.spec.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
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+
// Chart card titles render (skeleton would not include them).
62+
await expect(page.getByText(/project experiment runs/i)).toBeVisible();
63+
await expect(
64+
page.getByText(/scatter chart - emissions by run id/i),
65+
).toBeVisible();
66+
67+
// Experiments live in a Radix Select portal — open it to assert
68+
// the dropdown is populated from /projects/:id/experiments.
69+
await page.getByTestId("experiment-select").click();
70+
await expect(
71+
page.getByRole("option", { name: "Baseline run" }),
72+
).toBeVisible();
73+
await expect(
74+
page.getByRole("option", { name: "Optimized model" }),
75+
).toBeVisible();
76+
77+
// Pick one — that fires runs/sums and the scatter chart hydrates.
78+
await page.getByRole("option", { name: "Baseline run" }).click();
79+
await expect(page.getByTestId("experiment-details")).toBeVisible();
80+
81+
// Neither chart should be left on its "No data available" fallback —
82+
// that would mean the mock fetch interceptor missed a request.
83+
await expect(page.getByText(/no data available/i)).toHaveCount(0);
84+
85+
expect(errors).toEqual([]);
86+
});
87+
88+
test("Project settings page shows the API tokens table", async ({
89+
page,
90+
}) => {
91+
const errors = collectConsoleErrors(page);
92+
await logIn(page);
93+
await page.goto(`/${ORG_ID}/projects/${PROJECT_ID}/settings`);
94+
95+
// From MOCK.token.byProjectId:
96+
await expect(page.getByText("Local dev token")).toBeVisible();
97+
expect(errors).toEqual([]);
98+
});
99+
100+
test("Org dashboard renders without ErrorMessage (single-org fetch works)", async ({
101+
page,
102+
}) => {
103+
const errors = collectConsoleErrors(page);
104+
await logIn(page);
105+
await page.goto(`/${ORG_ID}`);
106+
107+
// OrgDashboardPage hits /organizations/<id> and /organizations/<id>/sums.
108+
// If either is missing from the mock, the page renders <ErrorMessage />.
109+
await expect(page.getByText(/an error occurred/i)).toHaveCount(0);
110+
111+
// The radial cards contain unit labels — at least one should be visible.
112+
await expect(page.getByText("kg eq CO2").first()).toBeVisible();
113+
expect(errors).toEqual([]);
114+
});
115+
116+
test("Members page lists organization users", async ({ page }) => {
117+
const errors = collectConsoleErrors(page);
118+
await logIn(page);
119+
await page.goto(`/${ORG_ID}/members`);
120+
121+
// MembersPage fetches /organizations/<id>/users — must not be the
122+
// ErrorMessage fallback.
123+
await expect(page.getByText(/an error occurred/i)).toHaveCount(0);
124+
125+
await expect(page.getByText("Mock Admin")).toBeVisible();
126+
await expect(page.getByText("Mock Member")).toBeVisible();
127+
expect(errors).toEqual([]);
128+
});
129+
});

webapp/src/api/experiments.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,10 @@ export async function createExperiment(
1919

2020
export async function getExperiments(projectId: string): Promise<Experiment[]> {
2121
try {
22-
const result = await fetchApi(
22+
return await fetchApi(
2323
`/projects/${projectId}/experiments`,
2424
ExperimentSchema.array(),
2525
);
26-
// Drop experiments that somehow lack a usable id — they cannot be
27-
// selected, fetched, or rendered downstream. Keeping them would
28-
// surface as unselectable rows whose click silently clears the
29-
// selection.
30-
return result.filter(
31-
(e) => typeof e.id === "string" && e.id.length > 0,
32-
);
3326
} catch (error) {
3427
console.error("[getExperiments] failed", error);
3528
return [];
@@ -54,12 +47,7 @@ export async function getProjectEmissionsByExperiment(
5447
}
5548

5649
try {
57-
const result = await fetchApi(url, ExperimentReportSchema.array());
58-
return result.filter(
59-
(r) =>
60-
typeof r.experiment_id === "string" &&
61-
r.experiment_id.length > 0,
62-
);
50+
return await fetchApi(url, ExperimentReportSchema.array());
6351
} catch (error) {
6452
console.error("[getProjectEmissionsByExperiment] failed", error);
6553
return [];

webapp/src/api/mock/handlers.ts

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,16 +101,6 @@ const handlers: Handler[] = [
101101
}
102102
if (method === "DELETE") return noContent();
103103
}
104-
const publicMatch = pathname.match(/^\/projects\/public\/([^/]+)$/);
105-
if (method === "GET" && publicMatch) {
106-
// Treat the encrypted_id as the project id for mock purposes.
107-
const project = MOCK.project.byId[publicMatch[1]];
108-
return project ? ok(project) : notFound();
109-
}
110-
const shareLink = pathname.match(/^\/projects\/([^/]+)\/share-link$/);
111-
if (method === "GET" && shareLink) {
112-
return ok({ encrypted_id: shareLink[1] });
113-
}
114104
return undefined;
115105
},
116106

0 commit comments

Comments
 (0)