mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 14:47:00 -05:00
Compare commits
2 Commits
783284a47a
...
116eaa2635
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
116eaa2635 | ||
|
|
e773ca58d1 |
@@ -18,6 +18,7 @@ import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary";
|
||||
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
||||
import { handleError } from "@/lib/errorHandler";
|
||||
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
|
||||
import { NetworkStatusBanner } from "@/components/ui/network-status-banner";
|
||||
|
||||
// Core routes (eager-loaded for best UX)
|
||||
import Index from "./pages/Index";
|
||||
@@ -134,6 +135,7 @@ function AppContent(): React.JSX.Element {
|
||||
<TooltipProvider>
|
||||
<NavigationTracker />
|
||||
<LocationAutoDetectProvider />
|
||||
<NetworkStatusBanner />
|
||||
<RetryStatusIndicator />
|
||||
<Toaster />
|
||||
<Sonner />
|
||||
|
||||
83
src/components/ui/network-status-banner.tsx
Normal file
83
src/components/ui/network-status-banner.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { XCircle, Loader2 } from 'lucide-react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { useCircuitBreakerStatus } from '@/hooks/useCircuitBreakerStatus';
|
||||
import { CircuitState } from '@/lib/circuitBreaker';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function NetworkStatusBanner() {
|
||||
const { state, failureCount, isOpen, isHalfOpen } = useCircuitBreakerStatus();
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
|
||||
// Countdown for next retry attempt (when OPEN)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
setCountdown(60); // Reset timeout from circuit breaker config
|
||||
const interval = setInterval(() => {
|
||||
setCountdown(prev => Math.max(0, prev - 1));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOpen]);
|
||||
|
||||
// Don't show if circuit is closed
|
||||
if (state === CircuitState.CLOSED) return null;
|
||||
|
||||
// OPEN state - critical error
|
||||
if (isOpen) {
|
||||
return (
|
||||
<Alert
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
variant="destructive"
|
||||
className={cn(
|
||||
"rounded-none border-x-0 border-t-0",
|
||||
"animate-in slide-in-from-top-4 duration-300",
|
||||
"bg-destructive/90 backdrop-blur-sm"
|
||||
)}
|
||||
>
|
||||
<XCircle className="h-5 w-5 animate-pulse" />
|
||||
<AlertTitle className="font-semibold text-lg">
|
||||
Database Unavailable
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm mt-1">
|
||||
Our systems detected a service outage. <strong>Data and authentication are temporarily unavailable.</strong>
|
||||
{' '}Retrying automatically... (next attempt in {countdown}s)
|
||||
{failureCount > 0 && (
|
||||
<span className="block mt-1 text-xs opacity-80">
|
||||
{failureCount} failed connection attempts detected
|
||||
</span>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// HALF_OPEN state - testing recovery
|
||||
if (isHalfOpen) {
|
||||
return (
|
||||
<Alert
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className={cn(
|
||||
"rounded-none border-x-0 border-t-0",
|
||||
"animate-in slide-in-from-top-4 duration-300",
|
||||
"bg-amber-500/20 dark:bg-amber-500/30 border-amber-500"
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-amber-600 dark:text-amber-400" />
|
||||
<AlertTitle className="font-semibold text-amber-900 dark:text-amber-100">
|
||||
Connection Unstable
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-sm text-amber-800 dark:text-amber-200 mt-1">
|
||||
Testing database connection... Some features may be slow or temporarily unavailable.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
54
src/hooks/useCircuitBreakerStatus.ts
Normal file
54
src/hooks/useCircuitBreakerStatus.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabaseCircuitBreaker, CircuitState } from '@/lib/circuitBreaker';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
export function useCircuitBreakerStatus() {
|
||||
const [state, setState] = useState<CircuitState>(CircuitState.CLOSED);
|
||||
const [failureCount, setFailureCount] = useState(0);
|
||||
const [lastStateChange, setLastStateChange] = useState<Date>(new Date());
|
||||
|
||||
useEffect(() => {
|
||||
// Check immediately on mount
|
||||
const checkState = () => {
|
||||
const currentState = supabaseCircuitBreaker.getState();
|
||||
const currentFailures = supabaseCircuitBreaker.getFailureCount();
|
||||
|
||||
setState(prevState => {
|
||||
if (prevState !== currentState) {
|
||||
setLastStateChange(new Date());
|
||||
|
||||
// Log state changes for monitoring
|
||||
logger.info('Circuit breaker state changed', {
|
||||
from: prevState,
|
||||
to: currentState,
|
||||
failureCount: currentFailures
|
||||
});
|
||||
|
||||
// Emit custom event for other components
|
||||
window.dispatchEvent(new CustomEvent('circuit-breaker-state-change', {
|
||||
detail: { state: currentState, failureCount: currentFailures }
|
||||
}));
|
||||
}
|
||||
return currentState;
|
||||
});
|
||||
|
||||
setFailureCount(currentFailures);
|
||||
};
|
||||
|
||||
checkState();
|
||||
|
||||
// Poll every 5 seconds
|
||||
const interval = setInterval(checkState, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
failureCount,
|
||||
lastStateChange,
|
||||
isOpen: state === CircuitState.OPEN,
|
||||
isHalfOpen: state === CircuitState.HALF_OPEN,
|
||||
isClosed: state === CircuitState.CLOSED
|
||||
};
|
||||
}
|
||||
@@ -98,6 +98,13 @@ export class CircuitBreaker {
|
||||
logger.info('Circuit breaker CLOSED - service recovered', {
|
||||
successCount: this.successCount
|
||||
});
|
||||
|
||||
// Emit event for UI components
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('circuit-breaker-closed', {
|
||||
detail: { successCount: this.successCount }
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +129,16 @@ export class CircuitBreaker {
|
||||
monitoringWindow: this.config.monitoringWindow,
|
||||
resetTimeout: this.config.resetTimeout
|
||||
});
|
||||
|
||||
// Emit event for UI components
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('circuit-breaker-opened', {
|
||||
detail: {
|
||||
failures: this.failures.length,
|
||||
threshold: this.config.failureThreshold
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CompanyDatabaseRecord, TimelineEventDatabaseRecord } from '@/types
|
||||
import { logger } from './logger';
|
||||
import { handleError } from './errorHandler';
|
||||
import type { TimelineEventFormData, EntityType } from '@/types/timeline';
|
||||
import { breadcrumb } from './errorBreadcrumbs';
|
||||
import {
|
||||
validateParkCreateFields,
|
||||
validateRideCreateFields,
|
||||
@@ -202,57 +203,106 @@ async function submitCompositeCreation(
|
||||
dependencies: CompositeSubmissionDependency[],
|
||||
userId: string
|
||||
): Promise<{ submitted: boolean; submissionId: string }> {
|
||||
// Check if user is banned
|
||||
const { data: profile } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
try {
|
||||
breadcrumb.userAction('Start composite submission', 'submitCompositeCreation', {
|
||||
primaryType: primaryEntity.type,
|
||||
dependencyCount: dependencies.length,
|
||||
userId
|
||||
});
|
||||
|
||||
if (profile?.banned) {
|
||||
throw new Error('Account suspended. Contact support for assistance.');
|
||||
}
|
||||
// Check if user is banned
|
||||
breadcrumb.apiCall('profiles', 'SELECT');
|
||||
try {
|
||||
const { data: profile, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('banned')
|
||||
.eq('user_id', userId)
|
||||
.single();
|
||||
|
||||
// Upload all pending images for all entities
|
||||
const uploadedEntities = await Promise.all([
|
||||
...dependencies.map(async (dep) => {
|
||||
if (dep.data.images?.uploaded && dep.data.images.uploaded.length > 0) {
|
||||
const uploadedImages = await uploadPendingImages(dep.data.images.uploaded);
|
||||
return {
|
||||
...dep,
|
||||
data: {
|
||||
...dep.data,
|
||||
images: { ...dep.data.images, uploaded: uploadedImages }
|
||||
}
|
||||
};
|
||||
if (error) {
|
||||
throw new Error(`Failed to check user status: ${error.message}`);
|
||||
}
|
||||
return dep;
|
||||
}),
|
||||
(async () => {
|
||||
if (primaryEntity.data.images?.uploaded && primaryEntity.data.images.uploaded.length > 0) {
|
||||
const uploadedImages = await uploadPendingImages(primaryEntity.data.images.uploaded);
|
||||
return {
|
||||
...primaryEntity,
|
||||
data: {
|
||||
...primaryEntity.data,
|
||||
images: { ...primaryEntity.data.images, uploaded: uploadedImages }
|
||||
}
|
||||
};
|
||||
|
||||
if (profile?.banned) {
|
||||
throw new Error('Account suspended. Contact support for assistance.');
|
||||
}
|
||||
return primaryEntity;
|
||||
})()
|
||||
]);
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(`User check failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
const uploadedDependencies = uploadedEntities.slice(0, -1) as CompositeSubmissionDependency[];
|
||||
const uploadedPrimary = uploadedEntities[uploadedEntities.length - 1] as typeof primaryEntity;
|
||||
// Upload all pending images for all entities
|
||||
breadcrumb.userAction('Upload images', 'submitCompositeCreation', {
|
||||
totalImages: dependencies.reduce((sum, dep) => sum + (dep.data.images?.uploaded?.length || 0), 0) +
|
||||
(primaryEntity.data.images?.uploaded?.length || 0)
|
||||
});
|
||||
|
||||
// Build submission items array with dependencies first
|
||||
const submissionItems: any[] = [];
|
||||
const tempIdMap = new Map<string, number>(); // Maps tempId to order_index
|
||||
const uploadedEntities = await Promise.all([
|
||||
...dependencies.map(async (dep, index) => {
|
||||
try {
|
||||
if (dep.data.images?.uploaded && dep.data.images.uploaded.length > 0) {
|
||||
const uploadedImages = await uploadPendingImages(dep.data.images.uploaded);
|
||||
return {
|
||||
...dep,
|
||||
data: {
|
||||
...dep.data,
|
||||
images: { ...dep.data.images, uploaded: uploadedImages }
|
||||
}
|
||||
};
|
||||
}
|
||||
return dep;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to upload images for ${dep.type} "${dep.data.name || 'unnamed'}": ${errorMsg}`
|
||||
);
|
||||
}
|
||||
}),
|
||||
(async () => {
|
||||
try {
|
||||
if (primaryEntity.data.images?.uploaded && primaryEntity.data.images.uploaded.length > 0) {
|
||||
const uploadedImages = await uploadPendingImages(primaryEntity.data.images.uploaded);
|
||||
return {
|
||||
...primaryEntity,
|
||||
data: {
|
||||
...primaryEntity.data,
|
||||
images: { ...primaryEntity.data.images, uploaded: uploadedImages }
|
||||
}
|
||||
};
|
||||
}
|
||||
return primaryEntity;
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Failed to upload images for ${primaryEntity.type} "${primaryEntity.data.name || 'unnamed'}": ${errorMsg}`
|
||||
);
|
||||
}
|
||||
})()
|
||||
]);
|
||||
|
||||
// Add dependency items (companies, models) first
|
||||
let orderIndex = 0;
|
||||
for (const dep of uploadedDependencies) {
|
||||
const uploadedDependencies = uploadedEntities.slice(0, -1) as CompositeSubmissionDependency[];
|
||||
const uploadedPrimary = uploadedEntities[uploadedEntities.length - 1] as typeof primaryEntity;
|
||||
|
||||
// Validate dependencies structure
|
||||
breadcrumb.stateChange('Validating dependencies', {
|
||||
dependencyCount: uploadedDependencies.length
|
||||
});
|
||||
|
||||
for (const dep of uploadedDependencies) {
|
||||
if (!dep.type) throw new Error('Dependency missing type');
|
||||
if (!dep.tempId) throw new Error('Dependency missing tempId');
|
||||
if (!dep.data) throw new Error('Dependency missing data');
|
||||
if (dep.type === 'company' && !dep.companyType) {
|
||||
throw new Error(`Company dependency "${dep.data.name || 'unnamed'}" missing companyType`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build submission items array with dependencies first
|
||||
const submissionItems: any[] = [];
|
||||
const tempIdMap = new Map<string, number>(); // Maps tempId to order_index
|
||||
|
||||
// Add dependency items (companies, models) first
|
||||
let orderIndex = 0;
|
||||
for (const dep of uploadedDependencies) {
|
||||
const itemType = dep.type === 'company' ? dep.companyType : dep.type;
|
||||
tempIdMap.set(dep.tempId, orderIndex);
|
||||
|
||||
@@ -370,11 +420,12 @@ async function submitCompositeCreation(
|
||||
}
|
||||
}
|
||||
|
||||
// Use RPC to create submission with items atomically with retry logic
|
||||
const { withRetry } = await import('./retryHelpers');
|
||||
const { toast } = await import('@/hooks/use-toast');
|
||||
|
||||
const result = await withRetry(
|
||||
// Use RPC to create submission with items atomically with retry logic
|
||||
breadcrumb.apiCall('create_submission_with_items', 'RPC');
|
||||
const { withRetry } = await import('./retryHelpers');
|
||||
const { toast } = await import('@/hooks/use-toast');
|
||||
|
||||
const result = await withRetry(
|
||||
async () => {
|
||||
const { data, error } = await supabase.rpc('create_submission_with_items', {
|
||||
p_user_id: userId,
|
||||
@@ -446,24 +497,41 @@ async function submitCompositeCreation(
|
||||
return isRetryableError(error);
|
||||
}
|
||||
}
|
||||
).catch((error) => {
|
||||
// Final failure - log and throw
|
||||
handleError(error, {
|
||||
action: 'Composite submission',
|
||||
metadata: {
|
||||
primaryType: uploadedPrimary.type,
|
||||
dependencyCount: dependencies.length,
|
||||
supabaseCode: (error as any).supabaseCode,
|
||||
supabaseDetails: (error as any).supabaseDetails,
|
||||
supabaseHint: (error as any).supabaseHint,
|
||||
retriesExhausted: true
|
||||
},
|
||||
).catch((error) => {
|
||||
// Final failure - log and throw
|
||||
handleError(error, {
|
||||
action: 'Composite submission',
|
||||
metadata: {
|
||||
primaryType: uploadedPrimary.type,
|
||||
dependencyCount: dependencies.length,
|
||||
supabaseCode: (error as any).supabaseCode,
|
||||
supabaseDetails: (error as any).supabaseDetails,
|
||||
supabaseHint: (error as any).supabaseHint,
|
||||
retriesExhausted: true
|
||||
},
|
||||
});
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
return { submitted: true, submissionId: result };
|
||||
breadcrumb.stateChange('Composite submission successful', {
|
||||
submissionId: result
|
||||
});
|
||||
|
||||
return { submitted: true, submissionId: result };
|
||||
} catch (error) {
|
||||
// Ensure error is always an Error instance with context
|
||||
const enrichedError = error instanceof Error
|
||||
? error
|
||||
: new Error(`Composite submission failed: ${String(error)}`);
|
||||
|
||||
// Attach metadata for better debugging
|
||||
(enrichedError as any).originalError = error;
|
||||
(enrichedError as any).primaryType = primaryEntity?.type;
|
||||
(enrichedError as any).dependencyCount = dependencies?.length;
|
||||
|
||||
throw enrichedError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -34,6 +34,7 @@ export const handleError = (
|
||||
let errorMessage: string;
|
||||
let stack: string | undefined;
|
||||
let errorName = 'UnknownError';
|
||||
let supabaseErrorDetails: Record<string, any> | undefined;
|
||||
|
||||
if (error instanceof Error) {
|
||||
errorMessage = error instanceof AppError
|
||||
@@ -41,6 +42,15 @@ export const handleError = (
|
||||
: error.message;
|
||||
stack = error.stack;
|
||||
errorName = error.name;
|
||||
|
||||
// Check if Error instance has attached Supabase metadata
|
||||
if ((error as any).supabaseCode) {
|
||||
supabaseErrorDetails = {
|
||||
code: (error as any).supabaseCode,
|
||||
details: (error as any).supabaseDetails,
|
||||
hint: (error as any).supabaseHint
|
||||
};
|
||||
}
|
||||
} else if (error && typeof error === 'object') {
|
||||
// Handle Supabase errors (plain objects with message/code/details)
|
||||
const supabaseError = error as {
|
||||
@@ -48,13 +58,24 @@ export const handleError = (
|
||||
code?: string;
|
||||
details?: string;
|
||||
hint?: string;
|
||||
stack?: string;
|
||||
};
|
||||
|
||||
errorMessage = supabaseError.message || 'An unexpected error occurred';
|
||||
errorName = 'SupabaseError';
|
||||
|
||||
// Create synthetic stack trace for Supabase errors to aid debugging
|
||||
if (supabaseError.code || supabaseError.details || supabaseError.hint) {
|
||||
// Capture Supabase error details for metadata
|
||||
supabaseErrorDetails = {
|
||||
code: supabaseError.code,
|
||||
details: supabaseError.details,
|
||||
hint: supabaseError.hint
|
||||
};
|
||||
|
||||
// Try to extract stack from object
|
||||
if (supabaseError.stack && typeof supabaseError.stack === 'string') {
|
||||
stack = supabaseError.stack;
|
||||
} else if (supabaseError.code || supabaseError.details || supabaseError.hint) {
|
||||
// Create synthetic stack trace for Supabase errors to aid debugging
|
||||
const stackParts = [
|
||||
`SupabaseError: ${errorMessage}`,
|
||||
supabaseError.code ? ` Code: ${supabaseError.code}` : null,
|
||||
@@ -68,8 +89,12 @@ export const handleError = (
|
||||
}
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
// Generate synthetic stack trace for string errors
|
||||
stack = new Error().stack?.replace(/^Error\n/, `StringError: ${error}\n`);
|
||||
} else {
|
||||
errorMessage = 'An unexpected error occurred';
|
||||
// Generate synthetic stack trace for unknown error types
|
||||
stack = new Error().stack?.replace(/^Error\n/, `UnknownError: ${String(error)}\n`);
|
||||
}
|
||||
|
||||
// Log to console/monitoring with enhanced debugging
|
||||
@@ -84,6 +109,7 @@ export const handleError = (
|
||||
errorConstructor: error?.constructor?.name,
|
||||
hasStack: !!stack,
|
||||
isSyntheticStack: !!(error && typeof error === 'object' && !(error instanceof Error) && stack),
|
||||
supabaseError: supabaseErrorDetails,
|
||||
});
|
||||
|
||||
// Additional debug logging when stack is missing
|
||||
@@ -114,11 +140,13 @@ export const handleError = (
|
||||
p_error_stack: stack,
|
||||
p_user_agent: navigator.userAgent,
|
||||
p_breadcrumbs: JSON.stringify({
|
||||
...breadcrumbs,
|
||||
breadcrumbs,
|
||||
isRetry: context.metadata?.isRetry || false,
|
||||
attempt: context.metadata?.attempt,
|
||||
retriesExhausted: context.metadata?.retriesExhausted || false,
|
||||
circuitBreakerState: context.metadata?.circuitState,
|
||||
supabaseError: supabaseErrorDetails,
|
||||
metadata: context.metadata
|
||||
}),
|
||||
p_timezone: envContext.timezone,
|
||||
p_referrer: document.referrer || undefined,
|
||||
|
||||
Reference in New Issue
Block a user