Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 2 additions & 7 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,9 @@ jobs:
./clever-tools-latest_linux/clever link $CLEVER_APP_ID
./clever-tools-latest_linux/clever deploy -f

- name: Deploy frontend to Clever Cloud (PROD)
- name: Deploy frontend (static SPA) to Clever Cloud (PROD)
env:
CLEVER_APP_ID: ${{ secrets.FE_CLEVER_APP_ID_PROD }}
APP_NAME: cc_dashboard_prod
run: |
echo $CLEVER_APP_ID
echo $APP_NAME
./clever-tools-latest_linux/clever link $CLEVER_APP_ID
# As the clever tools CLI aliasing is escaping _ character, a temporary hard-coded value is needed
# waiting for a fix from Clever
./clever-tools-latest_linux/clever deploy -f -a ccdashboardprod --quiet
./clever-tools-latest_linux/clever deploy -f
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,14 @@ def get_project_detailed_sums_by_experiment(
)
.join(
SqlModelEmission,
SqlModelRun.id == SqlModelEmission.run_id,
and_(
SqlModelRun.id == SqlModelEmission.run_id,
SqlModelEmission.timestamp >= start_date,
SqlModelEmission.timestamp <= end_date,
),
isouter=True,
)
.filter(SqlModelExperiment.project_id == project_id)
.filter(
and_(SqlModelEmission.timestamp >= start_date),
(SqlModelEmission.timestamp <= end_date),
)
.group_by(
SqlModelExperiment.id,
SqlModelExperiment.timestamp,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,27 +159,21 @@ def get_experiment_detailed_sums_by_run(
)
.join(
SqlModelEmission,
SqlModelRun.id == SqlModelEmission.run_id,
and_(
SqlModelRun.id == SqlModelEmission.run_id,
SqlModelEmission.timestamp >= start_date,
SqlModelEmission.timestamp <= end_date,
),
isouter=True,
)
.filter(SqlModelRun.experiment_id == experiment_id)
.filter(
and_(SqlModelEmission.timestamp >= start_date),
(SqlModelEmission.timestamp <= end_date),
)
.group_by(
SqlModelRun.id,
SqlModelRun.timestamp,
)
.all()
)
# TODO: Remove this log XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
logger.debug(f"get_experiment_detailed_sums_by_run {res=}")
if res is None:
return []
# Ca à l'air d'être le return qui n'est plus accepter car PyDantic refuse de
# faire rentrer res dans RunReport
return res
return res or []

def get_project_last_run(self, project_id, start_date, end_date) -> Union[Run]:
"""Find the last run of a project in database between two dates and return it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,8 @@ def compute_detailed_sum(
self, project_id: str, start_date, end_date, user=None
) -> List[ExperimentReport]:
# TODO: check permissions
sums = self._experiment_repository.get_project_detailed_sums_by_experiment(
return self._experiment_repository.get_project_detailed_sums_by_experiment(
project_id,
start_date,
end_date,
)
print(sums)
return sums
15 changes: 15 additions & 0 deletions webapp/.env.development
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Local dev defaults. Loaded automatically by Vite on `npm run dev`.
# Override per-developer with `.env.development.local` (gitignored).

# Empty VITE_API_URL → the mock fetch interceptor catches every
# same-origin /api request and serves from src/api/mock/data.ts.
# Set this to a real API base (e.g. http://localhost:8008) and unset
# VITE_USE_MOCK_DATA to talk to a local carbonserver instead.
VITE_API_URL=
VITE_BASE_URL=http://localhost:3000

VITE_USE_MOCK_DATA=true

# Used by share-project-button to produce shareable public links.
# Any 32-byte (256-bit) hex string works for dev.
VITE_PROJECT_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
24 changes: 24 additions & 0 deletions webapp/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Example webapp env. Copy to `.env.development.local` (gitignored) to
# override local dev defaults from `.env.development`, or to
# `.env.production.local` for production builds.

# Base URL of the carbonserver API. Leave empty in mock mode.
VITE_API_URL=http://localhost:8008

# Public origin of this webapp. Used to build OAuth redirect URLs and
# share links.
VITE_BASE_URL=http://localhost:3000

# Toggle the in-browser mock service (src/api/mock/). When "true", every
# same-origin fetch is resolved from synthetic data and no carbonserver
# is required.
VITE_USE_MOCK_DATA=false

# AES key used to encrypt project ids in shareable public links. Must be
# 64 hex chars (256 bits). Keep stable across the fleet — rotating it
# invalidates existing links.
VITE_PROJECT_ENCRYPTION_KEY=

# Fief profile base URL — surfaces a "Profile" item in the navbar that
# links to the IdP's account page. Leave empty to hide it.
VITE_FIEF_BASE_URL=
2 changes: 1 addition & 1 deletion webapp/e2e/landing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ test.describe("Landing page (mock mode)", () => {
).toBeVisible();

// In mock mode the real-login button is hidden — there is no real
// OAuth backend in this build.
// OAuth backend in this build. Only the mock button is rendered.
await expect(page.getByTestId("real-login")).toHaveCount(0);
await expect(page.getByTestId("mock-login")).toBeVisible();
});
Expand Down
129 changes: 129 additions & 0 deletions webapp/e2e/mock-service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { test, expect, Page } from "@playwright/test";

const ORG_ID = "mock-org-1";
const PROJECT_ID = "mock-project-1";

async function logIn(page: Page) {
await page.goto("/");
await page.getByTestId("mock-login").click();
// The mock login bounces to /home, which then redirects to /<org-id>
// as soon as the mock organizations call resolves. Wait for either.
await page.waitForURL(/\/(home|mock-org-1)/);
}

function collectConsoleErrors(page: Page): string[] {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("pageerror", (err) => errors.push(err.message));
return errors;
}

test.describe("Mock service end-to-end", () => {
test("HomePage redirects to the mock org dashboard", async ({ page }) => {
const errors = collectConsoleErrors(page);
await logIn(page);

// HomePage reads /organizations, picks the first, and navigates.
await expect(page).toHaveURL(new RegExp(`/${ORG_ID}$`));
expect(errors).toEqual([]);
});

test("Projects list shows both mock projects", async ({ page }) => {
const errors = collectConsoleErrors(page);
await logIn(page);
await page.goto(`/${ORG_ID}/projects`);

await expect(
page.getByRole("heading", { name: /^projects$/i }),
).toBeVisible();
await expect(page.getByText("ML Training Pipeline")).toBeVisible();
await expect(page.getByText("Inference Service")).toBeVisible();
expect(errors).toEqual([]);
});

test("Project dashboard loads experiments, charts, and metrics", async ({
page,
}) => {
const errors = collectConsoleErrors(page);
await logIn(page);
await page.goto(`/${ORG_ID}/projects/${PROJECT_ID}`);

// The page title shows the project name — proves getOneProject
// parsed the snake_case wire shape into the camelCase app shape.
await expect(
page.getByRole("heading", {
name: /^project ml training pipeline$/i,
}),
).toBeVisible();

// Chart card titles render (skeleton would not include them).
await expect(page.getByText(/project experiment runs/i)).toBeVisible();
await expect(
page.getByText(/scatter chart - emissions by run id/i),
).toBeVisible();

// Experiments live in a Radix Select portal — open it to assert
// the dropdown is populated from /projects/:id/experiments.
await page.getByTestId("experiment-select").click();
await expect(
page.getByRole("option", { name: "Baseline run" }),
).toBeVisible();
await expect(
page.getByRole("option", { name: "Optimized model" }),
).toBeVisible();

// Pick one — that fires runs/sums and the scatter chart hydrates.
await page.getByRole("option", { name: "Baseline run" }).click();
await expect(page.getByTestId("experiment-details")).toBeVisible();

// Neither chart should be left on its "No data available" fallback —
// that would mean the mock fetch interceptor missed a request.
await expect(page.getByText(/no data available/i)).toHaveCount(0);

expect(errors).toEqual([]);
});

test("Project settings page shows the API tokens table", async ({
page,
}) => {
const errors = collectConsoleErrors(page);
await logIn(page);
await page.goto(`/${ORG_ID}/projects/${PROJECT_ID}/settings`);

// From MOCK.token.byProjectId:
await expect(page.getByText("Local dev token")).toBeVisible();
expect(errors).toEqual([]);
});

test("Org dashboard renders without ErrorMessage (single-org fetch works)", async ({
page,
}) => {
const errors = collectConsoleErrors(page);
await logIn(page);
await page.goto(`/${ORG_ID}`);

// OrgDashboardPage hits /organizations/<id> and /organizations/<id>/sums.
// If either is missing from the mock, the page renders <ErrorMessage />.
await expect(page.getByText(/an error occurred/i)).toHaveCount(0);

// The radial cards contain unit labels — at least one should be visible.
await expect(page.getByText("kg eq CO2").first()).toBeVisible();
expect(errors).toEqual([]);
});

test("Members page lists organization users", async ({ page }) => {
const errors = collectConsoleErrors(page);
await logIn(page);
await page.goto(`/${ORG_ID}/members`);

// MembersPage fetches /organizations/<id>/users — must not be the
// ErrorMessage fallback.
await expect(page.getByText(/an error occurred/i)).toHaveCount(0);

await expect(page.getByText("Mock Admin")).toBeVisible();
await expect(page.getByText("Mock Member")).toBeVisible();
expect(errors).toEqual([]);
});
});
16 changes: 2 additions & 14 deletions webapp/src/api/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,10 @@ export async function createExperiment(

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

try {
const result = await fetchApi(url, ExperimentReportSchema.array());
return result.filter(
(r) =>
typeof r.experiment_id === "string" &&
r.experiment_id.length > 0,
);
return await fetchApi(url, ExperimentReportSchema.array());
} catch (error) {
console.error("[getProjectEmissionsByExperiment] failed", error);
return [];
Expand Down
10 changes: 0 additions & 10 deletions webapp/src/api/mock/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,6 @@ const handlers: Handler[] = [
}
if (method === "DELETE") return noContent();
}
const publicMatch = pathname.match(/^\/projects\/public\/([^/]+)$/);
if (method === "GET" && publicMatch) {
// Treat the encrypted_id as the project id for mock purposes.
const project = MOCK.project.byId[publicMatch[1]];
return project ? ok(project) : notFound();
}
const shareLink = pathname.match(/^\/projects\/([^/]+)\/share-link$/);
if (method === "GET" && shareLink) {
return ok({ encrypted_id: shareLink[1] });
}
return undefined;
},

Expand Down
32 changes: 25 additions & 7 deletions webapp/src/api/mock/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function installMockFetch(): void {

const apiBase = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, "");
const apiPathPrefix = apiBase
? new URL(apiBase).pathname.replace(/\/$/, "")
? (safeUrl(apiBase)?.pathname.replace(/\/$/, "") ?? "")
: "";
const realFetch = window.fetch.bind(window);

Expand All @@ -29,16 +29,26 @@ export function installMockFetch(): void {
? input.toString()
: input.url;

const isApiCall = apiBase ? rawUrl.startsWith(apiBase) : false;
// With an explicit VITE_API_URL we only intercept requests aimed at
// it. With no VITE_API_URL (the default dev/mock setup), every
// same-origin fetch is treated as an API call — Vite's static
// assets are loaded via <script>/<link>/<img>, not fetch(), so
// this interception is safe.
const absoluteUrl = new URL(rawUrl, window.location.origin);
const isApiCall = apiBase
? rawUrl.startsWith(apiBase)
: absoluteUrl.origin === window.location.origin;

if (!isApiCall) return realFetch(input, init);

const url = new URL(rawUrl);
const relPath =
apiPathPrefix && url.pathname.startsWith(apiPathPrefix)
? url.pathname.slice(apiPathPrefix.length) || "/"
: url.pathname;
const relUrl = new URL(relPath + url.search, "http://mock.local");
apiPathPrefix && absoluteUrl.pathname.startsWith(apiPathPrefix)
? absoluteUrl.pathname.slice(apiPathPrefix.length) || "/"
: absoluteUrl.pathname;
const relUrl = new URL(
relPath + absoluteUrl.search,
"http://mock.local",
);
const method = (init?.method ?? "GET").toUpperCase();
const parsedBody = parseBody(init?.body);
const result = resolveMock(relUrl, method, parsedBody);
Expand All @@ -61,6 +71,14 @@ export function installMockFetch(): void {
);
}

function safeUrl(value: string): URL | null {
try {
return new URL(value);
} catch {
return null;
}
}

function parseBody(body: BodyInit | null | undefined): unknown {
if (typeof body !== "string") return undefined;
try {
Expand Down
1 change: 0 additions & 1 deletion webapp/src/api/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export async function getOrganizations(): Promise<Organization[]> {
try {
return await fetchApi("/organizations", OrganizationSchema.array());
} catch (error) {
console.error("[getOrganizations] failed", error);
return [];
}
}
Expand Down
Loading
Loading