From 1f93e7433b37448fd1ad9dd94f123e6df7f3d35a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 5 Nov 2025 15:55:02 +0000 Subject: [PATCH] feat: Implement automatic API connectivity banner --- src/App.tsx | 26 +---- src/components/ui/api-status-banner.tsx | 20 ++-- src/contexts/APIConnectivityContext.tsx | 87 +++++++++++++++++ src/contexts/CronitorHealthContext.tsx | 63 ------------ src/hooks/useCronitorHealth.ts | 122 ------------------------ src/lib/errorHandler.ts | 34 +++++++ src/lib/supabaseClient.ts | 6 ++ 7 files changed, 140 insertions(+), 218 deletions(-) create mode 100644 src/contexts/APIConnectivityContext.tsx delete mode 100644 src/contexts/CronitorHealthContext.tsx delete mode 100644 src/hooks/useCronitorHealth.ts diff --git a/src/App.tsx b/src/App.tsx index 72658c6f..79d2887f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,11 +5,10 @@ import { TooltipProvider } from "@/components/ui/tooltip"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { BrowserRouter, Routes, Route, useLocation } from "react-router-dom"; -import * as Cronitor from '@cronitorio/cronitor-rum'; import { AuthProvider } from "@/hooks/useAuth"; import { AuthModalProvider } from "@/contexts/AuthModalContext"; import { MFAStepUpProvider } from "@/contexts/MFAStepUpContext"; -import { CronitorHealthProvider, useCronitorHealth } from "@/contexts/CronitorHealthContext"; +import { APIConnectivityProvider, useAPIConnectivity } from "@/contexts/APIConnectivityContext"; import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider"; import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper"; import { Footer } from "@/components/layout/Footer"; @@ -135,8 +134,8 @@ function NavigationTracker() { function AppContent(): React.JSX.Element { // Check if API status banner is visible to add padding - const { passing, isBannerDismissed } = useCronitorHealth(); - const showBanner = passing === false && !isBannerDismissed; + const { isAPIReachable, isBannerDismissed } = useAPIConnectivity(); + const showBanner = !isAPIReachable && !isBannerDismissed; return ( @@ -399,31 +398,16 @@ function AppContent(): React.JSX.Element { } const App = (): React.JSX.Element => { - // Initialize Cronitor RUM before router mounts - useEffect(() => { - try { - Cronitor.load("0b5d17d3f7625ce8766c2c4c85c1895d", { - debug: import.meta.env.DEV, // Enable debug logs in development only - trackMode: 'history', // Automatically track page views with React Router - }); - - // Log successful initialization - console.log('[Cronitor] RUM initialized'); - } catch (error) { - console.error('[Cronitor] Failed to initialize:', error); - } - }, []); - return ( - + - + diff --git a/src/components/ui/api-status-banner.tsx b/src/components/ui/api-status-banner.tsx index 750a4fd9..98420687 100644 --- a/src/components/ui/api-status-banner.tsx +++ b/src/components/ui/api-status-banner.tsx @@ -1,20 +1,16 @@ import { AlertTriangle, X, ExternalLink } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { useCronitorHealth } from '@/contexts/CronitorHealthContext'; +import { useAPIConnectivity } from '@/contexts/APIConnectivityContext'; /** - * Banner displayed when Cronitor detects Supabase API is down + * Banner displayed when Supabase API is unreachable * Includes link to status page and dismissal option */ export function APIStatusBanner() { - const { passing, isLoading, isBannerDismissed, dismissBanner } = useCronitorHealth(); + const { isAPIReachable, isBannerDismissed, dismissBanner } = useAPIConnectivity(); - // Don't show if: - // - Still loading initial data - // - API is healthy (passing === true) - // - User dismissed it - // - Status unknown (passing === null) after initial load - if (isLoading || passing === true || passing === null || isBannerDismissed) { + // Show banner when API is down AND not dismissed + if (isAPIReachable || isBannerDismissed) { return null; } @@ -25,9 +21,9 @@ export function APIStatusBanner() {
-

API Monitoring Alert

+

API Connection Issue

- Supabase services are experiencing issues. Some features may be temporarily unavailable. + Unable to reach the Supabase API. The service may be experiencing an outage or your connection may be interrupted.

- View Status Page + Check Status Page
diff --git a/src/contexts/APIConnectivityContext.tsx b/src/contexts/APIConnectivityContext.tsx new file mode 100644 index 00000000..f770d42c --- /dev/null +++ b/src/contexts/APIConnectivityContext.tsx @@ -0,0 +1,87 @@ +import { createContext, useContext, ReactNode, useState, useEffect } from 'react'; +import { logger } from '@/lib/logger'; + +interface APIConnectivityContextType { + isAPIReachable: boolean; + isBannerDismissed: boolean; + dismissBanner: () => void; +} + +const APIConnectivityContext = createContext(undefined); + +const DISMISSAL_DURATION = 15 * 60 * 1000; // 15 minutes +const DISMISSAL_KEY = 'api-connectivity-dismissed-until'; +const REACHABILITY_KEY = 'api-reachable'; + +export function APIConnectivityProvider({ children }: { children: ReactNode }) { + const [isAPIReachable, setIsAPIReachable] = useState(() => { + const stored = sessionStorage.getItem(REACHABILITY_KEY); + return stored !== 'false'; // Default to true, only false if explicitly set + }); + + const [dismissedUntil, setDismissedUntil] = useState(() => { + const stored = localStorage.getItem(DISMISSAL_KEY); + return stored ? parseInt(stored) : null; + }); + + const dismissBanner = () => { + const until = Date.now() + DISMISSAL_DURATION; + localStorage.setItem(DISMISSAL_KEY, until.toString()); + setDismissedUntil(until); + logger.info('API status banner dismissed', { until: new Date(until).toISOString() }); + }; + + const isBannerDismissed = dismissedUntil ? Date.now() < dismissedUntil : false; + + // Auto-clear dismissal when API is healthy again + useEffect(() => { + if (isAPIReachable && dismissedUntil) { + localStorage.removeItem(DISMISSAL_KEY); + setDismissedUntil(null); + logger.info('API status banner dismissal cleared (API recovered)'); + } + }, [isAPIReachable, dismissedUntil]); + + // Listen for custom events from error handler + useEffect(() => { + const handleAPIDown = () => { + logger.warn('API connectivity lost'); + setIsAPIReachable(false); + sessionStorage.setItem(REACHABILITY_KEY, 'false'); + }; + + const handleAPIUp = () => { + logger.info('API connectivity restored'); + setIsAPIReachable(true); + sessionStorage.setItem(REACHABILITY_KEY, 'true'); + }; + + window.addEventListener('api-connectivity-down', handleAPIDown); + window.addEventListener('api-connectivity-up', handleAPIUp); + + return () => { + window.removeEventListener('api-connectivity-down', handleAPIDown); + window.removeEventListener('api-connectivity-up', handleAPIUp); + }; + }, []); + + return ( + + {children} + + ); +} + +export function useAPIConnectivity() { + const context = useContext(APIConnectivityContext); + if (context === undefined) { + throw new Error('useAPIConnectivity must be used within APIConnectivityProvider'); + } + return context; +} diff --git a/src/contexts/CronitorHealthContext.tsx b/src/contexts/CronitorHealthContext.tsx deleted file mode 100644 index 59f9f7de..00000000 --- a/src/contexts/CronitorHealthContext.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { createContext, useContext, ReactNode, useState, useEffect } from 'react'; -import { useCronitorHealth as useCronitorHealthQuery } from '@/hooks/useCronitorHealth'; - -interface CronitorHealthContextType { - passing: boolean | null; // null = loading/unknown - isLoading: boolean; - error: Error | null; - lastChecked: Date | null; - isBannerDismissed: boolean; - dismissBanner: () => void; -} - -const CronitorHealthContext = createContext(undefined); - -const DISMISSAL_DURATION = 15 * 60 * 1000; // 15 minutes -const DISMISSAL_KEY = 'cronitor-banner-dismissed'; - -export function CronitorHealthProvider({ children }: { children: ReactNode }) { - const { data, isLoading, error } = useCronitorHealthQuery(); - const [dismissedUntil, setDismissedUntil] = useState(() => { - const stored = localStorage.getItem(DISMISSAL_KEY); - return stored ? parseInt(stored) : null; - }); - - const dismissBanner = () => { - const until = Date.now() + DISMISSAL_DURATION; - localStorage.setItem(DISMISSAL_KEY, until.toString()); - setDismissedUntil(until); - }; - - const isBannerDismissed = dismissedUntil ? Date.now() < dismissedUntil : false; - - // Auto-clear dismissal when API is healthy again - useEffect(() => { - if (data?.passing === true && dismissedUntil) { - localStorage.removeItem(DISMISSAL_KEY); - setDismissedUntil(null); - } - }, [data?.passing, dismissedUntil]); - - return ( - - {children} - - ); -} - -export function useCronitorHealth() { - const context = useContext(CronitorHealthContext); - if (context === undefined) { - throw new Error('useCronitorHealth must be used within CronitorHealthProvider'); - } - return context; -} diff --git a/src/hooks/useCronitorHealth.ts b/src/hooks/useCronitorHealth.ts deleted file mode 100644 index 65804e01..00000000 --- a/src/hooks/useCronitorHealth.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { isRetryableError } from '@/lib/retryHelpers'; -import { handleNonCriticalError } from '@/lib/errorHandler'; -import { logger } from '@/lib/logger'; - -const CRONITOR_API_URL = 'https://cronitor.io/api/monitors/88kG4W?env=production&format=json'; -const POLL_INTERVAL = 60000; // 60 seconds - -// Retry configuration -const MAX_RETRIES = 3; -const BASE_DELAY = 1000; // 1 second -const MAX_DELAY = 10000; // 10 seconds - -interface CronitorResponse { - passing: boolean; - [key: string]: any; // Other fields we don't need -} - -/** - * Calculate exponential backoff delay with jitter - */ -function calculateRetryDelay(failureCount: number): number { - const exponentialDelay = BASE_DELAY * Math.pow(2, failureCount - 1); - const cappedDelay = Math.min(exponentialDelay, MAX_DELAY); - - // Add ±30% jitter to prevent thundering herd - const jitterAmount = cappedDelay * 0.3; - const jitterOffset = (Math.random() * 2 - 1) * jitterAmount; - - return Math.max(0, cappedDelay + jitterOffset); -} - -/** - * Hook to poll Cronitor API for health status - * Returns the monitor's passing status (true = healthy, false = down) - * Implements exponential backoff retry with jitter - */ -export function useCronitorHealth() { - return useQuery({ - queryKey: ['cronitor-health'], - queryFn: async (): Promise => { - const response = await fetch(CRONITOR_API_URL, { - method: 'GET', - headers: { - 'Accept': 'application/json', - }, - }); - - if (!response.ok) { - throw new Error(`Cronitor API error: ${response.status}`); - } - - return response.json(); - }, - retry: (failureCount, error) => { - // Use existing retry logic to determine if error is retryable - if (!isRetryableError(error)) { - logger.warn('Cronitor health check: Non-retryable error', { error }); - - // Log non-retryable errors to error monitoring (non-critical) - handleNonCriticalError(error, { - action: 'Cronitor Health Check - Non-Retryable Error', - metadata: { - failureCount, - errorType: 'non_retryable', - }, - }); - - return false; - } - - // Retry up to MAX_RETRIES times - if (failureCount >= MAX_RETRIES) { - logger.error('Cronitor health check: Max retries exhausted', { - error, - totalAttempts: MAX_RETRIES, - }); - - // Track exhausted retries in Cronitor RUM and error monitoring - if (typeof window !== 'undefined' && window.cronitor) { - window.cronitor.track('cronitor_health_check_failed', { - totalAttempts: MAX_RETRIES, - error: error instanceof Error ? error.message : String(error), - severity: 'high', - }); - } - - // Log to error monitoring system (non-critical, background operation) - handleNonCriticalError(error, { - action: 'Cronitor Health Check - Max Retries Exhausted', - metadata: { - totalAttempts: MAX_RETRIES, - errorType: 'max_retries_exhausted', - }, - }); - - return false; - } - - // Track retry attempt in Cronitor RUM - if (typeof window !== 'undefined' && window.cronitor) { - window.cronitor.track('cronitor_health_check_retry', { - attempt: failureCount + 1, - maxAttempts: MAX_RETRIES, - error: error instanceof Error ? error.message : String(error), - }); - } - - logger.info(`Cronitor health check: Retry attempt ${failureCount + 1}/${MAX_RETRIES}`, { - attempt: failureCount + 1, - maxAttempts: MAX_RETRIES, - error, - }); - - return true; - }, - retryDelay: calculateRetryDelay, // Use exponential backoff with jitter - refetchInterval: POLL_INTERVAL, // Auto-poll every 60 seconds - staleTime: 30000, // Consider data stale after 30 seconds - gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes - }); -} diff --git a/src/lib/errorHandler.ts b/src/lib/errorHandler.ts index 79e21935..e1c7723f 100644 --- a/src/lib/errorHandler.ts +++ b/src/lib/errorHandler.ts @@ -32,6 +32,35 @@ export class AppError extends Error { } } +/** + * Check if error is a Supabase connection/API error + */ +function isSupabaseConnectionError(error: unknown): boolean { + if (error && typeof error === 'object') { + const supabaseError = error as { code?: string; status?: number; message?: string }; + + // Connection timeout errors + if (supabaseError.code === 'PGRST301') return true; // Timeout + if (supabaseError.code === 'PGRST000') return true; // Connection error + + // 5xx server errors + if (supabaseError.status && supabaseError.status >= 500) return true; + + // Database connection errors (08xxx codes) + if (supabaseError.code?.startsWith('08')) return true; + } + + // Network fetch errors + if (error instanceof TypeError) { + const message = error.message.toLowerCase(); + if (message.includes('fetch') || message.includes('network') || message.includes('failed to fetch')) { + return true; + } + } + + return false; +} + export const handleError = ( error: unknown, context: ErrorContext @@ -40,6 +69,11 @@ export const handleError = ( const errorId = (context.metadata?.requestId as string) || crypto.randomUUID(); const shortErrorId = errorId.slice(0, 8); + // Check if this is a connection error and dispatch event + if (isSupabaseConnectionError(error)) { + window.dispatchEvent(new CustomEvent('api-connectivity-down')); + } + // Enhanced error message and stack extraction let errorMessage: string; let stack: string | undefined; diff --git a/src/lib/supabaseClient.ts b/src/lib/supabaseClient.ts index 0b01771d..17422982 100644 --- a/src/lib/supabaseClient.ts +++ b/src/lib/supabaseClient.ts @@ -41,6 +41,12 @@ function createQueryProxy(queryBuilder: any, endpoint: string, operations: strin fullOperation || 'query', response?.error ? 400 : 200 ); + + // Dispatch API connectivity up event on successful requests + if (!response?.error) { + window.dispatchEvent(new CustomEvent('api-connectivity-up')); + } + return response; }, (error: any) => {