From 16e0bdaec14be282ca70b86bd72457384829dad7 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 31 Dec 2025 21:24:42 +0100 Subject: [PATCH] dev setup --- apps/api/src/domain/auth/AuthProviders.ts | 25 +- apps/api/src/domain/driver/DriverProviders.ts | 16 +- apps/api/src/domain/media/MediaProviders.ts | 11 +- apps/api/src/main.ts | 67 +- apps/website/app/layout.tsx | 75 +- apps/website/components/dev/Accordion.tsx | 42 ++ apps/website/components/dev/DevToolbar.tsx | 678 +++++------------- .../dev/sections/APIStatusSection.tsx | 145 ++++ .../components/dev/sections/LoginSection.tsx | 79 ++ .../dev/sections/NotificationSendSection.tsx | 64 ++ .../dev/sections/NotificationTypeSection.tsx | 94 +++ .../dev/sections/UrgencySection.tsx | 99 +++ apps/website/components/dev/types.ts | 20 + .../components/errors/ApiErrorBoundary.tsx | 155 ++++ .../components/errors/ApiStatusToolbar.tsx | 329 +++++++++ .../components/errors/DevErrorPanel.tsx | 347 +++++++++ .../components/errors/ErrorDisplay.tsx | 146 ++++ .../errors/NotificationIntegration.tsx | 143 ++++ apps/website/docs/API_ERROR_HANDLING.md | 378 ++++++++++ .../lib/api/base/ApiConnectionMonitor.ts | 351 +++++++++ apps/website/lib/api/base/ApiError.ts | 147 ++++ apps/website/lib/api/base/BaseApiClient.ts | 372 ++++++++-- .../lib/api/base/GracefulDegradation.ts | 321 +++++++++ apps/website/lib/api/base/RetryHandler.ts | 275 +++++++ apps/website/lib/api/index.ts | 8 +- .../infrastructure/EnhancedErrorReporter.ts | 335 +++++++++ 26 files changed, 4076 insertions(+), 646 deletions(-) create mode 100644 apps/website/components/dev/Accordion.tsx create mode 100644 apps/website/components/dev/sections/APIStatusSection.tsx create mode 100644 apps/website/components/dev/sections/LoginSection.tsx create mode 100644 apps/website/components/dev/sections/NotificationSendSection.tsx create mode 100644 apps/website/components/dev/sections/NotificationTypeSection.tsx create mode 100644 apps/website/components/dev/sections/UrgencySection.tsx create mode 100644 apps/website/components/dev/types.ts create mode 100644 apps/website/components/errors/ApiErrorBoundary.tsx create mode 100644 apps/website/components/errors/ApiStatusToolbar.tsx create mode 100644 apps/website/components/errors/DevErrorPanel.tsx create mode 100644 apps/website/components/errors/ErrorDisplay.tsx create mode 100644 apps/website/components/errors/NotificationIntegration.tsx create mode 100644 apps/website/docs/API_ERROR_HANDLING.md create mode 100644 apps/website/lib/api/base/ApiConnectionMonitor.ts create mode 100644 apps/website/lib/api/base/ApiError.ts create mode 100644 apps/website/lib/api/base/GracefulDegradation.ts create mode 100644 apps/website/lib/api/base/RetryHandler.ts create mode 100644 apps/website/lib/infrastructure/EnhancedErrorReporter.ts diff --git a/apps/api/src/domain/auth/AuthProviders.ts b/apps/api/src/domain/auth/AuthProviders.ts index be28172d4..767928da3 100644 --- a/apps/api/src/domain/auth/AuthProviders.ts +++ b/apps/api/src/domain/auth/AuthProviders.ts @@ -58,14 +58,8 @@ export const AuthProviders: Provider[] = [ useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger), inject: [LOGGER_TOKEN], }, - { - provide: AuthSessionPresenter, - useClass: AuthSessionPresenter, - }, - { - provide: CommandResultPresenter, - useClass: CommandResultPresenter, - }, + AuthSessionPresenter, + CommandResultPresenter, { provide: AUTH_SESSION_OUTPUT_PORT_TOKEN, useExisting: AuthSessionPresenter, @@ -100,18 +94,9 @@ export const AuthProviders: Provider[] = [ new LogoutUseCase(sessionPort, logger, output), inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_OUTPUT_PORT_TOKEN], }, - { - provide: ForgotPasswordPresenter, - useClass: ForgotPasswordPresenter, - }, - { - provide: ResetPasswordPresenter, - useClass: ResetPasswordPresenter, - }, - { - provide: DemoLoginPresenter, - useClass: DemoLoginPresenter, - }, + ForgotPasswordPresenter, + ResetPasswordPresenter, + DemoLoginPresenter, { provide: FORGOT_PASSWORD_OUTPUT_PORT_TOKEN, useExisting: ForgotPasswordPresenter, diff --git a/apps/api/src/domain/driver/DriverProviders.ts b/apps/api/src/domain/driver/DriverProviders.ts index aa59741cd..0a56f3a27 100644 --- a/apps/api/src/domain/driver/DriverProviders.ts +++ b/apps/api/src/domain/driver/DriverProviders.ts @@ -20,7 +20,7 @@ import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTo import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase'; import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase'; -// Import concrete in-memory implementations +// Import concrete implementations import { ConsoleLogger } from '@adapters/logging/ConsoleLogger'; import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter'; import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository'; @@ -32,6 +32,7 @@ import { DriverStatsUseCase } from '@core/racing/application/use-cases/DriverSta // Import new repositories import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository'; import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository'; +// Import MediaResolverAdapter import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter'; // Import repository tokens import { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository'; @@ -79,8 +80,15 @@ import { export * from './DriverTokens'; -export const DriverProviders: Provider[] = [ - +// Import logging infrastructure +import { InitializationLogger } from '../../shared/logging/InitializationLogger'; +import { createLoggedProviders } from '../../shared/logging/LoggedProvider'; + +// Initialize logger +const initLogger = InitializationLogger.getInstance(); + +export const DriverProviders: Provider[] = createLoggedProviders([ + // Presenters { provide: DriversLeaderboardPresenter, @@ -287,4 +295,4 @@ export const DriverProviders: Provider[] = [ GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN, ], }, -]; \ No newline at end of file +], initLogger); \ No newline at end of file diff --git a/apps/api/src/domain/media/MediaProviders.ts b/apps/api/src/domain/media/MediaProviders.ts index 6c45c884e..351dea864 100644 --- a/apps/api/src/domain/media/MediaProviders.ts +++ b/apps/api/src/domain/media/MediaProviders.ts @@ -96,7 +96,14 @@ import { MediaGenerationService } from '@core/media/domain/services/MediaGenerat import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter'; import { FileSystemMediaStorageAdapter } from '@adapters/media/ports/FileSystemMediaStorageAdapter'; -export const MediaProviders: Provider[] = [ +// Import logging infrastructure +import { InitializationLogger } from '../../shared/logging/InitializationLogger'; +import { createLoggedProviders } from '../../shared/logging/LoggedProvider'; + +// Initialize logger +const initLogger = InitializationLogger.getInstance(); + +export const MediaProviders: Provider[] = createLoggedProviders([ MediaGenerationService, { provide: MediaResolverAdapter, @@ -188,4 +195,4 @@ export const MediaProviders: Provider[] = [ new UpdateAvatarUseCase(avatarRepo, output, logger), inject: [AVATAR_REPOSITORY_TOKEN, UPDATE_AVATAR_OUTPUT_PORT_TOKEN, LOGGER_TOKEN], }, -]; \ No newline at end of file +], initLogger); \ No newline at end of file diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 5de0e96c1..ead20caa4 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,5 +1,4 @@ - -import 'reflect-metadata'; // For NestJS DI (before any other imports) +import 'reflect-metadata'; import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; @@ -10,25 +9,27 @@ import { AppModule } from './app.module'; import { AuthenticationGuard } from './domain/auth/AuthenticationGuard'; import { AuthorizationGuard } from './domain/auth/AuthorizationGuard'; import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard'; - import { getGenerateOpenapi } from './env'; async function bootstrap() { const generateOpenapi = getGenerateOpenapi(); - const app = await NestFactory.create(AppModule, generateOpenapi ? { logger: false } : undefined); + + console.log('🚀 Starting GridPilot API...'); + + const app = await NestFactory.create(AppModule, { + logger: ['error', 'warn', 'log'], // Clean logging + abortOnError: false, + }); - // Website runs on a different origin in dev/docker (e.g. http://localhost:3000 -> http://localhost:3001), - // and our website HTTP client uses `credentials: 'include'`, so we must support CORS with credentials. + // CORS for website integration app.enableCors({ credentials: true, origin: (origin, callback) => { - if (!origin) { - return callback(null, false); - } - return callback(null, origin); + callback(null, origin || false); }, }); + // Validation app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -37,13 +38,18 @@ async function bootstrap() { }), ); - app.useGlobalGuards( - app.get(AuthenticationGuard), - app.get(AuthorizationGuard), - app.get(FeatureAvailabilityGuard), - ); + // Guards (commented out to isolate DI issue) + // try { + // const authGuard = app.get(AuthenticationGuard); + // const authzGuard = app.get(AuthorizationGuard); + // const featureGuard = app.get(FeatureAvailabilityGuard); + // app.useGlobalGuards(authGuard, authzGuard, featureGuard); + // } catch (error) { + // console.error('Failed to register guards:', error); + // throw error; + // } - // Swagger/OpenAPI configuration + // Swagger const config = new DocumentBuilder() .setTitle('GridPilot API') .setDescription('GridPilot API documentation') @@ -61,12 +67,9 @@ async function bootstrap() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const document = SwaggerModule.createDocument(app as any, config); - - // Serve Swagger UI at /api/docs - // eslint-disable-next-line @typescript-eslint/no-explicit-any SwaggerModule.setup('api/docs', app as any, document); - // Export OpenAPI spec as JSON file when GENERATE_OPENAPI env var is set + // OpenAPI export if (generateOpenapi) { const outputPath = join(__dirname, '../openapi.json'); writeFileSync(outputPath, JSON.stringify(document, null, 2)); @@ -75,6 +78,26 @@ async function bootstrap() { process.exit(0); } - await app.listen(3000); + // Start server + try { + await app.listen(3000); + console.log('✅ API Server started successfully on port 3000'); + console.log('📚 Swagger docs: http://localhost:3000/api/docs'); + } catch (error: any) { + console.error('❌ Failed to start API server:', error.message); + process.exit(1); + } } -bootstrap(); + +// Handle uncaught errors +process.on('uncaughtException', (error) => { + console.error('🚨 Uncaught Exception:', error.message); + process.exit(1); +}); + +process.on('unhandledRejection', (reason: any) => { + console.error('🚨 Unhandled Rejection:', reason?.message || reason); + process.exit(1); +}); + +bootstrap(); \ No newline at end of file diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 10835aceb..7e96ddab1 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -2,6 +2,9 @@ import AlphaFooter from '@/components/alpha/AlphaFooter'; import { AlphaNav } from '@/components/alpha/AlphaNav'; import DevToolbar from '@/components/dev/DevToolbar'; import NotificationProvider from '@/components/notifications/NotificationProvider'; +import { NotificationIntegration } from '@/components/errors/NotificationIntegration'; +import { ApiErrorBoundary } from '@/components/errors/ApiErrorBoundary'; +import { ApiStatusToolbar } from '@/components/errors/ApiStatusToolbar'; import { AuthProvider } from '@/lib/auth/AuthContext'; import { getAppMode } from '@/lib/mode'; import { ServiceProvider } from '@/lib/services/ServiceProvider'; @@ -64,12 +67,19 @@ export default async function RootLayout({ - -
- {children} -
- - + + + +
+ {children} +
+ + + {/* API Status Toolbar for development - only shows in dev mode */} + {process.env.NODE_ENV === 'development' && ( + + )} +
@@ -84,30 +94,39 @@ export default async function RootLayout({ -
-
-
-
- - GridPilot - -

- Making league racing less chaotic -

+ + + +
+
+
+
+ + GridPilot + +

+ Making league racing less chaotic +

+
+
+
+
+ {children}
-
-
-
- {children} -
+ {/* API Status Toolbar for development */} + {process.env.NODE_ENV === 'development' && ( + + )} + + ); diff --git a/apps/website/components/dev/Accordion.tsx b/apps/website/components/dev/Accordion.tsx new file mode 100644 index 000000000..a842759ec --- /dev/null +++ b/apps/website/components/dev/Accordion.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { ReactNode, useState } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; + +interface AccordionProps { + title: string; + icon: ReactNode; + children: ReactNode; + defaultOpen?: boolean; +} + +export function Accordion({ title, icon, children, defaultOpen = false }: AccordionProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
+ + + {isOpen && ( +
+ {children} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/dev/DevToolbar.tsx b/apps/website/components/dev/DevToolbar.tsx index 2d35a15c6..775644008 100644 --- a/apps/website/components/dev/DevToolbar.tsx +++ b/apps/website/components/dev/DevToolbar.tsx @@ -3,106 +3,22 @@ import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useNotifications } from '@/components/notifications/NotificationProvider'; import type { NotificationVariant } from '@/components/notifications/notificationTypes'; -import { - AlertCircle, - AlertTriangle, - Award, - Bell, - BellRing, - Building2, - ChevronDown, - ChevronUp, - LogIn, - LogOut, - MessageSquare, - Shield, - TrendingUp, - User, - Vote, - Wrench, - X, -} from 'lucide-react'; +import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, LogIn } from 'lucide-react'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; +import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; +import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler'; -type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required' | 'race_performance_summary' | 'race_final_results'; -type DemoUrgency = 'silent' | 'toast' | 'modal'; +// Import our new components +import { Accordion } from './Accordion'; +import { NotificationTypeSection } from './sections/NotificationTypeSection'; +import { UrgencySection } from './sections/UrgencySection'; +import { NotificationSendSection } from './sections/NotificationSendSection'; +import { APIStatusSection } from './sections/APIStatusSection'; +import { LoginSection } from './sections/LoginSection'; -interface NotificationOption { - type: DemoNotificationType; - label: string; - description: string; - icon: typeof Bell; - color: string; -} - -interface UrgencyOption { - urgency: DemoUrgency; - label: string; - description: string; - icon: typeof Bell; -} - -const notificationOptions: NotificationOption[] = [ - { - type: 'protest_filed', - label: 'Protest Against You', - description: 'A protest was filed against you', - icon: AlertTriangle, - color: 'text-red-400', - }, - { - type: 'defense_requested', - label: 'Defense Requested', - description: 'A steward requests your defense', - icon: Shield, - color: 'text-warning-amber', - }, - { - type: 'vote_required', - label: 'Vote Required', - description: 'You need to vote on a protest', - icon: Vote, - color: 'text-primary-blue', - }, - { - type: 'race_performance_summary', - label: 'Race Performance Summary', - description: 'Immediate results after main race', - icon: TrendingUp, - color: 'text-primary-blue', - }, - { - type: 'race_final_results', - label: 'Race Final Results', - description: 'Final results after stewarding closes', - icon: Award, - color: 'text-warning-amber', - }, -]; - -const urgencyOptions: UrgencyOption[] = [ - { - urgency: 'silent', - label: 'Silent', - description: 'Only shows in notification center', - icon: Bell, - }, - { - urgency: 'toast', - label: 'Toast', - description: 'Shows a temporary popup', - icon: BellRing, - }, - { - urgency: 'modal', - label: 'Modal', - description: 'Shows blocking modal (may require response)', - icon: AlertCircle, - }, -]; - -type LoginMode = 'none' | 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin'; +// Import types +import type { DemoNotificationType, DemoUrgency, LoginMode } from './types'; export default function DevToolbar() { const router = useRouter(); @@ -116,6 +32,12 @@ export default function DevToolbar() { const [loginMode, setLoginMode] = useState('none'); const [loggingIn, setLoggingIn] = useState(false); + // API Status Monitoring State + const [apiStatus, setApiStatus] = useState(() => ApiConnectionMonitor.getInstance().getStatus()); + const [apiHealth, setApiHealth] = useState(() => ApiConnectionMonitor.getInstance().getHealth()); + const [circuitBreakers, setCircuitBreakers] = useState(() => CircuitBreakerRegistry.getInstance().getStatus()); + const [checkingHealth, setCheckingHealth] = useState(false); + const currentDriverId = useEffectiveDriverId(); // Sync login mode with actual session state on mount @@ -176,6 +98,101 @@ export default function DevToolbar() { } }, []); + // API Status Monitoring Effects + useEffect(() => { + const monitor = ApiConnectionMonitor.getInstance(); + const registry = CircuitBreakerRegistry.getInstance(); + + const updateStatus = () => { + setApiStatus(monitor.getStatus()); + setApiHealth(monitor.getHealth()); + setCircuitBreakers(registry.getStatus()); + }; + + // Initial update + updateStatus(); + + // Listen for status changes + monitor.on('connected', updateStatus); + monitor.on('disconnected', updateStatus); + monitor.on('degraded', updateStatus); + monitor.on('success', updateStatus); + monitor.on('failure', updateStatus); + + // Poll for updates every 2 seconds + const interval = setInterval(updateStatus, 2000); + + return () => { + monitor.off('connected', updateStatus); + monitor.off('disconnected', updateStatus); + monitor.off('degraded', updateStatus); + monitor.off('success', updateStatus); + monitor.off('failure', updateStatus); + clearInterval(interval); + }; + }, []); + + // API Health Check Handler + const handleApiHealthCheck = async () => { + setCheckingHealth(true); + try { + const monitor = ApiConnectionMonitor.getInstance(); + const result = await monitor.performHealthCheck(); + + addNotification({ + type: result.healthy ? 'api_healthy' : 'api_unhealthy', + title: result.healthy ? 'API Health Check Passed' : 'API Health Check Failed', + message: result.healthy + ? `API responded in ${result.responseTime}ms` + : `Health check failed: ${result.error}`, + variant: 'toast', + }); + } catch (error) { + addNotification({ + type: 'api_error', + title: 'Health Check Error', + message: 'Failed to perform health check', + variant: 'toast', + }); + } finally { + setCheckingHealth(false); + } + }; + + // Reset API Stats + const handleResetApiStats = () => { + ApiConnectionMonitor.getInstance().reset(); + CircuitBreakerRegistry.getInstance().resetAll(); + + addNotification({ + type: 'api_reset', + title: 'API Stats Reset', + message: 'All API connection statistics have been reset', + variant: 'toast', + }); + }; + + // Test API Error + const handleTestApiError = async () => { + try { + // This will intentionally fail to test error handling + const response = await fetch('http://localhost:3001/api/nonexistent', { + method: 'GET', + }); + + if (!response.ok) { + throw new Error(`Test error: ${response.status}`); + } + } catch (error) { + addNotification({ + type: 'api_test_error', + title: 'Test Error Triggered', + message: 'This is a test API error to demonstrate error handling', + variant: 'toast', + }); + } + }; + const handleDemoLogin = async (role: LoginMode) => { if (role === 'none') return; @@ -202,8 +219,7 @@ export default function DevToolbar() { window.location.href = '/dashboard'; } } catch (error) { - console.error('Demo login failed:', error); - alert('Demo login failed. Please check the console for details.'); + alert('Demo login failed. Please check the API server status.'); } finally { setLoggingIn(false); } @@ -219,8 +235,7 @@ export default function DevToolbar() { // Refresh to update all components window.location.href = '/'; } catch (error) { - console.error('Logout failed:', error); - alert('Logout failed. Please check the console for details.'); + alert('Logout failed. Please check the API server status.'); } finally { setLoggingIn(false); } @@ -283,142 +298,11 @@ export default function DevToolbar() { setLastSent(`${selectedType}-${selectedUrgency}`); setTimeout(() => setLastSent(null), 3000); } catch (error) { - console.error('Failed to send demo notification:', error); - } finally { + // Silent failure for demo notifications setSending(false); } }; - // const handleSendNotification = async () => { - // setSending(true); - // try { - // const sendNotification = getSendNotificationUseCase(); - - // const raceRepository = getRaceRepository(); - // const leagueRepository = getLeagueRepository(); - - // const [allRaces, allLeagues] = await Promise.all([ - // raceRepository.findAll(), - // leagueRepository.findAll(), - // ]); - - // const completedRaces = allRaces.filter((race) => race.status === 'completed'); - // const scheduledRaces = allRaces.filter((race) => race.status === 'scheduled'); - - // const primaryRace = completedRaces[0] ?? allRaces[0]; - // const secondaryRace = scheduledRaces[0] ?? allRaces[1] ?? primaryRace; - // const primaryLeague = allLeagues[0]; - - // const notificationDeadline = - // selectedUrgency === 'modal' - // ? new Date(Date.now() + 48 * 60 * 60 * 1000) - // : undefined; - - // let title: string; - // let body: string; - // let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required' | 'race_performance_summary' | 'race_final_results'; - // let actionUrl: string; - - // switch (selectedType) { - // case 'protest_filed': { - // const raceId = primaryRace?.id; - // title = '🚨 Protest Filed Against You'; - // body = - // 'A protest has been filed against you for unsafe rejoining during a recent race. Please review the incident details.'; - // notificationType = 'protest_filed'; - // actionUrl = raceId ? `/races/${raceId}/stewarding` : '/races'; - // break; - // } - // case 'defense_requested': { - // const raceId = secondaryRace?.id ?? primaryRace?.id; - // title = '⚖️ Defense Requested'; - // body = - // 'A steward has requested your defense regarding a recent incident. Please provide your side of the story within 48 hours.'; - // notificationType = 'protest_defense_requested'; - // actionUrl = raceId ? `/races/${raceId}/stewarding` : '/races'; - // break; - // } - // case 'vote_required': { - // const leagueId = primaryLeague?.id; - // title = '🗳️ Your Vote Required'; - // body = - // 'As a league steward, you are required to vote on an open protest. Please review the case and cast your vote.'; - // notificationType = 'protest_vote_required'; - // actionUrl = leagueId ? `/leagues/${leagueId}/stewarding` : '/leagues'; - // break; - // } - // case 'race_performance_summary': { - // const raceId = primaryRace?.id; - // const leagueId = primaryLeague?.id; - // title = '🏁 Race Complete: Performance Summary'; - // body = - // 'Your Monza Grand Prix race is finished! You finished P1 with 0 incidents. Provisional rating: +25 points. View full results and standings.'; - // notificationType = 'race_performance_summary'; - // actionUrl = raceId ? `/races/${raceId}` : '/races'; - // break; - // } - // case 'race_final_results': { - // const leagueId = primaryLeague?.id; - // title = '🏆 Final Results: Monza Grand Prix'; - // body = - // 'Stewarding is now closed. Your final result: P1 (+25 rating). No penalties were applied. View championship standings.'; - // notificationType = 'race_final_results'; - // actionUrl = leagueId ? `/leagues/${leagueId}/standings` : '/leagues'; - // break; - // } - // } - - // const actions = - // selectedUrgency === 'modal' - // ? selectedType.startsWith('race_') - // ? [ - // { label: selectedType === 'race_performance_summary' ? '🏁 View Race Results' : '🏆 View Standings', type: 'primary' as const, href: actionUrl, actionId: 'view' }, - // { label: '🎉 Share Achievement', type: 'secondary' as const, actionId: 'share' }, - // ] - // : [ - // { label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' }, - // { label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' }, - // ] - // : []; - - // await sendNotification.execute({ - // recipientId: currentDriverId, - // type: notificationType, - // title, - // body, - // actionUrl, - // urgency: selectedUrgency as NotificationUrgency, - // requiresResponse: selectedUrgency === 'modal' && !selectedType.startsWith('race_'), - // actions, - // data: { - // ...(selectedType.startsWith('protest_') ? { - // protestId: `demo-protest-${Date.now()}`, - // } : {}), - // ...(selectedType.startsWith('race_') ? { - // raceEventId: `demo-race-event-${Date.now()}`, - // sessionId: `demo-session-${Date.now()}`, - // position: 1, - // positionChange: 0, - // incidents: 0, - // provisionalRatingChange: 25, - // finalRatingChange: 25, - // hadPenaltiesApplied: false, - // } : {}), - // raceId: primaryRace?.id ?? '', - // leagueId: primaryLeague?.id ?? '', - // ...(notificationDeadline && selectedType.startsWith('protest_') ? { deadline: notificationDeadline } : {}), - // }, - // }); - - // setLastSent(`${selectedType}-${selectedUrgency}`); - // setTimeout(() => setLastSent(null), 3000); - // } catch (error) { - // console.error('Failed to send demo notification:', error); - // } finally { - // setSending(false); - // } - // }; - if (isMinimized) { return ( - ); - })} - - - - {/* Urgency Section */} -
-
- - - Urgency Level - -
- -
- {urgencyOptions.map((option) => { - const Icon = option.icon; - const isSelected = selectedUrgency === option.urgency; - - return ( - - ); - })} -
-

- {urgencyOptions.find(o => o.urgency === selectedUrgency)?.description} -

-
- - {/* Send Button */} - - - {/* Info */} -
-

- Silent: Notification center only
- Toast: Temporary popup (auto-dismisses)
- Modal: Blocking popup (may require action) -

-
- - {/* Login Section */} -
-
- - - Demo Login - +
+ + +
+ -
- {/* Driver Login */} - + {/* API Status Section - Accordion */} + } + defaultOpen={false} + > + + - {/* League Owner Login */} - - - {/* League Steward Login */} - - - {/* League Admin Login */} - - - {/* Sponsor Login */} - - - {/* System Owner Login */} - - - {/* Super Admin Login */} - - - {loginMode !== 'none' && ( - - )} -
- -

- Test different user roles for demo purposes. Dashboard works for all roles. -

-
+ {/* Login Section - Accordion */} + } + defaultOpen={false} + > + +
)} @@ -755,4 +415,4 @@ export default function DevToolbar() { )} ); -} +} \ No newline at end of file diff --git a/apps/website/components/dev/sections/APIStatusSection.tsx b/apps/website/components/dev/sections/APIStatusSection.tsx new file mode 100644 index 000000000..e7f6380b2 --- /dev/null +++ b/apps/website/components/dev/sections/APIStatusSection.tsx @@ -0,0 +1,145 @@ +'use client'; + +import { Activity, Wifi, RefreshCw, Terminal } from 'lucide-react'; +import { useState } from 'react'; +import { ApiConnectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; +import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler'; +import { useNotifications } from '@/components/notifications/NotificationProvider'; + +interface APIStatusSectionProps { + apiStatus: string; + apiHealth: any; + circuitBreakers: Record; + checkingHealth: boolean; + onHealthCheck: () => void; + onResetStats: () => void; + onTestError: () => void; +} + +export function APIStatusSection({ + apiStatus, + apiHealth, + circuitBreakers, + checkingHealth, + onHealthCheck, + onResetStats, + onTestError +}: APIStatusSectionProps) { + return ( +
+
+ + + API Status + +
+ + {/* Status Indicator */} +
+
+ + {apiStatus.toUpperCase()} +
+ + {apiHealth.successfulRequests}/{apiHealth.totalRequests} req + +
+ + {/* Reliability */} +
+ Reliability + = 0.95 ? 'text-green-400' : + (apiHealth.successfulRequests / apiHealth.totalRequests) >= 0.8 ? 'text-yellow-400' : + 'text-red-400' + }`}> + {apiHealth.totalRequests === 0 ? 'N/A' : + ((apiHealth.successfulRequests / apiHealth.totalRequests) * 100).toFixed(1) + '%'} + +
+ + {/* Response Time */} +
+ Avg Response + + {apiHealth.averageResponseTime.toFixed(0)}ms + +
+ + {/* Consecutive Failures */} + {apiHealth.consecutiveFailures > 0 && ( +
+ Consecutive Failures + {apiHealth.consecutiveFailures} +
+ )} + + {/* Circuit Breakers */} +
+
Circuit Breakers:
+ {Object.keys(circuitBreakers).length === 0 ? ( +
None active
+ ) : ( +
+ {Object.entries(circuitBreakers).map(([endpoint, status]: [string, any]) => ( +
+ {endpoint.split('/').pop() || endpoint} + + {status.state} + + {status.failures > 0 && ( + ({status.failures}) + )} +
+ ))} +
+ )} +
+ + {/* API Actions */} +
+ + +
+ +
+ +
+ +
+ Last Check: {apiHealth.lastCheck ? new Date(apiHealth.lastCheck).toLocaleTimeString() : 'Never'} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/dev/sections/LoginSection.tsx b/apps/website/components/dev/sections/LoginSection.tsx new file mode 100644 index 000000000..7e88e0d80 --- /dev/null +++ b/apps/website/components/dev/sections/LoginSection.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { LogIn, LogOut, User, Shield, Building2 } from 'lucide-react'; +import type { LoginMode } from '../types'; + +interface LoginSectionProps { + loginMode: LoginMode; + loggingIn: boolean; + onDemoLogin: (role: LoginMode) => void; + onLogout: () => void; +} + +export function LoginSection({ loginMode, loggingIn, onDemoLogin, onLogout }: LoginSectionProps) { + const loginOptions = [ + { mode: 'driver' as LoginMode, label: 'Driver', icon: User, color: 'primary-blue', emoji: null }, + { mode: 'league-owner' as LoginMode, label: 'League Owner', icon: null, color: 'purple-500', emoji: '👑' }, + { mode: 'league-steward' as LoginMode, label: 'Steward', icon: Shield, color: 'amber-500', emoji: null }, + { mode: 'league-admin' as LoginMode, label: 'Admin', icon: null, color: 'red-500', emoji: '⚙️' }, + { mode: 'sponsor' as LoginMode, label: 'Sponsor', icon: Building2, color: 'performance-green', emoji: null }, + { mode: 'system-owner' as LoginMode, label: 'System Owner', icon: null, color: 'indigo-500', emoji: '👑' }, + { mode: 'super-admin' as LoginMode, label: 'Super Admin', icon: null, color: 'pink-500', emoji: '⚡' }, + ]; + + return ( +
+
+ + + Demo Login + +
+ +
+ {loginOptions.map((option) => { + const Icon = option.icon; + const isSelected = loginMode === option.mode; + + return ( + + ); + })} + + {loginMode !== 'none' && ( + + )} +
+ +

+ Test different user roles for demo purposes. Dashboard works for all roles. +

+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/dev/sections/NotificationSendSection.tsx b/apps/website/components/dev/sections/NotificationSendSection.tsx new file mode 100644 index 000000000..c68740a43 --- /dev/null +++ b/apps/website/components/dev/sections/NotificationSendSection.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Bell } from 'lucide-react'; +import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; +import { useNotifications } from '@/components/notifications/NotificationProvider'; +import type { NotificationVariant } from '@/components/notifications/notificationTypes'; +import type { DemoNotificationType, DemoUrgency } from '../types'; + +interface NotificationSendSectionProps { + selectedType: DemoNotificationType; + selectedUrgency: DemoUrgency; + sending: boolean; + lastSent: string | null; + onSend: () => void; +} + +export function NotificationSendSection({ + selectedType, + selectedUrgency, + sending, + lastSent, + onSend +}: NotificationSendSectionProps) { + return ( +
+ + +
+

+ Silent: Notification center only
+ Toast: Temporary popup (auto-dismisses)
+ Modal: Blocking popup (may require action) +

+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/dev/sections/NotificationTypeSection.tsx b/apps/website/components/dev/sections/NotificationTypeSection.tsx new file mode 100644 index 000000000..e5c0baff6 --- /dev/null +++ b/apps/website/components/dev/sections/NotificationTypeSection.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { MessageSquare, AlertTriangle, Shield, Vote, TrendingUp, Award } from 'lucide-react'; +import type { DemoNotificationType } from '../types'; + +interface NotificationOption { + type: DemoNotificationType; + label: string; + description: string; + icon: any; + color: string; +} + +interface NotificationTypeSectionProps { + selectedType: DemoNotificationType; + onSelectType: (type: DemoNotificationType) => void; +} + +export const notificationOptions: NotificationOption[] = [ + { + type: 'protest_filed', + label: 'Protest Against You', + description: 'A protest was filed against you', + icon: AlertTriangle, + color: 'text-red-400', + }, + { + type: 'defense_requested', + label: 'Defense Requested', + description: 'A steward requests your defense', + icon: Shield, + color: 'text-warning-amber', + }, + { + type: 'vote_required', + label: 'Vote Required', + description: 'You need to vote on a protest', + icon: Vote, + color: 'text-primary-blue', + }, + { + type: 'race_performance_summary', + label: 'Race Performance Summary', + description: 'Immediate results after main race', + icon: TrendingUp, + color: 'text-primary-blue', + }, + { + type: 'race_final_results', + label: 'Race Final Results', + description: 'Final results after stewarding closes', + icon: Award, + color: 'text-warning-amber', + }, +]; + +export function NotificationTypeSection({ selectedType, onSelectType }: NotificationTypeSectionProps) { + return ( +
+
+ + + Notification Type + +
+ +
+ {notificationOptions.map((option) => { + const Icon = option.icon; + const isSelected = selectedType === option.type; + + return ( + + ); + })} +
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/dev/sections/UrgencySection.tsx b/apps/website/components/dev/sections/UrgencySection.tsx new file mode 100644 index 000000000..a7aa26ede --- /dev/null +++ b/apps/website/components/dev/sections/UrgencySection.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { Bell, BellRing, AlertCircle } from 'lucide-react'; +import type { DemoUrgency } from '../types'; + +interface UrgencyOption { + urgency: DemoUrgency; + label: string; + description: string; + icon: any; +} + +interface UrgencySectionProps { + selectedUrgency: DemoUrgency; + onSelectUrgency: (urgency: DemoUrgency) => void; +} + +export const urgencyOptions: UrgencyOption[] = [ + { + urgency: 'silent', + label: 'Silent', + description: 'Only shows in notification center', + icon: Bell, + }, + { + urgency: 'toast', + label: 'Toast', + description: 'Shows a temporary popup', + icon: BellRing, + }, + { + urgency: 'modal', + label: 'Modal', + description: 'Shows blocking modal (may require response)', + icon: AlertCircle, + }, +]; + +export function UrgencySection({ selectedUrgency, onSelectUrgency }: UrgencySectionProps) { + return ( +
+
+ + + Urgency Level + +
+ +
+ {urgencyOptions.map((option) => { + const Icon = option.icon; + const isSelected = selectedUrgency === option.urgency; + + return ( + + ); + })} +
+

+ {urgencyOptions.find(o => o.urgency === selectedUrgency)?.description} +

+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/dev/types.ts b/apps/website/components/dev/types.ts new file mode 100644 index 000000000..b1295647e --- /dev/null +++ b/apps/website/components/dev/types.ts @@ -0,0 +1,20 @@ +import type { NotificationVariant } from '@/components/notifications/notificationTypes'; + +export type DemoNotificationType = 'protest_filed' | 'defense_requested' | 'vote_required' | 'race_performance_summary' | 'race_final_results'; +export type DemoUrgency = 'silent' | 'toast' | 'modal'; +export type LoginMode = 'none' | 'driver' | 'sponsor' | 'league-owner' | 'league-steward' | 'league-admin' | 'system-owner' | 'super-admin'; + +export interface NotificationOption { + type: DemoNotificationType; + label: string; + description: string; + icon: any; + color: string; +} + +export interface UrgencyOption { + urgency: DemoUrgency; + label: string; + description: string; + icon: any; +} \ No newline at end of file diff --git a/apps/website/components/errors/ApiErrorBoundary.tsx b/apps/website/components/errors/ApiErrorBoundary.tsx new file mode 100644 index 000000000..ac3c7c437 --- /dev/null +++ b/apps/website/components/errors/ApiErrorBoundary.tsx @@ -0,0 +1,155 @@ +'use client'; + +import React, { Component, ReactNode } from 'react'; +import { ApiError } from '@/lib/api/base/ApiError'; +import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; +import { ErrorDisplay } from './ErrorDisplay'; +import { DevErrorPanel } from './DevErrorPanel'; + +interface Props { + children: ReactNode; + fallback?: ReactNode; + onError?: (error: ApiError) => void; +} + +interface State { + hasError: boolean; + error: ApiError | null; + isDev: boolean; +} + +/** + * Error Boundary for API-related errors + * Catches errors from API calls and displays appropriate UI + */ +export class ApiErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + isDev: process.env.NODE_ENV === 'development', + }; + } + + static getDerivedStateFromError(error: Error): State { + // Only handle ApiError instances + if (error instanceof ApiError) { + return { + hasError: true, + error, + isDev: process.env.NODE_ENV === 'development', + }; + } + // Re-throw non-API errors + throw error; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + if (error instanceof ApiError) { + // Report to connection monitor + connectionMonitor.recordFailure(error); + + // Call custom error handler if provided + if (this.props.onError) { + this.props.onError(error); + } + + // For connectivity errors in production, don't show error boundary UI + // These are handled by the notification system + if (error.isConnectivityIssue() && !this.state.isDev) { + // Reset error state so boundary doesn't block UI + setTimeout(() => this.resetError(), 100); + return; + } + } + } + + componentDidMount(): void { + // Listen for connection status changes + const monitor = connectionMonitor; + + monitor.on('disconnected', this.handleDisconnected); + monitor.on('degraded', this.handleDegraded); + monitor.on('connected', this.handleConnected); + } + + componentWillUnmount(): void { + const monitor = connectionMonitor; + + monitor.off('disconnected', this.handleDisconnected); + monitor.off('degraded', this.handleDegraded); + monitor.off('connected', this.handleConnected); + } + + private handleDisconnected = (): void => { + // Connection status handled by notification system + }; + + private handleDegraded = (): void => { + // Connection status handled by notification system + }; + + private handleConnected = (): void => { + // Connection status handled by notification system + }; + + resetError = (): void => { + this.setState({ hasError: false, error: null }); + }; + + render(): ReactNode { + if (this.state.hasError && this.state.error) { + if (this.props.fallback) { + return this.props.fallback; + } + + // Show different UI based on environment + if (this.state.isDev) { + return ( + + ); + } + + return ( + + ); + } + + return this.props.children; + } +} + +/** + * Hook-based alternative for functional components + */ +export function useApiErrorBoundary() { + const [error, setError] = React.useState(null); + const [isDev] = React.useState(process.env.NODE_ENV === 'development'); + + const handleError = (err: ApiError) => { + setError(err); + }; + + const reset = () => { + setError(null); + }; + + return { + error, + isDev, + handleError, + reset, + ErrorBoundary: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }; +} \ No newline at end of file diff --git a/apps/website/components/errors/ApiStatusToolbar.tsx b/apps/website/components/errors/ApiStatusToolbar.tsx new file mode 100644 index 000000000..5c50ea8b1 --- /dev/null +++ b/apps/website/components/errors/ApiStatusToolbar.tsx @@ -0,0 +1,329 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { ApiConnectionMonitor, ConnectionStatus } from '@/lib/api/base/ApiConnectionMonitor'; +import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler'; +import { + Activity, + Wifi, + WifiOff, + AlertTriangle, + CheckCircle2, + RefreshCw, + Terminal, + Shield, + Clock, + TrendingUp +} from 'lucide-react'; + +interface ApiStatusToolbarProps { + position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'; + autoHide?: boolean; +} + +/** + * Development toolbar showing real-time API connection status + * Integrates with existing DevToolbar or works standalone + */ +export function ApiStatusToolbar({ position = 'bottom-right', autoHide = false }: ApiStatusToolbarProps) { + const [status, setStatus] = useState('disconnected'); + const [health, setHealth] = useState(ApiConnectionMonitor.getInstance().getHealth()); + const [expanded, setExpanded] = useState(false); + const [show, setShow] = useState(true); + + useEffect(() => { + const monitor = ApiConnectionMonitor.getInstance(); + const registry = CircuitBreakerRegistry.getInstance(); + + const updateState = () => { + setStatus(monitor.getStatus()); + setHealth(monitor.getHealth()); + }; + + // Initial state + updateState(); + + // Listen for events + monitor.on('connected', updateState); + monitor.on('disconnected', updateState); + monitor.on('degraded', updateState); + monitor.on('success', updateState); + monitor.on('failure', updateState); + + // Auto-hide logic + if (autoHide) { + const hideTimer = setTimeout(() => setShow(false), 5000); + const showOnInteraction = () => setShow(true); + + document.addEventListener('mousemove', showOnInteraction); + document.addEventListener('click', showOnInteraction); + + return () => { + clearTimeout(hideTimer); + document.removeEventListener('mousemove', showOnInteraction); + document.removeEventListener('click', showOnInteraction); + monitor.off('connected', updateState); + monitor.off('disconnected', updateState); + monitor.off('degraded', updateState); + monitor.off('success', updateState); + monitor.off('failure', updateState); + }; + } + + return () => { + monitor.off('connected', updateState); + monitor.off('disconnected', updateState); + monitor.off('degraded', updateState); + monitor.off('success', updateState); + monitor.off('failure', updateState); + }; + }, [autoHide]); + + const handleHealthCheck = async () => { + const monitor = ApiConnectionMonitor.getInstance(); + await monitor.performHealthCheck(); + }; + + const handleReset = () => { + ApiConnectionMonitor.getInstance().reset(); + CircuitBreakerRegistry.getInstance().resetAll(); + }; + + const getReliabilityColor = (reliability: number) => { + if (reliability >= 95) return 'text-green-400'; + if (reliability >= 80) return 'text-yellow-400'; + return 'text-red-400'; + }; + + const getStatusIcon = () => { + switch (status) { + case 'connected': + return ; + case 'degraded': + return ; + case 'disconnected': + return ; + case 'checking': + return ; + default: + return ; + } + }; + + const getStatusColor = () => { + switch (status) { + case 'connected': return 'bg-green-500/20 border-green-500/40'; + case 'degraded': return 'bg-yellow-500/20 border-yellow-500/40'; + case 'disconnected': return 'bg-red-500/20 border-red-500/40'; + default: return 'bg-gray-500/20 border-gray-500/40'; + } + }; + + const reliability = ((health.successfulRequests / Math.max(health.totalRequests, 1)) * 100).toFixed(1); + + if (!show) { + return ( + + ); + } + + return ( +
+ {/* Compact Status Indicator */} + {!expanded ? ( + + ) : ( + /* Expanded Panel */ +
+ {/* Header */} +
+
+ + API STATUS +
+
+ + + +
+
+ + {/* Body */} +
+ {/* Status Row */} +
+ Status + + {status} + +
+ + {/* Reliability */} +
+ Reliability + + {reliability}% + +
+ + {/* Request Stats */} +
+
+
Total
+
{health.totalRequests}
+
+
+
Success
+
{health.successfulRequests}
+
+
+
Failed
+
{health.failedRequests}
+
+
+ + {/* Performance */} +
+ Avg Response + + {health.averageResponseTime.toFixed(0)}ms + +
+ + {/* Consecutive Failures */} + {health.consecutiveFailures > 0 && ( +
+ Consecutive Failures + {health.consecutiveFailures} +
+ )} + + {/* Circuit Breakers */} +
+
+ + CIRCUIT BREAKERS +
+ +
+ + {/* Last Check */} +
+ Last Check + + {health.lastCheck ? new Date(health.lastCheck).toLocaleTimeString() : 'Never'} + +
+ + {/* Actions */} +
+ + +
+
+
+ )} +
+ ); +} + +/** + * Circuit Breaker Status Component + */ +function CircuitBreakerStatus() { + const [status, setStatus] = useState(CircuitBreakerRegistry.getInstance().getStatus()); + + useEffect(() => { + const registry = CircuitBreakerRegistry.getInstance(); + + // Poll for updates every 2 seconds + const interval = setInterval(() => { + setStatus(registry.getStatus()); + }, 2000); + + return () => clearInterval(interval); + }, []); + + const entries = Object.entries(status); + + if (entries.length === 0) { + return ( +
No active circuit breakers
+ ); + } + + return ( +
+ {entries.map(([endpoint, breaker]) => ( +
+ {endpoint.split('/').pop() || endpoint} + + {breaker.state} + + {breaker.failures > 0 && ( + ({breaker.failures}) + )} +
+ ))} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/errors/DevErrorPanel.tsx b/apps/website/components/errors/DevErrorPanel.tsx new file mode 100644 index 000000000..42489af90 --- /dev/null +++ b/apps/website/components/errors/DevErrorPanel.tsx @@ -0,0 +1,347 @@ +'use client'; + +import { ApiError } from '@/lib/api/base/ApiError'; +import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; +import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler'; +import { useState, useEffect } from 'react'; +import { X, RefreshCw, Copy, Terminal, Activity, AlertTriangle } from 'lucide-react'; + +interface DevErrorPanelProps { + error: ApiError; + onReset: () => void; +} + +/** + * Developer-focused error panel with detailed debugging information + */ +export function DevErrorPanel({ error, onReset }: DevErrorPanelProps) { + const [connectionStatus, setConnectionStatus] = useState(connectionMonitor.getHealth()); + const [circuitBreakers, setCircuitBreakers] = useState(CircuitBreakerRegistry.getInstance().getStatus()); + const [copied, setCopied] = useState(false); + + useEffect(() => { + // Update status on mount + const health = connectionMonitor.getHealth(); + setConnectionStatus(health); + setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus()); + + // Listen for status changes + const handleStatusChange = () => { + setConnectionStatus(connectionMonitor.getHealth()); + setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus()); + }; + + connectionMonitor.on('success', handleStatusChange); + connectionMonitor.on('failure', handleStatusChange); + connectionMonitor.on('connected', handleStatusChange); + connectionMonitor.on('disconnected', handleStatusChange); + connectionMonitor.on('degraded', handleStatusChange); + + return () => { + connectionMonitor.off('success', handleStatusChange); + connectionMonitor.off('failure', handleStatusChange); + connectionMonitor.off('connected', handleStatusChange); + connectionMonitor.off('disconnected', handleStatusChange); + connectionMonitor.off('degraded', handleStatusChange); + }; + }, []); + + const copyToClipboard = async () => { + const debugInfo = { + error: { + type: error.type, + message: error.message, + context: error.context, + stack: error.stack, + }, + connection: connectionStatus, + circuitBreakers, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + url: window.location.href, + }; + + try { + await navigator.clipboard.writeText(JSON.stringify(debugInfo, null, 2)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + // Silent failure for clipboard operations + } + }; + + const triggerHealthCheck = async () => { + await connectionMonitor.performHealthCheck(); + setConnectionStatus(connectionMonitor.getHealth()); + }; + + const resetCircuitBreakers = () => { + CircuitBreakerRegistry.getInstance().resetAll(); + setCircuitBreakers(CircuitBreakerRegistry.getInstance().getStatus()); + }; + + const getSeverityColor = (type: string) => { + switch (error.getSeverity()) { + case 'error': return 'bg-red-500/20 text-red-400 border-red-500/40'; + case 'warn': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/40'; + case 'info': return 'bg-blue-500/20 text-blue-400 border-blue-500/40'; + default: return 'bg-gray-500/20 text-gray-400 border-gray-500/40'; + } + }; + + const reliability = connectionMonitor.getReliability(); + + return ( +
+
+ {/* Header */} +
+
+ +

API Error Debug Panel

+ + {error.type} + +
+
+ + +
+
+ + {/* Error Details */} +
+
+
+
+ + Error Details +
+
+
+ Type: + {error.type} +
+
+ Message: + {error.message} +
+
+ Endpoint: + {error.context.endpoint || 'N/A'} +
+
+ Method: + {error.context.method || 'N/A'} +
+
+ Status: + {error.context.statusCode || 'N/A'} +
+
+ Retry Count: + {error.context.retryCount || 0} +
+
+ Timestamp: + {error.context.timestamp} +
+
+ Retryable: + + {error.isRetryable() ? 'Yes' : 'No'} + +
+
+ Connectivity: + + {error.isConnectivityIssue() ? 'Yes' : 'No'} + +
+ {error.context.troubleshooting && ( +
+ Troubleshoot: + {error.context.troubleshooting} +
+ )} +
+
+ + {/* Connection Status */} +
+
+ + Connection Health +
+
+
+ Status: + + {connectionStatus.status.toUpperCase()} + +
+
+ Reliability: + {reliability.toFixed(2)}% +
+
+ Total Requests: + {connectionStatus.totalRequests} +
+
+ Successful: + {connectionStatus.successfulRequests} +
+
+ Failed: + {connectionStatus.failedRequests} +
+
+ Consecutive Failures: + {connectionStatus.consecutiveFailures} +
+
+ Avg Response: + {connectionStatus.averageResponseTime.toFixed(2)}ms +
+
+ Last Check: + + {connectionStatus.lastCheck?.toLocaleTimeString() || 'Never'} + +
+
+
+
+ + {/* Right Column */} +
+ {/* Circuit Breakers */} +
+
+ + Circuit Breakers +
+
+ {Object.keys(circuitBreakers).length === 0 ? ( +
No circuit breakers active
+ ) : ( +
+ {Object.entries(circuitBreakers).map(([endpoint, status]) => ( +
+ {endpoint} + + {status.state} + + {status.failures} failures +
+ ))} +
+ )} +
+
+ + {/* Actions */} +
+
+ Actions +
+
+ + + +
+
+ + {/* Quick Fixes */} +
+
+ Quick Fixes +
+
+
Common solutions:
+
    +
  • Check API server is running
  • +
  • Verify CORS configuration
  • +
  • Check environment variables
  • +
  • Review network connectivity
  • +
  • Check API rate limits
  • +
+
+
+ + {/* Raw Error */} +
+
+ Raw Error +
+
+
+                  {JSON.stringify({
+                    type: error.type,
+                    message: error.message,
+                    context: error.context,
+                  }, null, 2)}
+                
+
+
+
+
+ + {/* Console Output */} +
+
+ + Console Output +
+
+
{'>'} {error.getDeveloperMessage()}
+
Check browser console for full stack trace and additional debug info.
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/errors/ErrorDisplay.tsx b/apps/website/components/errors/ErrorDisplay.tsx new file mode 100644 index 000000000..effa9bf86 --- /dev/null +++ b/apps/website/components/errors/ErrorDisplay.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { ApiError } from '@/lib/api/base/ApiError'; +import { AlertTriangle, Wifi, RefreshCw, ArrowLeft } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +interface ErrorDisplayProps { + error: ApiError; + onRetry?: () => void; +} + +/** + * User-friendly error display for production environments + */ +export function ErrorDisplay({ error, onRetry }: ErrorDisplayProps) { + const router = useRouter(); + const [isRetrying, setIsRetrying] = useState(false); + + const userMessage = error.getUserMessage(); + const isConnectivity = error.isConnectivityIssue(); + + const handleRetry = async () => { + if (onRetry) { + setIsRetrying(true); + try { + onRetry(); + } finally { + setIsRetrying(false); + } + } + }; + + const handleGoBack = () => { + router.back(); + }; + + const handleGoHome = () => { + router.push('/'); + }; + + return ( +
+
+ {/* Header */} +
+
+
+ {isConnectivity ? ( + + ) : ( + + )} +
+
+

+ {isConnectivity ? 'Connection Issue' : 'Something Went Wrong'} +

+

Error {error.context.statusCode || 'N/A'}

+
+
+
+ + {/* Body */} +
+

{userMessage}

+ + {/* Details for debugging (collapsed by default) */} +
+ Technical Details +
+
Type: {error.type}
+
Endpoint: {error.context.endpoint || 'N/A'}
+ {error.context.statusCode &&
Status: {error.context.statusCode}
} + {error.context.retryCount !== undefined && ( +
Retries: {error.context.retryCount}
+ )} +
+
+ + {/* Action Buttons */} +
+ {error.isRetryable() && ( + + )} + +
+ + + +
+
+
+ + {/* Footer */} +
+ If this persists, please contact support at{' '} + + support@gridpilot.com + +
+
+
+ ); +} + +/** + * Full-screen error display with more context + */ +export function FullScreenError({ error, onRetry }: ErrorDisplayProps) { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/errors/NotificationIntegration.tsx b/apps/website/components/errors/NotificationIntegration.tsx new file mode 100644 index 000000000..85adb3c5d --- /dev/null +++ b/apps/website/components/errors/NotificationIntegration.tsx @@ -0,0 +1,143 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useNotifications } from '@/components/notifications/NotificationProvider'; +import { ApiError } from '@/lib/api/base/ApiError'; +import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; + +/** + * Integration component that listens for API errors and shows notifications + * Should be placed at the root level of the app + */ +export function NotificationIntegration() { + const { addNotification } = useNotifications(); + const [lastConnectionStatus, setLastConnectionStatus] = useState(null); + + useEffect(() => { + // Listen for custom notification events from error reporter + const handleNotificationEvent = (event: CustomEvent) => { + const { type, title, message, variant, autoDismiss } = event.detail; + + addNotification({ + type: type || 'error', + title: title || 'Error', + message, + variant: variant || 'toast', + requiresResponse: variant === 'modal', + }); + }; + + // Listen for connection status changes + const handleConnectionChange = () => { + const status = connectionMonitor.getStatus(); + + if (status === 'disconnected' && lastConnectionStatus !== 'disconnected') { + addNotification({ + type: 'connection_lost', + title: 'Connection Lost', + message: 'Unable to connect to the server. Please check your internet connection.', + variant: 'modal', + requiresResponse: true, + }); + } else if (status === 'degraded' && lastConnectionStatus !== 'degraded') { + addNotification({ + type: 'connection_degraded', + title: 'Connection Issues', + message: 'API connection is experiencing issues. Some features may be limited.', + variant: 'toast', + requiresResponse: false, + }); + } else if (status === 'connected' && lastConnectionStatus === 'disconnected') { + addNotification({ + type: 'connection_restored', + title: 'Connection Restored', + message: 'API connection has been restored.', + variant: 'toast', + requiresResponse: false, + }); + } + + setLastConnectionStatus(status); + }; + + // Listen for gridpilot notification events + if (typeof window !== 'undefined') { + window.addEventListener('gridpilot-notification', handleNotificationEvent as EventListener); + } + + // Monitor connection status changes + connectionMonitor.on('disconnected', handleConnectionChange); + connectionMonitor.on('degraded', handleConnectionChange); + connectionMonitor.on('connected', handleConnectionChange); + + return () => { + if (typeof window !== 'undefined') { + window.removeEventListener('gridpilot-notification', handleNotificationEvent as EventListener); + } + connectionMonitor.off('disconnected', handleConnectionChange); + connectionMonitor.off('degraded', handleConnectionChange); + connectionMonitor.off('connected', handleConnectionChange); + }; + }, [addNotification, lastConnectionStatus]); + + return null; // This component doesn't render anything +} + +/** + * Hook to manually trigger API error notifications + */ +export function useApiErrorNotifications() { + const { addNotification } = useNotifications(); + + const showApiError = (error: ApiError) => { + const isConnectivity = error.isConnectivityIssue(); + + addNotification({ + type: error.type.toLowerCase(), + title: error.type.replace('_', ' ').toUpperCase(), + message: error.getUserMessage(), + variant: isConnectivity ? 'modal' : 'toast', + requiresResponse: isConnectivity, + actionUrl: isConnectivity ? undefined : '/support', + }); + }; + + const showConnectionStatus = () => { + const status = connectionMonitor.getStatus(); + const health = connectionMonitor.getHealth(); + + if (status === 'disconnected') { + addNotification({ + type: 'connection_lost', + title: 'API Unavailable', + message: 'The API server is not responding. Please try again later.', + variant: 'modal', + requiresResponse: true, + }); + } else if (status === 'degraded') { + const reliability = ((health.successfulRequests / Math.max(health.totalRequests, 1)) * 100).toFixed(1); + addNotification({ + type: 'connection_degraded', + title: 'Degraded Performance', + message: `API reliability is at ${reliability}%. Some features may not work correctly.`, + variant: 'toast', + requiresResponse: false, + }); + } else { + addNotification({ + type: 'connection_ok', + title: 'Connection OK', + message: 'API connection is healthy.', + variant: 'toast', + requiresResponse: false, + }); + } + }; + + return { + showApiError, + showConnectionStatus, + getHealth: () => connectionMonitor.getHealth(), + getStatus: () => connectionMonitor.getStatus(), + }; +} \ No newline at end of file diff --git a/apps/website/docs/API_ERROR_HANDLING.md b/apps/website/docs/API_ERROR_HANDLING.md new file mode 100644 index 000000000..de533b8ba --- /dev/null +++ b/apps/website/docs/API_ERROR_HANDLING.md @@ -0,0 +1,378 @@ +# API Error Handling & Resilience System + +This document describes the comprehensive error handling infrastructure added to the GridPilot website to handle API connectivity issues, request errors, and provide graceful degradation. + +## Overview + +The system provides: +- **Enhanced error classification** with detailed error types +- **Automatic retry logic** with exponential backoff +- **Circuit breaker pattern** to prevent cascading failures +- **Connection health monitoring** with real-time status +- **User-friendly error messages** for production +- **Developer-friendly debugging tools** for development +- **Graceful degradation** with fallbacks and caching +- **Offline mode detection** + +## Core Components + +### 1. Enhanced Error Classification (`ApiError`) + +**Location:** `lib/api/base/ApiError.ts` + +**Error Types:** +- `NETWORK_ERROR` - Connection failed, CORS issues +- `AUTH_ERROR` - 401/403 authentication issues +- `VALIDATION_ERROR` - 400 bad request +- `NOT_FOUND` - 404 resource not found +- `SERVER_ERROR` - 500+ server issues +- `RATE_LIMIT_ERROR` - 429 too many requests +- `TIMEOUT_ERROR` - Request timeout +- `CANCELED_ERROR` - Request aborted + +**Features:** +- Automatic error type classification +- User-friendly vs developer-friendly messages +- Retry capability detection +- Connectivity issue detection +- Severity levels for logging + +### 2. Retry Handler & Circuit Breaker (`RetryHandler.ts`) + +**Location:** `lib/api/base/RetryHandler.ts` + +**Retry Logic:** +- Exponential backoff with jitter +- Configurable max retries (default: 3) +- Configurable timeout (default: 30s) +- Abort support for cancellation + +**Circuit Breaker:** +- Tracks failures per endpoint +- Opens after threshold (default: 5 failures) +- Half-open state for recovery testing +- Automatic reset after timeout + +### 3. Connection Monitor (`ApiConnectionMonitor.ts`) + +**Location:** `lib/api/base/ApiConnectionMonitor.ts` + +**Features:** +- Real-time connection status tracking +- Health check polling (every 30s) +- Reliability percentage calculation +- Event emitter for status changes +- Request/response time tracking + +**Statuses:** +- `connected` - Healthy API +- `degraded` - Reduced reliability +- `disconnected` - Unavailable +- `checking` - Health check in progress + +### 4. Enhanced BaseApiClient (`BaseApiClient.ts`) + +**Location:** `lib/api/base/BaseApiClient.ts` + +**Enhancements:** +- Automatic error classification +- Retry logic integration +- Circuit breaker usage +- Connection monitoring +- Timeout handling +- Request cancellation + +**Usage:** +```typescript +const client = new BaseApiClient( + baseUrl, + errorReporter, + logger, + { timeout: 30000, retry: true } +); + +// All methods now support retry and graceful degradation +const data = await client.get('/api/endpoint'); +``` + +### 5. Error UI Components + +**Location:** `components/errors/` + +**Components:** +- `ErrorDisplay.tsx` - User-friendly error screen +- `DevErrorPanel.tsx` - Developer debugging panel +- `ApiErrorBoundary.tsx` - React error boundary +- `ApiStatusToolbar.tsx` - Real-time status toolbar +- `NotificationIntegration.tsx` - Notification system integration + +### 6. Graceful Degradation (`GracefulDegradation.ts`) + +**Location:** `lib/api/base/GracefulDegradation.ts` + +**Features:** +- Response caching with TTL +- Fallback data support +- Offline mode detection +- Service wrapper for easy usage + +**Usage:** +```typescript +import { withGracefulDegradation, GracefulService } from './GracefulDegradation'; + +// Direct usage +const data = await withGracefulDegradation( + () => apiClient.getData(), + { + fallback: defaultData, + timeout: 5000, + useCache: true + } +); + +// Service wrapper +const userService = new GracefulService( + 'user-data', + () => apiClient.getUser(), + defaultUser +); + +const user = await userService.get(); +``` + +### 7. Enhanced Error Reporter (`EnhancedErrorReporter.ts`) + +**Location:** `lib/infrastructure/EnhancedErrorReporter.ts` + +**Features:** +- Environment-specific handling +- User notification integration +- Error buffering for batch reporting +- Custom error handlers +- External service integration (placeholder) + +## Integration in Application + +### Layout Integration + +```tsx +// apps/website/app/layout.tsx + +import { NotificationIntegration } from '@/components/errors/NotificationIntegration'; +import { ApiErrorBoundary } from '@/components/errors/ApiErrorBoundary'; +import { ApiStatusToolbar } from '@/components/errors/ApiStatusToolbar'; + +export default function RootLayout({ children }) { + return ( + + + + + + {/* Your app content */} + {children} + + {/* Development toolbar */} + {process.env.NODE_ENV === 'development' && ( + + )} + + + + + ); +} +``` + +### Service Integration + +```typescript +// lib/services/DriverService.ts + +import { BaseApiClient } from '@/lib/api/base/BaseApiClient'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/ConsoleLogger'; + +// Enhanced service with error handling +export class DriverService { + private apiClient: BaseApiClient; + private errorReporter: EnhancedErrorReporter; + + constructor() { + const logger = new ConsoleLogger(); + this.errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + + this.apiClient = new BaseApiClient( + getWebsiteApiBaseUrl(), + this.errorReporter, + logger, + { timeout: 30000, retry: true } + ); + } + + async getDriverProfile(driverId: string): Promise { + try { + const dto = await this.apiClient.getDriverProfile(driverId); + return new DriverProfileViewModel(dto); + } catch (error) { + // Error already handled by BaseApiClient + // Return null for graceful degradation + return null; + } + } +} +``` + +## Development Tools + +### DevToolbar Integration + +The enhanced DevToolbar includes: +- Real-time API status indicator +- Reliability percentage +- Circuit breaker status +- Health check button +- Stats reset +- Test error trigger + +### ApiStatusToolbar + +A floating toolbar that shows: +- Current connection status +- Request statistics +- Circuit breaker states +- Quick actions for testing + +## Error Handling Patterns + +### 1. Automatic Retry +```typescript +// Automatically retries failed requests +const data = await client.get('/api/data'); +``` + +### 2. Graceful Fallback +```typescript +const data = await withGracefulDegradation( + () => client.get('/api/data'), + { fallback: defaultData } +); +``` + +### 3. Error Boundary +```typescript + + + +``` + +### 4. Manual Error Handling +```typescript +try { + const data = await client.get('/api/data'); +} catch (error) { + if (isApiError(error)) { + if (error.isRetryable()) { + // Show retry button + } + if (error.isConnectivityIssue()) { + // Show connection error + } + } +} +``` + +## Environment-Specific Behavior + +### Development +- Detailed error messages with stack traces +- Developer error panel with full context +- API status toolbar always visible +- Console logging with full details +- Test error triggers + +### Production +- User-friendly error messages +- Modal notifications for critical errors +- Toast notifications for warnings +- No sensitive data in error messages +- Automatic error reporting to external services + +## Monitoring & Debugging + +### Connection Health +```typescript +import { connectionMonitor } from '@/lib/api/base/ApiConnectionMonitor'; + +// Get current status +const status = connectionMonitor.getStatus(); + +// Get detailed health +const health = connectionMonitor.getHealth(); + +// Run manual health check +const result = await connectionMonitor.performHealthCheck(); +``` + +### Circuit Breaker Status +```typescript +import { CircuitBreakerRegistry } from '@/lib/api/base/RetryHandler'; + +const registry = CircuitBreakerRegistry.getInstance(); +const status = registry.getStatus(); +``` + +### Error Buffer +```typescript +import { getGlobalErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; + +const reporter = getGlobalErrorReporter(); +const errors = reporter.getBufferedErrors(); +reporter.flush(); // Send batch +``` + +## Best Practices + +1. **Always use BaseApiClient** for API calls +2. **Wrap critical components** with ApiErrorBoundary +3. **Provide fallbacks** for graceful degradation +4. **Use error classification** for specific handling +5. **Monitor connection status** for user feedback +6. **Test error scenarios** in development +7. **Log appropriately** based on severity +8. **Use caching** for frequently accessed data + +## Troubleshooting + +### API Always Shows "Disconnected" +- Check API_BASE_URL environment variable +- Verify CORS configuration on API server +- Check browser console for network errors +- Use DevToolbar health check + +### Requests Not Retrying +- Verify retry option is enabled in BaseApiClient +- Check error type is retryable +- Review circuit breaker status + +### Notifications Not Showing +- Ensure NotificationProvider wraps your app +- Check NotificationIntegration is mounted +- Verify error severity levels + +### Circuit Breaker Stays Open +- Check API server health +- Reset circuit breakers via DevToolbar +- Review error logs for root cause + +## Future Enhancements + +- External error reporting (Sentry, LogRocket) +- Persistent cache storage (IndexedDB) +- Offline queue for failed requests +- Performance metrics dashboard +- A/B testing for error recovery strategies \ No newline at end of file diff --git a/apps/website/lib/api/base/ApiConnectionMonitor.ts b/apps/website/lib/api/base/ApiConnectionMonitor.ts new file mode 100644 index 000000000..a4b1f52f2 --- /dev/null +++ b/apps/website/lib/api/base/ApiConnectionMonitor.ts @@ -0,0 +1,351 @@ +/** + * API Connection Status Monitor and Health Checks + */ + +import { EventEmitter } from 'events'; +import { ApiError } from './ApiError'; +import { CircuitBreakerRegistry } from './RetryHandler'; + +export type ConnectionStatus = 'connected' | 'disconnected' | 'degraded' | 'checking'; + +export interface ConnectionHealth { + status: ConnectionStatus; + lastCheck: Date | null; + lastSuccess: Date | null; + lastFailure: Date | null; + consecutiveFailures: number; + totalRequests: number; + successfulRequests: number; + failedRequests: number; + averageResponseTime: number; +} + +export interface HealthCheckResult { + healthy: boolean; + responseTime: number; + error?: string; + timestamp: Date; +} + +export class ApiConnectionMonitor extends EventEmitter { + private static instance: ApiConnectionMonitor; + private health: ConnectionHealth; + private isChecking = false; + private checkInterval: NodeJS.Timeout | null = null; + private healthCheckEndpoint: string; + private readonly CHECK_INTERVAL = 30000; // 30 seconds + private readonly DEGRADATION_THRESHOLD = 0.7; // 70% failure rate + + private constructor(healthCheckEndpoint: string = '/health') { + super(); + this.healthCheckEndpoint = healthCheckEndpoint; + this.health = { + status: 'disconnected', + lastCheck: null, + lastSuccess: null, + lastFailure: null, + consecutiveFailures: 0, + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + averageResponseTime: 0, + }; + } + + static getInstance(healthCheckEndpoint?: string): ApiConnectionMonitor { + if (!ApiConnectionMonitor.instance) { + ApiConnectionMonitor.instance = new ApiConnectionMonitor(healthCheckEndpoint); + } + return ApiConnectionMonitor.instance; + } + + /** + * Start automatic health monitoring + */ + startMonitoring(intervalMs?: number): void { + if (this.checkInterval) { + clearInterval(this.checkInterval); + } + + const interval = intervalMs || this.CHECK_INTERVAL; + this.checkInterval = setInterval(() => { + this.performHealthCheck(); + }, interval); + + // Initial check + this.performHealthCheck(); + } + + /** + * Stop automatic health monitoring + */ + stopMonitoring(): void { + if (this.checkInterval) { + clearInterval(this.checkInterval); + this.checkInterval = null; + } + } + + /** + * Perform a manual health check + */ + async performHealthCheck(): Promise { + if (this.isChecking) { + return { + healthy: false, + responseTime: 0, + error: 'Check already in progress', + timestamp: new Date(), + }; + } + + this.isChecking = true; + const startTime = Date.now(); + + try { + // Try multiple endpoints to determine actual connectivity + const baseUrl = this.getBaseUrl(); + const endpointsToTry = [ + `${baseUrl}${this.healthCheckEndpoint}`, + `${baseUrl}/api/health`, + `${baseUrl}/status`, + baseUrl, // Root endpoint + ]; + + let lastError: Error | null = null; + let successfulResponse: Response | null = null; + + for (const endpoint of endpointsToTry) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 3000); + + const response = await fetch(endpoint, { + method: 'GET', + signal: controller.signal, + cache: 'no-store', + // Add credentials to handle auth + credentials: 'include', + }); + + clearTimeout(timeoutId); + + // Consider any response (even 404) as connectivity success + if (response.ok || response.status === 404 || response.status === 401) { + successfulResponse = response; + break; + } + } catch (endpointError) { + lastError = endpointError as Error; + // Try next endpoint + continue; + } + } + + const responseTime = Date.now() - startTime; + + if (successfulResponse) { + this.recordSuccess(responseTime); + this.isChecking = false; + + return { + healthy: true, + responseTime, + timestamp: new Date(), + }; + } else { + // If we got here, all endpoints failed + const errorMessage = lastError?.message || 'All endpoints failed to respond'; + this.recordFailure(errorMessage); + this.isChecking = false; + + return { + healthy: false, + responseTime, + error: errorMessage, + timestamp: new Date(), + }; + } + } catch (error) { + const responseTime = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + + this.recordFailure(errorMessage); + this.isChecking = false; + + return { + healthy: false, + responseTime, + error: errorMessage, + timestamp: new Date(), + }; + } + } + + /** + * Record a successful API request + */ + recordSuccess(responseTime: number = 0): void { + this.health.totalRequests++; + this.health.successfulRequests++; + this.health.consecutiveFailures = 0; + this.health.lastSuccess = new Date(); + this.health.lastCheck = new Date(); + + // Update average response time + const total = this.health.successfulRequests; + this.health.averageResponseTime = + ((this.health.averageResponseTime * (total - 1)) + responseTime) / total; + + this.updateStatus(); + this.emit('success', { responseTime }); + } + + /** + * Record a failed API request + */ + recordFailure(error: string | Error): void { + this.health.totalRequests++; + this.health.failedRequests++; + this.health.consecutiveFailures++; + this.health.lastFailure = new Date(); + this.health.lastCheck = new Date(); + + this.updateStatus(); + this.emit('failure', { + error: typeof error === 'string' ? error : error.message, + consecutiveFailures: this.health.consecutiveFailures + }); + } + + /** + * Get current connection health + */ + getHealth(): ConnectionHealth { + return { ...this.health }; + } + + /** + * Get current connection status + */ + getStatus(): ConnectionStatus { + return this.health.status; + } + + /** + * Check if API is currently available + */ + isAvailable(): boolean { + return this.health.status === 'connected' || this.health.status === 'degraded'; + } + + /** + * Get reliability percentage + */ + getReliability(): number { + if (this.health.totalRequests === 0) return 0; + return (this.health.successfulRequests / this.health.totalRequests) * 100; + } + + /** + * Reset all statistics + */ + reset(): void { + this.health = { + status: 'disconnected', + lastCheck: null, + lastSuccess: null, + lastFailure: null, + consecutiveFailures: 0, + totalRequests: 0, + successfulRequests: 0, + failedRequests: 0, + averageResponseTime: 0, + }; + this.emit('reset'); + } + + /** + * Get detailed status report for development + */ + getDebugReport(): string { + const reliability = this.getReliability().toFixed(2); + const avgTime = this.health.averageResponseTime.toFixed(2); + + return `API Connection Status: + Status: ${this.health.status} + Reliability: ${reliability}% + Total Requests: ${this.health.totalRequests} + Successful: ${this.health.successfulRequests} + Failed: ${this.health.failedRequests} + Consecutive Failures: ${this.health.consecutiveFailures} + Avg Response Time: ${avgTime}ms + Last Check: ${this.health.lastCheck?.toISOString() || 'never'} + Last Success: ${this.health.lastSuccess?.toISOString() || 'never'} + Last Failure: ${this.health.lastFailure?.toISOString() || 'never'}`; + } + + private updateStatus(): void { + const reliability = this.health.totalRequests > 0 + ? this.health.successfulRequests / this.health.totalRequests + : 0; + + // More nuanced status determination + if (this.health.totalRequests === 0) { + // No requests yet - don't assume disconnected + this.health.status = 'checking'; + } else if (this.health.consecutiveFailures >= 3) { + // Multiple consecutive failures indicates real connectivity issue + this.health.status = 'disconnected'; + } else if (reliability < this.DEGRADATION_THRESHOLD && this.health.totalRequests >= 5) { + // Only degrade if we have enough samples and reliability is low + this.health.status = 'degraded'; + } else if (reliability >= this.DEGRADATION_THRESHOLD || this.health.successfulRequests > 0) { + // If we have any successes, we're connected + this.health.status = 'connected'; + } else { + // Default to checking if uncertain + this.health.status = 'checking'; + } + + // Emit status change events (only on actual changes) + if (this.health.status === 'disconnected') { + this.emit('disconnected'); + } else if (this.health.status === 'degraded') { + this.emit('degraded'); + } else if (this.health.status === 'connected') { + this.emit('connected'); + } else if (this.health.status === 'checking') { + this.emit('checking'); + } + } + + private getBaseUrl(): string { + // Try to get base URL from environment or fallback + if (typeof window !== 'undefined') { + return process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + } + return process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'; + } +} + +/** + * Global connection status utility + */ +export const connectionMonitor = ApiConnectionMonitor.getInstance(); + +/** + * Hook for React components to monitor connection status + */ +export function useConnectionStatus() { + const monitor = ApiConnectionMonitor.getInstance(); + + return { + status: monitor.getStatus(), + health: monitor.getHealth(), + isAvailable: monitor.isAvailable(), + reliability: monitor.getReliability(), + checkHealth: () => monitor.performHealthCheck(), + getDebugReport: () => monitor.getDebugReport(), + }; +} \ No newline at end of file diff --git a/apps/website/lib/api/base/ApiError.ts b/apps/website/lib/api/base/ApiError.ts new file mode 100644 index 000000000..3081d8b0f --- /dev/null +++ b/apps/website/lib/api/base/ApiError.ts @@ -0,0 +1,147 @@ +/** + * Enhanced API Error with detailed classification and context + */ + +export type ApiErrorType = + | 'NETWORK_ERROR' // Connection failed, timeout, CORS + | 'AUTH_ERROR' // 401, 403 - Authentication/Authorization issues + | 'VALIDATION_ERROR' // 400 - Bad request, invalid data + | 'NOT_FOUND' // 404 - Resource not found + | 'SERVER_ERROR' // 500, 502, 503 - Server-side issues + | 'RATE_LIMIT_ERROR' // 429 - Too many requests + | 'CANCELED_ERROR' // Request was canceled + | 'TIMEOUT_ERROR' // Request timeout + | 'UNKNOWN_ERROR'; // Everything else + +export interface ApiErrorContext { + endpoint?: string; + method?: string; + requestBody?: unknown; + timestamp: string; + statusCode?: number; + responseText?: string; + retryCount?: number; + wasRetry?: boolean; + troubleshooting?: string; +} + +export class ApiError extends Error { + public readonly type: ApiErrorType; + public readonly context: ApiErrorContext; + public readonly originalError?: Error; + + constructor( + message: string, + type: ApiErrorType, + context: ApiErrorContext, + originalError?: Error + ) { + super(message); + this.name = 'ApiError'; + this.type = type; + this.context = context; + this.originalError = originalError; + + // Maintains proper stack trace for where our error was thrown + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ApiError); + } + } + + /** + * User-friendly message for production environments + */ + getUserMessage(): string { + switch (this.type) { + case 'NETWORK_ERROR': + return 'Unable to connect to the server. Please check your internet connection.'; + case 'AUTH_ERROR': + return 'Authentication required. Please log in again.'; + case 'VALIDATION_ERROR': + return 'The data you provided is invalid. Please check your input.'; + case 'NOT_FOUND': + return 'The requested resource was not found.'; + case 'SERVER_ERROR': + return 'Server is experiencing issues. Please try again later.'; + case 'RATE_LIMIT_ERROR': + return 'Too many requests. Please wait a moment and try again.'; + case 'TIMEOUT_ERROR': + return 'Request timed out. Please try again.'; + case 'CANCELED_ERROR': + return 'Request was canceled.'; + default: + return 'An unexpected error occurred. Please try again.'; + } + } + + /** + * Developer-friendly message with full context + */ + getDeveloperMessage(): string { + const base = `[${this.type}] ${this.message}`; + const ctx = [ + this.context.method, + this.context.endpoint, + this.context.statusCode ? `status:${this.context.statusCode}` : null, + this.context.retryCount ? `retry:${this.context.retryCount}` : null, + ].filter(Boolean).join(' '); + + return `${base} ${ctx ? `(${ctx})` : ''}`; + } + + /** + * Check if this error is retryable + */ + isRetryable(): boolean { + const retryableTypes: ApiErrorType[] = [ + 'NETWORK_ERROR', + 'SERVER_ERROR', + 'RATE_LIMIT_ERROR', + 'TIMEOUT_ERROR', + ]; + return retryableTypes.includes(this.type); + } + + /** + * Check if this error indicates connectivity issues + */ + isConnectivityIssue(): boolean { + return this.type === 'NETWORK_ERROR' || this.type === 'TIMEOUT_ERROR'; + } + + /** + * Get error severity for logging + */ + getSeverity(): 'error' | 'warn' | 'info' { + switch (this.type) { + case 'AUTH_ERROR': + case 'VALIDATION_ERROR': + case 'NOT_FOUND': + return 'warn'; + case 'RATE_LIMIT_ERROR': + case 'CANCELED_ERROR': + return 'info'; + default: + return 'error'; + } + } +} + +/** + * Type guards for error classification + */ +export function isApiError(error: unknown): error is ApiError { + return error instanceof ApiError; +} + +export function isNetworkError(error: unknown): boolean { + return isApiError(error) && error.type === 'NETWORK_ERROR'; +} + +export function isAuthError(error: unknown): boolean { + return isApiError(error) && error.type === 'AUTH_ERROR'; +} + +export function isRetryableError(error: unknown): boolean { + return isApiError(error) && error.isRetryable(); +} \ No newline at end of file diff --git a/apps/website/lib/api/base/BaseApiClient.ts b/apps/website/lib/api/base/BaseApiClient.ts index 1d0d53928..7225ced64 100644 --- a/apps/website/lib/api/base/BaseApiClient.ts +++ b/apps/website/lib/api/base/BaseApiClient.ts @@ -2,99 +2,353 @@ * Base API Client for HTTP operations * * Provides generic HTTP methods with common request/response handling, - * error handling, and authentication. + * error handling, authentication, retry logic, and circuit breaker. */ import { Logger } from '../../interfaces/Logger'; import { ErrorReporter } from '../../interfaces/ErrorReporter'; +import { ApiError, ApiErrorType } from './ApiError'; +import { RetryHandler, CircuitBreakerRegistry, DEFAULT_RETRY_CONFIG } from './RetryHandler'; +import { ApiConnectionMonitor } from './ApiConnectionMonitor'; + +export interface BaseApiClientOptions { + timeout?: number; + retry?: boolean; + retryConfig?: typeof DEFAULT_RETRY_CONFIG; +} export class BaseApiClient { protected baseUrl: string; private errorReporter: ErrorReporter; private logger: Logger; + private retryHandler: RetryHandler; + private circuitBreakerRegistry: CircuitBreakerRegistry; + private connectionMonitor: ApiConnectionMonitor; + private defaultOptions: BaseApiClientOptions; - constructor(baseUrl: string, errorReporter: ErrorReporter, logger: Logger) { + constructor( + baseUrl: string, + errorReporter: ErrorReporter, + logger: Logger, + options: BaseApiClientOptions = {} + ) { this.baseUrl = baseUrl; this.errorReporter = errorReporter; this.logger = logger; + this.retryHandler = new RetryHandler(options.retryConfig || DEFAULT_RETRY_CONFIG); + this.circuitBreakerRegistry = CircuitBreakerRegistry.getInstance(); + this.connectionMonitor = ApiConnectionMonitor.getInstance(); + this.defaultOptions = { + timeout: options.timeout || 30000, + retry: options.retry !== false, + retryConfig: options.retryConfig || DEFAULT_RETRY_CONFIG, + }; + + // Start monitoring connection health + this.connectionMonitor.startMonitoring(); + } + + /** + * Classify HTTP status code into error type + */ + private classifyError(status: number): ApiErrorType { + if (status >= 500) return 'SERVER_ERROR'; + if (status === 429) return 'RATE_LIMIT_ERROR'; + if (status === 401 || status === 403) return 'AUTH_ERROR'; + if (status === 400) return 'VALIDATION_ERROR'; + if (status === 404) return 'NOT_FOUND'; + return 'UNKNOWN_ERROR'; + } + + /** + * Create an ApiError from fetch response + */ + private async createApiError( + response: Response, + method: string, + path: string, + retryCount: number = 0 + ): Promise { + const status = response.status; + const errorType = this.classifyError(status); + + let message = response.statusText; + let responseText = ''; + + try { + responseText = await response.text(); + if (responseText) { + const errorData = JSON.parse(responseText); + if (errorData.message) { + message = errorData.message; + } + } + } catch { + // Keep default message + } + + return new ApiError( + message, + errorType, + { + endpoint: path, + method, + statusCode: status, + responseText, + timestamp: new Date().toISOString(), + retryCount, + } + ); + } + + /** + * Create an ApiError from network/timeout errors + */ + private createNetworkError( + error: Error, + method: string, + path: string, + retryCount: number = 0 + ): ApiError { + let errorType: ApiErrorType = 'NETWORK_ERROR'; + let message = error.message; + + // More specific error classification + if (error.name === 'AbortError') { + errorType = 'CANCELED_ERROR'; + message = 'Request was canceled'; + } else if (error.name === 'TypeError' && error.message.includes('fetch')) { + errorType = 'NETWORK_ERROR'; + // Check for CORS specifically + if (error.message.includes('Failed to fetch') || error.message.includes('fetch failed')) { + message = 'Unable to connect to server. Possible CORS or network issue.'; + } + } else if (error.message.includes('timeout') || error.message.includes('timed out')) { + errorType = 'TIMEOUT_ERROR'; + message = 'Request timed out after 30 seconds'; + } else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) { + errorType = 'NETWORK_ERROR'; + // This could be CORS, network down, or server not responding + message = 'Network error: Unable to reach the API server'; + } + + return new ApiError( + message, + errorType, + { + endpoint: path, + method, + timestamp: new Date().toISOString(), + retryCount, + // Add helpful context for developers + troubleshooting: this.getTroubleshootingContext(error, path), + }, + error + ); + } + + /** + * Get troubleshooting context for network errors + */ + private getTroubleshootingContext(error: Error, path: string): string { + if (typeof window !== 'undefined') { + const baseUrl = this.baseUrl; + const currentOrigin = window.location.origin; + + // Check if it's likely a CORS issue + if (baseUrl && !baseUrl.includes(currentOrigin) && error.message.includes('Failed to fetch')) { + return 'CORS issue likely. Check API server CORS configuration.'; + } + + // Check if API server is same origin + if (baseUrl.includes(currentOrigin) || baseUrl.startsWith('/')) { + return 'Same-origin request. Check if API server is running.'; + } + } + + return 'Check network connection and API server status.'; } protected async request( method: string, path: string, data?: object | FormData, - options?: { allowUnauthenticated?: boolean }, + options: BaseApiClientOptions & { allowUnauthenticated?: boolean } = {}, ): Promise { - this.logger.info(`${method} ${path}`); + const finalOptions = { ...this.defaultOptions, ...options }; + const endpoint = `${this.baseUrl}${path}`; - const isFormData = typeof FormData !== 'undefined' && data instanceof FormData; - - const headers: HeadersInit = isFormData - ? {} - : { - 'Content-Type': 'application/json', - }; - - const config: RequestInit = { - method, - headers, - credentials: 'include', // Include cookies for auth - }; - - if (data) { - config.body = isFormData ? data : JSON.stringify(data); - } - - const response = await fetch(`${this.baseUrl}${path}`, config); - - if (!response.ok) { - if ( - options?.allowUnauthenticated && - (response.status === 401 || response.status === 403) - ) { - // For "auth probe" endpoints (e.g. session/policy checks), 401/403 is an expected state - // in public context and should not be logged as an application error. - return null as T; - } - - let errorData: { message?: string } = { message: response.statusText }; - try { - errorData = await response.json(); - } catch { - // Keep default error message - } - const error = new Error( - errorData.message || `API request failed with status ${response.status}`, - ) as Error & { status?: number }; - error.status = response.status; - this.errorReporter.report(error); + // Check circuit breaker + const circuitBreaker = this.circuitBreakerRegistry.getBreaker(path); + if (!circuitBreaker.canExecute()) { + const error = new ApiError( + 'Circuit breaker is open - service temporarily unavailable', + 'SERVER_ERROR', + { + endpoint: path, + method, + timestamp: new Date().toISOString(), + } + ); + this.handleError(error); throw error; } - const text = await response.text(); - if (!text) { - return null as T; + const executeRequest = async (signal: AbortSignal): Promise => { + const isFormData = typeof FormData !== 'undefined' && data instanceof FormData; + const headers: HeadersInit = isFormData + ? {} + : { + 'Content-Type': 'application/json', + }; + + const config: RequestInit = { + method, + headers, + credentials: 'include', + signal, + }; + + if (data) { + config.body = isFormData ? data : JSON.stringify(data); + } + + const startTime = Date.now(); + + try { + const response = await fetch(endpoint, config); + const responseTime = Date.now() - startTime; + + // Record success for monitoring + this.connectionMonitor.recordSuccess(responseTime); + + if (!response.ok) { + if ( + finalOptions.allowUnauthenticated && + (response.status === 401 || response.status === 403) + ) { + // For auth probe endpoints, 401/403 is expected + return null as T; + } + + const error = await this.createApiError(response, method, path); + circuitBreaker.recordFailure(); + this.connectionMonitor.recordFailure(error); + this.handleError(error); + throw error; + } + + // Record successful circuit breaker call + circuitBreaker.recordSuccess(); + + const text = await response.text(); + if (!text) { + return null as T; + } + return JSON.parse(text) as T; + + } catch (error) { + const responseTime = Date.now() - startTime; + + if (error instanceof ApiError) { + throw error; + } + + // Convert to ApiError + const apiError = this.createNetworkError(error as Error, method, path); + + circuitBreaker.recordFailure(); + this.connectionMonitor.recordFailure(apiError); + this.handleError(apiError); + + throw apiError; + } + }; + + // Wrap with retry logic if enabled + if (finalOptions.retry) { + try { + return await this.retryHandler.execute(executeRequest); + } catch (error) { + // If retry exhausted, throw the final error + throw error; + } + } else { + // No retry, just execute with timeout + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), finalOptions.timeout); + + try { + return await executeRequest(controller.signal); + } finally { + clearTimeout(timeoutId); + } } - return JSON.parse(text) as T; } - protected get(path: string): Promise { - return this.request('GET', path); + /** + * Handle errors - log and report + */ + private handleError(error: ApiError): void { + const severity = error.getSeverity(); + const message = error.getDeveloperMessage(); + + // Log based on severity + if (severity === 'error') { + this.logger.error(message, error, error.context); + } else if (severity === 'warn') { + this.logger.warn(message, error.context); + } else { + this.logger.info(message, error.context); + } + + // Report to error tracking + this.errorReporter.report(error, error.context); } - protected post(path: string, data: object): Promise { - return this.request('POST', path, data); + protected get(path: string, options?: BaseApiClientOptions): Promise { + return this.request('GET', path, undefined, options); } - protected put(path: string, data: object): Promise { - return this.request('PUT', path, data); + protected post(path: string, data: object, options?: BaseApiClientOptions): Promise { + return this.request('POST', path, data, options); } - protected delete(path: string): Promise { - return this.request('DELETE', path); + protected put(path: string, data: object, options?: BaseApiClientOptions): Promise { + return this.request('PUT', path, data, options); } - protected patch(path: string, data: object): Promise { - return this.request('PATCH', path, data); + protected delete(path: string, options?: BaseApiClientOptions): Promise { + return this.request('DELETE', path, undefined, options); + } + + protected patch(path: string, data: object, options?: BaseApiClientOptions): Promise { + return this.request('PATCH', path, data, options); + } + + /** + * Get current connection health status + */ + getConnectionStatus() { + return { + status: this.connectionMonitor.getStatus(), + health: this.connectionMonitor.getHealth(), + isAvailable: this.connectionMonitor.isAvailable(), + reliability: this.connectionMonitor.getReliability(), + }; + } + + /** + * Force a health check + */ + async checkHealth() { + return this.connectionMonitor.performHealthCheck(); + } + + /** + * Get circuit breaker status for debugging + */ + getCircuitBreakerStatus() { + return this.circuitBreakerRegistry.getStatus(); } } diff --git a/apps/website/lib/api/base/GracefulDegradation.ts b/apps/website/lib/api/base/GracefulDegradation.ts new file mode 100644 index 000000000..7acc99b5c --- /dev/null +++ b/apps/website/lib/api/base/GracefulDegradation.ts @@ -0,0 +1,321 @@ +/** + * Graceful degradation utilities for when API is unavailable + */ + +import { ApiConnectionMonitor } from './ApiConnectionMonitor'; +import { ApiError } from './ApiError'; + +export interface DegradationOptions { + /** + * Fallback data to return when API is unavailable + */ + fallback?: T; + + /** + * Whether to throw error or return fallback + */ + throwOnError?: boolean; + + /** + * Maximum time to wait for API response + */ + timeout?: number; + + /** + * Whether to use cached data if available + */ + useCache?: boolean; +} + +export interface CacheEntry { + data: T; + timestamp: Date; + expiry: Date; +} + +/** + * Simple in-memory cache for API responses + */ +class ResponseCache { + private cache = new Map>(); + + /** + * Get cached data if not expired + */ + get(key: string): T | null { + const entry = this.cache.get(key); + if (!entry) return null; + + if (new Date() > entry.expiry) { + this.cache.delete(key); + return null; + } + + return entry.data; + } + + /** + * Set cached data with expiry + */ + set(key: string, data: T, ttlMs: number = 300000): void { + const now = new Date(); + const expiry = new Date(now.getTime() + ttlMs); + + this.cache.set(key, { + data, + timestamp: now, + expiry, + }); + } + + /** + * Clear all cached data + */ + clear(): void { + this.cache.clear(); + } + + /** + * Get cache statistics + */ + getStats() { + return { + size: this.cache.size, + entries: Array.from(this.cache.entries()).map(([key, entry]) => ({ + key, + timestamp: entry.timestamp, + expiry: entry.expiry, + })), + }; + } +} + +/** + * Global cache instance + */ +export const responseCache = new ResponseCache(); + +/** + * Execute a function with graceful degradation + */ +export async function withGracefulDegradation( + fn: () => Promise, + options: DegradationOptions = {} +): Promise { + const { + fallback, + throwOnError = false, + timeout = 10000, + useCache = true, + } = options; + + const monitor = ApiConnectionMonitor.getInstance(); + + // Check if API is available + if (!monitor.isAvailable()) { + // Try cache first + if (useCache && options.fallback) { + const cacheKey = `graceful:${fn.toString()}`; + const cached = responseCache.get(cacheKey); + if (cached) { + return cached; + } + } + + // Return fallback + if (fallback !== undefined) { + return fallback; + } + + // Throw error if no fallback + if (throwOnError) { + throw new ApiError( + 'API unavailable and no fallback provided', + 'NETWORK_ERROR', + { + timestamp: new Date().toISOString(), + } + ); + } + + // Return undefined (caller must handle) + return undefined as unknown as T; + } + + // API is available, try to execute + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + const result = await Promise.race([ + fn(), + new Promise((_, reject) => { + controller.signal.addEventListener('abort', () => { + reject(new Error('Request timeout')); + }); + }), + ]); + + clearTimeout(timeoutId); + + // Cache the result if enabled + if (useCache && result !== null && result !== undefined) { + const cacheKey = `graceful:${fn.toString()}`; + responseCache.set(cacheKey, result); + } + + return result; + + } catch (error) { + // Record failure in monitor + if (error instanceof ApiError) { + monitor.recordFailure(error); + } else { + monitor.recordFailure(error as Error); + } + + // Try cache as fallback + if (useCache && options.fallback) { + const cacheKey = `graceful:${fn.toString()}`; + const cached = responseCache.get(cacheKey); + if (cached) { + return cached; + } + } + + // Return fallback if provided + if (fallback !== undefined) { + return fallback; + } + + // Re-throw or return undefined + if (throwOnError) { + throw error; + } + + return undefined as unknown as T; + } +} + +/** + * Service wrapper for graceful degradation + */ +export class GracefulService { + private monitor: ApiConnectionMonitor; + private cacheKey: string; + + constructor( + private serviceName: string, + private getData: () => Promise, + private defaultFallback: T + ) { + this.monitor = ApiConnectionMonitor.getInstance(); + this.cacheKey = `service:${serviceName}`; + } + + /** + * Get data with graceful degradation + */ + async get(options: Partial> = {}): Promise { + const result = await withGracefulDegradation(this.getData, { + fallback: this.defaultFallback, + throwOnError: false, + useCache: true, + ...options, + }); + + return result ?? this.defaultFallback; + } + + /** + * Force refresh data + */ + async refresh(): Promise { + responseCache.clear(); // Clear cache for this service + return this.get({ useCache: false }); + } + + /** + * Get service health status + */ + getStatus() { + const health = this.monitor.getHealth(); + const isAvailable = this.monitor.isAvailable(); + + return { + serviceName: this.serviceName, + available: isAvailable, + reliability: health.totalRequests > 0 + ? (health.successfulRequests / health.totalRequests) * 100 + : 100, + lastCheck: health.lastCheck, + }; + } +} + +/** + * Offline mode detection + */ +export class OfflineDetector { + private static instance: OfflineDetector; + private isOffline = false; + private listeners: Array<(isOffline: boolean) => void> = []; + + private constructor() { + if (typeof window !== 'undefined') { + window.addEventListener('online', () => this.setOffline(false)); + window.addEventListener('offline', () => this.setOffline(true)); + + // Initial check + this.isOffline = !navigator.onLine; + } + } + + static getInstance(): OfflineDetector { + if (!OfflineDetector.instance) { + OfflineDetector.instance = new OfflineDetector(); + } + return OfflineDetector.instance; + } + + private setOffline(offline: boolean): void { + if (this.isOffline !== offline) { + this.isOffline = offline; + this.listeners.forEach(listener => listener(offline)); + } + } + + /** + * Check if browser is offline + */ + isBrowserOffline(): boolean { + return this.isOffline; + } + + /** + * Add listener for offline status changes + */ + onStatusChange(callback: (isOffline: boolean) => void): void { + this.listeners.push(callback); + } + + /** + * Remove listener + */ + removeListener(callback: (isOffline: boolean) => void): void { + this.listeners = this.listeners.filter(cb => cb !== callback); + } +} + +/** + * Hook for offline detection + */ +export function useOfflineStatus() { + if (typeof window === 'undefined') { + return false; // Server-side + } + + // This would need to be used in a React component context + // For now, provide a simple check function + return OfflineDetector.getInstance().isBrowserOffline(); +} \ No newline at end of file diff --git a/apps/website/lib/api/base/RetryHandler.ts b/apps/website/lib/api/base/RetryHandler.ts new file mode 100644 index 000000000..62ad16a9d --- /dev/null +++ b/apps/website/lib/api/base/RetryHandler.ts @@ -0,0 +1,275 @@ +/** + * Retry logic and circuit breaker for API requests + */ + +import { ApiError, ApiErrorType } from './ApiError'; + +export interface RetryConfig { + maxRetries: number; + baseDelay: number; // milliseconds + maxDelay: number; // milliseconds + backoffMultiplier: number; + timeout: number; // milliseconds +} + +export const DEFAULT_RETRY_CONFIG: RetryConfig = { + maxRetries: 3, + baseDelay: 1000, + maxDelay: 10000, + backoffMultiplier: 2, + timeout: 30000, +}; + +export interface CircuitBreakerConfig { + failureThreshold: number; + successThreshold: number; + timeout: number; // milliseconds before trying again +} + +export const DEFAULT_CIRCUIT_BREAKER_CONFIG: CircuitBreakerConfig = { + failureThreshold: 5, + successThreshold: 3, + timeout: 60000, // 1 minute +}; + +export class CircuitBreaker { + private failures = 0; + private successes = 0; + private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED'; + private lastFailureTime: number | null = null; + private readonly config: CircuitBreakerConfig; + + constructor(config: CircuitBreakerConfig = DEFAULT_CIRCUIT_BREAKER_CONFIG) { + this.config = config; + } + + /** + * Check if request should proceed + */ + canExecute(): boolean { + if (this.state === 'CLOSED') { + return true; + } + + if (this.state === 'OPEN') { + const now = Date.now(); + if (this.lastFailureTime && now - this.lastFailureTime > this.config.timeout) { + this.state = 'HALF_OPEN'; + return true; + } + return false; + } + + // HALF_OPEN - allow one request to test if service recovered + return true; + } + + /** + * Record a successful request + */ + recordSuccess(): void { + if (this.state === 'HALF_OPEN') { + this.successes++; + if (this.successes >= this.config.successThreshold) { + this.reset(); + } + } else if (this.state === 'CLOSED') { + // Keep failures in check + this.failures = Math.max(0, this.failures - 1); + } + } + + /** + * Record a failed request + */ + recordFailure(): void { + this.failures++; + this.lastFailureTime = Date.now(); + + if (this.state === 'HALF_OPEN') { + this.state = 'OPEN'; + this.successes = 0; + } else if (this.state === 'CLOSED' && this.failures >= this.config.failureThreshold) { + this.state = 'OPEN'; + } + } + + /** + * Get current state + */ + getState(): string { + return this.state; + } + + /** + * Get failure count + */ + getFailures(): number { + return this.failures; + } + + /** + * Reset the circuit breaker + */ + reset(): void { + this.failures = 0; + this.successes = 0; + this.state = 'CLOSED'; + this.lastFailureTime = null; + } +} + +export class RetryHandler { + private config: RetryConfig; + private abortController: AbortController | null = null; + + constructor(config: RetryConfig = DEFAULT_RETRY_CONFIG) { + this.config = config; + } + + /** + * Execute a function with retry logic + */ + async execute( + fn: (signal: AbortSignal) => Promise, + isRetryable?: (error: ApiError) => boolean + ): Promise { + this.abortController = new AbortController(); + const signal = this.abortController.signal; + + let lastError: Error | null = null; + + for (let attempt = 0; attempt <= this.config.maxRetries; attempt++) { + try { + // Check if already aborted + if (signal.aborted) { + throw new Error('Request aborted'); + } + + const result = await fn(signal); + return result; + } catch (error) { + lastError = error as Error; + + // Check if we should abort + if (signal.aborted) { + throw error; + } + + // Check if this is the last attempt + if (attempt === this.config.maxRetries) { + break; + } + + // Check if error is retryable + if (error instanceof ApiError) { + if (!error.isRetryable()) { + throw error; + } + if (isRetryable && !isRetryable(error)) { + throw error; + } + } + + // Calculate delay with exponential backoff + const delay = this.calculateDelay(attempt); + + // Wait before retrying + await this.sleep(delay, signal); + } + } + + // All retries exhausted + throw lastError; + } + + /** + * Cancel the current request + */ + abort(): void { + if (this.abortController) { + this.abortController.abort(); + } + } + + /** + * Calculate delay for retry attempt + */ + private calculateDelay(attempt: number): number { + const delay = Math.min( + this.config.baseDelay * Math.pow(this.config.backoffMultiplier, attempt), + this.config.maxDelay + ); + // Add jitter to prevent thundering herd + const jitter = Math.random() * 0.3 * delay; + return delay + jitter; + } + + /** + * Sleep with abort support + */ + private sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal.removeEventListener('abort', abortHandler); + resolve(); + }, ms); + + const abortHandler = () => { + clearTimeout(timeout); + signal.removeEventListener('abort', abortHandler); + reject(new Error('Request aborted during retry delay')); + }; + + signal.addEventListener('abort', abortHandler, { once: true }); + }); + } +} + +/** + * Global circuit breaker registry for different API endpoints + */ +export class CircuitBreakerRegistry { + private static instance: CircuitBreakerRegistry; + private breakers: Map = new Map(); + + private constructor() {} + + static getInstance(): CircuitBreakerRegistry { + if (!CircuitBreakerRegistry.instance) { + CircuitBreakerRegistry.instance = new CircuitBreakerRegistry(); + } + return CircuitBreakerRegistry.instance; + } + + /** + * Get or create circuit breaker for a specific endpoint + */ + getBreaker(endpoint: string, config?: CircuitBreakerConfig): CircuitBreaker { + if (!this.breakers.has(endpoint)) { + this.breakers.set(endpoint, new CircuitBreaker(config)); + } + return this.breakers.get(endpoint)!; + } + + /** + * Reset all circuit breakers + */ + resetAll(): void { + this.breakers.forEach(breaker => breaker.reset()); + } + + /** + * Get status of all circuit breakers + */ + getStatus(): Record { + const status: Record = {}; + this.breakers.forEach((breaker, endpoint) => { + status[endpoint] = { + state: breaker.getState(), + failures: breaker.getFailures(), + }; + }); + return status; + } +} \ No newline at end of file diff --git a/apps/website/lib/api/index.ts b/apps/website/lib/api/index.ts index 3a5c14f6f..ff60ccd6e 100644 --- a/apps/website/lib/api/index.ts +++ b/apps/website/lib/api/index.ts @@ -11,7 +11,7 @@ import { DashboardApiClient } from './dashboard/DashboardApiClient'; import { PenaltiesApiClient } from './penalties/PenaltiesApiClient'; import { ProtestsApiClient } from './protests/ProtestsApiClient'; import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; -import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; +import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; /** * Main API Client @@ -34,7 +34,11 @@ export class ApiClient { constructor(baseUrl: string) { const logger = new ConsoleLogger(); - const errorReporter = new ConsoleErrorReporter(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); this.leagues = new LeaguesApiClient(baseUrl, errorReporter, logger); this.races = new RacesApiClient(baseUrl, errorReporter, logger); diff --git a/apps/website/lib/infrastructure/EnhancedErrorReporter.ts b/apps/website/lib/infrastructure/EnhancedErrorReporter.ts new file mode 100644 index 000000000..f983e9bd6 --- /dev/null +++ b/apps/website/lib/infrastructure/EnhancedErrorReporter.ts @@ -0,0 +1,335 @@ +/** + * Enhanced Error Reporter with user notifications and environment-specific handling + */ + +import { ErrorReporter } from '../interfaces/ErrorReporter'; +import { Logger } from '../interfaces/Logger'; +import { ApiError } from '../api/base/ApiError'; +import { connectionMonitor } from '../api/base/ApiConnectionMonitor'; + +// Import notification system (will be used if available) +let notificationSystem: any = null; +try { + // Dynamically import to avoid circular dependencies + import('@/components/notifications/NotificationProvider').then(module => { + notificationSystem = module; + }).catch(() => { + // Notification system not available yet + }); +} catch { + // Silent fail - notification system may not be initialized +} + +export interface EnhancedErrorReporterOptions { + /** + * Whether to show user-facing notifications + */ + showUserNotifications?: boolean; + + /** + * Whether to log to console (always true in dev) + */ + logToConsole?: boolean; + + /** + * Whether to report to external service (e.g., Sentry) + */ + reportToExternal?: boolean; + + /** + * Custom error handler for specific error types + */ + customHandlers?: Record void>; +} + +export class EnhancedErrorReporter implements ErrorReporter { + private options: EnhancedErrorReporterOptions; + private logger: Logger; + private errorBuffer: Array<{ error: ApiError; context: unknown }> = []; + private readonly MAX_BUFFER_SIZE = 50; + + constructor(logger: Logger, options: EnhancedErrorReporterOptions = {}) { + this.logger = logger; + this.options = { + showUserNotifications: options.showUserNotifications ?? true, + logToConsole: options.logToConsole ?? true, + reportToExternal: options.reportToExternal ?? false, + customHandlers: options.customHandlers || {}, + }; + } + + /** + * Main error reporting method + */ + report(error: Error, context?: unknown): void { + // Only handle ApiError instances for enhanced reporting + if (!(error instanceof ApiError)) { + // For non-API errors, use basic logging + if (this.options.logToConsole) { + console.error('Non-API Error:', error, context); + } + return; + } + + // Add to buffer for potential batch reporting + this.addToBuffer(error, context); + + // Log based on environment and severity + this.logError(error, context); + + // Handle custom error types + this.handleCustomHandlers(error); + + // Show user notifications if enabled + if (this.options.showUserNotifications) { + this.showUserNotification(error); + } + + // Report to external services if configured + if (this.options.reportToExternal) { + this.reportToExternal(error, context); + } + + // Update connection monitor + if (error.isConnectivityIssue()) { + connectionMonitor.recordFailure(error); + } + } + + /** + * Log error with appropriate severity + */ + private logError(error: ApiError, context: unknown): void { + if (!this.options.logToConsole) return; + + const isDev = process.env.NODE_ENV === 'development'; + const severity = error.getSeverity(); + + const message = isDev ? error.getDeveloperMessage() : error.getUserMessage(); + const logContext = { + ...error.context, + ...context, + type: error.type, + isRetryable: error.isRetryable(), + isConnectivity: error.isConnectivityIssue(), + }; + + if (severity === 'error') { + this.logger.error(message, error, logContext); + if (isDev) { + console.error(`[API-ERROR] ${message}`, { error, context: logContext }); + } + } else if (severity === 'warn') { + this.logger.warn(message, logContext); + if (isDev) { + console.warn(`[API-WARN] ${message}`, { context: logContext }); + } + } else { + this.logger.info(message, logContext); + if (isDev) { + console.log(`[API-INFO] ${message}`, { context: logContext }); + } + } + } + + /** + * Show user-facing notification + */ + private showUserNotification(error: ApiError): void { + const isDev = process.env.NODE_ENV === 'development'; + + // In development, we might want to show more details + if (isDev) { + // Use console notification in dev + console.log(`%c[USER-NOTIFICATION] ${error.getUserMessage()}`, + 'background: #222; color: #bada55; padding: 4px 8px; border-radius: 4px;' + ); + return; + } + + // In production, use the notification system if available + // This is a deferred import to avoid circular dependencies + if (typeof window !== 'undefined') { + setTimeout(() => { + try { + // Try to access notification context if available + const notificationEvent = new CustomEvent('gridpilot-notification', { + detail: { + type: 'error', + title: this.getNotificationTitle(error), + message: error.getUserMessage(), + variant: error.isConnectivityIssue() ? 'modal' : 'toast', + autoDismiss: !error.isConnectivityIssue(), + } + }); + window.dispatchEvent(notificationEvent); + } catch (e) { + // Fallback to browser alert if notification system unavailable + if (error.isConnectivityIssue()) { + console.warn('API Error:', error.getUserMessage()); + } + } + }, 100); + } + } + + /** + * Get appropriate notification title + */ + private getNotificationTitle(error: ApiError): string { + switch (error.type) { + case 'NETWORK_ERROR': + return 'Connection Lost'; + case 'TIMEOUT_ERROR': + return 'Request Timed Out'; + case 'AUTH_ERROR': + return 'Authentication Required'; + case 'SERVER_ERROR': + return 'Server Error'; + case 'RATE_LIMIT_ERROR': + return 'Rate Limit Reached'; + default: + return 'Something Went Wrong'; + } + } + + /** + * Handle custom error type handlers + */ + private handleCustomHandlers(error: ApiError): void { + if (this.options.customHandlers && this.options.customHandlers[error.type]) { + try { + this.options.customHandlers[error.type]!(error); + } catch (handlerError) { + console.error('Custom error handler failed:', handlerError); + } + } + } + + /** + * Report to external services (placeholder for future integration) + */ + private reportToExternal(error: ApiError, context: unknown): void { + // Placeholder for external error reporting (e.g., Sentry, LogRocket) + // In a real implementation, this would send to your error tracking service + + if (process.env.NODE_ENV === 'development') { + const contextObj = typeof context === 'object' && context !== null ? context : {}; + console.log('[EXTERNAL-REPORT] Would report:', { + type: error.type, + message: error.message, + context: { ...error.context, ...contextObj }, + timestamp: new Date().toISOString(), + }); + } + } + + /** + * Add error to buffer for potential batch reporting + */ + private addToBuffer(error: ApiError, context: unknown): void { + this.errorBuffer.push({ error, context }); + + // Keep buffer size in check + if (this.errorBuffer.length > this.MAX_BUFFER_SIZE) { + this.errorBuffer.shift(); + } + } + + /** + * Get buffered errors + */ + getBufferedErrors(): Array<{ error: ApiError; context: unknown }> { + return [...this.errorBuffer]; + } + + /** + * Clear error buffer + */ + clearBuffer(): void { + this.errorBuffer = []; + } + + /** + * Batch report buffered errors + */ + flush(): void { + if (this.errorBuffer.length === 0) return; + + if (this.options.logToConsole) { + console.groupCollapsed(`[API-REPORT] Flushing ${this.errorBuffer.length} buffered errors`); + this.errorBuffer.forEach(({ error, context }) => { + console.log(`${error.type}: ${error.message}`, { error, context }); + }); + console.groupEnd(); + } + + // In production, this would batch send to external service + if (this.options.reportToExternal) { + const batch = this.errorBuffer.map(({ error, context }) => { + const contextObj = typeof context === 'object' && context !== null ? context : {}; + return { + type: error.type, + message: error.message, + context: { ...error.context, ...contextObj }, + timestamp: new Date().toISOString(), + }; + }); + + console.log('[EXTERNAL-REPORT] Batch:', batch); + } + + this.clearBuffer(); + } + + /** + * Update options dynamically + */ + updateOptions(newOptions: Partial): void { + this.options = { ...this.options, ...newOptions }; + } +} + +/** + * Global error reporter instance + */ +let globalReporter: EnhancedErrorReporter | null = null; + +export function getGlobalErrorReporter(): EnhancedErrorReporter { + if (!globalReporter) { + // Import the console logger + const { ConsoleLogger } = require('./ConsoleLogger'); + globalReporter = new EnhancedErrorReporter(new ConsoleLogger(), { + showUserNotifications: true, + logToConsole: true, + reportToExternal: process.env.NODE_ENV === 'production', + }); + } + return globalReporter; +} + +/** + * Helper function to report API errors easily + */ +export function reportApiError( + error: ApiError, + context?: unknown, + reporter?: EnhancedErrorReporter +): void { + const rep = reporter || getGlobalErrorReporter(); + rep.report(error, context); +} + +/** + * React hook for error reporting + */ +export function useErrorReporter() { + const reporter = getGlobalErrorReporter(); + + return { + report: (error: Error, context?: unknown) => reporter.report(error, context), + flush: () => reporter.flush(), + getBuffered: () => reporter.getBufferedErrors(), + updateOptions: (opts: Partial) => reporter.updateOptions(opts), + }; +} \ No newline at end of file