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); }