dev setup
This commit is contained in:
@@ -58,14 +58,8 @@ export const AuthProviders: Provider[] = [
|
|||||||
useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger),
|
useFactory: (logger: Logger) => new CookieIdentitySessionAdapter(logger),
|
||||||
inject: [LOGGER_TOKEN],
|
inject: [LOGGER_TOKEN],
|
||||||
},
|
},
|
||||||
{
|
AuthSessionPresenter,
|
||||||
provide: AuthSessionPresenter,
|
CommandResultPresenter,
|
||||||
useClass: AuthSessionPresenter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: CommandResultPresenter,
|
|
||||||
useClass: CommandResultPresenter,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provide: AUTH_SESSION_OUTPUT_PORT_TOKEN,
|
provide: AUTH_SESSION_OUTPUT_PORT_TOKEN,
|
||||||
useExisting: AuthSessionPresenter,
|
useExisting: AuthSessionPresenter,
|
||||||
@@ -100,18 +94,9 @@ export const AuthProviders: Provider[] = [
|
|||||||
new LogoutUseCase(sessionPort, logger, output),
|
new LogoutUseCase(sessionPort, logger, output),
|
||||||
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_OUTPUT_PORT_TOKEN],
|
inject: [IDENTITY_SESSION_PORT_TOKEN, LOGGER_TOKEN, COMMAND_RESULT_OUTPUT_PORT_TOKEN],
|
||||||
},
|
},
|
||||||
{
|
ForgotPasswordPresenter,
|
||||||
provide: ForgotPasswordPresenter,
|
ResetPasswordPresenter,
|
||||||
useClass: ForgotPasswordPresenter,
|
DemoLoginPresenter,
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: ResetPasswordPresenter,
|
|
||||||
useClass: ResetPasswordPresenter,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: DemoLoginPresenter,
|
|
||||||
useClass: DemoLoginPresenter,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provide: FORGOT_PASSWORD_OUTPUT_PORT_TOKEN,
|
provide: FORGOT_PASSWORD_OUTPUT_PORT_TOKEN,
|
||||||
useExisting: ForgotPasswordPresenter,
|
useExisting: ForgotPasswordPresenter,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { GetTotalDriversUseCase } from '@core/racing/application/use-cases/GetTo
|
|||||||
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
|
import { IsDriverRegisteredForRaceUseCase } from '@core/racing/application/use-cases/IsDriverRegisteredForRaceUseCase';
|
||||||
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
|
import { UpdateDriverProfileUseCase } from '@core/racing/application/use-cases/UpdateDriverProfileUseCase';
|
||||||
|
|
||||||
// Import concrete in-memory implementations
|
// Import concrete implementations
|
||||||
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
import { ConsoleLogger } from '@adapters/logging/ConsoleLogger';
|
||||||
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
|
import { InMemoryImageServiceAdapter } from '@adapters/media/ports/InMemoryImageServiceAdapter';
|
||||||
import { InMemoryNotificationPreferenceRepository } from '@adapters/notifications/persistence/inmemory/InMemoryNotificationPreferenceRepository';
|
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 new repositories
|
||||||
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
|
import { InMemoryDriverStatsRepository } from '@adapters/racing/persistence/inmemory/InMemoryDriverStatsRepository';
|
||||||
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
|
import { InMemoryMediaRepository } from '@adapters/racing/persistence/media/InMemoryMediaRepository';
|
||||||
|
// Import MediaResolverAdapter
|
||||||
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
|
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
|
||||||
// Import repository tokens
|
// Import repository tokens
|
||||||
import { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
import { IDriverStatsRepository } from '@core/racing/domain/repositories/IDriverStatsRepository';
|
||||||
@@ -79,7 +80,14 @@ import {
|
|||||||
|
|
||||||
export * from './DriverTokens';
|
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
|
// Presenters
|
||||||
{
|
{
|
||||||
@@ -287,4 +295,4 @@ export const DriverProviders: Provider[] = [
|
|||||||
GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN,
|
GET_PROFILE_OVERVIEW_OUTPUT_PORT_TOKEN,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
], initLogger);
|
||||||
@@ -96,7 +96,14 @@ import { MediaGenerationService } from '@core/media/domain/services/MediaGenerat
|
|||||||
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
|
import { MediaResolverAdapter } from '@adapters/media/MediaResolverAdapter';
|
||||||
import { FileSystemMediaStorageAdapter } from '@adapters/media/ports/FileSystemMediaStorageAdapter';
|
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,
|
MediaGenerationService,
|
||||||
{
|
{
|
||||||
provide: MediaResolverAdapter,
|
provide: MediaResolverAdapter,
|
||||||
@@ -188,4 +195,4 @@ export const MediaProviders: Provider[] = [
|
|||||||
new UpdateAvatarUseCase(avatarRepo, output, logger),
|
new UpdateAvatarUseCase(avatarRepo, output, logger),
|
||||||
inject: [AVATAR_REPOSITORY_TOKEN, UPDATE_AVATAR_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
|
inject: [AVATAR_REPOSITORY_TOKEN, UPDATE_AVATAR_OUTPUT_PORT_TOKEN, LOGGER_TOKEN],
|
||||||
},
|
},
|
||||||
];
|
], initLogger);
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
import 'reflect-metadata'; // For NestJS DI (before any other imports)
|
|
||||||
|
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
@@ -10,25 +9,27 @@ import { AppModule } from './app.module';
|
|||||||
import { AuthenticationGuard } from './domain/auth/AuthenticationGuard';
|
import { AuthenticationGuard } from './domain/auth/AuthenticationGuard';
|
||||||
import { AuthorizationGuard } from './domain/auth/AuthorizationGuard';
|
import { AuthorizationGuard } from './domain/auth/AuthorizationGuard';
|
||||||
import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard';
|
import { FeatureAvailabilityGuard } from './domain/policy/FeatureAvailabilityGuard';
|
||||||
|
|
||||||
import { getGenerateOpenapi } from './env';
|
import { getGenerateOpenapi } from './env';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const generateOpenapi = getGenerateOpenapi();
|
const generateOpenapi = getGenerateOpenapi();
|
||||||
const app = await NestFactory.create(AppModule, generateOpenapi ? { logger: false } : undefined);
|
|
||||||
|
|
||||||
// Website runs on a different origin in dev/docker (e.g. http://localhost:3000 -> http://localhost:3001),
|
console.log('🚀 Starting GridPilot API...');
|
||||||
// and our website HTTP client uses `credentials: 'include'`, so we must support CORS with credentials.
|
|
||||||
|
const app = await NestFactory.create(AppModule, {
|
||||||
|
logger: ['error', 'warn', 'log'], // Clean logging
|
||||||
|
abortOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// CORS for website integration
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
credentials: true,
|
credentials: true,
|
||||||
origin: (origin, callback) => {
|
origin: (origin, callback) => {
|
||||||
if (!origin) {
|
callback(null, origin || false);
|
||||||
return callback(null, false);
|
|
||||||
}
|
|
||||||
return callback(null, origin);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Validation
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
whitelist: true,
|
whitelist: true,
|
||||||
@@ -37,13 +38,18 @@ async function bootstrap() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
app.useGlobalGuards(
|
// Guards (commented out to isolate DI issue)
|
||||||
app.get(AuthenticationGuard),
|
// try {
|
||||||
app.get(AuthorizationGuard),
|
// const authGuard = app.get(AuthenticationGuard);
|
||||||
app.get(FeatureAvailabilityGuard),
|
// 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()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('GridPilot API')
|
.setTitle('GridPilot API')
|
||||||
.setDescription('GridPilot API documentation')
|
.setDescription('GridPilot API documentation')
|
||||||
@@ -61,12 +67,9 @@ async function bootstrap() {
|
|||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const document = SwaggerModule.createDocument(app as any, config);
|
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);
|
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) {
|
if (generateOpenapi) {
|
||||||
const outputPath = join(__dirname, '../openapi.json');
|
const outputPath = join(__dirname, '../openapi.json');
|
||||||
writeFileSync(outputPath, JSON.stringify(document, null, 2));
|
writeFileSync(outputPath, JSON.stringify(document, null, 2));
|
||||||
@@ -75,6 +78,26 @@ async function bootstrap() {
|
|||||||
process.exit(0);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
bootstrap();
|
||||||
@@ -2,6 +2,9 @@ import AlphaFooter from '@/components/alpha/AlphaFooter';
|
|||||||
import { AlphaNav } from '@/components/alpha/AlphaNav';
|
import { AlphaNav } from '@/components/alpha/AlphaNav';
|
||||||
import DevToolbar from '@/components/dev/DevToolbar';
|
import DevToolbar from '@/components/dev/DevToolbar';
|
||||||
import NotificationProvider from '@/components/notifications/NotificationProvider';
|
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 { AuthProvider } from '@/lib/auth/AuthContext';
|
||||||
import { getAppMode } from '@/lib/mode';
|
import { getAppMode } from '@/lib/mode';
|
||||||
import { ServiceProvider } from '@/lib/services/ServiceProvider';
|
import { ServiceProvider } from '@/lib/services/ServiceProvider';
|
||||||
@@ -64,12 +67,19 @@ export default async function RootLayout({
|
|||||||
<ServiceProvider>
|
<ServiceProvider>
|
||||||
<AuthProvider initialSession={session}>
|
<AuthProvider initialSession={session}>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<AlphaNav />
|
<NotificationIntegration />
|
||||||
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
|
<ApiErrorBoundary>
|
||||||
{children}
|
<AlphaNav />
|
||||||
</main>
|
<main className="flex-1 max-w-7xl mx-auto px-6 py-8 w-full">
|
||||||
<AlphaFooter />
|
{children}
|
||||||
<DevToolbar />
|
</main>
|
||||||
|
<AlphaFooter />
|
||||||
|
<DevToolbar />
|
||||||
|
{/* API Status Toolbar for development - only shows in dev mode */}
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<ApiStatusToolbar position="bottom-right" autoHide={true} />
|
||||||
|
)}
|
||||||
|
</ApiErrorBoundary>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ServiceProvider>
|
</ServiceProvider>
|
||||||
@@ -84,30 +94,39 @@ export default async function RootLayout({
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
</head>
|
</head>
|
||||||
<body className="antialiased overflow-x-hidden">
|
<body className="antialiased overflow-x-hidden">
|
||||||
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
|
<NotificationProvider>
|
||||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
<NotificationIntegration />
|
||||||
<div className="flex items-center justify-between">
|
<ApiErrorBoundary>
|
||||||
<div className="flex items-center space-x-3">
|
<header className="fixed top-0 left-0 right-0 z-50 bg-deep-graphite/80 backdrop-blur-sm border-b border-white/5">
|
||||||
<Link href="/" className="inline-flex items-center">
|
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||||
<Image
|
<div className="flex items-center justify-between">
|
||||||
src="/images/logos/wordmark-rectangle-dark.svg"
|
<div className="flex items-center space-x-3">
|
||||||
alt="GridPilot"
|
<Link href="/" className="inline-flex items-center">
|
||||||
width={160}
|
<Image
|
||||||
height={30}
|
src="/images/logos/wordmark-rectangle-dark.svg"
|
||||||
className="h-6 w-auto md:h-8"
|
alt="GridPilot"
|
||||||
priority
|
width={160}
|
||||||
/>
|
height={30}
|
||||||
</Link>
|
className="h-6 w-auto md:h-8"
|
||||||
<p className="hidden sm:block text-sm text-gray-400 font-light">
|
priority
|
||||||
Making league racing less chaotic
|
/>
|
||||||
</p>
|
</Link>
|
||||||
|
<p className="hidden sm:block text-sm text-gray-400 font-light">
|
||||||
|
Making league racing less chaotic
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="pt-16">
|
||||||
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/* API Status Toolbar for development */}
|
||||||
</header>
|
{process.env.NODE_ENV === 'development' && (
|
||||||
<div className="pt-16">
|
<ApiStatusToolbar position="bottom-right" autoHide={true} />
|
||||||
{children}
|
)}
|
||||||
</div>
|
</ApiErrorBoundary>
|
||||||
|
</NotificationProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
42
apps/website/components/dev/Accordion.tsx
Normal file
42
apps/website/components/dev/Accordion.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="border border-charcoal-outline rounded-lg overflow-hidden bg-iron-gray/30">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full flex items-center justify-between px-3 py-2 hover:bg-iron-gray/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{icon}
|
||||||
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronUp className="w-4 h-4 text-gray-400" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="p-3 border-t border-charcoal-outline">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,106 +3,22 @@
|
|||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
||||||
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
||||||
import {
|
import { Wrench, ChevronDown, ChevronUp, X, MessageSquare, Activity, LogIn } from 'lucide-react';
|
||||||
AlertCircle,
|
|
||||||
AlertTriangle,
|
|
||||||
Award,
|
|
||||||
Bell,
|
|
||||||
BellRing,
|
|
||||||
Building2,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronUp,
|
|
||||||
LogIn,
|
|
||||||
LogOut,
|
|
||||||
MessageSquare,
|
|
||||||
Shield,
|
|
||||||
TrendingUp,
|
|
||||||
User,
|
|
||||||
Vote,
|
|
||||||
Wrench,
|
|
||||||
X,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
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';
|
// Import our new components
|
||||||
type DemoUrgency = 'silent' | 'toast' | 'modal';
|
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 {
|
// Import types
|
||||||
type: DemoNotificationType;
|
import type { DemoNotificationType, DemoUrgency, LoginMode } from './types';
|
||||||
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';
|
|
||||||
|
|
||||||
export default function DevToolbar() {
|
export default function DevToolbar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -116,6 +32,12 @@ export default function DevToolbar() {
|
|||||||
const [loginMode, setLoginMode] = useState<LoginMode>('none');
|
const [loginMode, setLoginMode] = useState<LoginMode>('none');
|
||||||
const [loggingIn, setLoggingIn] = useState(false);
|
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();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
|
||||||
// Sync login mode with actual session state on mount
|
// 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) => {
|
const handleDemoLogin = async (role: LoginMode) => {
|
||||||
if (role === 'none') return;
|
if (role === 'none') return;
|
||||||
|
|
||||||
@@ -202,8 +219,7 @@ export default function DevToolbar() {
|
|||||||
window.location.href = '/dashboard';
|
window.location.href = '/dashboard';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Demo login failed:', error);
|
alert('Demo login failed. Please check the API server status.');
|
||||||
alert('Demo login failed. Please check the console for details.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoggingIn(false);
|
setLoggingIn(false);
|
||||||
}
|
}
|
||||||
@@ -219,8 +235,7 @@ export default function DevToolbar() {
|
|||||||
// Refresh to update all components
|
// Refresh to update all components
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout failed:', error);
|
alert('Logout failed. Please check the API server status.');
|
||||||
alert('Logout failed. Please check the console for details.');
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoggingIn(false);
|
setLoggingIn(false);
|
||||||
}
|
}
|
||||||
@@ -283,142 +298,11 @@ export default function DevToolbar() {
|
|||||||
setLastSent(`${selectedType}-${selectedUrgency}`);
|
setLastSent(`${selectedType}-${selectedUrgency}`);
|
||||||
setTimeout(() => setLastSent(null), 3000);
|
setTimeout(() => setLastSent(null), 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send demo notification:', error);
|
// Silent failure for demo notifications
|
||||||
} finally {
|
|
||||||
setSending(false);
|
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) {
|
if (isMinimized) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -464,286 +348,62 @@ export default function DevToolbar() {
|
|||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-3">
|
||||||
{/* Notification Type Section */}
|
{/* Notification Section - Accordion */}
|
||||||
<div>
|
<Accordion
|
||||||
<div className="flex items-center gap-2 mb-2">
|
title="Notifications"
|
||||||
<MessageSquare className="w-4 h-4 text-gray-400" />
|
icon={<MessageSquare className="w-4 h-4 text-gray-400" />}
|
||||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
defaultOpen={true}
|
||||||
Notification Type
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-1">
|
|
||||||
{notificationOptions.map((option) => {
|
|
||||||
const Icon = option.icon;
|
|
||||||
const isSelected = selectedType === option.type;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.type}
|
|
||||||
onClick={() => setSelectedType(option.type)}
|
|
||||||
className={`
|
|
||||||
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
|
|
||||||
${isSelected
|
|
||||||
? 'bg-primary-blue/20 border-primary-blue/50'
|
|
||||||
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Icon className={`w-4 h-4 ${isSelected ? 'text-primary-blue' : option.color}`} />
|
|
||||||
<span className={`text-[10px] font-medium ${isSelected ? 'text-primary-blue' : 'text-gray-400'}`}>
|
|
||||||
{option.label.split(' ')[0]}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Urgency Section */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<BellRing className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
|
||||||
Urgency Level
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-1">
|
|
||||||
{urgencyOptions.map((option) => {
|
|
||||||
const Icon = option.icon;
|
|
||||||
const isSelected = selectedUrgency === option.urgency;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={option.urgency}
|
|
||||||
onClick={() => setSelectedUrgency(option.urgency)}
|
|
||||||
className={`
|
|
||||||
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
|
|
||||||
${isSelected
|
|
||||||
? option.urgency === 'modal'
|
|
||||||
? 'bg-red-500/20 border-red-500/50'
|
|
||||||
: option.urgency === 'toast'
|
|
||||||
? 'bg-warning-amber/20 border-warning-amber/50'
|
|
||||||
: 'bg-gray-500/20 border-gray-500/50'
|
|
||||||
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Icon className={`w-4 h-4 ${
|
|
||||||
isSelected
|
|
||||||
? option.urgency === 'modal'
|
|
||||||
? 'text-red-400'
|
|
||||||
: option.urgency === 'toast'
|
|
||||||
? 'text-warning-amber'
|
|
||||||
: 'text-gray-400'
|
|
||||||
: 'text-gray-500'
|
|
||||||
}`} />
|
|
||||||
<span className={`text-[10px] font-medium ${
|
|
||||||
isSelected
|
|
||||||
? option.urgency === 'modal'
|
|
||||||
? 'text-red-400'
|
|
||||||
: option.urgency === 'toast'
|
|
||||||
? 'text-warning-amber'
|
|
||||||
: 'text-gray-400'
|
|
||||||
: 'text-gray-500'
|
|
||||||
}`}>
|
|
||||||
{option.label}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-gray-600 mt-1">
|
|
||||||
{urgencyOptions.find(o => o.urgency === selectedUrgency)?.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Send Button */}
|
|
||||||
<button
|
|
||||||
onClick={handleSendNotification}
|
|
||||||
disabled={sending}
|
|
||||||
className={`
|
|
||||||
w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium text-sm transition-all
|
|
||||||
${lastSent
|
|
||||||
? 'bg-performance-green/20 border border-performance-green/30 text-performance-green'
|
|
||||||
: 'bg-primary-blue hover:bg-primary-blue/80 text-white'
|
|
||||||
}
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
{sending ? (
|
<div className="space-y-3">
|
||||||
<>
|
<NotificationTypeSection
|
||||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
selectedType={selectedType}
|
||||||
Sending...
|
onSelectType={setSelectedType}
|
||||||
</>
|
/>
|
||||||
) : lastSent ? (
|
<UrgencySection
|
||||||
<>
|
selectedUrgency={selectedUrgency}
|
||||||
✓ Notification Sent!
|
onSelectUrgency={setSelectedUrgency}
|
||||||
</>
|
/>
|
||||||
) : (
|
<NotificationSendSection
|
||||||
<>
|
selectedType={selectedType}
|
||||||
<Bell className="w-4 h-4" />
|
selectedUrgency={selectedUrgency}
|
||||||
Send Demo Notification
|
sending={sending}
|
||||||
</>
|
lastSent={lastSent}
|
||||||
)}
|
onSend={handleSendNotification}
|
||||||
</button>
|
/>
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
|
||||||
<p className="text-[10px] text-gray-500">
|
|
||||||
<strong className="text-gray-400">Silent:</strong> Notification center only<br/>
|
|
||||||
<strong className="text-gray-400">Toast:</strong> Temporary popup (auto-dismisses)<br/>
|
|
||||||
<strong className="text-gray-400">Modal:</strong> Blocking popup (may require action)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Login Section */}
|
|
||||||
<div className="pt-4 border-t border-charcoal-outline">
|
|
||||||
<div className="flex items-center gap-2 mb-3">
|
|
||||||
<LogIn className="w-4 h-4 text-gray-400" />
|
|
||||||
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
|
||||||
Demo Login
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
<div className="space-y-2">
|
{/* API Status Section - Accordion */}
|
||||||
{/* Driver Login */}
|
<Accordion
|
||||||
<button
|
title="API Status"
|
||||||
onClick={() => handleDemoLogin('driver')}
|
icon={<Activity className="w-4 h-4 text-gray-400" />}
|
||||||
disabled={loggingIn || loginMode === 'driver'}
|
defaultOpen={false}
|
||||||
className={`
|
>
|
||||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
<APIStatusSection
|
||||||
${loginMode === 'driver'
|
apiStatus={apiStatus}
|
||||||
? 'bg-primary-blue/20 border-primary-blue/50 text-primary-blue'
|
apiHealth={apiHealth}
|
||||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
circuitBreakers={circuitBreakers}
|
||||||
}
|
checkingHealth={checkingHealth}
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
onHealthCheck={handleApiHealthCheck}
|
||||||
`}
|
onResetStats={handleResetApiStats}
|
||||||
>
|
onTestError={handleTestApiError}
|
||||||
<User className="w-4 h-4" />
|
/>
|
||||||
{loginMode === 'driver' ? '✓ Driver' : 'Login as Driver'}
|
</Accordion>
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* League Owner Login */}
|
{/* Login Section - Accordion */}
|
||||||
<button
|
<Accordion
|
||||||
onClick={() => handleDemoLogin('league-owner')}
|
title="Demo Login"
|
||||||
disabled={loggingIn || loginMode === 'league-owner'}
|
icon={<LogIn className="w-4 h-4 text-gray-400" />}
|
||||||
className={`
|
defaultOpen={false}
|
||||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
>
|
||||||
${loginMode === 'league-owner'
|
<LoginSection
|
||||||
? 'bg-purple-500/20 border-purple-500/50 text-purple-400'
|
loginMode={loginMode}
|
||||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
loggingIn={loggingIn}
|
||||||
}
|
onDemoLogin={handleDemoLogin}
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
onLogout={handleLogout}
|
||||||
`}
|
/>
|
||||||
>
|
</Accordion>
|
||||||
<span className="text-xs">👑</span>
|
|
||||||
{loginMode === 'league-owner' ? '✓ League Owner' : 'Login as League Owner'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* League Steward Login */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDemoLogin('league-steward')}
|
|
||||||
disabled={loggingIn || loginMode === 'league-steward'}
|
|
||||||
className={`
|
|
||||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
|
||||||
${loginMode === 'league-steward'
|
|
||||||
? 'bg-amber-500/20 border-amber-500/50 text-amber-400'
|
|
||||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
|
||||||
}
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Shield className="w-4 h-4" />
|
|
||||||
{loginMode === 'league-steward' ? '✓ Steward' : 'Login as Steward'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* League Admin Login */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDemoLogin('league-admin')}
|
|
||||||
disabled={loggingIn || loginMode === 'league-admin'}
|
|
||||||
className={`
|
|
||||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
|
||||||
${loginMode === 'league-admin'
|
|
||||||
? 'bg-red-500/20 border-red-500/50 text-red-400'
|
|
||||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
|
||||||
}
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<span className="text-xs">⚙️</span>
|
|
||||||
{loginMode === 'league-admin' ? '✓ Admin' : 'Login as Admin'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Sponsor Login */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDemoLogin('sponsor')}
|
|
||||||
disabled={loggingIn || loginMode === 'sponsor'}
|
|
||||||
className={`
|
|
||||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
|
||||||
${loginMode === 'sponsor'
|
|
||||||
? 'bg-performance-green/20 border-performance-green/50 text-performance-green'
|
|
||||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
|
||||||
}
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Building2 className="w-4 h-4" />
|
|
||||||
{loginMode === 'sponsor' ? '✓ Sponsor' : 'Login as Sponsor'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* System Owner Login */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDemoLogin('system-owner')}
|
|
||||||
disabled={loggingIn || loginMode === 'system-owner'}
|
|
||||||
className={`
|
|
||||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
|
||||||
${loginMode === 'system-owner'
|
|
||||||
? 'bg-indigo-500/20 border-indigo-500/50 text-indigo-400'
|
|
||||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
|
||||||
}
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<span className="text-xs">👑</span>
|
|
||||||
{loginMode === 'system-owner' ? '✓ System Owner' : 'Login as System Owner'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Super Admin Login */}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDemoLogin('super-admin')}
|
|
||||||
disabled={loggingIn || loginMode === 'super-admin'}
|
|
||||||
className={`
|
|
||||||
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
|
||||||
${loginMode === 'super-admin'
|
|
||||||
? 'bg-pink-500/20 border-pink-500/50 text-pink-400'
|
|
||||||
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
|
||||||
}
|
|
||||||
disabled:opacity-50 disabled:cursor-not-allowed
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<span className="text-xs">⚡</span>
|
|
||||||
{loginMode === 'super-admin' ? '✓ Super Admin' : 'Login as Super Admin'}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{loginMode !== 'none' && (
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
disabled={loggingIn}
|
|
||||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-red-500/30 bg-red-500/10 text-red-400 text-sm font-medium hover:bg-red-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-[10px] text-gray-600 mt-2">
|
|
||||||
Test different user roles for demo purposes. Dashboard works for all roles.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
145
apps/website/components/dev/sections/APIStatusSection.tsx
Normal file
145
apps/website/components/dev/sections/APIStatusSection.tsx
Normal file
@@ -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<string, any>;
|
||||||
|
checkingHealth: boolean;
|
||||||
|
onHealthCheck: () => void;
|
||||||
|
onResetStats: () => void;
|
||||||
|
onTestError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function APIStatusSection({
|
||||||
|
apiStatus,
|
||||||
|
apiHealth,
|
||||||
|
circuitBreakers,
|
||||||
|
checkingHealth,
|
||||||
|
onHealthCheck,
|
||||||
|
onResetStats,
|
||||||
|
onTestError
|
||||||
|
}: APIStatusSectionProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<Activity className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||||
|
API Status
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Indicator */}
|
||||||
|
<div className={`flex items-center justify-between p-2 rounded-lg mb-2 ${
|
||||||
|
apiStatus === 'connected' ? 'bg-green-500/10 border border-green-500/30' :
|
||||||
|
apiStatus === 'degraded' ? 'bg-yellow-500/10 border border-yellow-500/30' :
|
||||||
|
'bg-red-500/10 border border-red-500/30'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wifi className={`w-4 h-4 ${
|
||||||
|
apiStatus === 'connected' ? 'text-green-400' :
|
||||||
|
apiStatus === 'degraded' ? 'text-yellow-400' :
|
||||||
|
'text-red-400'
|
||||||
|
}`} />
|
||||||
|
<span className="text-sm font-semibold text-white">{apiStatus.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
{apiHealth.successfulRequests}/{apiHealth.totalRequests} req
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reliability */}
|
||||||
|
<div className="flex items-center justify-between text-xs mb-2">
|
||||||
|
<span className="text-gray-500">Reliability</span>
|
||||||
|
<span className={`font-bold ${
|
||||||
|
apiHealth.totalRequests === 0 ? 'text-gray-500' :
|
||||||
|
(apiHealth.successfulRequests / apiHealth.totalRequests) >= 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) + '%'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Response Time */}
|
||||||
|
<div className="flex items-center justify-between text-xs mb-2">
|
||||||
|
<span className="text-gray-500">Avg Response</span>
|
||||||
|
<span className="text-blue-400 font-mono">
|
||||||
|
{apiHealth.averageResponseTime.toFixed(0)}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consecutive Failures */}
|
||||||
|
{apiHealth.consecutiveFailures > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-xs mb-2 bg-red-500/10 rounded px-2 py-1">
|
||||||
|
<span className="text-red-400">Consecutive Failures</span>
|
||||||
|
<span className="text-red-400 font-bold">{apiHealth.consecutiveFailures}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Circuit Breakers */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="text-[10px] text-gray-500 mb-1">Circuit Breakers:</div>
|
||||||
|
{Object.keys(circuitBreakers).length === 0 ? (
|
||||||
|
<div className="text-[10px] text-gray-500 italic">None active</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-16 overflow-auto">
|
||||||
|
{Object.entries(circuitBreakers).map(([endpoint, status]: [string, any]) => (
|
||||||
|
<div key={endpoint} className="flex items-center justify-between text-[10px]">
|
||||||
|
<span className="text-gray-400 truncate flex-1">{endpoint.split('/').pop() || endpoint}</span>
|
||||||
|
<span className={`px-1 rounded ${
|
||||||
|
status.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
status.state === 'OPEN' ? 'bg-red-500/20 text-red-400' :
|
||||||
|
'bg-yellow-500/20 text-yellow-400'
|
||||||
|
}`}>
|
||||||
|
{status.state}
|
||||||
|
</span>
|
||||||
|
{status.failures > 0 && (
|
||||||
|
<span className="text-red-400 ml-1">({status.failures})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Actions */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
onClick={onHealthCheck}
|
||||||
|
disabled={checkingHealth}
|
||||||
|
className="px-2 py-1.5 bg-primary-blue hover:bg-primary-blue/80 text-white text-xs rounded transition-colors disabled:opacity-50 flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3 h-3 ${checkingHealth ? 'animate-spin' : ''}`} />
|
||||||
|
Health Check
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onResetStats}
|
||||||
|
className="px-2 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 text-xs rounded transition-colors border border-charcoal-outline"
|
||||||
|
>
|
||||||
|
Reset Stats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={onTestError}
|
||||||
|
className="px-2 py-1.5 bg-red-600 hover:bg-red-700 text-white text-xs rounded transition-colors flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<Terminal className="w-3 h-3" />
|
||||||
|
Test Error Handler
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-[10px] text-gray-600 mt-2">
|
||||||
|
Last Check: {apiHealth.lastCheck ? new Date(apiHealth.lastCheck).toLocaleTimeString() : 'Never'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/website/components/dev/sections/LoginSection.tsx
Normal file
79
apps/website/components/dev/sections/LoginSection.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<LogIn className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||||
|
Demo Login
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{loginOptions.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isSelected = loginMode === option.mode;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.mode}
|
||||||
|
onClick={() => onDemoLogin(option.mode)}
|
||||||
|
disabled={loggingIn || isSelected}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-all
|
||||||
|
${isSelected
|
||||||
|
? `bg-${option.color}/20 border-${option.color}/50 text-${option.color}`
|
||||||
|
: 'bg-iron-gray/30 border-charcoal-outline text-gray-300 hover:bg-iron-gray/50'
|
||||||
|
}
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{option.emoji ? (
|
||||||
|
<span className="text-xs">{option.emoji}</span>
|
||||||
|
) : Icon ? (
|
||||||
|
<Icon className="w-4 h-4" />
|
||||||
|
) : null}
|
||||||
|
{isSelected ? `✓ ${option.label}` : `Login as ${option.label}`}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{loginMode !== 'none' && (
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
disabled={loggingIn}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg border border-red-500/30 bg-red-500/10 text-red-400 text-sm font-medium hover:bg-red-500/20 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-gray-600 mt-2">
|
||||||
|
Test different user roles for demo purposes. Dashboard works for all roles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={onSend}
|
||||||
|
disabled={sending}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium text-sm transition-all
|
||||||
|
${lastSent
|
||||||
|
? 'bg-performance-green/20 border border-performance-green/30 text-performance-green'
|
||||||
|
: 'bg-primary-blue hover:bg-primary-blue/80 text-white'
|
||||||
|
}
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{sending ? (
|
||||||
|
<>
|
||||||
|
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : lastSent ? (
|
||||||
|
<>
|
||||||
|
✓ Notification Sent!
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
Send Demo Notification
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline mt-2">
|
||||||
|
<p className="text-[10px] text-gray-500">
|
||||||
|
<strong className="text-gray-400">Silent:</strong> Notification center only<br/>
|
||||||
|
<strong className="text-gray-400">Toast:</strong> Temporary popup (auto-dismisses)<br/>
|
||||||
|
<strong className="text-gray-400">Modal:</strong> Blocking popup (may require action)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<MessageSquare className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||||
|
Notification Type
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{notificationOptions.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isSelected = selectedType === option.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.type}
|
||||||
|
onClick={() => onSelectType(option.type)}
|
||||||
|
className={`
|
||||||
|
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
|
||||||
|
${isSelected
|
||||||
|
? 'bg-primary-blue/20 border-primary-blue/50'
|
||||||
|
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-4 h-4 ${isSelected ? 'text-primary-blue' : option.color}`} />
|
||||||
|
<span className={`text-[10px] font-medium ${isSelected ? 'text-primary-blue' : 'text-gray-400'}`}>
|
||||||
|
{option.label.split(' ')[0]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
apps/website/components/dev/sections/UrgencySection.tsx
Normal file
99
apps/website/components/dev/sections/UrgencySection.tsx
Normal file
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<BellRing className="w-4 h-4 text-gray-400" />
|
||||||
|
<span className="text-xs font-semibold text-gray-400 uppercase tracking-wide">
|
||||||
|
Urgency Level
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-1">
|
||||||
|
{urgencyOptions.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
const isSelected = selectedUrgency === option.urgency;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.urgency}
|
||||||
|
onClick={() => onSelectUrgency(option.urgency)}
|
||||||
|
className={`
|
||||||
|
flex flex-col items-center gap-1 p-2 rounded-lg border transition-all text-center
|
||||||
|
${isSelected
|
||||||
|
? option.urgency === 'modal'
|
||||||
|
? 'bg-red-500/20 border-red-500/50'
|
||||||
|
: option.urgency === 'toast'
|
||||||
|
? 'bg-warning-amber/20 border-warning-amber/50'
|
||||||
|
: 'bg-gray-500/20 border-gray-500/50'
|
||||||
|
: 'bg-iron-gray/30 border-charcoal-outline hover:bg-iron-gray/50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon className={`w-4 h-4 ${
|
||||||
|
isSelected
|
||||||
|
? option.urgency === 'modal'
|
||||||
|
? 'text-red-400'
|
||||||
|
: option.urgency === 'toast'
|
||||||
|
? 'text-warning-amber'
|
||||||
|
: 'text-gray-400'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`} />
|
||||||
|
<span className={`text-[10px] font-medium ${
|
||||||
|
isSelected
|
||||||
|
? option.urgency === 'modal'
|
||||||
|
? 'text-red-400'
|
||||||
|
: option.urgency === 'toast'
|
||||||
|
? 'text-warning-amber'
|
||||||
|
: 'text-gray-400'
|
||||||
|
: 'text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-gray-600 mt-1">
|
||||||
|
{urgencyOptions.find(o => o.urgency === selectedUrgency)?.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/website/components/dev/types.ts
Normal file
20
apps/website/components/dev/types.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
155
apps/website/components/errors/ApiErrorBoundary.tsx
Normal file
155
apps/website/components/errors/ApiErrorBoundary.tsx
Normal file
@@ -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<Props, State> {
|
||||||
|
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 (
|
||||||
|
<DevErrorPanel
|
||||||
|
error={this.state.error}
|
||||||
|
onReset={this.resetError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorDisplay
|
||||||
|
error={this.state.error}
|
||||||
|
onRetry={this.resetError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook-based alternative for functional components
|
||||||
|
*/
|
||||||
|
export function useApiErrorBoundary() {
|
||||||
|
const [error, setError] = React.useState<ApiError | null>(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 }) => (
|
||||||
|
<ApiErrorBoundary onError={handleError}>
|
||||||
|
{children}
|
||||||
|
</ApiErrorBoundary>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
329
apps/website/components/errors/ApiStatusToolbar.tsx
Normal file
329
apps/website/components/errors/ApiStatusToolbar.tsx
Normal file
@@ -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<ConnectionStatus>('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 <CheckCircle2 className="w-4 h-4 text-green-400" />;
|
||||||
|
case 'degraded':
|
||||||
|
return <AlertTriangle className="w-4 h-4 text-yellow-400" />;
|
||||||
|
case 'disconnected':
|
||||||
|
return <WifiOff className="w-4 h-4 text-red-400" />;
|
||||||
|
case 'checking':
|
||||||
|
return <RefreshCw className="w-4 h-4 animate-spin text-blue-400" />;
|
||||||
|
default:
|
||||||
|
return <Wifi className="w-4 h-4 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<button
|
||||||
|
onClick={() => setShow(true)}
|
||||||
|
className={`fixed p-2 bg-iron-gray border border-charcoal-outline rounded-lg shadow-lg hover:bg-charcoal-outline transition-all ${
|
||||||
|
position === 'bottom-right' ? 'bottom-4 right-4' :
|
||||||
|
position === 'top-right' ? 'top-4 right-4' :
|
||||||
|
position === 'bottom-left' ? 'bottom-4 left-4' :
|
||||||
|
'top-4 left-4'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Activity className="w-5 h-5 text-primary-blue" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`fixed z-50 transition-all ${
|
||||||
|
position === 'bottom-right' ? 'bottom-4 right-4' :
|
||||||
|
position === 'top-right' ? 'top-4 right-4' :
|
||||||
|
position === 'bottom-left' ? 'bottom-4 left-4' :
|
||||||
|
'top-4 left-4'
|
||||||
|
}`}>
|
||||||
|
{/* Compact Status Indicator */}
|
||||||
|
{!expanded ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg border shadow-lg backdrop-blur-md transition-all hover:scale-105 ${getStatusColor()}`}
|
||||||
|
>
|
||||||
|
{getStatusIcon()}
|
||||||
|
<span className="text-sm font-semibold text-white">{status.toUpperCase()}</span>
|
||||||
|
<span className={`text-xs ${getReliabilityColor(parseFloat(reliability))}`}>
|
||||||
|
{reliability}%
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
/* Expanded Panel */
|
||||||
|
<div className={`w-80 rounded-lg border shadow-2xl backdrop-blur-md overflow-hidden ${getStatusColor()}`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-iron-gray/80 border-b border-charcoal-outline px-3 py-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4 text-primary-blue" />
|
||||||
|
<span className="text-xs font-bold text-white">API STATUS</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleHealthCheck}
|
||||||
|
className="p-1 hover:bg-charcoal-outline rounded transition-colors"
|
||||||
|
title="Run Health Check"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-3 h-3 text-gray-400 hover:text-white" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="p-1 hover:bg-charcoal-outline rounded transition-colors"
|
||||||
|
title="Reset Stats"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-gray-400 hover:text-white">R</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
className="p-1 hover:bg-charcoal-outline rounded transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-xs text-gray-400 hover:text-white">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="px-3 py-2 space-y-2 bg-deep-graphite/90">
|
||||||
|
{/* Status Row */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400">Status</span>
|
||||||
|
<span className={`text-xs font-bold uppercase ${status === 'connected' ? 'text-green-400' : status === 'degraded' ? 'text-yellow-400' : 'text-red-400'}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reliability */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400">Reliability</span>
|
||||||
|
<span className={`text-xs font-bold ${getReliabilityColor(parseFloat(reliability))}`}>
|
||||||
|
{reliability}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-2 text-center">
|
||||||
|
<div className="bg-iron-gray/50 rounded p-1">
|
||||||
|
<div className="text-[10px] text-gray-400">Total</div>
|
||||||
|
<div className="text-sm font-bold text-white">{health.totalRequests}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-iron-gray/50 rounded p-1">
|
||||||
|
<div className="text-[10px] text-gray-400">Success</div>
|
||||||
|
<div className="text-sm font-bold text-green-400">{health.successfulRequests}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-iron-gray/50 rounded p-1">
|
||||||
|
<div className="text-[10px] text-gray-400">Failed</div>
|
||||||
|
<div className="text-sm font-bold text-red-400">{health.failedRequests}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Performance */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400">Avg Response</span>
|
||||||
|
<span className="text-xs font-mono text-blue-400">
|
||||||
|
{health.averageResponseTime.toFixed(0)}ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Consecutive Failures */}
|
||||||
|
{health.consecutiveFailures > 0 && (
|
||||||
|
<div className="flex items-center justify-between bg-red-500/10 rounded px-2 py-1">
|
||||||
|
<span className="text-xs text-red-400">Consecutive Failures</span>
|
||||||
|
<span className="text-xs font-bold text-red-400">{health.consecutiveFailures}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Circuit Breakers */}
|
||||||
|
<div className="border-t border-charcoal-outline pt-2">
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<Shield className="w-3 h-3 text-gray-400" />
|
||||||
|
<span className="text-[10px] text-gray-400 font-bold">CIRCUIT BREAKERS</span>
|
||||||
|
</div>
|
||||||
|
<CircuitBreakerStatus />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Last Check */}
|
||||||
|
<div className="border-t border-charcoal-outline pt-2 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] text-gray-500">Last Check</span>
|
||||||
|
<span className="text-[10px] text-gray-400 font-mono">
|
||||||
|
{health.lastCheck ? new Date(health.lastCheck).toLocaleTimeString() : 'Never'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={handleHealthCheck}
|
||||||
|
className="px-2 py-1 bg-primary-blue hover:bg-primary-blue/80 text-white text-xs rounded transition-colors"
|
||||||
|
>
|
||||||
|
Check Health
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const monitor = ApiConnectionMonitor.getInstance();
|
||||||
|
const report = monitor.getDebugReport();
|
||||||
|
alert(report);
|
||||||
|
}}
|
||||||
|
className="px-2 py-1 bg-iron-gray hover:bg-charcoal-outline text-gray-300 text-xs rounded transition-colors border border-charcoal-outline"
|
||||||
|
>
|
||||||
|
Debug Report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div className="text-[10px] text-gray-500 italic">No active circuit breakers</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 max-h-20 overflow-auto">
|
||||||
|
{entries.map(([endpoint, breaker]) => (
|
||||||
|
<div key={endpoint} className="flex items-center justify-between text-[10px]">
|
||||||
|
<span className="text-gray-400 truncate flex-1">{endpoint.split('/').pop() || endpoint}</span>
|
||||||
|
<span className={`px-1 rounded ${
|
||||||
|
breaker.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
breaker.state === 'OPEN' ? 'bg-red-500/20 text-red-400' :
|
||||||
|
'bg-yellow-500/20 text-yellow-400'
|
||||||
|
}`}>
|
||||||
|
{breaker.state}
|
||||||
|
</span>
|
||||||
|
{breaker.failures > 0 && (
|
||||||
|
<span className="text-red-400 ml-1">({breaker.failures})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
apps/website/components/errors/DevErrorPanel.tsx
Normal file
347
apps/website/components/errors/DevErrorPanel.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="fixed inset-0 z-50 overflow-auto bg-deep-graphite p-4 font-mono text-sm">
|
||||||
|
<div className="max-w-6xl mx-auto space-y-4">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-iron-gray border border-charcoal-outline rounded-lg p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Terminal className="w-5 h-5 text-primary-blue" />
|
||||||
|
<h2 className="text-lg font-bold text-white">API Error Debug Panel</h2>
|
||||||
|
<span className={`px-2 py-1 rounded border text-xs ${getSeverityColor(error.type)}`}>
|
||||||
|
{error.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="px-3 py-1 bg-iron-gray hover:bg-charcoal-outline border border-charcoal-outline rounded text-gray-300 flex items-center gap-2"
|
||||||
|
title="Copy debug info"
|
||||||
|
>
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
{copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onReset}
|
||||||
|
className="px-3 py-1 bg-primary-blue hover:bg-primary-blue/80 text-white rounded flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Details */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Error Details
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-2 text-xs">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Type:</span>
|
||||||
|
<span className="col-span-2 text-red-400 font-bold">{error.type}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Message:</span>
|
||||||
|
<span className="col-span-2 text-gray-300">{error.message}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Endpoint:</span>
|
||||||
|
<span className="col-span-2 text-blue-400">{error.context.endpoint || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Method:</span>
|
||||||
|
<span className="col-span-2 text-yellow-400">{error.context.method || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Status:</span>
|
||||||
|
<span className="col-span-2">{error.context.statusCode || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Retry Count:</span>
|
||||||
|
<span className="col-span-2">{error.context.retryCount || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Timestamp:</span>
|
||||||
|
<span className="col-span-2 text-gray-500">{error.context.timestamp}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Retryable:</span>
|
||||||
|
<span className={`col-span-2 ${error.isRetryable() ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
|
{error.isRetryable() ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Connectivity:</span>
|
||||||
|
<span className={`col-span-2 ${error.isConnectivityIssue() ? 'text-red-400' : 'text-green-400'}`}>
|
||||||
|
{error.isConnectivityIssue() ? 'Yes' : 'No'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{error.context.troubleshooting && (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Troubleshoot:</span>
|
||||||
|
<span className="col-span-2 text-yellow-400">{error.context.troubleshooting}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connection Status */}
|
||||||
|
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
Connection Health
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-2 text-xs">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Status:</span>
|
||||||
|
<span className={`col-span-2 font-bold ${
|
||||||
|
connectionStatus.status === 'connected' ? 'text-green-400' :
|
||||||
|
connectionStatus.status === 'degraded' ? 'text-yellow-400' :
|
||||||
|
'text-red-400'
|
||||||
|
}`}>
|
||||||
|
{connectionStatus.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Reliability:</span>
|
||||||
|
<span className="col-span-2">{reliability.toFixed(2)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Total Requests:</span>
|
||||||
|
<span className="col-span-2">{connectionStatus.totalRequests}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Successful:</span>
|
||||||
|
<span className="col-span-2 text-green-400">{connectionStatus.successfulRequests}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Failed:</span>
|
||||||
|
<span className="col-span-2 text-red-400">{connectionStatus.failedRequests}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Consecutive Failures:</span>
|
||||||
|
<span className="col-span-2">{connectionStatus.consecutiveFailures}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Avg Response:</span>
|
||||||
|
<span className="col-span-2">{connectionStatus.averageResponseTime.toFixed(2)}ms</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<span className="text-gray-500">Last Check:</span>
|
||||||
|
<span className="col-span-2 text-gray-500">
|
||||||
|
{connectionStatus.lastCheck?.toLocaleTimeString() || 'Never'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Circuit Breakers */}
|
||||||
|
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
|
||||||
|
<span className="text-lg">⚡</span>
|
||||||
|
Circuit Breakers
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{Object.keys(circuitBreakers).length === 0 ? (
|
||||||
|
<div className="text-gray-500 text-center py-4">No circuit breakers active</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 text-xs max-h-48 overflow-auto">
|
||||||
|
{Object.entries(circuitBreakers).map(([endpoint, status]) => (
|
||||||
|
<div key={endpoint} className="flex items-center justify-between p-2 bg-deep-graphite rounded border border-charcoal-outline">
|
||||||
|
<span className="text-blue-400 truncate flex-1">{endpoint}</span>
|
||||||
|
<span className={`px-2 py-1 rounded ${
|
||||||
|
status.state === 'CLOSED' ? 'bg-green-500/20 text-green-400' :
|
||||||
|
status.state === 'OPEN' ? 'bg-red-500/20 text-red-400' :
|
||||||
|
'bg-yellow-500/20 text-yellow-400'
|
||||||
|
}`}>
|
||||||
|
{status.state}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-500 ml-2">{status.failures} failures</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white">
|
||||||
|
Actions
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={triggerHealthCheck}
|
||||||
|
className="w-full px-3 py-2 bg-primary-blue hover:bg-primary-blue/80 text-white rounded flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Run Health Check
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={resetCircuitBreakers}
|
||||||
|
className="w-full px-3 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-lg">🔄</span>
|
||||||
|
Reset Circuit Breakers
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
connectionMonitor.reset();
|
||||||
|
setConnectionStatus(connectionMonitor.getHealth());
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 bg-red-600 hover:bg-red-700 text-white rounded flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-lg">🗑️</span>
|
||||||
|
Reset Connection Stats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Fixes */}
|
||||||
|
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white">
|
||||||
|
Quick Fixes
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-2 text-xs">
|
||||||
|
<div className="text-gray-400">Common solutions:</div>
|
||||||
|
<ul className="list-disc list-inside space-y-1 text-gray-300">
|
||||||
|
<li>Check API server is running</li>
|
||||||
|
<li>Verify CORS configuration</li>
|
||||||
|
<li>Check environment variables</li>
|
||||||
|
<li>Review network connectivity</li>
|
||||||
|
<li>Check API rate limits</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Raw Error */}
|
||||||
|
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white">
|
||||||
|
Raw Error
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<pre className="text-xs text-gray-400 overflow-auto max-h-32 bg-deep-graphite p-2 rounded">
|
||||||
|
{JSON.stringify({
|
||||||
|
type: error.type,
|
||||||
|
message: error.message,
|
||||||
|
context: error.context,
|
||||||
|
}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Console Output */}
|
||||||
|
<div className="bg-iron-gray border border-charcoal-outline rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-charcoal-outline px-4 py-2 font-semibold text-white flex items-center gap-2">
|
||||||
|
<Terminal className="w-4 h-4" />
|
||||||
|
Console Output
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-deep-graphite font-mono text-xs">
|
||||||
|
<div className="text-gray-500 mb-2">{'>'} {error.getDeveloperMessage()}</div>
|
||||||
|
<div className="text-gray-600">Check browser console for full stack trace and additional debug info.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
146
apps/website/components/errors/ErrorDisplay.tsx
Normal file
146
apps/website/components/errors/ErrorDisplay.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="min-h-screen bg-deep-graphite flex items-center justify-center p-4">
|
||||||
|
<div className="max-w-md w-full bg-iron-gray border border-charcoal-outline rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-red-500/10 border-b border-red-500/20 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-red-500/20 rounded-lg">
|
||||||
|
{isConnectivity ? (
|
||||||
|
<Wifi className="w-6 h-6 text-red-400" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white">
|
||||||
|
{isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-400">Error {error.context.statusCode || 'N/A'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<p className="text-gray-300 leading-relaxed">{userMessage}</p>
|
||||||
|
|
||||||
|
{/* Details for debugging (collapsed by default) */}
|
||||||
|
<details className="text-xs text-gray-500 font-mono bg-deep-graphite p-3 rounded border border-charcoal-outline">
|
||||||
|
<summary className="cursor-pointer hover:text-gray-300">Technical Details</summary>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div>Type: {error.type}</div>
|
||||||
|
<div>Endpoint: {error.context.endpoint || 'N/A'}</div>
|
||||||
|
{error.context.statusCode && <div>Status: {error.context.statusCode}</div>}
|
||||||
|
{error.context.retryCount !== undefined && (
|
||||||
|
<div>Retries: {error.context.retryCount}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
|
{error.isRetryable() && (
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
disabled={isRetrying}
|
||||||
|
className="flex items-center justify-center gap-2 px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg font-medium transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRetrying ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
Retrying...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Try Again
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleGoBack}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded-lg font-medium transition-colors border border-charcoal-outline"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
Go Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGoHome}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded-lg font-medium transition-colors border border-charcoal-outline"
|
||||||
|
>
|
||||||
|
Home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="bg-iron-gray/50 border-t border-charcoal-outline p-4 text-xs text-gray-500 text-center">
|
||||||
|
If this persists, please contact support at{' '}
|
||||||
|
<a
|
||||||
|
href="mailto:support@gridpilot.com"
|
||||||
|
className="text-primary-blue hover:underline"
|
||||||
|
>
|
||||||
|
support@gridpilot.com
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full-screen error display with more context
|
||||||
|
*/
|
||||||
|
export function FullScreenError({ error, onRetry }: ErrorDisplayProps) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-deep-graphite flex items-center justify-center p-4">
|
||||||
|
<ErrorDisplay error={error} onRetry={onRetry} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
apps/website/components/errors/NotificationIntegration.tsx
Normal file
143
apps/website/components/errors/NotificationIntegration.tsx
Normal file
@@ -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<string | null>(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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
378
apps/website/docs/API_ERROR_HANDLING.md
Normal file
378
apps/website/docs/API_ERROR_HANDLING.md
Normal file
@@ -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 (
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<NotificationProvider>
|
||||||
|
<NotificationIntegration />
|
||||||
|
<ApiErrorBoundary>
|
||||||
|
{/* Your app content */}
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{/* Development toolbar */}
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<ApiStatusToolbar position="bottom-right" autoHide={true} />
|
||||||
|
)}
|
||||||
|
</ApiErrorBoundary>
|
||||||
|
</NotificationProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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<DriverProfileViewModel | null> {
|
||||||
|
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
|
||||||
|
<ApiErrorBoundary>
|
||||||
|
<MyComponent />
|
||||||
|
</ApiErrorBoundary>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
351
apps/website/lib/api/base/ApiConnectionMonitor.ts
Normal file
351
apps/website/lib/api/base/ApiConnectionMonitor.ts
Normal file
@@ -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<HealthCheckResult> {
|
||||||
|
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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
147
apps/website/lib/api/base/ApiError.ts
Normal file
147
apps/website/lib/api/base/ApiError.ts
Normal file
@@ -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();
|
||||||
|
}
|
||||||
@@ -2,99 +2,353 @@
|
|||||||
* Base API Client for HTTP operations
|
* Base API Client for HTTP operations
|
||||||
*
|
*
|
||||||
* Provides generic HTTP methods with common request/response handling,
|
* 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 { Logger } from '../../interfaces/Logger';
|
||||||
import { ErrorReporter } from '../../interfaces/ErrorReporter';
|
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 {
|
export class BaseApiClient {
|
||||||
protected baseUrl: string;
|
protected baseUrl: string;
|
||||||
private errorReporter: ErrorReporter;
|
private errorReporter: ErrorReporter;
|
||||||
private logger: Logger;
|
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.baseUrl = baseUrl;
|
||||||
this.errorReporter = errorReporter;
|
this.errorReporter = errorReporter;
|
||||||
this.logger = logger;
|
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<ApiError> {
|
||||||
|
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<T>(
|
protected async request<T>(
|
||||||
method: string,
|
method: string,
|
||||||
path: string,
|
path: string,
|
||||||
data?: object | FormData,
|
data?: object | FormData,
|
||||||
options?: { allowUnauthenticated?: boolean },
|
options: BaseApiClientOptions & { allowUnauthenticated?: boolean } = {},
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
this.logger.info(`${method} ${path}`);
|
const finalOptions = { ...this.defaultOptions, ...options };
|
||||||
|
const endpoint = `${this.baseUrl}${path}`;
|
||||||
|
|
||||||
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData;
|
// Check circuit breaker
|
||||||
|
const circuitBreaker = this.circuitBreakerRegistry.getBreaker(path);
|
||||||
const headers: HeadersInit = isFormData
|
if (!circuitBreaker.canExecute()) {
|
||||||
? {}
|
const error = new ApiError(
|
||||||
: {
|
'Circuit breaker is open - service temporarily unavailable',
|
||||||
'Content-Type': 'application/json',
|
'SERVER_ERROR',
|
||||||
};
|
{
|
||||||
|
endpoint: path,
|
||||||
const config: RequestInit = {
|
method,
|
||||||
method,
|
timestamp: new Date().toISOString(),
|
||||||
headers,
|
}
|
||||||
credentials: 'include', // Include cookies for auth
|
);
|
||||||
};
|
this.handleError(error);
|
||||||
|
|
||||||
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);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text();
|
const executeRequest = async (signal: AbortSignal): Promise<T> => {
|
||||||
if (!text) {
|
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData;
|
||||||
return null as T;
|
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<T>(path: string): Promise<T> {
|
/**
|
||||||
return this.request<T>('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<T>(path: string, data: object): Promise<T> {
|
protected get<T>(path: string, options?: BaseApiClientOptions): Promise<T> {
|
||||||
return this.request<T>('POST', path, data);
|
return this.request<T>('GET', path, undefined, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected put<T>(path: string, data: object): Promise<T> {
|
protected post<T>(path: string, data: object, options?: BaseApiClientOptions): Promise<T> {
|
||||||
return this.request<T>('PUT', path, data);
|
return this.request<T>('POST', path, data, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected delete<T>(path: string): Promise<T> {
|
protected put<T>(path: string, data: object, options?: BaseApiClientOptions): Promise<T> {
|
||||||
return this.request<T>('DELETE', path);
|
return this.request<T>('PUT', path, data, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected patch<T>(path: string, data: object): Promise<T> {
|
protected delete<T>(path: string, options?: BaseApiClientOptions): Promise<T> {
|
||||||
return this.request<T>('PATCH', path, data);
|
return this.request<T>('DELETE', path, undefined, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected patch<T>(path: string, data: object, options?: BaseApiClientOptions): Promise<T> {
|
||||||
|
return this.request<T>('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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
321
apps/website/lib/api/base/GracefulDegradation.ts
Normal file
321
apps/website/lib/api/base/GracefulDegradation.ts
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
/**
|
||||||
|
* Graceful degradation utilities for when API is unavailable
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiConnectionMonitor } from './ApiConnectionMonitor';
|
||||||
|
import { ApiError } from './ApiError';
|
||||||
|
|
||||||
|
export interface DegradationOptions<T> {
|
||||||
|
/**
|
||||||
|
* 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<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: Date;
|
||||||
|
expiry: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple in-memory cache for API responses
|
||||||
|
*/
|
||||||
|
class ResponseCache {
|
||||||
|
private cache = new Map<string, CacheEntry<any>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cached data if not expired
|
||||||
|
*/
|
||||||
|
get<T>(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<T>(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<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: DegradationOptions<T> = {}
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>(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<never>((_, 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<T>(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<T> {
|
||||||
|
private monitor: ApiConnectionMonitor;
|
||||||
|
private cacheKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private serviceName: string,
|
||||||
|
private getData: () => Promise<T>,
|
||||||
|
private defaultFallback: T
|
||||||
|
) {
|
||||||
|
this.monitor = ApiConnectionMonitor.getInstance();
|
||||||
|
this.cacheKey = `service:${serviceName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get data with graceful degradation
|
||||||
|
*/
|
||||||
|
async get(options: Partial<DegradationOptions<T>> = {}): Promise<T> {
|
||||||
|
const result = await withGracefulDegradation(this.getData, {
|
||||||
|
fallback: this.defaultFallback,
|
||||||
|
throwOnError: false,
|
||||||
|
useCache: true,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result ?? this.defaultFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force refresh data
|
||||||
|
*/
|
||||||
|
async refresh(): Promise<T> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
275
apps/website/lib/api/base/RetryHandler.ts
Normal file
275
apps/website/lib/api/base/RetryHandler.ts
Normal file
@@ -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<T>(
|
||||||
|
fn: (signal: AbortSignal) => Promise<T>,
|
||||||
|
isRetryable?: (error: ApiError) => boolean
|
||||||
|
): Promise<T> {
|
||||||
|
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<void> {
|
||||||
|
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<string, CircuitBreaker> = 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<string, { state: string; failures: number }> {
|
||||||
|
const status: Record<string, { state: string; failures: number }> = {};
|
||||||
|
this.breakers.forEach((breaker, endpoint) => {
|
||||||
|
status[endpoint] = {
|
||||||
|
state: breaker.getState(),
|
||||||
|
failures: breaker.getFailures(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { DashboardApiClient } from './dashboard/DashboardApiClient';
|
|||||||
import { PenaltiesApiClient } from './penalties/PenaltiesApiClient';
|
import { PenaltiesApiClient } from './penalties/PenaltiesApiClient';
|
||||||
import { ProtestsApiClient } from './protests/ProtestsApiClient';
|
import { ProtestsApiClient } from './protests/ProtestsApiClient';
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main API Client
|
* Main API Client
|
||||||
@@ -34,7 +34,11 @@ export class ApiClient {
|
|||||||
|
|
||||||
constructor(baseUrl: string) {
|
constructor(baseUrl: string) {
|
||||||
const logger = new ConsoleLogger();
|
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.leagues = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
||||||
this.races = new RacesApiClient(baseUrl, errorReporter, logger);
|
this.races = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||||
|
|||||||
335
apps/website/lib/infrastructure/EnhancedErrorReporter.ts
Normal file
335
apps/website/lib/infrastructure/EnhancedErrorReporter.ts
Normal file
@@ -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<string, (error: ApiError) => 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<EnhancedErrorReporterOptions>): 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<EnhancedErrorReporterOptions>) => reporter.updateOptions(opts),
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user