diff --git a/carbonserver/carbonserver/api/routers/authenticate.py b/carbonserver/carbonserver/api/routers/authenticate.py
index bfd4c75c0..bb96f85d3 100644
--- a/carbonserver/carbonserver/api/routers/authenticate.py
+++ b/carbonserver/carbonserver/api/routers/authenticate.py
@@ -134,11 +134,16 @@ async def logout(
"""
if auth_provider is None:
raise HTTPException(status_code=501, detail="Authentication not configured")
+
+ # Revoke the access token at the OIDC provider before clearing it locally
+ access_token = request.cookies.get(SESSION_COOKIE_NAME)
+ if access_token:
+ await auth_provider.revoke_token(access_token)
+
base_url = request.base_url
response = auth_provider.create_redirect_response(str(base_url))
response.delete_cookie(SESSION_COOKIE_NAME)
if hasattr(request, "session"):
request.session.clear()
- # TODO: also revoke the token at auth provider level if possible
return response
diff --git a/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py b/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py
index f1cc38a05..b890cd2ff 100644
--- a/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py
+++ b/carbonserver/carbonserver/api/services/auth_providers/oidc_auth_provider.py
@@ -56,10 +56,6 @@ def get_client_credentials(self) -> Tuple[str, str]:
async def _decode_token(self, token: str) -> Dict[str, Any]:
try:
- LOGGER.debug(f"Jwks_data: {token}")
- LOGGER.debug(f"Base url: {fief.base_url}")
- LOGGER.debug(f"Client id: {fief.client_id}")
- LOGGER.debug(f"User info: {await fief.userinfo(token)}")
access_token_info = await fief.validate_access_token(token)
return access_token_info
except Exception as e:
@@ -67,12 +63,9 @@ async def _decode_token(self, token: str) -> Dict[str, Any]:
...
jwks_data = await self.client.fetch_jwk_set()
- LOGGER.debug(f"Jwks_data: {jwks_data}")
keyset = JsonWebKey.import_key_set(jwks_data)
claims = jose_jwt.decode(token, keyset)
claims.validate()
- LOGGER.debug(f"Decoded claims: {claims}")
- LOGGER.debug(f"Claims validate: {claims.validate()}")
return dict(claims)
async def validate_access_token(self, token: str) -> bool:
@@ -83,6 +76,41 @@ async def get_user_info(self, access_token: str) -> Dict[str, Any]:
decoded_token = await self._decode_token(access_token)
return decoded_token
+ async def revoke_token(self, token: str) -> None:
+ """Revoke an access token at the OIDC provider (RFC 7009).
+ Best-effort — logs and swallows errors so logout always succeeds.
+ """
+ try:
+ metadata = await self.client.load_server_metadata()
+ revocation_endpoint = metadata.get("revocation_endpoint")
+ if not revocation_endpoint:
+ LOGGER.debug(
+ "OIDC provider does not expose a revocation_endpoint, "
+ "skipping token revocation"
+ )
+ return
+
+ async with self.client._get_oauth_client(**metadata) as client:
+ resp = await client.request(
+ "POST",
+ revocation_endpoint,
+ withhold_token=True,
+ data={
+ "token": token,
+ "token_type_hint": "access_token",
+ },
+ )
+ if resp.status_code == 200:
+ LOGGER.info("Access token revoked successfully")
+ else:
+ LOGGER.warning(
+ "Token revocation returned status %s: %s",
+ resp.status_code,
+ resp.text,
+ )
+ except Exception as e:
+ LOGGER.warning("Token revocation failed (non-blocking): %s", e)
+
@staticmethod
def create_redirect_response(url: str) -> Response:
"""RedirectResponse doesn't work with clevercloud, so we return a HTML page with a script to redirect the user
diff --git a/carbonserver/tests/api/routers/test_authenticate.py b/carbonserver/tests/api/routers/test_authenticate.py
index b1296e4a1..8200c9dd5 100644
--- a/carbonserver/tests/api/routers/test_authenticate.py
+++ b/carbonserver/tests/api/routers/test_authenticate.py
@@ -1,9 +1,14 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.middleware.sessions import SessionMiddleware
from carbonserver.api.routers import authenticate
+from carbonserver.api.services.auth_providers.oidc_auth_provider import (
+ OIDCAuthProvider,
+)
from carbonserver.container import ServerContainer
SESSION_COOKIE_NAME = "user_session"
@@ -55,3 +60,89 @@ class FakeRequest:
)
# We cannot directly check session cleared, but can check that logout returns redirect
assert "window.location.href" in response.text
+
+
+# --- Token revocation tests ---
+
+
+@pytest.fixture
+def mock_oidc_client():
+ """Create a mock OIDC client with load_server_metadata and _get_oauth_client."""
+ client = MagicMock()
+ client.load_server_metadata = AsyncMock()
+ client._get_oauth_client = MagicMock()
+ return client
+
+
+@pytest.fixture
+def oidc_provider(mock_oidc_client):
+ """Create an OIDCAuthProvider with a mocked client."""
+ with patch.object(OIDCAuthProvider, "__init__", lambda self, **kw: None):
+ provider = OIDCAuthProvider()
+ provider.client = mock_oidc_client
+ return provider
+
+
+@pytest.mark.asyncio
+async def test_revoke_token_success(oidc_provider, mock_oidc_client):
+ """Token is revoked successfully when the provider exposes a revocation_endpoint."""
+ mock_oidc_client.load_server_metadata.return_value = {
+ "revocation_endpoint": "https://auth.example.com/revoke",
+ }
+
+ mock_response = MagicMock(status_code=200)
+ mock_http_client = AsyncMock()
+ mock_http_client.request = AsyncMock(return_value=mock_response)
+ mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
+ mock_http_client.__aexit__ = AsyncMock(return_value=False)
+ mock_oidc_client._get_oauth_client.return_value = mock_http_client
+
+ await oidc_provider.revoke_token("test-access-token")
+
+ mock_http_client.request.assert_called_once_with(
+ "POST",
+ "https://auth.example.com/revoke",
+ withhold_token=True,
+ data={"token": "test-access-token", "token_type_hint": "access_token"},
+ )
+
+
+@pytest.mark.asyncio
+async def test_revoke_token_no_endpoint(oidc_provider, mock_oidc_client):
+ """Revocation is silently skipped when the provider has no revocation_endpoint."""
+ mock_oidc_client.load_server_metadata.return_value = {
+ "authorization_endpoint": "https://auth.example.com/authorize",
+ }
+
+ await oidc_provider.revoke_token("test-access-token")
+
+ mock_oidc_client._get_oauth_client.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_revoke_token_http_error(oidc_provider, mock_oidc_client):
+ """Revocation failure does not raise — logout must always succeed."""
+ mock_oidc_client.load_server_metadata.return_value = {
+ "revocation_endpoint": "https://auth.example.com/revoke",
+ }
+
+ mock_response = MagicMock(status_code=503, text="Service Unavailable")
+ mock_http_client = AsyncMock()
+ mock_http_client.request = AsyncMock(return_value=mock_response)
+ mock_http_client.__aenter__ = AsyncMock(return_value=mock_http_client)
+ mock_http_client.__aexit__ = AsyncMock(return_value=False)
+ mock_oidc_client._get_oauth_client.return_value = mock_http_client
+
+ # Should not raise
+ await oidc_provider.revoke_token("test-access-token")
+
+
+@pytest.mark.asyncio
+async def test_revoke_token_exception(oidc_provider, mock_oidc_client):
+ """Revocation is non-blocking even when load_server_metadata raises."""
+ mock_oidc_client.load_server_metadata.side_effect = ConnectionError(
+ "Network unreachable"
+ )
+
+ # Should not raise
+ await oidc_provider.revoke_token("test-access-token")
diff --git a/webapp/next.config.mjs b/webapp/next.config.mjs
index 212902d4b..80c223869 100644
--- a/webapp/next.config.mjs
+++ b/webapp/next.config.mjs
@@ -1,4 +1,9 @@
/** @type {import('next').NextConfig} */
-const nextConfig = { output: "standalone" };
+const nextConfig = {
+ output: "standalone",
+ experimental: {
+ optimizePackageImports: ["lucide-react", "recharts", "date-fns"],
+ },
+};
export default nextConfig;
diff --git a/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx b/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx
index f058c5e49..f50a7527f 100644
--- a/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx
+++ b/webapp/src/app/(dashboard)/[organizationId]/members/members-list.tsx
@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { z } from "zod";
import { toast } from "sonner";
+import { fetchApi } from "@/utils/api";
export default function MembersList({
users,
@@ -44,36 +45,14 @@ export default function MembersList({
await toast
.promise(
- fetch(
- `${process.env.NEXT_PUBLIC_API_URL}/organizations/${organizationId}/add-user`,
- {
- method: "POST",
- headers: {
- Accept: "application/json",
- "Content-Type": "application/json",
- },
- body: body,
- },
- ).then(async (result) => {
- const data = await result.json();
- if (result.status !== 200) {
- const errorObject = data.detail;
- let errorMessage = "Failed to add user";
-
- if (
- Array.isArray(errorObject) &&
- errorObject.length > 0
- ) {
- errorMessage = errorObject
- .map((error: any) => error.msg)
- .join("\n");
- } else if (errorObject) {
- errorMessage = JSON.stringify(errorObject);
- }
-
- throw new Error(errorMessage);
+ fetchApi(`/organizations/${organizationId}/add-user`, {
+ method: "POST",
+ body: body,
+ }).then(async (result) => {
+ if (!result) {
+ throw new Error("Failed to add user");
}
- return data;
+ return result;
}),
{
loading: `Adding user ${email}...`,
diff --git a/webapp/src/app/(dashboard)/[organizationId]/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/page.tsx
index 54d871c01..1de1f3318 100644
--- a/webapp/src/app/(dashboard)/[organizationId]/page.tsx
+++ b/webapp/src/app/(dashboard)/[organizationId]/page.tsx
@@ -1,16 +1,35 @@
"use client";
import Image from "next/image";
+import dynamic from "next/dynamic";
import { use, useEffect, useState } from "react";
import ErrorMessage from "@/components/error-message";
import Loader from "@/components/loader";
-import RadialChart from "@/components/radial-chart";
+import { Card, CardContent } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+// Lazy-load chart to keep recharts off the critical path
+const RadialChart = dynamic(() => import("@/components/radial-chart"), {
+ loading: () => (
+
+
+
+
+
+ ),
+ ssr: false,
+});
import {
getEquivalentCarKm,
getEquivalentCitizenPercentage,
getEquivalentTvTime,
} from "@/helpers/constants";
+import {
+ REFRESH_INTERVAL_ONE_MINUTE,
+ THIRTY_DAYS_MS,
+ SECONDS_PER_DAY,
+} from "@/helpers/time-constants";
import { fetcher } from "@/helpers/swr";
import { getOrganizationEmissionsByProject } from "@/server-functions/organizations";
import { Organization } from "@/types/organization";
@@ -29,12 +48,12 @@ export default function OrganizationPage({
isLoading,
error,
} = useSWR(`/organizations/${organizationId}`, fetcher, {
- refreshInterval: 1000 * 60, // Refresh every minute
+ refreshInterval: REFRESH_INTERVAL_ONE_MINUTE,
});
const today = new Date();
const [date, setDate] = useState({
- from: new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000),
+ from: new Date(today.getTime() - THIRTY_DAYS_MS),
to: today,
});
const [organizationReport, setOrganizationReport] = useState<
@@ -86,7 +105,9 @@ export default function OrganizationPage({
label: "days",
value: organizationReport?.duration
? parseFloat(
- (organizationReport.duration / 86400, 0).toFixed(2),
+ (organizationReport.duration / SECONDS_PER_DAY).toFixed(
+ 2,
+ ),
)
: 0,
},
diff --git a/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/settings/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/settings/page.tsx
index 28cd87413..4df47eec5 100644
--- a/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/settings/page.tsx
+++ b/webapp/src/app/(dashboard)/[organizationId]/projects/[projectId]/settings/page.tsx
@@ -16,14 +16,11 @@ async function updateProjectAction(projectId: string, formData: FormData) {
const description = formData.get("description") as string;
const isPublic = formData.has("isPublic");
- console.log("SAVING PROJECT:", { name, description, public: isPublic });
-
- const response = await updateProject(projectId, {
+ await updateProject(projectId, {
name,
description,
public: isPublic,
});
- console.log("RESPONSE:", response);
revalidatePath(`/projects/${projectId}/settings`);
}
diff --git a/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx b/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx
index 5c2cb9671..eb3ae2a42 100644
--- a/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx
+++ b/webapp/src/app/(dashboard)/[organizationId]/projects/page.tsx
@@ -10,6 +10,8 @@ import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Table, TableBody } from "@/components/ui/table";
import { fetcher } from "@/helpers/swr";
+import { REFRESH_INTERVAL_ONE_MINUTE } from "@/helpers/time-constants";
+import { useModal } from "@/hooks/useModal";
import { getProjects, deleteProject } from "@/server-functions/projects";
import { Project } from "@/types/project";
import { use, useEffect, useState } from "react";
@@ -22,15 +24,15 @@ export default function ProjectsPage({
params: Promise<{ organizationId: string }>;
}) {
const { organizationId } = use(params);
- const [isModalOpen, setIsModalOpen] = useState(false);
+ const createModal = useModal();
+ const deleteModal = useModal();
const [projectList, setProjectList] = useState([]);
- const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [projectToDelete, setProjectToDelete] = useState(
null,
);
const handleClick = async () => {
- setIsModalOpen(true);
+ createModal.open();
};
const refreshProjectList = async () => {
@@ -41,7 +43,7 @@ export default function ProjectsPage({
const handleDeleteClick = (project: Project) => {
setProjectToDelete(project);
- setDeleteModalOpen(true);
+ deleteModal.open();
};
const handleDeleteConfirm = async (projectId: string) => {
@@ -61,7 +63,7 @@ export default function ProjectsPage({
error,
isLoading,
} = useSWR(`/projects?organization=${organizationId}`, fetcher, {
- refreshInterval: 1000 * 60, // Refresh every minute
+ refreshInterval: REFRESH_INTERVAL_ONE_MINUTE,
});
useEffect(() => {
@@ -104,8 +106,8 @@ export default function ProjectsPage({
setIsModalOpen(false)}
+ isOpen={createModal.isOpen}
+ onClose={createModal.close}
onProjectCreated={refreshProjectList}
/>
@@ -141,8 +143,8 @@ export default function ProjectsPage({
{projectToDelete && (
{
// TODO: implement without fief
@@ -10,17 +11,8 @@ async function getUser(): Promise {
if (!userId) {
return null;
}
- const res = await fetch(
- `${process.env.NEXT_PUBLIC_API_URL}/users/${userId}`,
- );
-
- if (!res.ok) {
- // This will activate the closest `error.js` Error Boundary
- console.error("Failed to fetch user", res.statusText);
- return null;
- }
- return res.json();
+ return await fetchApiServer(`/users/${userId}`);
}
export default async function ProfilePage() {
diff --git a/webapp/src/components/area-chart-stacked.tsx b/webapp/src/components/area-chart-stacked.tsx
deleted file mode 100644
index 4f287006c..000000000
--- a/webapp/src/components/area-chart-stacked.tsx
+++ /dev/null
@@ -1,90 +0,0 @@
-"use client";
-
-import { Area, AreaChart, CartesianGrid, XAxis } from "recharts";
-
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import {
- ChartConfig,
- ChartContainer,
- ChartTooltip,
- ChartTooltipContent,
-} from "@/components/ui/chart";
-const chartData = [
- { month: "January", desktop: 186, mobile: 80 },
- { month: "February", desktop: 305, mobile: 200 },
- { month: "March", desktop: 237, mobile: 120 },
- { month: "April", desktop: 73, mobile: 190 },
- { month: "May", desktop: 209, mobile: 130 },
- { month: "June", desktop: 214, mobile: 140 },
-];
-
-const chartConfig = {
- desktop: {
- label: "Desktop",
- color: "hsl(var(--chart-1))",
- },
- mobile: {
- label: "Mobile",
- color: "hsl(var(--chart-2))",
- },
-} satisfies ChartConfig;
-
-export default function AreaChartStacked() {
- return (
-
-
- Area Chart - Stacked
-
- Showing total visitors for the last 6 months
-
-
-
-
-
-
- value.slice(0, 3)}
- />
- }
- />
-
-
-
-
-
-
- );
-}
diff --git a/webapp/src/components/bar-chart-multiple.tsx b/webapp/src/components/bar-chart-multiple.tsx
deleted file mode 100644
index 66258690f..000000000
--- a/webapp/src/components/bar-chart-multiple.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import { TrendingUp } from "lucide-react";
-import { Bar, BarChart, CartesianGrid, XAxis } from "recharts";
-
-import {
- Card,
- CardContent,
- CardDescription,
- CardFooter,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import {
- ChartConfig,
- ChartContainer,
- ChartTooltip,
- ChartTooltipContent,
-} from "@/components/ui/chart";
-const chartData = [
- { month: "January", desktop: 186, mobile: 80 },
- { month: "February", desktop: 305, mobile: 200 },
- { month: "March", desktop: 237, mobile: 120 },
- { month: "April", desktop: 73, mobile: 190 },
- { month: "May", desktop: 209, mobile: 130 },
- { month: "June", desktop: 214, mobile: 140 },
-];
-
-const chartConfig = {
- desktop: {
- label: "Desktop",
- color: "hsl(var(--chart-1))",
- },
- mobile: {
- label: "Mobile",
- color: "hsl(var(--chart-2))",
- },
-} satisfies ChartConfig;
-
-export default function BarChartMultiple() {
- return (
-
-
- Bar Chart - Multiple
-
-
-
-
-
-
- value.slice(0, 3)}
- />
- }
- />
-
-
-
-
-
-
- );
-}
diff --git a/webapp/src/components/createExperimentModal.tsx b/webapp/src/components/createExperimentModal.tsx
index 6f1225eaf..4f789bce3 100644
--- a/webapp/src/components/createExperimentModal.tsx
+++ b/webapp/src/components/createExperimentModal.tsx
@@ -27,7 +27,6 @@ export default function CreateExperimentModal({
onClose: () => void;
onExperimentCreated: () => void;
}) {
- console.log("projectId", projectId);
const [isCopied, setIsCopied] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isCreated, setIsCreated] = useState(false);
@@ -74,7 +73,6 @@ export default function CreateExperimentModal({
setIsSaving(true);
try {
- console.log("experimentData", experimentData);
const newExperiment = await createExperiment(experimentData);
setCreatedExperiment(newExperiment);
setIsCreated(true);
diff --git a/webapp/src/components/createProjectModal.tsx b/webapp/src/components/createProjectModal.tsx
index c61753ca0..1191fbd6e 100644
--- a/webapp/src/components/createProjectModal.tsx
+++ b/webapp/src/components/createProjectModal.tsx
@@ -36,8 +36,6 @@ const CreateProjectModal: React.FC = ({
name: "",
description: "",
});
- const [isCreated, setIsCreated] = useState(false);
- const [createdProject, setCreatedProject] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const handleSave = async () => {
@@ -49,8 +47,6 @@ const CreateProjectModal: React.FC = ({
organizationId,
formData,
);
- setCreatedProject(newProject);
- setIsCreated(true);
await onProjectCreated(); // Call the callback to refresh the project list
handleClose(); // Automatically close the modal after successful creation
return newProject; // Return for the success message
@@ -72,8 +68,6 @@ const CreateProjectModal: React.FC = ({
const handleClose = () => {
// Reset state when closing
setFormData({ name: "", description: "" });
- setIsCreated(false);
- setCreatedProject(null);
onClose();
};
diff --git a/webapp/src/components/date-range-picker.tsx b/webapp/src/components/date-range-picker.tsx
index a05782c4a..8f39839d6 100644
--- a/webapp/src/components/date-range-picker.tsx
+++ b/webapp/src/components/date-range-picker.tsx
@@ -69,7 +69,6 @@ export function DateRangePicker({ date, onDateChange }: DateRangePickerProps) {
defaultMonth={date?.from}
selected={tempDateRange}
onSelect={(range) => {
- console.log("onSelect called with:", range);
setTempDateRange(range);
}}
numberOfMonths={2}
diff --git a/webapp/src/components/navbar.tsx b/webapp/src/components/navbar.tsx
index b8e4fe0e1..37bd26d91 100644
--- a/webapp/src/components/navbar.tsx
+++ b/webapp/src/components/navbar.tsx
@@ -25,6 +25,7 @@ import {
import CreateOrganizationModal from "./createOrganizationModal";
import { getOrganizations } from "@/server-functions/organizations";
import { Button } from "./ui/button";
+import { useModal } from "@/hooks/useModal";
const USER_PROFILE_URL = process.env.NEXT_PUBLIC_FIEF_BASE_URL; // Redirect to Fief profile to handle profile updates there
export default function NavBar({
@@ -40,7 +41,7 @@ export default function NavBar({
const [selectedOrg, setSelectedOrg] = useState(null);
const iconStyles = "h-4 w-4 flex-shrink-0 text-muted-foreground";
const pathname = usePathname();
- const [isNewOrgModalOpen, setNewOrgModalOpen] = useState(false);
+ const newOrgModal = useModal();
const [organizationList, setOrganizationList] = useState<
Organization[] | undefined
>([]);
@@ -120,7 +121,7 @@ export default function NavBar({
}, [pathname, organizationList, selectedOrg]);
const handleNewOrgClick = async () => {
- setNewOrgModalOpen(true);
+ newOrgModal.open();
setDropdownOpen(false); // Close the dropdown menu
};
@@ -247,8 +248,8 @@ export default function NavBar({
)}
setNewOrgModalOpen(false)}
+ isOpen={newOrgModal.isOpen}
+ onClose={newOrgModal.close}
onOrganizationCreated={refreshOrgList}
/>
{USER_PROFILE_URL && (
diff --git a/webapp/src/components/project-dashboard-base.tsx b/webapp/src/components/project-dashboard-base.tsx
index 579d8c356..2cb061bad 100644
--- a/webapp/src/components/project-dashboard-base.tsx
+++ b/webapp/src/components/project-dashboard-base.tsx
@@ -1,14 +1,11 @@
import { DateRangePicker } from "@/components/date-range-picker";
-import EmissionsTimeSeriesChart from "@/components/emissions-time-series";
-import ExperimentsBarChart from "@/components/experiment-bar-chart";
-import RadialChart from "@/components/radial-chart";
-import RunsScatterChart from "@/components/runs-scatter-chart";
import { Separator } from "@/components/ui/separator";
import { getDefaultDateRange } from "@/helpers/date-utils";
import { ExperimentReport } from "@/types/experiment-report";
import { Project } from "@/types/project";
import { ConvertedValues, RadialChartData } from "@/types/project-dashboard";
import Image from "next/image";
+import dynamic from "next/dynamic";
import { ReactNode, useState } from "react";
import { DateRange } from "react-day-picker";
import ChartSkeleton from "./chart-skeleton";
@@ -20,6 +17,30 @@ import { useRouter } from "next/navigation";
import { Table, TableBody, TableHeader } from "./ui/table";
import { Experiment } from "@/types/experiment";
+// Lazy-load chart components to keep recharts (~370kB) off the critical path
+const RadialChart = dynamic(() => import("@/components/radial-chart"), {
+ loading: () => (
+
+
+
+
+
+ ),
+ ssr: false,
+});
+const ExperimentsBarChart = dynamic(
+ () => import("@/components/experiment-bar-chart"),
+ { loading: () => , ssr: false },
+);
+const RunsScatterChart = dynamic(
+ () => import("@/components/runs-scatter-chart"),
+ { loading: () => , ssr: false },
+);
+const EmissionsTimeSeriesChart = dynamic(
+ () => import("@/components/emissions-time-series"),
+ { loading: () => , ssr: false },
+);
+
export interface ProjectDashboardBaseProps {
isPublicView: boolean;
project: Project;
diff --git a/webapp/src/components/project-dashboard.tsx b/webapp/src/components/project-dashboard.tsx
index 8c0f87ca3..de6c354de 100644
--- a/webapp/src/components/project-dashboard.tsx
+++ b/webapp/src/components/project-dashboard.tsx
@@ -18,6 +18,7 @@ import { toast } from "sonner";
import ProjectDashboardBase from "./project-dashboard-base";
import ProjectSettingsModal from "./project-settings-modal";
import ShareProjectButton from "./share-project-button";
+import { useModal } from "@/hooks/useModal";
export default function ProjectDashboard({
project,
@@ -35,7 +36,7 @@ export default function ProjectDashboard({
onSettingsClick,
isLoading,
}: ProjectDashboardProps) {
- const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
+ const settingsModal = useModal();
const [isExporting, setIsExporting] = useState(false);
const handleJsonExport = () => {
@@ -163,7 +164,7 @@ export default function ProjectDashboard({
className="p-1 rounded-full"
variant="outline"
size="icon"
- onClick={() => setIsSettingsModalOpen(true)}
+ onClick={settingsModal.open}
>
@@ -192,8 +193,8 @@ export default function ProjectDashboard({
/>
{
// Call the original onSettingsClick to refresh the data
diff --git a/webapp/src/helpers/api-client.ts b/webapp/src/helpers/api-client.ts
index 867d5b0a5..bbae063e0 100644
--- a/webapp/src/helpers/api-client.ts
+++ b/webapp/src/helpers/api-client.ts
@@ -22,6 +22,7 @@ export async function fetchApiClient(
): Promise {
const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
+ credentials: "include",
headers: {
"Content-Type": "application/json",
...(options?.headers || {}),
@@ -36,7 +37,12 @@ export async function fetchApiClient(
} catch (e) {
// Ignore JSON parsing errors
}
- console.log(errorMessage);
+ console.error(errorMessage);
+ return null;
+ }
+
+ // Handle 204 No Content responses (e.g., DELETE operations)
+ if (response.status === 204) {
return null;
}
diff --git a/webapp/src/helpers/api-server.ts b/webapp/src/helpers/api-server.ts
index 0a610ad5d..d40e43851 100644
--- a/webapp/src/helpers/api-server.ts
+++ b/webapp/src/helpers/api-server.ts
@@ -39,7 +39,7 @@ export async function fetchApiServer(
} catch (e) {
// Ignore JSON parsing errors
}
- console.log(errorMessage);
+ console.error(errorMessage);
return null;
}
@@ -48,35 +48,16 @@ export async function fetchApiServer(
return null;
}
- // Special handling for endpoints that might return null
- if (
- endpoint.includes("/organizations/") &&
- endpoint.includes("/sums")
- ) {
- // For organization sums endpoint that might return null
- try {
- return await response.json();
- } catch (e) {
- // If JSON parsing fails (e.g., empty response), return default values
- console.warn(
- "Empty response from organization sums endpoint, using default values",
- );
- return {
- name: "",
- description: "",
- emissions: 0,
- energy_consumed: 0,
- duration: 0,
- cpu_power: 0,
- gpu_power: 0,
- ram_power: 0,
- emissions_rate: 0,
- emissions_count: 0,
- } as unknown as T;
- }
+ // Parse JSON response
+ try {
+ return await response.json();
+ } catch (e) {
+ // If JSON parsing fails (e.g., empty response body), return null
+ console.warn(
+ `Empty or invalid JSON response from ${endpoint}, returning null`,
+ );
+ return null;
}
-
- return await response.json();
} catch (error) {
// Log server-side error with more details
console.error("API server request failed:", {
@@ -84,25 +65,7 @@ export async function fetchApiServer(
error: error instanceof Error ? error.message : String(error),
});
- // For organization sums endpoint, return default values instead of throwing
- if (
- endpoint.includes("/organizations/") &&
- endpoint.includes("/sums")
- ) {
- return {
- name: "",
- description: "",
- emissions: 0,
- energy_consumed: 0,
- duration: 0,
- cpu_power: 0,
- gpu_power: 0,
- ram_power: 0,
- emissions_rate: 0,
- emissions_count: 0,
- } as unknown as T;
- }
-
- throw new Error("API request failed. Please try again.");
+ // Return null to let callers handle defaults appropriately
+ return null;
}
}
diff --git a/webapp/src/helpers/dashboard-calculations.ts b/webapp/src/helpers/dashboard-calculations.ts
new file mode 100644
index 000000000..8b4b67e27
--- /dev/null
+++ b/webapp/src/helpers/dashboard-calculations.ts
@@ -0,0 +1,95 @@
+import { ExperimentReport } from "@/types/experiment-report";
+import {
+ getEquivalentCarKm,
+ getEquivalentCitizenPercentage,
+ getEquivalentTvTime,
+} from "./constants";
+import { SECONDS_PER_DAY } from "./time-constants";
+
+export type RadialChartData = {
+ energy: { label: string; value: number };
+ emissions: { label: string; value: number };
+ duration: { label: string; value: number };
+};
+
+export type ConvertedValues = {
+ citizen: string;
+ transportation: string;
+ tvTime: string;
+};
+
+/**
+ * Calculate radial chart data from experiment reports
+ */
+export function calculateRadialChartData(
+ report: ExperimentReport[],
+): RadialChartData {
+ return {
+ energy: {
+ label: "kWh",
+ value: parseFloat(
+ report
+ .reduce((n, { energy_consumed }) => n + energy_consumed, 0)
+ .toFixed(2),
+ ),
+ },
+ emissions: {
+ label: "kg eq CO2",
+ value: parseFloat(
+ report
+ .reduce((n, { emissions }) => n + emissions, 0)
+ .toFixed(2),
+ ),
+ },
+ duration: {
+ label: "days",
+ value: parseFloat(
+ report
+ .reduce(
+ (n, { duration }) => n + duration / SECONDS_PER_DAY,
+ 0,
+ )
+ .toFixed(2),
+ ),
+ },
+ };
+}
+
+/**
+ * Calculate converted equivalent values from radial chart data
+ */
+export function calculateConvertedValues(
+ radialChartData: RadialChartData,
+): ConvertedValues {
+ return {
+ citizen: getEquivalentCitizenPercentage(
+ radialChartData.emissions.value,
+ ).toFixed(2),
+ transportation: getEquivalentCarKm(
+ radialChartData.emissions.value,
+ ).toFixed(2),
+ tvTime: getEquivalentTvTime(radialChartData.energy.value).toFixed(2),
+ };
+}
+
+/**
+ * Get default radial chart data (all zeros)
+ */
+export function getDefaultRadialChartData(): RadialChartData {
+ return {
+ energy: { label: "kWh", value: 0 },
+ emissions: { label: "kg eq CO2", value: 0 },
+ duration: { label: "days", value: 0 },
+ };
+}
+
+/**
+ * Get default converted values (all zeros)
+ */
+export function getDefaultConvertedValues(): ConvertedValues {
+ return {
+ citizen: "0",
+ transportation: "0",
+ tvTime: "0",
+ };
+}
diff --git a/webapp/src/helpers/time-constants.ts b/webapp/src/helpers/time-constants.ts
new file mode 100644
index 000000000..85a8c479b
--- /dev/null
+++ b/webapp/src/helpers/time-constants.ts
@@ -0,0 +1,18 @@
+/**
+ * Time-related constants to avoid magic numbers
+ */
+
+// Base time units
+const SECONDS_PER_MINUTE = 60;
+const MINUTES_PER_HOUR = 60;
+const HOURS_PER_DAY = 24;
+
+// Millisecond durations
+const ONE_MINUTE_MS = 1000 * SECONDS_PER_MINUTE;
+const ONE_DAY_MS = ONE_MINUTE_MS * MINUTES_PER_HOUR * HOURS_PER_DAY;
+
+// Exported constants used across the app
+export const THIRTY_DAYS_MS = 30 * ONE_DAY_MS;
+export const SECONDS_PER_DAY =
+ SECONDS_PER_MINUTE * MINUTES_PER_HOUR * HOURS_PER_DAY;
+export const REFRESH_INTERVAL_ONE_MINUTE = ONE_MINUTE_MS;
diff --git a/webapp/src/hooks/useModal.ts b/webapp/src/hooks/useModal.ts
new file mode 100644
index 000000000..51c784ffc
--- /dev/null
+++ b/webapp/src/hooks/useModal.ts
@@ -0,0 +1,18 @@
+import { useCallback, useState } from "react";
+
+/**
+ * Custom hook for managing modal open/close state
+ * Reduces boilerplate for modal state management
+ *
+ * @param defaultOpen - Initial open state (default: false)
+ * @returns Object with isOpen state and open/close/toggle functions
+ */
+export function useModal(defaultOpen = false) {
+ const [isOpen, setIsOpen] = useState(defaultOpen);
+
+ const open = useCallback(() => setIsOpen(true), []);
+ const close = useCallback(() => setIsOpen(false), []);
+ const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
+
+ return { isOpen, open, close, toggle, setIsOpen };
+}
diff --git a/webapp/src/hooks/useProjectDashboard.ts b/webapp/src/hooks/useProjectDashboard.ts
new file mode 100644
index 000000000..3f242b65f
--- /dev/null
+++ b/webapp/src/hooks/useProjectDashboard.ts
@@ -0,0 +1,141 @@
+import { useCallback, useEffect, useState } from "react";
+import { DateRange } from "react-day-picker";
+import { Experiment } from "@/types/experiment";
+import { ExperimentReport } from "@/types/experiment-report";
+import {
+ calculateConvertedValues,
+ calculateRadialChartData,
+ ConvertedValues,
+ getDefaultConvertedValues,
+ getDefaultRadialChartData,
+ RadialChartData,
+} from "@/helpers/dashboard-calculations";
+import { getExperiments } from "@/server-functions/experiments";
+
+export type RunData = {
+ experimentId: string;
+ startDate: string;
+ endDate: string;
+};
+
+export type ProjectDashboardData = {
+ radialChartData: RadialChartData;
+ convertedValues: ConvertedValues;
+ experimentsReportData: ExperimentReport[];
+ projectExperiments: Experiment[];
+ runData: RunData;
+ selectedExperimentId: string;
+ selectedRunId: string;
+ isLoading: boolean;
+ setSelectedExperimentId: (id: string) => void;
+ setSelectedRunId: (id: string) => void;
+ handleExperimentClick: (experimentId: string) => void;
+ handleRunClick: (runId: string) => void;
+ refreshExperimentList: () => Promise;
+ processReportData: (data: ExperimentReport[]) => void;
+ setIsLoading: (loading: boolean) => void;
+};
+
+/**
+ * Custom hook for managing project dashboard state and logic
+ * Extracts common logic shared between authenticated and public dashboard pages
+ */
+export function useProjectDashboard(
+ projectId: string | null,
+ date: DateRange,
+): ProjectDashboardData {
+ const [isLoading, setIsLoading] = useState(true);
+ const [radialChartData, setRadialChartData] = useState(
+ getDefaultRadialChartData(),
+ );
+ const [projectExperiments, setProjectExperiments] = useState(
+ [],
+ );
+ const [experimentsReportData, setExperimentsReportData] = useState<
+ ExperimentReport[]
+ >([]);
+ const [runData, setRunData] = useState({
+ experimentId: "",
+ startDate: date.from?.toISOString() || "",
+ endDate: date.to?.toISOString() || "",
+ });
+ const [convertedValues, setConvertedValues] = useState(
+ getDefaultConvertedValues(),
+ );
+ const [selectedExperimentId, setSelectedExperimentId] =
+ useState("");
+ const [selectedRunId, setSelectedRunId] = useState("");
+
+ const refreshExperimentList = useCallback(async () => {
+ if (!projectId) return;
+ const experiments: Experiment[] = await getExperiments(projectId);
+ setProjectExperiments(experiments);
+ }, [projectId]);
+
+ const handleExperimentClick = useCallback(
+ (experimentId: string) => {
+ if (experimentId === selectedExperimentId) {
+ setSelectedExperimentId("");
+ setSelectedRunId("");
+ return;
+ }
+ setSelectedExperimentId(experimentId);
+ setSelectedRunId("");
+ },
+ [selectedExperimentId],
+ );
+
+ const handleRunClick = useCallback(
+ (runId: string) => {
+ if (runId === selectedRunId) {
+ setSelectedRunId("");
+ return;
+ }
+ setSelectedRunId(runId);
+ },
+ [selectedRunId],
+ );
+
+ /**
+ * Process experiment report data and update all derived state
+ */
+ const processReportData = useCallback(
+ (report: ExperimentReport[]) => {
+ setExperimentsReportData(report);
+
+ const newRadialChartData = calculateRadialChartData(report);
+ setRadialChartData(newRadialChartData);
+
+ setRunData({
+ experimentId: report[0]?.experiment_id ?? "",
+ startDate: date?.from?.toISOString() ?? "",
+ endDate: date?.to?.toISOString() ?? "",
+ });
+
+ setSelectedExperimentId(report[0]?.experiment_id ?? "");
+
+ const newConvertedValues =
+ calculateConvertedValues(newRadialChartData);
+ setConvertedValues(newConvertedValues);
+ },
+ [date],
+ );
+
+ return {
+ radialChartData,
+ convertedValues,
+ experimentsReportData,
+ projectExperiments,
+ runData,
+ selectedExperimentId,
+ selectedRunId,
+ isLoading,
+ setSelectedExperimentId,
+ setSelectedRunId,
+ handleExperimentClick,
+ handleRunClick,
+ refreshExperimentList,
+ processReportData,
+ setIsLoading,
+ };
+}
diff --git a/webapp/src/server-functions/ERROR_HANDLING.md b/webapp/src/server-functions/ERROR_HANDLING.md
new file mode 100644
index 000000000..7928252a9
--- /dev/null
+++ b/webapp/src/server-functions/ERROR_HANDLING.md
@@ -0,0 +1,151 @@
+# Error Handling Standards for Server Functions
+
+## Overview
+
+This document defines the standard error handling patterns for all server-side API functions in the codebase.
+
+## Core Principles
+
+1. **User feedback comes from the UI layer** - Server functions focus on data retrieval/mutation
+2. **Consistent patterns** - Similar operations should handle errors the same way
+3. **Graceful degradation** - Read operations should fail gracefully with empty data
+4. **Clear failure signals** - Write operations should throw errors for the UI to catch
+
+---
+
+## Standard Patterns
+
+### Pattern A: Read Operations (GET)
+
+**Use for:** Fetching data, list operations, queries
+
+```typescript
+export async function getData(id: string): Promise {
+ const result = await fetchApi(`/endpoint/${id}`);
+
+ // Return empty array/null on failure - UI will show "no data" state
+ if (!result) {
+ return []; // or null for single items
+ }
+
+ return result;
+}
+```
+
+**Why:**
+
+- Users can still use the app even if one data source fails
+- UI naturally shows "no data" or "empty" states
+- Errors are already logged by `fetchApi`
+
+### Pattern B: Write Operations (POST/PUT/PATCH/DELETE)
+
+**Use for:** Creating, updating, deleting data
+
+```typescript
+export async function createData(data: Data): Promise {
+ const result = await fetchApi("/endpoint", {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+
+ // Throw error - UI will catch and show toast/error message
+ if (!result) {
+ throw new Error("Failed to create data");
+ }
+
+ return result;
+}
+```
+
+**Why:**
+
+- Write operations are user-initiated actions that need feedback
+- UI layer can catch the error and show appropriate toast/modal
+- Clear signal that the operation failed
+
+### Pattern C: Critical Read Operations
+
+**Use for:** Data required for the page to function (rare)
+
+```typescript
+export async function getCriticalData(id: string): Promise {
+ const result = await fetchApi(`/critical/${id}`);
+
+ if (!result) {
+ throw new Error("Failed to load required data");
+ }
+
+ return result;
+}
+```
+
+**Why:**
+
+- Some data is essential for the page to work
+- UI can show error boundary or redirect
+
+---
+
+## Migration Guide
+
+### ❌ Avoid Try-Catch in Server Functions
+
+```typescript
+// DON'T DO THIS - fetchApi already handles errors
+try {
+ const result = await fetchApi(endpoint);
+ return result || [];
+} catch (error) {
+ console.error(error);
+ return [];
+}
+```
+
+```typescript
+// DO THIS - Let fetchApi handle errors, check result
+const result = await fetchApi(endpoint);
+return result || [];
+```
+
+### UI Layer Responsibilities
+
+The UI components should handle errors from write operations:
+
+```typescript
+// In React component
+const handleCreate = async () => {
+ try {
+ await createData(formData);
+ toast.success("Created successfully");
+ } catch (error) {
+ toast.error(error.message || "Failed to create");
+ }
+};
+```
+
+---
+
+## Examples by Function Type
+
+| Function Type | Pattern | Return on Error | Example |
+| -------------------- | ------- | --------------- | ------------------ |
+| `getProjects()` | A | `[]` | List of projects |
+| `getOneProject()` | A | `null` | Single project |
+| `createProject()` | B | `throw` | Create new project |
+| `updateProject()` | B | `throw` | Update project |
+| `deleteProject()` | B | `void` (throws) | Delete project |
+| `getOrganizations()` | A | `[]` | List of orgs |
+| `getUserProfile()` | C | `throw` | Required for auth |
+
+---
+
+## Decision Tree
+
+```
+Is this a READ operation?
+├─ Yes: Is the data critical for the page?
+│ ├─ Yes: Use Pattern C (throw)
+│ └─ No: Use Pattern A (return empty)
+└─ No (WRITE operation): Use Pattern B (throw)
+```
diff --git a/webapp/src/server-functions/experiments.ts b/webapp/src/server-functions/experiments.ts
index 0a4ddcbe3..986313be2 100644
--- a/webapp/src/server-functions/experiments.ts
+++ b/webapp/src/server-functions/experiments.ts
@@ -6,33 +6,26 @@ import { DateRange } from "react-day-picker";
export async function createExperiment(
experiment: Experiment,
): Promise {
- const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/experiments`, {
+ const result = await fetchApi("/experiments", {
method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- ...experiment,
- }),
+ body: JSON.stringify(experiment),
});
- if (!res.ok) {
+ if (!result) {
throw new Error("Failed to create experiment");
}
- const result = await res.json();
return result;
}
export async function getExperiments(projectId: string): Promise {
- const res = await fetch(
- `${process.env.NEXT_PUBLIC_API_URL}/projects/${projectId}/experiments`,
+ const result = await fetchApi(
+ `/projects/${projectId}/experiments`,
);
- if (!res.ok) {
- throw new Error("Failed to fetch experiments");
+ if (!result) {
+ return [];
}
- const result = await res.json();
return result.map((experiment: Experiment) => {
return {
id: experiment.id,
diff --git a/webapp/src/server-functions/organizations.ts b/webapp/src/server-functions/organizations.ts
index 8b274dc9e..bca8c7b13 100644
--- a/webapp/src/server-functions/organizations.ts
+++ b/webapp/src/server-functions/organizations.ts
@@ -6,40 +6,17 @@ import { fetchApiServer } from "@/helpers/api-server";
export async function getOrganizationEmissionsByProject(
organizationId: string,
dateRange: DateRange | undefined,
-): Promise {
- try {
- let endpoint = `/organizations/${organizationId}/sums`;
+): Promise {
+ let endpoint = `/organizations/${organizationId}/sums`;
- if (dateRange?.from && dateRange?.to) {
- endpoint += `?start_date=${dateRange.from.toISOString()}&end_date=${dateRange.to.toISOString()}`;
- }
-
- const result = await fetchApiServer(endpoint);
-
- if (!result) {
- return null;
- }
+ if (dateRange?.from && dateRange?.to) {
+ endpoint += `?start_date=${dateRange.from.toISOString()}&end_date=${dateRange.to.toISOString()}`;
+ }
- // Handle case when no emissions data is found
- if (!result || result === null) {
- // Return zeros for all metrics
- return {
- name: "",
- emissions: 0,
- energy_consumed: 0,
- duration: 0,
- };
- }
+ const result = await fetchApiServer(endpoint);
- return {
- name: result.name || "",
- emissions: result.emissions || 0,
- energy_consumed: result.energy_consumed || 0,
- duration: result.duration || 0,
- };
- } catch (error) {
- console.error("Error fetching organization emissions:", error);
- // Return default values if there's an error
+ // Handle case when no emissions data is found
+ if (!result) {
return {
name: "",
emissions: 0,
@@ -47,44 +24,45 @@ export async function getOrganizationEmissionsByProject(
duration: 0,
};
}
+
+ return result;
}
export async function getDefaultOrgId(): Promise {
- try {
- const orgs = await fetchApiServer("/organizations");
- if (!orgs) {
- return null;
- }
+ const orgs = await fetchApiServer("/organizations");
- if (orgs.length > 0) {
- return orgs[0].id;
- }
- } catch (err) {
- console.warn("error processing organizations list", err);
+ // Return null on failure (Pattern A - Read operation)
+ if (!orgs || orgs.length === 0) {
+ return null;
}
- return null;
+
+ return orgs[0].id;
}
export async function getOrganizations(): Promise {
- try {
- const orgs = await fetchApiServer("/organizations");
- if (!orgs) {
- return [];
- }
+ const orgs = await fetchApiServer("/organizations");
- return orgs;
- } catch (err) {
- console.warn("error fetching organizations list", err);
+ // Return empty array on failure (Pattern A - Read operation)
+ if (!orgs) {
return [];
}
+
+ return orgs;
}
export const createOrganization = async (organization: {
name: string;
description: string;
-}): Promise => {
- return fetchApiServer("/organizations", {
+}): Promise => {
+ const result = await fetchApiServer("/organizations", {
method: "POST",
body: JSON.stringify(organization),
});
+
+ // Throw on failure (Pattern B - Write operation)
+ if (!result) {
+ throw new Error("Failed to create organization");
+ }
+
+ return result;
};
diff --git a/webapp/src/server-functions/projectTokens.ts b/webapp/src/server-functions/projectTokens.ts
index 3a593861a..e692aeb04 100644
--- a/webapp/src/server-functions/projectTokens.ts
+++ b/webapp/src/server-functions/projectTokens.ts
@@ -1,4 +1,5 @@
import { IProjectToken } from "@/types/project";
+import { fetchApi } from "@/utils/api";
/**
* Retrieves the list of tokens for a given project
@@ -6,20 +7,16 @@ import { IProjectToken } from "@/types/project";
export async function getProjectTokens(
projectId: string,
): Promise {
- try {
- const URL = `${process.env.NEXT_PUBLIC_API_URL}/projects/${projectId}/api-tokens`;
- const res = await fetch(URL);
- if (!res.ok) {
- // This will activate the closest `error.js` Error Boundary
- console.error("Failed to fetch data", res.statusText);
- throw new Error("Failed to fetch data");
- }
- const data = await res.json();
- return data;
- } catch (error) {
- // This will activate the closest `error.js` Error Boundary
- throw new Error("Failed to fetch data");
+ const data = await fetchApi(
+ `/projects/${projectId}/api-tokens`,
+ );
+
+ // Return empty array on failure (Pattern A - Read operation)
+ if (!data) {
+ return [];
}
+
+ return data;
}
export async function createProjectToken(
@@ -27,52 +24,25 @@ export async function createProjectToken(
tokenName: string,
access?: Number,
): Promise {
- try {
- const res = await fetch(
- `${process.env.NEXT_PUBLIC_API_URL}/projects/${projectId}/api-tokens`,
- {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify({ name: tokenName, access }),
- },
- );
- if (!res.ok) {
- // This will activate the closest `error.js` Error Boundary
- console.error("Failed to fetch data", res.statusText);
- throw new Error("Failed to fetch data");
- }
- return res.json();
- } catch (error) {
- // This will activate the closest `error.js` Error Boundary
- console.error("Failed to fetch data", error);
- throw new Error("Failed to fetch data");
+ const result = await fetchApi(
+ `/projects/${projectId}/api-tokens`,
+ {
+ method: "POST",
+ body: JSON.stringify({ name: tokenName, access }),
+ },
+ );
+
+ if (!result) {
+ throw new Error("Failed to create project token");
}
+
+ return result;
}
export async function deleteProjectToken(
projectId: string,
tokenId: string,
): Promise {
- try {
- const res = await fetch(
- `${process.env.NEXT_PUBLIC_API_URL}/projects/${projectId}/api-tokens/${tokenId}`,
- {
- method: "DELETE",
- headers: {
- "Content-Type": "application/json",
- },
- },
- );
- if (res.status !== 204) {
- // This will activate the closest `error.js` Error Boundary
- console.error("Failed to fetch data", res.statusText);
- throw new Error("Failed to fetch data");
- }
- return;
- } catch (error) {
- // This will activate the closest `error.js` Error Boundary
- console.error("Failed to fetch data", error);
- throw new Error("Failed to fetch data");
- }
+ await fetchApi(`/projects/${projectId}/api-tokens/${tokenId}`, {
+ method: "DELETE",
+ });
}
diff --git a/webapp/src/server-functions/projects.ts b/webapp/src/server-functions/projects.ts
index 16824ca46..056d0ac9b 100644
--- a/webapp/src/server-functions/projects.ts
+++ b/webapp/src/server-functions/projects.ts
@@ -4,7 +4,7 @@ import { fetchApiServer } from "@/helpers/api-server";
export const createProject = async (
organizationId: string,
project: { name: string; description: string },
-): Promise => {
+): Promise => {
const result = await fetchApiServer("/projects", {
method: "POST",
body: JSON.stringify({
@@ -13,23 +13,28 @@ export const createProject = async (
}),
});
+ // Throw on failure (Pattern B - Write operation)
if (!result) {
- return null;
+ throw new Error("Failed to create project");
}
+
return result;
};
export const updateProject = async (
projectId: string,
project: ProjectInputs,
-): Promise => {
+): Promise => {
const result = await fetchApiServer(`/projects/${projectId}`, {
method: "PATCH",
body: JSON.stringify(project),
});
+
+ // Throw on failure (Pattern B - Write operation)
if (!result) {
- return null;
+ throw new Error("Failed to update project");
}
+
return result;
};
@@ -49,10 +54,6 @@ export const getOneProject = async (
projectId: string,
): Promise => {
const project = await fetchApiServer(`/projects/${projectId}`);
- console.log("project", JSON.stringify(project, null, 2));
- if (!project) {
- return null;
- }
return project;
};
diff --git a/webapp/src/server-functions/runs.ts b/webapp/src/server-functions/runs.ts
index cee56c3ca..0ce1bf725 100644
--- a/webapp/src/server-functions/runs.ts
+++ b/webapp/src/server-functions/runs.ts
@@ -4,10 +4,11 @@ import { RunMetadata } from "@/types/run-metadata";
import { fetchApi } from "@/utils/api";
import { RunReport } from "@/types/run-report";
-export async function getRunMetadata(runId: string): Promise {
- const url = `${process.env.NEXT_PUBLIC_API_URL}/runs/${runId}`;
- const res = await fetch(url);
- return await res.json();
+export async function getRunMetadata(
+ runId: string,
+): Promise {
+ const result = await fetchApi(`/runs/${runId}`);
+ return result;
}
export async function getRunEmissionsByExperiment(
@@ -19,15 +20,14 @@ export async function getRunEmissionsByExperiment(
return [];
}
- const url = `${process.env.NEXT_PUBLIC_API_URL}/experiments/${experimentId}/runs/sums?start_date=${startDate}&end_date=${endDate}`;
- const res = await fetch(url);
+ const result = await fetchApi(
+ `/experiments/${experimentId}/runs/sums?start_date=${startDate}&end_date=${endDate}`,
+ );
- if (!res.ok) {
- // Log error waiting for a better error management
- console.log("Failed to fetch data");
+ if (!result) {
return [];
}
- const result = await res.json();
+
return result.map((runReport: any) => {
return {
runId: runReport.run_id,
@@ -42,59 +42,55 @@ export async function getRunEmissionsByExperiment(
export async function getEmissionsTimeSeries(
runId: string,
): Promise {
- try {
- const runMetadataData = await fetchApi(`/runs/${runId}`);
- const emissionsData = await fetchApi<{ items: Emission[] }>(
- `/runs/${runId}/emissions`,
- );
-
- if (!runMetadataData || !emissionsData) {
- return {
- runId,
- emissions: [],
- metadata: null,
- };
- }
-
- 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: any) => ({
- 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,
- }));
+ const [runMetadataData, emissionsData] = await Promise.all([
+ fetchApi(`/runs/${runId}`),
+ fetchApi<{ items: Emission[] }>(`/runs/${runId}/emissions`),
+ ]);
+ // Return empty data on failure (Pattern A - Read operation)
+ if (!runMetadataData || !emissionsData) {
return {
runId,
- emissions,
- metadata,
+ emissions: [],
+ metadata: null,
};
- } catch (error) {
- console.error("Failed to fetch emissions time series:", error);
- throw error;
}
+
+ 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: any) => ({
+ 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,
+ };
}
diff --git a/webapp/src/utils/api.ts b/webapp/src/utils/api.ts
index 566aae071..2b0cf11d5 100644
--- a/webapp/src/utils/api.ts
+++ b/webapp/src/utils/api.ts
@@ -11,27 +11,10 @@ export async function fetchApi(
endpoint: string,
options?: RequestInit,
): Promise {
- try {
- // Check if we're in the browser
- if (typeof window !== "undefined") {
- return await fetchApiClient(endpoint, options);
- } else {
- // Server-side - use fetchApiServer
- return await fetchApiServer(endpoint, options);
- }
- } catch (error) {
- // Log and rethrow the error for other endpoints
- console.error(`API error for ${endpoint}:`, error);
- throw error;
+ // Both fetchApiClient and fetchApiServer handle errors internally
+ // and return null on failure, so no try/catch needed here
+ if (typeof window !== "undefined") {
+ return await fetchApiClient(endpoint, options);
}
-}
-
-// Helper function to check if we're running on the client
-export function isClient(): boolean {
- return typeof window !== "undefined";
-}
-
-// Helper function to check if we're running on the server
-export function isServer(): boolean {
- return typeof window === "undefined";
+ return await fetchApiServer(endpoint, options);
}