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..4a10d3e07 100644 --- a/webapp/src/app/(dashboard)/[organizationId]/page.tsx +++ b/webapp/src/app/(dashboard)/[organizationId]/page.tsx @@ -11,6 +11,11 @@ import { 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 +34,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 +91,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..9d3e95055 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,7 +24,8 @@ 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( @@ -30,7 +33,7 @@ export default function ProjectsPage({ ); const handleClick = async () => { - setIsModalOpen(true); + createModal.open(); }; const refreshProjectList = async () => { @@ -61,7 +64,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 +107,8 @@ export default function ProjectsPage({ setIsModalOpen(false)} + isOpen={createModal.isOpen} + onClose={createModal.close} onProjectCreated={refreshProjectList} /> diff --git a/webapp/src/app/(dashboard)/profile/page.tsx b/webapp/src/app/(dashboard)/profile/page.tsx index 60d5a9ee9..fc37c9c79 100644 --- a/webapp/src/app/(dashboard)/profile/page.tsx +++ b/webapp/src/app/(dashboard)/profile/page.tsx @@ -4,23 +4,15 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { fiefAuth } from "@/helpers/fief"; import { User } from "@/types/user"; +import { fetchApiServer } from "@/helpers/api-server"; async function getUser(): Promise { const userId = await fiefAuth.getUserId(); 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/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 8ae42cc63..95000ad5e 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} /> { @@ -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/components/ui/calendar.tsx b/webapp/src/components/ui/calendar.tsx index 99ac94c4e..87f9d0a24 100644 --- a/webapp/src/components/ui/calendar.tsx +++ b/webapp/src/components/ui/calendar.tsx @@ -59,9 +59,7 @@ function Calendar({ Chevron: ({ className, orientation, ...props }) => { const Icon = orientation === "left" ? ChevronLeft : ChevronRight; - return ( - - ); + return ; }, }} {...props} diff --git a/webapp/src/helpers/api-client.ts b/webapp/src/helpers/api-client.ts index 867d5b0a5..5a2d6cb3b 100644 --- a/webapp/src/helpers/api-client.ts +++ b/webapp/src/helpers/api-client.ts @@ -36,7 +36,7 @@ export async function fetchApiClient( } catch (e) { // Ignore JSON parsing errors } - console.log(errorMessage); + console.error(errorMessage); return null; } diff --git a/webapp/src/helpers/api-server.ts b/webapp/src/helpers/api-server.ts index ee1ec2c21..6887c7b6f 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,21 @@ 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; - } + // Handle 204 No Content responses (e.g., DELETE operations) + if (response.status === 204) { + return null; } - return await response.json(); + // 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; + } } catch (error) { // Log server-side error with more details console.error("API server request failed:", { @@ -84,25 +70,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..33c1fbe10 --- /dev/null +++ b/webapp/src/helpers/time-constants.ts @@ -0,0 +1,28 @@ +/** + * Time-related constants to avoid magic numbers + */ + +// Base time units in milliseconds +export const MILLISECONDS_PER_SECOND = 1000; +export const SECONDS_PER_MINUTE = 60; +export const MINUTES_PER_HOUR = 60; +export const HOURS_PER_DAY = 24; +export const DAYS_PER_WEEK = 7; +export const WEEKS_PER_YEAR = 52; + +// Composite time units in milliseconds +export const ONE_SECOND_MS = MILLISECONDS_PER_SECOND; +export const ONE_MINUTE_MS = ONE_SECOND_MS * SECONDS_PER_MINUTE; +export const ONE_HOUR_MS = ONE_MINUTE_MS * MINUTES_PER_HOUR; +export const ONE_DAY_MS = ONE_HOUR_MS * HOURS_PER_DAY; +export const ONE_WEEK_MS = ONE_DAY_MS * DAYS_PER_WEEK; + +// Common time intervals +export const THIRTY_DAYS_MS = 30 * ONE_DAY_MS; + +// Seconds conversions +export const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR; +export const SECONDS_PER_DAY = SECONDS_PER_HOUR * HOURS_PER_DAY; + +// Common refresh intervals +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..6f38dd7ad --- /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; + setExperimentsReportData: (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, + setExperimentsReportData: 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..9b299f7c9 100644 --- a/webapp/src/server-functions/organizations.ts +++ b/webapp/src/server-functions/organizations.ts @@ -6,22 +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 (dateRange?.from && dateRange?.to) { + endpoint += `?start_date=${dateRange.from.toISOString()}&end_date=${dateRange.to.toISOString()}`; + } - if (!result) { - return null; - } + const result = await fetchApiServer(endpoint); // Handle case when no emissions data is found - if (!result || result === null) { + if (!result) { // Return zeros for all metrics return { name: "", @@ -31,60 +26,44 @@ export async function getOrganizationEmissionsByProject( }; } - 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 - return { - name: "", - emissions: 0, - energy_consumed: 0, - 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..66cc8e9ac 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,15 @@ 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`, + ); + + if (!data) { + throw new Error("Failed to fetch project tokens"); } + + return data; } export async function createProjectToken( @@ -27,52 +23,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..c8b7b7804 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 = await fetchApi(`/runs/${runId}`); + const emissionsData = await 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..66b8d6e98 100644 --- a/webapp/src/utils/api.ts +++ b/webapp/src/utils/api.ts @@ -25,13 +25,3 @@ export async function fetchApi( throw error; } } - -// 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"; -}