diff --git a/web/src/components/shared/error-boundary.tsx b/web/src/components/shared/error-boundary.tsx new file mode 100644 index 000000000..0e35db8cd --- /dev/null +++ b/web/src/components/shared/error-boundary.tsx @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2026 Vishnu Muthiah +// SPDX-License-Identifier: AGPL-3.0-only + +import { Component, type ErrorInfo, type ReactNode } from "react"; +import { AlertCircle, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + state: State = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("[ErrorBoundary]", error, info.componentStack); + } + + render() { + if (this.state.hasError) { + return ( +
+
+ +

Something went wrong

+

+ An unexpected error occurred. Please reload the page. +

+ +
+
+ ); + } + + return this.props.children; + } +} diff --git a/web/src/hooks/use-auth.ts b/web/src/hooks/use-auth.ts index d1607f962..509f3bcdd 100644 --- a/web/src/hooks/use-auth.ts +++ b/web/src/hooks/use-auth.ts @@ -7,7 +7,11 @@ import { useEffect, useSyncExternalStore } from "react"; import { useRouter, useLocation } from "@tanstack/react-router"; -import { auth, setUserRole, getUserRole, clearSession, refreshAccessToken } from "@/lib/api"; +import { auth, setUserRole, getUserRole, clearSession, refreshAccessTokenWithReason } from "@/lib/api"; + +function isNetworkError(err: unknown): boolean { + return err instanceof TypeError || (typeof navigator !== "undefined" && !navigator.onLine); +} function subscribe(cb: () => void) { window.addEventListener("storage", cb); @@ -44,15 +48,15 @@ export function useAuthGuard() { // New tab: no access token but refresh token exists. Try silent refresh. if (isRefreshing) { - refreshAccessToken().then((ok) => { - if (ok) { - // Token restored, trigger whoami to resolve role + refreshAccessTokenWithReason().then((result) => { + if (result === "ok") { window.dispatchEvent(new Event("storage")); - } else { + } else if (result === "rejected") { clearSession(); window.dispatchEvent(new Event("storage")); router.navigate({ to: "/login", replace: true }); } + // "network_error": do nothing, leave session intact }); return; } @@ -67,7 +71,8 @@ export function useAuthGuard() { auth.whoami().then((user) => { setUserRole(user.role); window.dispatchEvent(new Event("storage")); - }).catch(() => { + }).catch((err) => { + if (isNetworkError(err)) return; clearSession(); window.dispatchEvent(new Event("storage")); router.navigate({ to: "/login", replace: true }); @@ -95,7 +100,8 @@ export function useOptionalAuth() { auth.whoami().then((user) => { setUserRole(user.role); window.dispatchEvent(new Event("storage")); - }).catch(() => { + }).catch((err) => { + if (isNetworkError(err)) return; clearSession(); window.dispatchEvent(new Event("storage")); }); diff --git a/web/src/hooks/use-deployment-config.ts b/web/src/hooks/use-deployment-config.ts index 27c77978d..373cafcfc 100644 --- a/web/src/hooks/use-deployment-config.ts +++ b/web/src/hooks/use-deployment-config.ts @@ -8,11 +8,11 @@ import { useQuery } from "@tanstack/react-query"; import { config, type PublicConfig } from "@/lib/api"; export function useDeploymentConfig() { - const { data, isLoading } = useQuery({ + const { data, isLoading, isError } = useQuery({ queryKey: ["config", "public"], queryFn: config.public, staleTime: 5 * 60 * 1000, // cache for 5 minutes - retry: 1, + retry: 2, }); return { @@ -26,6 +26,7 @@ export function useDeploymentConfig() { brandingAppName: data?.branding_app_name ?? null, brandingWordmark: data?.branding_wordmark ?? null, loading: isLoading, + configError: isError, }; } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 72be1d713..e634c46b1 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -160,11 +160,13 @@ export function getUserAvatar(): string | null { return localStorage.getItem(STORAGE_KEY_USER_AVATAR); } -let _refreshPromise: Promise | null = null; +let _refreshPromise: Promise | null = null; -async function _tryRefreshToken(): Promise { +type RefreshResult = "ok" | "rejected" | "network_error"; + +async function _tryRefreshToken(): Promise { const refreshToken = getRefreshToken(); - if (!refreshToken) return false; + if (!refreshToken) return "rejected"; try { const res = await fetch(`${API}/auth/token/refresh`, { @@ -173,13 +175,13 @@ async function _tryRefreshToken(): Promise { body: JSON.stringify({ refresh_token: refreshToken }), }); - if (!res.ok) return false; + if (!res.ok) return "rejected"; const data = await res.json(); setTokens(data.access_token, data.refresh_token); - return true; + return "ok"; } catch { - return false; + return "network_error"; } } @@ -188,6 +190,11 @@ async function _tryRefreshToken(): Promise { * Returns true if the access token was restored successfully. */ export async function refreshAccessToken(): Promise { + const result = await _tryRefreshToken(); + return result === "ok"; +} + +export async function refreshAccessTokenWithReason(): Promise { return _tryRefreshToken(); } @@ -225,9 +232,9 @@ async function request( _refreshPromise = null; }); } - const refreshed = await _refreshPromise; + const refreshResult = await _refreshPromise; - if (refreshed) { + if (refreshResult === "ok") { // Retry the original request with new token const newToken = getAccessToken(); if (newToken) headers["Authorization"] = `Bearer ${newToken}`; @@ -241,15 +248,17 @@ async function request( if (retryRes.status === 204) return undefined as T; return retryRes.json() as Promise; } - // Retry failed but refresh succeeded, so the individual call - // fails gracefully without killing the session const retryText = await retryRes.text().catch(() => "Request failed"); const retryErr = new Error(retryText); (retryErr as Error & { status: number }).status = retryRes.status; throw retryErr; } - // Refresh itself failed: session is truly expired + if (refreshResult === "network_error") { + throw new Error("Network unavailable"); + } + + // Real rejection: session is truly expired clearSession(); if (typeof window !== "undefined") { window.location.href = "/login?reason=session_expired"; @@ -290,7 +299,9 @@ async function request( : JSON.stringify(parsed.error); } } catch { - // not JSON, use raw text + if (detail.length > 200 || detail.includes("Traceback") || detail.includes("Error:")) { + detail = `Request failed (${response.status})`; + } } } const err = new Error(detail); @@ -917,10 +928,19 @@ export const insights = { selection ?? {}, ), exportHtml: async (agentId: string, reportId: string): Promise => { - const token = getAccessToken(); - const res = await fetch(`${API}/agents/${agentId}/insights/reports/${reportId}/export/html`, { + let token = getAccessToken(); + let res = await fetch(`${API}/agents/${agentId}/insights/reports/${reportId}/export/html`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); + if (res.status === 401) { + const refreshed = await _tryRefreshToken(); + if (refreshed) { + token = getAccessToken(); + res = await fetch(`${API}/agents/${agentId}/insights/reports/${reportId}/export/html`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + } + } if (!res.ok) throw new Error("Export failed"); const blob = await res.blob(); const url = URL.createObjectURL(blob); diff --git a/web/src/pages/admin/audit-log.tsx b/web/src/pages/admin/audit-log.tsx index d5ee8e212..378018f0c 100644 --- a/web/src/pages/admin/audit-log.tsx +++ b/web/src/pages/admin/audit-log.tsx @@ -3,6 +3,7 @@ import { useState, useMemo, useCallback, useRef } from "react"; +import { toast } from "sonner"; import { useSearch, useLocation } from "@tanstack/react-router"; import { ScrollText, @@ -230,7 +231,7 @@ export default function AuditLogPage() { a.click(); URL.revokeObjectURL(url); } catch { - // export failed + toast.error("Failed to export audit log. Please try again."); } }, [searchQuery]); diff --git a/web/src/pages/admin/dashboard/components/adoption-tab.tsx b/web/src/pages/admin/dashboard/components/adoption-tab.tsx index 3f129bf6f..2c4a26845 100644 --- a/web/src/pages/admin/dashboard/components/adoption-tab.tsx +++ b/web/src/pages/admin/dashboard/components/adoption-tab.tsx @@ -11,11 +11,19 @@ import { DashboardRangeContext } from "../context"; export function AdoptionTab() { const range = useContext(DashboardRangeContext); - const { data: adoption, isLoading: adoptionLoading } = useExecAdoption(); - const { data: agents, isLoading: agentsLoading } = useExecAgentCounts(); + const { data: adoption, isLoading: adoptionLoading, isError: adoptionError } = useExecAdoption(); + const { data: agents, isLoading: agentsLoading, isError: agentsError } = useExecAgentCounts(); const { data: usage } = useExecUsageByCategory(range); const { data: platforms } = useExecPlatformCoverage(); + if (adoptionError || agentsError) { + return ( +
+

Failed to load adoption data. Check your connection and try again.

+
+ ); + } + if (adoptionLoading || agentsLoading) { return (
diff --git a/web/src/pages/admin/dashboard/components/cost-tab.tsx b/web/src/pages/admin/dashboard/components/cost-tab.tsx index 3e476d178..eb0091019 100644 --- a/web/src/pages/admin/dashboard/components/cost-tab.tsx +++ b/web/src/pages/admin/dashboard/components/cost-tab.tsx @@ -9,6 +9,7 @@ import { useExecCostSummary, useExecROIProjections } from "@/hooks/use-api"; import { exec } from "@/lib/api"; import { StatCard } from "./stat-card"; import { Loader2, TrendingUp } from "lucide-react"; +import { toast } from "sonner"; import { DashboardRangeContext } from "../context"; const DEFAULT_CATEGORIES = [ @@ -41,7 +42,7 @@ function BaselinesConfigForm({ onSaved }: { onSaved: () => void }) { }); onSaved(); } catch { - // error handled by React Query elsewhere + toast.error("Failed to save baselines. Please try again."); } finally { setSaving(false); } @@ -207,9 +208,17 @@ function ROIProjections() { export function CostTab() { const range = useContext(DashboardRangeContext); - const { data: cost, isLoading, refetch } = useExecCostSummary(range); + const { data: cost, isLoading, isError, refetch } = useExecCostSummary(range); const [showEditBaselines, setShowEditBaselines] = useState(false); + if (isError) { + return ( +
+

Failed to load cost data. Check your connection and try again.

+
+ ); + } + if (isLoading) { return (
diff --git a/web/src/pages/admin/dashboard/components/departments-tab.tsx b/web/src/pages/admin/dashboard/components/departments-tab.tsx index c12522fa0..f6def38d1 100644 --- a/web/src/pages/admin/dashboard/components/departments-tab.tsx +++ b/web/src/pages/admin/dashboard/components/departments-tab.tsx @@ -10,9 +10,17 @@ import { DashboardRangeContext } from "../context"; export function DepartmentsTab() { const range = useContext(DashboardRangeContext); - const { data: depts, isLoading: deptsLoading } = useExecDepartments(range); + const { data: depts, isLoading: deptsLoading, isError: deptsError } = useExecDepartments(range); const { data: tokens, isLoading: tokensLoading } = useExecDeptTokens(range); + if (deptsError) { + return ( +
+

Failed to load department data. Check your connection and try again.

+
+ ); + } + if (deptsLoading) { return (
diff --git a/web/src/pages/admin/dashboard/components/insights-tab.tsx b/web/src/pages/admin/dashboard/components/insights-tab.tsx index d57162d37..87eb63e1a 100644 --- a/web/src/pages/admin/dashboard/components/insights-tab.tsx +++ b/web/src/pages/admin/dashboard/components/insights-tab.tsx @@ -75,7 +75,15 @@ function DeveloperBreakdown() { } export function InsightsTab() { - const { data: insights, isLoading } = useExecAIInsights(); + const { data: insights, isLoading, isError } = useExecAIInsights(); + + if (isError) { + return ( +
+

Failed to load insights. Check your connection and try again.

+
+ ); + } if (isLoading) { return ( diff --git a/web/src/pages/admin/dashboard/components/investments-tab.tsx b/web/src/pages/admin/dashboard/components/investments-tab.tsx index ea9ae0966..de83bc313 100644 --- a/web/src/pages/admin/dashboard/components/investments-tab.tsx +++ b/web/src/pages/admin/dashboard/components/investments-tab.tsx @@ -30,9 +30,17 @@ function deriveRadarData(p: ExecPlatformScore, best: { latency: number; cost: nu } export function InvestmentsTab() { - const { data: platforms, isLoading } = useExecPlatforms(); + const { data: platforms, isLoading, isError } = useExecPlatforms(); const [selected, setSelected] = useState(0); + if (isError) { + return ( +
+

Failed to load platform data. Check your connection and try again.

+
+ ); + } + if (isLoading) { return (
diff --git a/web/src/pages/admin/dashboard/components/velocity-tab.tsx b/web/src/pages/admin/dashboard/components/velocity-tab.tsx index 6b28fa42f..12ae67c77 100644 --- a/web/src/pages/admin/dashboard/components/velocity-tab.tsx +++ b/web/src/pages/admin/dashboard/components/velocity-tab.tsx @@ -32,9 +32,17 @@ function Sparkline({ data }: { data: number[] }) { } export function VelocityTab() { - const { data: velocity, isLoading: velLoading } = useExecVelocity(); + const { data: velocity, isLoading: velLoading, isError: velError } = useExecVelocity(); const { data: topAgents, isLoading: agentsLoading } = useExecTopAgents(10); + if (velError) { + return ( +
+

Failed to load velocity data. Check your connection and try again.

+
+ ); + } + if (velLoading) { return (
diff --git a/web/src/pages/admin/settings.tsx b/web/src/pages/admin/settings.tsx index 7caab6bea..e31c4c79c 100644 --- a/web/src/pages/admin/settings.tsx +++ b/web/src/pages/admin/settings.tsx @@ -279,13 +279,13 @@ export default function SettingsPage() { admin .getTracePrivacy() .then((res) => setTracePrivacy(res.trace_privacy)) - .catch(() => {}) + .catch(() => { toast.error("Failed to load trace privacy setting"); }) .finally(() => setTracePrivacyLoading(false)); if (hasMinRole(getUserRole(), "super_admin")) { admin .getRegisteredAgentsOnly() .then((res) => setRegisteredAgentsOnly(res.registered_agents_only)) - .catch(() => {}) + .catch(() => { toast.error("Failed to load registered-agents-only setting"); }) .finally(() => setRegisteredAgentsOnlyLoading(false)); } admin @@ -298,7 +298,7 @@ export default function SettingsPage() { setMaxTraceCount(res.max_trace_count?.toString() || ""); setRetentionGlobal(res.global_retention_days); }) - .catch(() => {}) + .catch(() => { toast.error("Failed to load retention settings"); }) .finally(() => setRetentionLoading(false)); }, []); diff --git a/web/src/pages/registry/home.tsx b/web/src/pages/registry/home.tsx index 8eb5d280b..6874bec62 100644 --- a/web/src/pages/registry/home.tsx +++ b/web/src/pages/registry/home.tsx @@ -343,15 +343,15 @@ export default function RegistryHome() { const [search, setSearch] = useState(""); const router = useRouter(); const { data: whoami } = useWhoami(); - const { data: sessionsToday, isLoading: sessionsLoading } = useSessions2({ + const { data: sessionsToday, isLoading: sessionsLoading, isError: sessionsError } = useSessions2({ days: 1, limit: 200, mine: true, refetchInterval: 30_000, }); - const { data: myAgents, isLoading: myAgentsLoading } = useMyAgents(); - const { data: leaderboard, isLoading: leaderboardLoading } = useLeaderboard("7d", 50); - const { data: topAgents, isLoading: topLoading } = useTopAgents(6); + const { data: myAgents, isLoading: myAgentsLoading, isError: myAgentsError } = useMyAgents(); + const { data: leaderboard, isLoading: leaderboardLoading, isError: leaderboardError } = useLeaderboard("7d", 50); + const { data: topAgents, isLoading: topLoading, isError: topError } = useTopAgents(6); const { data: agents, isLoading: agentsLoading, @@ -454,8 +454,10 @@ export default function RegistryHome() { } } - const displayName = whoami?.name || whoami?.username || whoami?.email || "there"; - const primarySummary = sessionsLoading + const displayName = whoami?.name || whoami?.username || whoami?.email || "Hey there"; + const primarySummary = sessionsError + ? "Unable to load today's activity" + : sessionsLoading ? "Loading today's activity" : todayStats.sessions > 0 && todayStats.hasTokenData ? `${todayStats.sessions} session${todayStats.sessions === 1 ? "" : "s"} today using ${formatTokens(todayStats.totalTokens)} tokens.` diff --git a/web/src/pages/registry/leaderboard.tsx b/web/src/pages/registry/leaderboard.tsx index 46aa5151d..cf7df791c 100644 --- a/web/src/pages/registry/leaderboard.tsx +++ b/web/src/pages/registry/leaderboard.tsx @@ -56,14 +56,22 @@ export default function LeaderboardPage() { return () => clearTimeout(timer); }, [userFilterInput]); - const { data: leaderboard, isLoading: agentsLoading } = useLeaderboard( + const { data: leaderboard, isLoading: agentsLoading, isError: agentsError } = useLeaderboard( window, 50, userFilter || undefined, ); - const { data: componentLeaderboard, isLoading: componentsLoading } = + const { data: componentLeaderboard, isLoading: componentsLoading, isError: componentsError } = useComponentLeaderboard(window, 50); + if (agentsError && componentsError) { + return ( +
+

Failed to load leaderboard data. Check your connection and try again.

+
+ ); + } + const rankedComponents = useMemo( () => componentLeaderboard diff --git a/web/src/routes/__root.tsx b/web/src/routes/__root.tsx index 458a5984b..9b7231ada 100644 --- a/web/src/routes/__root.tsx +++ b/web/src/routes/__root.tsx @@ -7,6 +7,7 @@ import { QueryClientProvider } from "@tanstack/react-query"; import { ThemeProvider } from "@/lib/theme"; import { makeQueryClient } from "@/lib/query-client"; import { DynamicTitle } from "@/components/dynamic-title"; +import { ErrorBoundary } from "@/components/shared/error-boundary"; import { VersionMismatchBanner } from "@/components/shared/version-mismatch-banner"; import "@/app.css"; @@ -32,13 +33,15 @@ function RootComponent() { const [queryClient] = useState(makeQueryClient); return ( - - - - - - - + + + + + + + + + ); }