feat: Implement automatic API connectivity banner

This commit is contained in:
gpt-engineer-app[bot]
2025-11-05 15:55:02 +00:00
parent 09de0772ea
commit 1f93e7433b
7 changed files with 140 additions and 218 deletions

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

View File

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