Skip to content
Open
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
17 changes: 10 additions & 7 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,17 @@ jobs:
./clever-tools-latest_linux/clever link $CLEVER_APP_ID
./clever-tools-latest_linux/clever deploy -f

- name: Deploy frontend to Clever Cloud (PROD)
# The frontend used to be a Next.js Node.js app on Clever; it's now a
# Vite-built SPA served by Clever's static (Caddy) engine. The deploy
# command is identical (git push), only the target app changes —
# `FE_CLEVER_APP_ID_PROD` must be pointed at the new static app and
# `FE_CLEVER_APP_ALIAS_PROD` set to that app's alias (no underscores;
# see https://github.com/CleverCloud/clever-tools for the alias-escape
# workaround required by `clever deploy -a <alias>`).
- name: Deploy frontend (static SPA) to Clever Cloud (PROD)
env:
CLEVER_APP_ID: ${{ secrets.FE_CLEVER_APP_ID_PROD }}
APP_NAME: cc_dashboard_prod
CLEVER_APP_ALIAS: ${{ secrets.FE_CLEVER_APP_ALIAS_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 -a $CLEVER_APP_ALIAS --quiet
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
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
33 changes: 2 additions & 31 deletions webapp/src/api/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@ import { z } from "zod";
import { fetchApi } from "./client";
import {
Emission,
EmissionSchema,
EmissionsTimeSeries,
RunMetadata,
RunMetadataSchema,
RunReport,
RunReportSchema,
} from "./schemas";

export async function getRunMetadata(
Expand Down Expand Up @@ -85,39 +83,12 @@ export async function getEmissionsTimeSeries(
),
]);

const metadata: RunMetadata = {
timestamp: runMetadataData.timestamp,
experiment_id: runMetadataData.experiment_id,
os: runMetadataData.os,
python_version: runMetadataData.python_version,
codecarbon_version: runMetadataData.codecarbon_version,
cpu_count: runMetadataData.cpu_count,
cpu_model: runMetadataData.cpu_model,
gpu_count: runMetadataData.gpu_count,
gpu_model: runMetadataData.gpu_model,
longitude: runMetadataData.longitude,
latitude: runMetadataData.latitude,
region: runMetadataData.region,
provider: runMetadataData.provider,
ram_total_size: runMetadataData.ram_total_size,
tracking_mode: runMetadataData.tracking_mode,
};

const emissions: Emission[] = emissionsData.items.map((item) => ({
...item,
emission_id: item.run_id,
timestamp: item.timestamp,
emissions_sum: item.emissions_sum,
emissions_rate: item.emissions_rate,
cpu_power: item.cpu_power,
gpu_power: item.gpu_power,
ram_power: item.ram_power,
cpu_energy: item.cpu_energy,
gpu_energy: item.gpu_energy,
ram_energy: item.ram_energy,
energy_consumed: item.energy_consumed,
}));

return { runId, emissions, metadata };
return { runId, emissions, metadata: runMetadataData };
} catch (error) {
console.error("[getEmissionsTimeSeries] failed", error);
return { runId, emissions: [], metadata: null };
Expand Down
49 changes: 25 additions & 24 deletions webapp/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,22 @@ export const ProjectTokenSchema = z.object({
});
export type IProjectToken = z.infer<typeof ProjectTokenSchema>;

// `id` is required when reading from the API (the backend never returns
// experiments without one). It is allowed to be absent only on the
// create path — see `ExperimentInputSchema` below.
// Backend's `Optional[...]` fields are serialized as JSON `null` (Pydantic
// default, no `exclude_none`). Zod's `.optional()` accepts `undefined`
// only — `.nullish()` accepts `null | undefined`, which is what we need
// for every column the backend marks as nullable.
export const ExperimentSchema = z.object({
id: z.string().min(1),
timestamp: z.string().optional(),
timestamp: z.string().nullish(),
name: z.string(),
description: z.string(),
on_cloud: z.boolean().optional(),
on_cloud: z.boolean().nullish(),
project_id: z.string(),
country_name: z.string().optional(),
country_iso_code: z.string().optional(),
region: z.string().optional(),
cloud_provider: z.string().optional(),
cloud_region: z.string().optional(),
country_name: z.string().nullish(),
country_iso_code: z.string().nullish(),
region: z.string().nullish(),
cloud_provider: z.string().nullish(),
cloud_region: z.string().nullish(),
});
export type Experiment = z.infer<typeof ExperimentSchema>;

Expand All @@ -86,7 +87,7 @@ export const ExperimentReportSchema = z.object({
emissions: z.number(),
energy_consumed: z.number(),
duration: z.number(),
description: z.string().optional(),
description: z.string().nullish(),
});
export type ExperimentReport = z.infer<typeof ExperimentReportSchema>;

Expand Down Expand Up @@ -117,19 +118,19 @@ export type Emission = z.infer<typeof EmissionSchema>;
export const RunMetadataSchema = z.object({
timestamp: z.string(),
experiment_id: z.string(),
os: z.string(),
python_version: z.string(),
codecarbon_version: z.string(),
cpu_count: z.number(),
cpu_model: z.string(),
gpu_count: z.number(),
gpu_model: z.string(),
longitude: z.number(),
latitude: z.number(),
region: z.string(),
provider: z.string(),
ram_total_size: z.number(),
tracking_mode: z.string(),
os: z.string().nullish(),
python_version: z.string().nullish(),
codecarbon_version: z.string().nullish(),
cpu_count: z.number().nullish(),
cpu_model: z.string().nullish(),
gpu_count: z.number().nullish(),
gpu_model: z.string().nullish(),
longitude: z.number().nullish(),
latitude: z.number().nullish(),
region: z.string().nullish(),
provider: z.string().nullish(),
ram_total_size: z.number().nullish(),
tracking_mode: z.string().nullish(),
});
export type RunMetadata = z.infer<typeof RunMetadataSchema>;

Expand Down
5 changes: 5 additions & 0 deletions webapp/src/components/createOrganizationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ const CreateOrganizationModal: React.FC<ModalProps> = ({
placeholder="Organization Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="org-description">
Organization Description
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="org-description">
Organization Description
Expand Down
5 changes: 5 additions & 0 deletions webapp/src/components/createProjectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ const CreateProjectModal: React.FC<ModalProps> = ({
placeholder="Project Name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-description">
Project Description
</Label>
</div>
<div className="space-y-2">
<Label htmlFor="project-description">
Project Description
Expand Down
10 changes: 9 additions & 1 deletion webapp/src/components/navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,13 @@ export default function NavBar({
const [selected, setSelected] = useState<string | null>(null);
const navigate = useNavigate();
const [isCollapsed, setIsCollapsed] = useState(false);
const [selectedOrg, setSelectedOrg] = useState<string | null>(null);
const [selectedOrg, setSelectedOrg] = useState<string | null>(() => {
try {
return localStorage.getItem("organizationId");
} catch {
return null;
}
});
const iconStyles = "h-4 w-4 flex-shrink-0 text-muted-foreground";
const { pathname } = useLocation();
const newOrgModal = useModal();
Expand Down Expand Up @@ -156,6 +162,7 @@ export default function NavBar({
<NavItem
isSelected={selected === "projects"}
onClick={() => {
if (!selectedOrg) return;
setSelected("projects");
setSheetOpened?.(false);
navigate(`/${selectedOrg}/projects`);
Expand All @@ -168,6 +175,7 @@ export default function NavBar({
<NavItem
isSelected={selected === "members"}
onClick={() => {
if (!selectedOrg) return;
setSelected("members");
setSheetOpened?.(false);
navigate(`/${selectedOrg}/members`);
Expand Down
Loading
Loading