feat: Implement Cronitor health monitor

This commit is contained in:
gpt-engineer-app[bot]
2025-11-05 15:38:11 +00:00
parent c1ef28e2f6
commit 35fdd16c6c
4 changed files with 173 additions and 5 deletions

View File

@@ -9,6 +9,7 @@ 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 { LocationAutoDetectProvider } from "@/components/providers/LocationAutoDetectProvider";
import { AnalyticsWrapper } from "@/components/analytics/AnalyticsWrapper";
import { Footer } from "@/components/layout/Footer";
@@ -19,6 +20,8 @@ import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary";
import { breadcrumb } from "@/lib/errorBreadcrumbs";
import { handleError } from "@/lib/errorHandler";
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)
import Index from "./pages/Index";
@@ -142,8 +145,14 @@ function AppContent(): React.JSX.Element {
console.log('[Cronitor] RUM initialized');
}, []);
// Check if API status banner is visible to add padding
const { passing, isBannerDismissed } = useCronitorHealth();
const showBanner = passing === false && !isBannerDismissed;
return (
<TooltipProvider>
<APIStatusBanner />
<div className={cn(showBanner && "pt-20")}>
<NavigationTracker />
<LocationAutoDetectProvider />
<RetryStatusIndicator />
@@ -395,6 +404,7 @@ function AppContent(): React.JSX.Element {
</div>
<Footer />
</div>
</div>
</TooltipProvider>
);
}
@@ -404,9 +414,11 @@ const App = (): React.JSX.Element => (
<AuthProvider>
<AuthModalProvider>
<MFAStepUpProvider>
<CronitorHealthProvider>
<BrowserRouter>
<AppContent />
</BrowserRouter>
</CronitorHealthProvider>
</MFAStepUpProvider>
</AuthModalProvider>
</AuthProvider>

View 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>
);
}

View 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;
}

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
const CRONITOR_API_URL = 'https://cronitor.io/api/monitors/88kG4W?env=production&format=json';
const POLL_INTERVAL = 60000; // 60 seconds
interface CronitorResponse {
passing: boolean;
[key: string]: any; // Other fields we don't need
}
/**
* Hook to poll Cronitor API for health status
* Returns the monitor's passing status (true = healthy, false = down)
*/
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();
},
refetchInterval: POLL_INTERVAL, // Auto-poll every 60 seconds
retry: 2, // Retry failed requests twice
staleTime: 30000, // Consider data stale after 30 seconds
gcTime: 5 * 60 * 1000, // Keep in cache for 5 minutes
});
}