mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:31:13 -05:00
feat: Implement automatic API connectivity banner
This commit is contained in:
26
src/App.tsx
26
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 (
|
||||
<TooltipProvider>
|
||||
@@ -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 (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<AuthModalProvider>
|
||||
<MFAStepUpProvider>
|
||||
<CronitorHealthProvider>
|
||||
<APIConnectivityProvider>
|
||||
<BrowserRouter>
|
||||
<AppContent />
|
||||
</BrowserRouter>
|
||||
</CronitorHealthProvider>
|
||||
</APIConnectivityProvider>
|
||||
</MFAStepUpProvider>
|
||||
</AuthModalProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold">API Monitoring Alert</p>
|
||||
<p className="font-semibold">API Connection Issue</p>
|
||||
<p className="text-sm opacity-90">
|
||||
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.
|
||||
</p>
|
||||
<a
|
||||
href="https://status.thrillwiki.com"
|
||||
@@ -35,7 +31,7 @@ export function APIStatusBanner() {
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm inline-flex items-center gap-1 mt-1 underline hover:opacity-80 transition-opacity"
|
||||
>
|
||||
View Status Page
|
||||
Check Status Page
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
87
src/contexts/APIConnectivityContext.tsx
Normal file
87
src/contexts/APIConnectivityContext.tsx
Normal file
@@ -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<APIConnectivityContextType | undefined>(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<boolean>(() => {
|
||||
const stored = sessionStorage.getItem(REACHABILITY_KEY);
|
||||
return stored !== 'false'; // Default to true, only false if explicitly set
|
||||
});
|
||||
|
||||
const [dismissedUntil, setDismissedUntil] = useState<number | null>(() => {
|
||||
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 (
|
||||
<APIConnectivityContext.Provider
|
||||
value={{
|
||||
isAPIReachable,
|
||||
isBannerDismissed,
|
||||
dismissBanner,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</APIConnectivityContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAPIConnectivity() {
|
||||
const context = useContext(APIConnectivityContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAPIConnectivity must be used within APIConnectivityProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -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<CronitorHealthContextType | undefined>(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<number | null>(() => {
|
||||
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 (
|
||||
<CronitorHealthContext.Provider
|
||||
value={{
|
||||
passing: data?.passing ?? null,
|
||||
isLoading,
|
||||
error: error as Error | null,
|
||||
lastChecked: data ? new Date() : null,
|
||||
isBannerDismissed,
|
||||
dismissBanner,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CronitorHealthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCronitorHealth() {
|
||||
const context = useContext(CronitorHealthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useCronitorHealth must be used within CronitorHealthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -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<CronitorResponse> => {
|
||||
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
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user