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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,6 @@ tests/test_data/rapl/*
credentials*
.codecarbon.config*
scripts/agent-vm.personal.config.sh

# Vite cache
.vite/
4 changes: 3 additions & 1 deletion webapp/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/logo.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap"
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Inter:wght@500&display=swap"
rel="stylesheet"
/>
<title>CodeCarbon</title>
Expand Down
Binary file added webapp/public/fonts/Disket-mono_EULA.pdf
Binary file not shown.
Binary file added webapp/public/fonts/DisketMono-Bold.ttf
Binary file not shown.
Binary file added webapp/public/fonts/DisketMono-Regular.ttf
Binary file not shown.
112 changes: 103 additions & 9 deletions webapp/src/api/mock/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
ExperimentReport,
Organization,
OrganizationReport,
OrganizationUser,
IProjectToken,
RunMetadata,
User,
Expand Down Expand Up @@ -242,6 +243,30 @@ function makeRunRow(args: {
};
}

/*
* Fixture timestamps are anchored to the current date rather than hardcoded, so
* the mock always has data inside the dashboards' default 30-day window. They are
* spread across that window so that narrowing the date range visibly changes the
* numbers — which is the point of having a date filter to exercise.
*/
const DAY_MS = 24 * 60 * 60 * 1000;
const daysAgo = (days: number, hour = 10) => {
const d = new Date(Date.now() - days * DAY_MS);
d.setUTCHours(hour, 0, 0, 0);
return d.toISOString();
};

/** Spacing between samples in `makeEmissionSeries`, in seconds. */
export const EMISSION_INTERVAL_SECONDS = 5 * 60;

const AT = {
baseline1: daysAgo(20),
baseline2: daysAgo(20, 11),
optimized1: daysAgo(5),
production: daysAgo(2),
tokenLastUsed: daysAgo(1, 8),
};

// ─── Composed data (built top-down from the aggregate root) ────────────────

const organization = makeOrganization({
Expand Down Expand Up @@ -294,15 +319,15 @@ const experimentBaseline = makeExperiment({
projectId: ID.projects.training,
name: "Baseline run",
description: "First experiment baseline",
timestamp: "2026-04-01T10:00:00Z",
timestamp: AT.baseline1,
});

const experimentOptimized = makeExperiment({
id: ID.experiments.optimized,
projectId: ID.projects.training,
name: "Optimized model",
description: "Quantized variant",
timestamp: "2026-04-15T10:00:00Z",
timestamp: AT.optimized1,
onCloud: true,
cloudProvider: "aws",
cloudRegion: "eu-west-3",
Expand All @@ -313,7 +338,7 @@ const experimentProduction = makeExperiment({
projectId: ID.projects.inference,
name: "Production rollout",
description: "Live inference",
timestamp: "2026-04-20T10:00:00Z",
timestamp: AT.production,
onCloud: true,
cloudProvider: "gcp",
cloudRegion: "europe-west1",
Expand All @@ -340,7 +365,7 @@ const optimizedReport = makeExperimentReport({
const runBaseline1 = makeRunRow({
runId: ID.runs.baseline1,
experimentId: ID.experiments.baseline,
timestamp: "2026-04-01T10:00:00Z",
timestamp: AT.baseline1,
emissions: 0.617,
energyConsumed: 2.839,
durationSeconds: 1800,
Expand All @@ -349,7 +374,7 @@ const runBaseline1 = makeRunRow({
const runBaseline2 = makeRunRow({
runId: ID.runs.baseline2,
experimentId: ID.experiments.baseline,
timestamp: "2026-04-01T11:00:00Z",
timestamp: AT.baseline2,
emissions: 0.617,
energyConsumed: 2.839,
durationSeconds: 1800,
Expand All @@ -358,7 +383,7 @@ const runBaseline2 = makeRunRow({
const runOptimized1 = makeRunRow({
runId: ID.runs.optimized1,
experimentId: ID.experiments.optimized,
timestamp: "2026-04-15T10:00:00Z",
timestamp: AT.optimized1,
emissions: 0.567,
energyConsumed: 2.345,
durationSeconds: 1800,
Expand All @@ -368,11 +393,63 @@ const ciToken = makeProjectToken({
id: ID.tokens.ci,
projectId: ID.projects.training,
name: "Local dev token",
lastUsed: "2026-04-30T08:00:00Z",
lastUsed: AT.tokenLastUsed,
});

// ─── Exported aggregate (consumed by handlers.ts) ──────────────────────────

/*
* The organization's emission rows, flattened across every run. The backend's
* `/organizations/{id}/sums` filters this table by `emissions.timestamp` and
* aggregates the matches, so the mock does the same rather than returning a
* constant — otherwise a date filter cannot be exercised locally at all.
*/
function organizationEmissionRows(): Emission[] {
return [
...makeEmissionSeries({
runId: ID.runs.baseline1,
samples: 12,
startedAt: new Date(AT.baseline1),
}),
...makeEmissionSeries({
runId: ID.runs.baseline2,
samples: 12,
startedAt: new Date(AT.baseline2),
}),
...makeEmissionSeries({
runId: ID.runs.optimized1,
samples: 6,
startedAt: new Date(AT.optimized1),
}),
];
}

const round = (n: number, dp = 3) => Number(n.toFixed(dp));

/**
* Aggregate the organization's emissions over a date range, the way
* `read_organization_detailed_sums` does. Bounds are inclusive; either may be
* omitted, matching the endpoint's optional query parameters.
*/
export function organizationReportBetween(
start?: Date | null,
end?: Date | null,
): OrganizationReport {
const rows = organizationEmissionRows().filter((e) => {
const t = new Date(e.timestamp).getTime();
if (start && t < start.getTime()) return false;
if (end && t > end.getTime()) return false;
return true;
});
return {
name: organization.name,
emissions: round(rows.reduce((a, e) => a + e.emissions_sum, 0)),
energy_consumed: round(rows.reduce((a, e) => a + e.energy_consumed, 0)),
// Each row covers one sampling interval.
duration: rows.length * EMISSION_INTERVAL_SECONDS,
};
}

export const MOCK = {
user: adminUser,

Expand All @@ -382,9 +459,26 @@ export const MOCK = {
[organization.id]: organization,
} as Record<string, Organization>,
report: organizationReport,
/*
* `GET /organizations/{id}/users` returns the backend's
* `OrganizationUser`: a user plus their membership of that organization,
* including `is_admin`. The admin/member split mirrors the two fixture
* users.
*/
usersByOrgId: {
[organization.id]: [adminUser, memberUser],
} as Record<string, User[]>,
[organization.id]: [
{
...adminUser,
organization_id: organization.id,
is_admin: true,
},
{
...memberUser,
organization_id: organization.id,
is_admin: false,
},
],
} as Record<string, OrganizationUser[]>,
},

project: {
Expand Down
19 changes: 16 additions & 3 deletions webapp/src/api/mock/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ID, MOCK, MockProjectWire } from "./data";
import { ID, MOCK, MockProjectWire, organizationReportBetween } from "./data";

export type MockResponse = { status: number; body?: unknown };

Expand Down Expand Up @@ -27,7 +27,7 @@ const handlers: Handler[] = [
},

// ─── Organizations ─────────────────────────────────────────────────────
({ pathname, method, body }) => {
({ pathname, method, searchParams, body }) => {
if (method === "GET" && pathname === "/organizations") {
return ok(MOCK.organization.list);
}
Expand All @@ -49,7 +49,20 @@ const handlers: Handler[] = [
}
const sums = pathname.match(/^\/organizations\/([^/]+)\/sums$/);
if (method === "GET" && sums) {
return ok(MOCK.organization.report);
// Honour the same query parameters as the real endpoint, so the
// dashboard's date picker actually changes the figures locally.
const parse = (key: string) => {
const raw = searchParams.get(key);
if (!raw) return null;
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
};
return ok(
organizationReportBetween(
parse("start_date"),
parse("end_date"),
),
);
}
const users = pathname.match(/^\/organizations\/([^/]+)\/users$/);
if (method === "GET" && users) {
Expand Down
20 changes: 19 additions & 1 deletion webapp/src/api/organizations.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fetchApi } from "./client";
import { fetchApi, fetchApiVoid } from "./client";
import {
Organization,
OrganizationSchema,
Expand Down Expand Up @@ -42,3 +42,21 @@ export async function createOrganization(organization: {
body: JSON.stringify(organization),
});
}

/*
* Add a member to an organization by email address.
*
* The endpoint looks the address up among existing accounts and subscribes it
* to the organization; it does not send an invitation, and it answers with a
* bare status object rather than the member it added, so there is nothing to
* validate and the caller refetches the list.
*/
export async function addOrganizationUser(
organizationId: string,
email: string,
): Promise<void> {
await fetchApiVoid(`/organizations/${organizationId}/add-user`, {
method: "POST",
body: JSON.stringify({ email }),
});
}
15 changes: 14 additions & 1 deletion webapp/src/api/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,20 @@ export const UserSchema = z.object({
});
export type User = z.infer<typeof UserSchema>;

/*
* `GET /organizations/{id}/users` returns the backend's `OrganizationUser`: a
* user plus their membership of that organization. `is_admin` is the only place
* the API exposes admin rights, and it is per-organization.
*/
export const OrganizationUserSchema = z.object({
id: z.string(),
email: z.string(),
name: z.string(),
organization_id: z.string(),
is_admin: z.boolean(),
});
export type OrganizationUser = z.infer<typeof OrganizationUserSchema>;

// Backend returns snake_case keys (`organization_id`); the rest of the
// codebase consumes camelCase (`organizationId`). Zod's `.transform` lets
// us validate the wire shape and expose the camelCase shape to the app.
Expand Down Expand Up @@ -190,7 +204,6 @@ export interface ProjectDashboardProps {
selectedRunId: string;
onExperimentClick: (experimentId: string) => void;
onRunClick: (runId: string) => void;
onSettingsClick: () => void;
onRefresh: () => void;
isLoading?: boolean;
}
Expand Down
Loading