mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 14:26:58 -05:00
Compare commits
3 Commits
c1ef28e2f6
...
09de0772ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09de0772ea | ||
|
|
6c9cd57190 | ||
|
|
35fdd16c6c |
72
src/App.tsx
72
src/App.tsx
@@ -9,6 +9,7 @@ import * as Cronitor from '@cronitorio/cronitor-rum';
|
|||||||
import { AuthProvider } from "@/hooks/useAuth";
|
import { AuthProvider } from "@/hooks/useAuth";
|
||||||
import { AuthModalProvider } from "@/contexts/AuthModalContext";
|
import { AuthModalProvider } from "@/contexts/AuthModalContext";
|
||||||
import { MFAStepUpProvider } from "@/contexts/MFAStepUpContext";
|
import { MFAStepUpProvider } from "@/contexts/MFAStepUpContext";
|
||||||
|
import { CronitorHealthProvider, useCronitorHealth } from "@/contexts/CronitorHealthContext";
|
||||||
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
|
import { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
|
||||||
import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper";
|
import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper";
|
||||||
import { Footer } from "@/components/layout/Footer";
|
import { Footer } from "@/components/layout/Footer";
|
||||||
@@ -19,6 +20,8 @@ import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary";
|
|||||||
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
||||||
import { handleError } from "@/lib/errorHandler";
|
import { handleError } from "@/lib/errorHandler";
|
||||||
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
|
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
|
||||||
|
import { APIStatusBanner } from "@/components/ui/api-status-banner";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// Core routes (eager-loaded for best UX)
|
// Core routes (eager-loaded for best UX)
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
@@ -131,20 +134,15 @@ function NavigationTracker() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppContent(): React.JSX.Element {
|
function AppContent(): React.JSX.Element {
|
||||||
// Initialize Cronitor RUM inside BrowserRouter (where history is available)
|
// Check if API status banner is visible to add padding
|
||||||
useEffect(() => {
|
const { passing, isBannerDismissed } = useCronitorHealth();
|
||||||
Cronitor.load("0b5d17d3f7625ce8766c2c4c85c1895d", {
|
const showBanner = passing === false && !isBannerDismissed;
|
||||||
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');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<NavigationTracker />
|
<APIStatusBanner />
|
||||||
|
<div className={cn(showBanner && "pt-20")}>
|
||||||
|
<NavigationTracker />
|
||||||
<LocationAutoDetectProvider />
|
<LocationAutoDetectProvider />
|
||||||
<RetryStatusIndicator />
|
<RetryStatusIndicator />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
@@ -395,24 +393,44 @@ function AppContent(): React.JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const App = (): React.JSX.Element => (
|
const App = (): React.JSX.Element => {
|
||||||
<QueryClientProvider client={queryClient}>
|
// Initialize Cronitor RUM before router mounts
|
||||||
<AuthProvider>
|
useEffect(() => {
|
||||||
<AuthModalProvider>
|
try {
|
||||||
<MFAStepUpProvider>
|
Cronitor.load("0b5d17d3f7625ce8766c2c4c85c1895d", {
|
||||||
<BrowserRouter>
|
debug: import.meta.env.DEV, // Enable debug logs in development only
|
||||||
<AppContent />
|
trackMode: 'history', // Automatically track page views with React Router
|
||||||
</BrowserRouter>
|
});
|
||||||
</MFAStepUpProvider>
|
|
||||||
</AuthModalProvider>
|
// Log successful initialization
|
||||||
</AuthProvider>
|
console.log('[Cronitor] RUM initialized');
|
||||||
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} position="bottom" />}
|
} catch (error) {
|
||||||
<AnalyticsWrapper />
|
console.error('[Cronitor] Failed to initialize:', error);
|
||||||
</QueryClientProvider>
|
}
|
||||||
);
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AuthProvider>
|
||||||
|
<AuthModalProvider>
|
||||||
|
<MFAStepUpProvider>
|
||||||
|
<CronitorHealthProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppContent />
|
||||||
|
</BrowserRouter>
|
||||||
|
</CronitorHealthProvider>
|
||||||
|
</MFAStepUpProvider>
|
||||||
|
</AuthModalProvider>
|
||||||
|
</AuthProvider>
|
||||||
|
{import.meta.env.DEV && <ReactQueryDevtools initialIsOpen={false} position="bottom" />}
|
||||||
|
<AnalyticsWrapper />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|||||||
56
src/components/ui/api-status-banner.tsx
Normal file
56
src/components/ui/api-status-banner.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { AlertTriangle, X, ExternalLink } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useCronitorHealth } from '@/contexts/CronitorHealthContext';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banner displayed when Cronitor detects Supabase API is down
|
||||||
|
* Includes link to status page and dismissal option
|
||||||
|
*/
|
||||||
|
export function APIStatusBanner() {
|
||||||
|
const { passing, isLoading, isBannerDismissed, dismissBanner } = useCronitorHealth();
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 left-0 right-0 z-50 bg-destructive text-destructive-foreground shadow-lg">
|
||||||
|
<div className="container mx-auto px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<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="text-sm opacity-90">
|
||||||
|
Supabase services are experiencing issues. Some features may be temporarily unavailable.
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href="https://status.thrillwiki.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-sm inline-flex items-center gap-1 mt-1 underline hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
View Status Page
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={dismissBanner}
|
||||||
|
className="flex-shrink-0 hover:bg-destructive-foreground/10 text-destructive-foreground"
|
||||||
|
aria-label="Dismiss alert"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/contexts/CronitorHealthContext.tsx
Normal file
63
src/contexts/CronitorHealthContext.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
122
src/hooks/useCronitorHealth.ts
Normal file
122
src/hooks/useCronitorHealth.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user