mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 07:07:04 -05:00
Compare commits
2 Commits
5e0640252c
...
80826a83a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80826a83a8 | ||
|
|
ec5181b9e6 |
@@ -17,6 +17,7 @@ import { AdminErrorBoundary } from "@/components/error/AdminErrorBoundary";
|
|||||||
import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary";
|
import { EntityErrorBoundary } from "@/components/error/EntityErrorBoundary";
|
||||||
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
import { breadcrumb } from "@/lib/errorBreadcrumbs";
|
||||||
import { handleError } from "@/lib/errorHandler";
|
import { handleError } from "@/lib/errorHandler";
|
||||||
|
import { RetryStatusIndicator } from "@/components/ui/retry-status-indicator";
|
||||||
|
|
||||||
// Core routes (eager-loaded for best UX)
|
// Core routes (eager-loaded for best UX)
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
@@ -133,6 +134,7 @@ function AppContent(): React.JSX.Element {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<NavigationTracker />
|
<NavigationTracker />
|
||||||
<LocationAutoDetectProvider />
|
<LocationAutoDetectProvider />
|
||||||
|
<RetryStatusIndicator />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Sonner />
|
<Sonner />
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
|
|||||||
75
src/components/ui/retry-status-indicator.tsx
Normal file
75
src/components/ui/retry-status-indicator.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { Card } from '@/components/ui/card';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
|
||||||
|
interface RetryStatus {
|
||||||
|
attempt: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
delay: number;
|
||||||
|
type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Global retry status indicator
|
||||||
|
* Shows visual feedback when submissions are being retried due to transient failures
|
||||||
|
*/
|
||||||
|
export function RetryStatusIndicator() {
|
||||||
|
const [retryStatus, setRetryStatus] = useState<RetryStatus | null>(null);
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRetry = (event: Event) => {
|
||||||
|
const customEvent = event as CustomEvent<RetryStatus>;
|
||||||
|
const { attempt, maxAttempts, delay, type } = customEvent.detail;
|
||||||
|
setRetryStatus({ attempt, maxAttempts, delay, type });
|
||||||
|
setCountdown(delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('submission-retry', handleRetry);
|
||||||
|
return () => window.removeEventListener('submission-retry', handleRetry);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown > 0) {
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setCountdown((prev) => {
|
||||||
|
if (prev <= 100) {
|
||||||
|
setRetryStatus(null);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return prev - 100;
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
if (!retryStatus) return null;
|
||||||
|
|
||||||
|
const progress = ((retryStatus.delay - countdown) / retryStatus.delay) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="fixed bottom-4 right-4 z-50 p-4 shadow-lg border-amber-500 bg-amber-50 dark:bg-amber-950 w-80 animate-in slide-in-from-bottom-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-100">
|
||||||
|
Retrying submission...
|
||||||
|
</p>
|
||||||
|
<span className="text-xs font-mono text-amber-700 dark:text-amber-300">
|
||||||
|
{retryStatus.attempt}/{retryStatus.maxAttempts}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-amber-700 dark:text-amber-300">
|
||||||
|
Network issue detected. Retrying {retryStatus.type} submission in {Math.ceil(countdown / 1000)}s
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Progress value={progress} className="h-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,8 @@ import { useAuth } from "@/hooks/useAuth";
|
|||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { Camera, CheckCircle, AlertCircle, Info } from "lucide-react";
|
import { Camera, CheckCircle, AlertCircle, Info } from "lucide-react";
|
||||||
import { UppyPhotoSubmissionUploadProps } from "@/types/submissions";
|
import { UppyPhotoSubmissionUploadProps } from "@/types/submissions";
|
||||||
|
import { withRetry } from "@/lib/retryHelpers";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export function UppyPhotoSubmissionUpload({
|
export function UppyPhotoSubmissionUpload({
|
||||||
onSubmissionComplete,
|
onSubmissionComplete,
|
||||||
@@ -94,6 +96,9 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "uploading" as const } : p)));
|
setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "uploading" as const } : p)));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Wrap Cloudflare upload in retry logic
|
||||||
|
const cloudflareUrl = await withRetry(
|
||||||
|
async () => {
|
||||||
// Get upload URL from edge function
|
// Get upload URL from edge function
|
||||||
const { data: uploadData, error: uploadError } = await invokeWithTracking(
|
const { data: uploadData, error: uploadError } = await invokeWithTracking(
|
||||||
"upload-image",
|
"upload-image",
|
||||||
@@ -155,6 +160,29 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
throw new Error("Upload processing timeout");
|
throw new Error("Upload processing timeout");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return cloudflareUrl;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onRetry: (attempt, error, delay) => {
|
||||||
|
logger.warn('Retrying photo upload', {
|
||||||
|
attempt,
|
||||||
|
delay,
|
||||||
|
fileName: photo.file?.name
|
||||||
|
});
|
||||||
|
|
||||||
|
// Emit event for UI indicator
|
||||||
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
|
detail: {
|
||||||
|
attempt,
|
||||||
|
maxAttempts: 3,
|
||||||
|
delay,
|
||||||
|
type: 'photo upload'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Revoke object URL
|
// Revoke object URL
|
||||||
URL.revokeObjectURL(photo.url);
|
URL.revokeObjectURL(photo.url);
|
||||||
|
|
||||||
@@ -185,6 +213,9 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
|
|
||||||
setUploadProgress(null);
|
setUploadProgress(null);
|
||||||
|
|
||||||
|
// Create submission records with retry logic
|
||||||
|
await withRetry(
|
||||||
|
async () => {
|
||||||
// Create content_submission record first
|
// Create content_submission record first
|
||||||
const { data: submissionData, error: submissionError } = await supabase
|
const { data: submissionData, error: submissionError } = await supabase
|
||||||
.from("content_submissions")
|
.from("content_submissions")
|
||||||
@@ -238,6 +269,22 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
if (itemsError) {
|
if (itemsError) {
|
||||||
throw itemsError;
|
throw itemsError;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onRetry: (attempt, error, delay) => {
|
||||||
|
logger.warn('Retrying photo submission creation', { attempt, delay });
|
||||||
|
|
||||||
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
|
detail: {
|
||||||
|
attempt,
|
||||||
|
maxAttempts: 3,
|
||||||
|
delay,
|
||||||
|
type: 'photo submission'
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: "Submission Successful",
|
title: "Submission Successful",
|
||||||
@@ -259,7 +306,12 @@ export function UppyPhotoSubmissionUpload({
|
|||||||
handleError(error, {
|
handleError(error, {
|
||||||
action: 'Submit Photo Submission',
|
action: 'Submit Photo Submission',
|
||||||
userId: user?.id,
|
userId: user?.id,
|
||||||
metadata: { entityType, entityId, photoCount: photos.length }
|
metadata: {
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
photoCount: photos.length,
|
||||||
|
retriesExhausted: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
|
|||||||
214
src/lib/circuitBreaker.ts
Normal file
214
src/lib/circuitBreaker.ts
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
/**
|
||||||
|
* Circuit Breaker Pattern Implementation
|
||||||
|
*
|
||||||
|
* Prevents cascade failures by temporarily blocking requests when service is degraded.
|
||||||
|
* Implements three states: CLOSED (normal), OPEN (blocking), HALF_OPEN (testing recovery)
|
||||||
|
*
|
||||||
|
* @see https://martinfowler.com/bliki/CircuitBreaker.html
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from './logger';
|
||||||
|
import { supabase } from './supabaseClient';
|
||||||
|
|
||||||
|
export interface CircuitBreakerConfig {
|
||||||
|
/** Number of failures before opening circuit (default: 5) */
|
||||||
|
failureThreshold: number;
|
||||||
|
/** Milliseconds to wait before testing recovery (default: 60000 = 1 min) */
|
||||||
|
resetTimeout: number;
|
||||||
|
/** Milliseconds window to track failures (default: 120000 = 2 min) */
|
||||||
|
monitoringWindow: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CircuitState {
|
||||||
|
CLOSED = 'closed', // Normal operation, requests pass through
|
||||||
|
OPEN = 'open', // Failures detected, block all requests
|
||||||
|
HALF_OPEN = 'half_open' // Testing if service recovered
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CircuitBreaker {
|
||||||
|
private state: CircuitState = CircuitState.CLOSED;
|
||||||
|
private failures: number[] = []; // Timestamps of recent failures
|
||||||
|
private lastFailureTime: number | null = null;
|
||||||
|
private successCount: number = 0;
|
||||||
|
private config: Required<CircuitBreakerConfig>;
|
||||||
|
|
||||||
|
constructor(config: Partial<CircuitBreakerConfig> = {}) {
|
||||||
|
this.config = {
|
||||||
|
failureThreshold: config.failureThreshold ?? 5,
|
||||||
|
resetTimeout: config.resetTimeout ?? 60000,
|
||||||
|
monitoringWindow: config.monitoringWindow ?? 120000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update configuration from admin settings
|
||||||
|
*/
|
||||||
|
async updateConfig(newConfig: Partial<CircuitBreakerConfig>): Promise<void> {
|
||||||
|
this.config = {
|
||||||
|
...this.config,
|
||||||
|
...newConfig
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Circuit breaker config updated', { config: this.config });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function through the circuit breaker
|
||||||
|
* @throws Error if circuit is OPEN (service unavailable)
|
||||||
|
*/
|
||||||
|
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
// If circuit is OPEN, check if we should transition to HALF_OPEN
|
||||||
|
if (this.state === CircuitState.OPEN) {
|
||||||
|
const timeSinceFailure = Date.now() - (this.lastFailureTime || 0);
|
||||||
|
|
||||||
|
if (timeSinceFailure > this.config.resetTimeout) {
|
||||||
|
this.state = CircuitState.HALF_OPEN;
|
||||||
|
this.successCount = 0;
|
||||||
|
logger.info('Circuit breaker transitioning to HALF_OPEN', {
|
||||||
|
timeSinceFailure,
|
||||||
|
resetTimeout: this.config.resetTimeout
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const timeRemaining = Math.ceil((this.config.resetTimeout - timeSinceFailure) / 1000);
|
||||||
|
throw new Error(
|
||||||
|
`Service temporarily unavailable. Our systems detected an outage and are protecting server resources. Please try again in ${timeRemaining} seconds.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn();
|
||||||
|
this.onSuccess();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
this.onFailure();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSuccess(): void {
|
||||||
|
if (this.state === CircuitState.HALF_OPEN) {
|
||||||
|
this.successCount++;
|
||||||
|
|
||||||
|
// After 2 successful requests, close the circuit
|
||||||
|
if (this.successCount >= 2) {
|
||||||
|
this.state = CircuitState.CLOSED;
|
||||||
|
this.failures = [];
|
||||||
|
this.lastFailureTime = null;
|
||||||
|
logger.info('Circuit breaker CLOSED - service recovered', {
|
||||||
|
successCount: this.successCount
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onFailure(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
this.lastFailureTime = now;
|
||||||
|
|
||||||
|
// Remove failures outside monitoring window
|
||||||
|
this.failures = this.failures.filter(
|
||||||
|
(timestamp) => now - timestamp < this.config.monitoringWindow
|
||||||
|
);
|
||||||
|
|
||||||
|
this.failures.push(now);
|
||||||
|
|
||||||
|
// Open circuit if threshold exceeded
|
||||||
|
if (this.failures.length >= this.config.failureThreshold) {
|
||||||
|
this.state = CircuitState.OPEN;
|
||||||
|
logger.error('Circuit breaker OPENED', {
|
||||||
|
failures: this.failures.length,
|
||||||
|
threshold: this.config.failureThreshold,
|
||||||
|
monitoringWindow: this.config.monitoringWindow,
|
||||||
|
resetTimeout: this.config.resetTimeout
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current circuit state
|
||||||
|
*/
|
||||||
|
getState(): CircuitState {
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get number of recent failures
|
||||||
|
*/
|
||||||
|
getFailureCount(): number {
|
||||||
|
const now = Date.now();
|
||||||
|
return this.failures.filter(
|
||||||
|
(timestamp) => now - timestamp < this.config.monitoringWindow
|
||||||
|
).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force reset the circuit (testing/debugging only)
|
||||||
|
*/
|
||||||
|
reset(): void {
|
||||||
|
this.state = CircuitState.CLOSED;
|
||||||
|
this.failures = [];
|
||||||
|
this.lastFailureTime = null;
|
||||||
|
this.successCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load circuit breaker configuration from admin settings
|
||||||
|
* Falls back to defaults if settings unavailable
|
||||||
|
*/
|
||||||
|
export async function loadCircuitBreakerConfig(): Promise<CircuitBreakerConfig> {
|
||||||
|
try {
|
||||||
|
const { data: settings } = await supabase
|
||||||
|
.from('admin_settings')
|
||||||
|
.select('setting_key, setting_value')
|
||||||
|
.in('setting_key', [
|
||||||
|
'circuit_breaker.failure_threshold',
|
||||||
|
'circuit_breaker.reset_timeout',
|
||||||
|
'circuit_breaker.monitoring_window'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!settings || settings.length === 0) {
|
||||||
|
return {
|
||||||
|
failureThreshold: 5,
|
||||||
|
resetTimeout: 60000,
|
||||||
|
monitoringWindow: 120000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: any = {};
|
||||||
|
settings.forEach(s => {
|
||||||
|
const key = s.setting_key.replace('circuit_breaker.', '');
|
||||||
|
const camelKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
config[camelKey] = parseInt(String(s.setting_value));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
failureThreshold: config.failureThreshold ?? 5,
|
||||||
|
resetTimeout: config.resetTimeout ?? 60000,
|
||||||
|
monitoringWindow: config.monitoringWindow ?? 120000
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load circuit breaker config from admin settings', { error });
|
||||||
|
return {
|
||||||
|
failureThreshold: 5,
|
||||||
|
resetTimeout: 60000,
|
||||||
|
monitoringWindow: 120000
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton circuit breaker for Supabase operations
|
||||||
|
* Shared across all submission flows to detect service-wide outages
|
||||||
|
*/
|
||||||
|
export const supabaseCircuitBreaker = new CircuitBreaker({
|
||||||
|
failureThreshold: 5,
|
||||||
|
resetTimeout: 60000,
|
||||||
|
monitoringWindow: 120000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load config from admin settings on startup
|
||||||
|
loadCircuitBreakerConfig().then(config => {
|
||||||
|
supabaseCircuitBreaker.updateConfig(config);
|
||||||
|
});
|
||||||
@@ -3,6 +3,8 @@ import type { Json } from '@/integrations/supabase/types';
|
|||||||
import { uploadPendingImages } from './imageUploadHelper';
|
import { uploadPendingImages } from './imageUploadHelper';
|
||||||
import { CompanyFormData, TempCompanyData } from '@/types/company';
|
import { CompanyFormData, TempCompanyData } from '@/types/company';
|
||||||
import { handleError } from './errorHandler';
|
import { handleError } from './errorHandler';
|
||||||
|
import { withRetry } from './retryHelpers';
|
||||||
|
import { logger } from './logger';
|
||||||
|
|
||||||
export type { CompanyFormData, TempCompanyData };
|
export type { CompanyFormData, TempCompanyData };
|
||||||
|
|
||||||
@@ -11,12 +13,18 @@ export async function submitCompanyCreation(
|
|||||||
companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner',
|
companyType: 'manufacturer' | 'designer' | 'operator' | 'property_owner',
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
// Check if user is banned
|
// Check if user is banned (with quick retry for read operation)
|
||||||
|
const profile = await withRetry(
|
||||||
|
async () => {
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('banned')
|
.select('banned')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
return profile;
|
||||||
|
},
|
||||||
|
{ maxAttempts: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
if (profile?.banned) {
|
if (profile?.banned) {
|
||||||
throw new Error('Account suspended. Contact support for assistance.');
|
throw new Error('Account suspended. Contact support for assistance.');
|
||||||
@@ -40,6 +48,9 @@ export async function submitCompanyCreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create submission with retry logic
|
||||||
|
const result = await withRetry(
|
||||||
|
async () => {
|
||||||
// Create the main submission record
|
// Create the main submission record
|
||||||
const { data: submissionData, error: submissionError } = await supabase
|
const { data: submissionData, error: submissionError } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
@@ -80,6 +91,40 @@ export async function submitCompanyCreation(
|
|||||||
if (itemError) throw itemError;
|
if (itemError) throw itemError;
|
||||||
|
|
||||||
return { submitted: true, submissionId: submissionData.id };
|
return { submitted: true, submissionId: submissionData.id };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
onRetry: (attempt, error, delay) => {
|
||||||
|
logger.warn('Retrying company submission', { attempt, delay, companyType });
|
||||||
|
|
||||||
|
// Emit event for UI indicator
|
||||||
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
|
detail: { attempt, maxAttempts: 3, delay, type: companyType }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
shouldRetry: (error) => {
|
||||||
|
// Don't retry validation/business logic errors
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
if (message.includes('required')) return false;
|
||||||
|
if (message.includes('banned')) return false;
|
||||||
|
if (message.includes('slug')) return false;
|
||||||
|
if (message.includes('permission')) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isRetryableError } = require('./retryHelpers');
|
||||||
|
return isRetryableError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch((error) => {
|
||||||
|
handleError(error, {
|
||||||
|
action: `${companyType} submission`,
|
||||||
|
metadata: { retriesExhausted: true },
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function submitCompanyUpdate(
|
export async function submitCompanyUpdate(
|
||||||
@@ -87,12 +132,18 @@ export async function submitCompanyUpdate(
|
|||||||
data: CompanyFormData,
|
data: CompanyFormData,
|
||||||
userId: string
|
userId: string
|
||||||
) {
|
) {
|
||||||
// Check if user is banned
|
// Check if user is banned (with quick retry for read operation)
|
||||||
|
const profile = await withRetry(
|
||||||
|
async () => {
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('banned')
|
.select('banned')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
return profile;
|
||||||
|
},
|
||||||
|
{ maxAttempts: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
if (profile?.banned) {
|
if (profile?.banned) {
|
||||||
throw new Error('Account suspended. Contact support for assistance.');
|
throw new Error('Account suspended. Contact support for assistance.');
|
||||||
@@ -126,6 +177,9 @@ export async function submitCompanyUpdate(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create submission with retry logic
|
||||||
|
const result = await withRetry(
|
||||||
|
async () => {
|
||||||
// Create the main submission record
|
// Create the main submission record
|
||||||
const { data: submissionData, error: submissionError } = await supabase
|
const { data: submissionData, error: submissionError } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
@@ -168,4 +222,38 @@ export async function submitCompanyUpdate(
|
|||||||
if (itemError) throw itemError;
|
if (itemError) throw itemError;
|
||||||
|
|
||||||
return { submitted: true, submissionId: submissionData.id };
|
return { submitted: true, submissionId: submissionData.id };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
onRetry: (attempt, error, delay) => {
|
||||||
|
logger.warn('Retrying company update', { attempt, delay, companyId });
|
||||||
|
|
||||||
|
// Emit event for UI indicator
|
||||||
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
|
detail: { attempt, maxAttempts: 3, delay, type: `${existingCompany.company_type} update` }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
shouldRetry: (error) => {
|
||||||
|
// Don't retry validation/business logic errors
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
if (message.includes('required')) return false;
|
||||||
|
if (message.includes('banned')) return false;
|
||||||
|
if (message.includes('slug')) return false;
|
||||||
|
if (message.includes('permission')) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isRetryableError } = require('./retryHelpers');
|
||||||
|
return isRetryableError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch((error) => {
|
||||||
|
handleError(error, {
|
||||||
|
action: `${existingCompany.company_type} update`,
|
||||||
|
metadata: { retriesExhausted: true, companyId },
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -530,19 +530,27 @@ export async function submitParkCreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard single-entity creation
|
// Standard single-entity creation with retry logic
|
||||||
// Check if user is banned
|
const { withRetry } = await import('./retryHelpers');
|
||||||
|
|
||||||
|
// Check if user is banned (with quick retry for read operation)
|
||||||
|
const profile = await withRetry(
|
||||||
|
async () => {
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('banned')
|
.select('banned')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
return profile;
|
||||||
|
},
|
||||||
|
{ maxAttempts: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
if (profile?.banned) {
|
if (profile?.banned) {
|
||||||
throw new Error('Account suspended. Contact support for assistance.');
|
throw new Error('Account suspended. Contact support for assistance.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload any pending local images first
|
// Upload any pending local images first (no retry - handled internally)
|
||||||
let processedImages = data.images;
|
let processedImages = data.images;
|
||||||
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -559,6 +567,9 @@ export async function submitParkCreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create submission with retry logic
|
||||||
|
const result = await withRetry(
|
||||||
|
async () => {
|
||||||
// Create the main submission record
|
// Create the main submission record
|
||||||
const { data: submissionData, error: submissionError } = await supabase
|
const { data: submissionData, error: submissionError } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
@@ -626,6 +637,40 @@ export async function submitParkCreation(
|
|||||||
if (itemError) throw itemError;
|
if (itemError) throw itemError;
|
||||||
|
|
||||||
return { submitted: true, submissionId: submissionData.id };
|
return { submitted: true, submissionId: submissionData.id };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
onRetry: (attempt, error, delay) => {
|
||||||
|
logger.warn('Retrying park submission', { attempt, delay });
|
||||||
|
|
||||||
|
// Emit event for UI indicator
|
||||||
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
|
detail: { attempt, maxAttempts: 3, delay, type: 'park' }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
shouldRetry: (error) => {
|
||||||
|
// Don't retry validation/business logic errors
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
if (message.includes('required')) return false;
|
||||||
|
if (message.includes('banned')) return false;
|
||||||
|
if (message.includes('slug')) return false;
|
||||||
|
if (message.includes('permission')) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isRetryableError } = require('./retryHelpers');
|
||||||
|
return isRetryableError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch((error) => {
|
||||||
|
handleError(error, {
|
||||||
|
action: 'Park submission',
|
||||||
|
metadata: { retriesExhausted: true },
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -847,19 +892,27 @@ export async function submitRideCreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard single-entity creation
|
// Standard single-entity creation with retry logic
|
||||||
// Check if user is banned
|
const { withRetry } = await import('./retryHelpers');
|
||||||
|
|
||||||
|
// Check if user is banned (with quick retry for read operation)
|
||||||
|
const profile = await withRetry(
|
||||||
|
async () => {
|
||||||
const { data: profile } = await supabase
|
const { data: profile } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('banned')
|
.select('banned')
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.single();
|
.single();
|
||||||
|
return profile;
|
||||||
|
},
|
||||||
|
{ maxAttempts: 2 }
|
||||||
|
);
|
||||||
|
|
||||||
if (profile?.banned) {
|
if (profile?.banned) {
|
||||||
throw new Error('Account suspended. Contact support for assistance.');
|
throw new Error('Account suspended. Contact support for assistance.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload any pending local images first
|
// Upload any pending local images first (no retry - handled internally)
|
||||||
let processedImages = data.images;
|
let processedImages = data.images;
|
||||||
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
if (data.images?.uploaded && data.images.uploaded.length > 0) {
|
||||||
try {
|
try {
|
||||||
@@ -876,6 +929,9 @@ export async function submitRideCreation(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create submission with retry logic
|
||||||
|
const result = await withRetry(
|
||||||
|
async () => {
|
||||||
// Create the main submission record
|
// Create the main submission record
|
||||||
const { data: submissionData, error: submissionError } = await supabase
|
const { data: submissionData, error: submissionError } = await supabase
|
||||||
.from('content_submissions')
|
.from('content_submissions')
|
||||||
@@ -892,7 +948,7 @@ export async function submitRideCreation(
|
|||||||
|
|
||||||
if (submissionError) throw submissionError;
|
if (submissionError) throw submissionError;
|
||||||
|
|
||||||
// ✅ FIXED: Get image URLs/IDs from processed images using assignments
|
// Get image URLs/IDs from processed images using assignments
|
||||||
const uploadedImages = processedImages?.uploaded || [];
|
const uploadedImages = processedImages?.uploaded || [];
|
||||||
const bannerIndex = processedImages?.banner_assignment;
|
const bannerIndex = processedImages?.banner_assignment;
|
||||||
const cardIndex = processedImages?.card_assignment;
|
const cardIndex = processedImages?.card_assignment;
|
||||||
@@ -956,6 +1012,40 @@ export async function submitRideCreation(
|
|||||||
if (itemError) throw itemError;
|
if (itemError) throw itemError;
|
||||||
|
|
||||||
return { submitted: true, submissionId: submissionData.id };
|
return { submitted: true, submissionId: submissionData.id };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
maxAttempts: 3,
|
||||||
|
onRetry: (attempt, error, delay) => {
|
||||||
|
logger.warn('Retrying ride submission', { attempt, delay });
|
||||||
|
|
||||||
|
// Emit event for UI indicator
|
||||||
|
window.dispatchEvent(new CustomEvent('submission-retry', {
|
||||||
|
detail: { attempt, maxAttempts: 3, delay, type: 'ride' }
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
shouldRetry: (error) => {
|
||||||
|
// Don't retry validation/business logic errors
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.toLowerCase();
|
||||||
|
if (message.includes('required')) return false;
|
||||||
|
if (message.includes('banned')) return false;
|
||||||
|
if (message.includes('slug')) return false;
|
||||||
|
if (message.includes('permission')) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { isRetryableError } = require('./retryHelpers');
|
||||||
|
return isRetryableError(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
).catch((error) => {
|
||||||
|
handleError(error, {
|
||||||
|
action: 'Ride submission',
|
||||||
|
metadata: { retriesExhausted: true },
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export const handleError = (
|
|||||||
isRetry: context.metadata?.isRetry || false,
|
isRetry: context.metadata?.isRetry || false,
|
||||||
attempt: context.metadata?.attempt,
|
attempt: context.metadata?.attempt,
|
||||||
retriesExhausted: context.metadata?.retriesExhausted || false,
|
retriesExhausted: context.metadata?.retriesExhausted || false,
|
||||||
|
circuitBreakerState: context.metadata?.circuitState,
|
||||||
}),
|
}),
|
||||||
p_timezone: envContext.timezone,
|
p_timezone: envContext.timezone,
|
||||||
p_referrer: document.referrer || undefined,
|
p_referrer: document.referrer || undefined,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { logger } from './logger';
|
import { logger } from './logger';
|
||||||
|
import { supabaseCircuitBreaker } from './circuitBreaker';
|
||||||
|
import { supabase } from './supabaseClient';
|
||||||
|
|
||||||
export interface RetryOptions {
|
export interface RetryOptions {
|
||||||
/** Maximum number of attempts (default: 3) */
|
/** Maximum number of attempts (default: 3) */
|
||||||
@@ -96,6 +98,81 @@ function calculateBackoffDelay(attempt: number, options: Required<RetryOptions>)
|
|||||||
return cappedDelay;
|
return cappedDelay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load retry configuration from admin settings
|
||||||
|
*/
|
||||||
|
export async function loadRetryConfig(): Promise<Required<RetryOptions>> {
|
||||||
|
try {
|
||||||
|
const { data: settings } = await supabase
|
||||||
|
.from('admin_settings')
|
||||||
|
.select('setting_key, setting_value')
|
||||||
|
.in('setting_key', [
|
||||||
|
'retry.max_attempts',
|
||||||
|
'retry.base_delay',
|
||||||
|
'retry.max_delay',
|
||||||
|
'retry.backoff_multiplier'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!settings || settings.length === 0) {
|
||||||
|
return getDefaultRetryConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: any = {};
|
||||||
|
settings.forEach(s => {
|
||||||
|
const key = s.setting_key.replace('retry.', '');
|
||||||
|
const camelKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase());
|
||||||
|
|
||||||
|
if (key === 'backoff_multiplier') {
|
||||||
|
config[camelKey] = parseFloat(String(s.setting_value));
|
||||||
|
} else {
|
||||||
|
config[camelKey] = parseInt(String(s.setting_value));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
maxAttempts: config.maxAttempts ?? 3,
|
||||||
|
baseDelay: config.baseDelay ?? 1000,
|
||||||
|
maxDelay: config.maxDelay ?? 10000,
|
||||||
|
backoffMultiplier: config.backoffMultiplier ?? 2,
|
||||||
|
jitter: true,
|
||||||
|
onRetry: () => {},
|
||||||
|
shouldRetry: isRetryableError
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load retry config', { error });
|
||||||
|
return getDefaultRetryConfig();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultRetryConfig(): Required<RetryOptions> {
|
||||||
|
return {
|
||||||
|
maxAttempts: 3,
|
||||||
|
baseDelay: 1000,
|
||||||
|
maxDelay: 10000,
|
||||||
|
backoffMultiplier: 2,
|
||||||
|
jitter: true,
|
||||||
|
onRetry: () => {},
|
||||||
|
shouldRetry: isRetryableError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache admin config for 5 minutes
|
||||||
|
let cachedRetryConfig: Required<RetryOptions> | null = null;
|
||||||
|
let configCacheTime: number = 0;
|
||||||
|
const CONFIG_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
|
|
||||||
|
async function getCachedRetryConfig(): Promise<Required<RetryOptions>> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (cachedRetryConfig && (now - configCacheTime < CONFIG_CACHE_TTL)) {
|
||||||
|
return cachedRetryConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedRetryConfig = await loadRetryConfig();
|
||||||
|
configCacheTime = now;
|
||||||
|
return cachedRetryConfig;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes a function with retry logic and exponential backoff
|
* Executes a function with retry logic and exponential backoff
|
||||||
*
|
*
|
||||||
@@ -121,22 +198,28 @@ export async function withRetry<T>(
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
options?: RetryOptions
|
options?: RetryOptions
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
// Load config from admin settings
|
||||||
|
const adminConfig = await getCachedRetryConfig();
|
||||||
|
|
||||||
|
// Merge: options override admin settings
|
||||||
const config: Required<RetryOptions> = {
|
const config: Required<RetryOptions> = {
|
||||||
maxAttempts: options?.maxAttempts ?? 3,
|
maxAttempts: options?.maxAttempts ?? adminConfig.maxAttempts,
|
||||||
baseDelay: options?.baseDelay ?? 1000,
|
baseDelay: options?.baseDelay ?? adminConfig.baseDelay,
|
||||||
maxDelay: options?.maxDelay ?? 10000,
|
maxDelay: options?.maxDelay ?? adminConfig.maxDelay,
|
||||||
backoffMultiplier: options?.backoffMultiplier ?? 2,
|
backoffMultiplier: options?.backoffMultiplier ?? adminConfig.backoffMultiplier,
|
||||||
jitter: options?.jitter ?? true,
|
jitter: options?.jitter ?? adminConfig.jitter,
|
||||||
onRetry: options?.onRetry ?? (() => {}),
|
onRetry: options?.onRetry ?? adminConfig.onRetry,
|
||||||
shouldRetry: options?.shouldRetry ?? isRetryableError,
|
shouldRetry: options?.shouldRetry ?? adminConfig.shouldRetry,
|
||||||
};
|
};
|
||||||
|
|
||||||
let lastError: unknown;
|
let lastError: unknown;
|
||||||
|
|
||||||
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
|
||||||
try {
|
try {
|
||||||
// Execute the function
|
// Execute the function through circuit breaker
|
||||||
const result = await fn();
|
const result = await supabaseCircuitBreaker.execute(async () => {
|
||||||
|
return await fn();
|
||||||
|
});
|
||||||
|
|
||||||
// Log successful retry if not first attempt
|
// Log successful retry if not first attempt
|
||||||
if (attempt > 0) {
|
if (attempt > 0) {
|
||||||
@@ -150,6 +233,15 @@ export async function withRetry<T>(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
|
|
||||||
|
// Check if circuit breaker blocked the request
|
||||||
|
if (error instanceof Error && error.message.includes('Circuit breaker is OPEN')) {
|
||||||
|
logger.error('Circuit breaker prevented retry', {
|
||||||
|
attempt: attempt + 1,
|
||||||
|
circuitState: supabaseCircuitBreaker.getState()
|
||||||
|
});
|
||||||
|
throw error; // Don't retry if circuit is open
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we should retry
|
// Check if we should retry
|
||||||
const isLastAttempt = attempt === config.maxAttempts - 1;
|
const isLastAttempt = attempt === config.maxAttempts - 1;
|
||||||
const shouldRetry = config.shouldRetry(error);
|
const shouldRetry = config.shouldRetry(error);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useAdminSettings } from '@/hooks/useAdminSettings';
|
|||||||
import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility';
|
import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility';
|
||||||
import { TestDataGenerator } from '@/components/admin/TestDataGenerator';
|
import { TestDataGenerator } from '@/components/admin/TestDataGenerator';
|
||||||
import { IntegrationTestRunner } from '@/components/admin/IntegrationTestRunner';
|
import { IntegrationTestRunner } from '@/components/admin/IntegrationTestRunner';
|
||||||
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube } from 'lucide-react';
|
import { Loader2, Save, Clock, Users, Bell, Shield, Settings, Trash2, Plug, AlertTriangle, Lock, TestTube, RefreshCw, Info, AlertCircle } from 'lucide-react';
|
||||||
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
import { useDocumentTitle } from '@/hooks/useDocumentTitle';
|
||||||
|
|
||||||
export default function AdminSettings() {
|
export default function AdminSettings() {
|
||||||
@@ -372,6 +372,249 @@ export default function AdminSettings() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry settings
|
||||||
|
if (setting.setting_key === 'retry.max_attempts') {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4 text-blue-500" />
|
||||||
|
<Label className="text-base font-medium">Maximum Retry Attempts</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
How many times to retry failed operations (entity/photo submissions)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select value={localValue?.toString()} onValueChange={(value) => {
|
||||||
|
const numValue = parseInt(value);
|
||||||
|
setLocalValue(numValue);
|
||||||
|
updateSetting(setting.setting_key, numValue);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1 attempt</SelectItem>
|
||||||
|
<SelectItem value="2">2 attempts</SelectItem>
|
||||||
|
<SelectItem value="3">3 attempts</SelectItem>
|
||||||
|
<SelectItem value="5">5 attempts</SelectItem>
|
||||||
|
<SelectItem value="10">10 attempts</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Badge variant="outline">Current: {localValue}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.setting_key === 'retry.base_delay') {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-green-500" />
|
||||||
|
<Label className="text-base font-medium">Initial Retry Delay</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Milliseconds to wait before first retry attempt
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select value={localValue?.toString()} onValueChange={(value) => {
|
||||||
|
const numValue = parseInt(value);
|
||||||
|
setLocalValue(numValue);
|
||||||
|
updateSetting(setting.setting_key, numValue);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="100">100ms (very fast)</SelectItem>
|
||||||
|
<SelectItem value="500">500ms</SelectItem>
|
||||||
|
<SelectItem value="1000">1 second</SelectItem>
|
||||||
|
<SelectItem value="2000">2 seconds</SelectItem>
|
||||||
|
<SelectItem value="5000">5 seconds</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Badge variant="outline">Current: {localValue}ms</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.setting_key === 'retry.max_delay') {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-orange-500" />
|
||||||
|
<Label className="text-base font-medium">Maximum Retry Delay</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Maximum delay between retry attempts (exponential backoff cap)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select value={localValue?.toString()} onValueChange={(value) => {
|
||||||
|
const numValue = parseInt(value);
|
||||||
|
setLocalValue(numValue);
|
||||||
|
updateSetting(setting.setting_key, numValue);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="5000">5 seconds</SelectItem>
|
||||||
|
<SelectItem value="10000">10 seconds</SelectItem>
|
||||||
|
<SelectItem value="20000">20 seconds</SelectItem>
|
||||||
|
<SelectItem value="30000">30 seconds</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Badge variant="outline">Current: {localValue}ms</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.setting_key === 'retry.backoff_multiplier') {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4 text-purple-500" />
|
||||||
|
<Label className="text-base font-medium">Backoff Multiplier</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Growth rate for exponential backoff between retries
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select value={localValue?.toString()} onValueChange={(value) => {
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
setLocalValue(numValue);
|
||||||
|
updateSetting(setting.setting_key, numValue);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1.5">1.5x</SelectItem>
|
||||||
|
<SelectItem value="2">2x</SelectItem>
|
||||||
|
<SelectItem value="2.5">2.5x</SelectItem>
|
||||||
|
<SelectItem value="3">3x</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Badge variant="outline">Current: {localValue}x</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.setting_key === 'circuit_breaker.failure_threshold') {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||||
|
<Label className="text-base font-medium">Circuit Breaker Threshold</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Number of consecutive failures before blocking all requests temporarily
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select value={localValue?.toString()} onValueChange={(value) => {
|
||||||
|
const numValue = parseInt(value);
|
||||||
|
setLocalValue(numValue);
|
||||||
|
updateSetting(setting.setting_key, numValue);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="3">3 failures</SelectItem>
|
||||||
|
<SelectItem value="5">5 failures</SelectItem>
|
||||||
|
<SelectItem value="10">10 failures</SelectItem>
|
||||||
|
<SelectItem value="15">15 failures</SelectItem>
|
||||||
|
<SelectItem value="20">20 failures</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Badge variant="outline">Current: {localValue}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.setting_key === 'circuit_breaker.reset_timeout') {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-red-500" />
|
||||||
|
<Label className="text-base font-medium">Circuit Reset Timeout</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
How long to wait before testing if service has recovered
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select value={localValue?.toString()} onValueChange={(value) => {
|
||||||
|
const numValue = parseInt(value);
|
||||||
|
setLocalValue(numValue);
|
||||||
|
updateSetting(setting.setting_key, numValue);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="30000">30 seconds</SelectItem>
|
||||||
|
<SelectItem value="60000">1 minute</SelectItem>
|
||||||
|
<SelectItem value="120000">2 minutes</SelectItem>
|
||||||
|
<SelectItem value="300000">5 minutes</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Badge variant="outline">Current: {Math.floor(Number(localValue) / 1000)}s</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (setting.setting_key === 'circuit_breaker.monitoring_window') {
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4 text-yellow-500" />
|
||||||
|
<Label className="text-base font-medium">Monitoring Window</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Time window to track failures for circuit breaker
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Select value={localValue?.toString()} onValueChange={(value) => {
|
||||||
|
const numValue = parseInt(value);
|
||||||
|
setLocalValue(numValue);
|
||||||
|
updateSetting(setting.setting_key, numValue);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="60000">1 minute</SelectItem>
|
||||||
|
<SelectItem value="120000">2 minutes</SelectItem>
|
||||||
|
<SelectItem value="180000">3 minutes</SelectItem>
|
||||||
|
<SelectItem value="300000">5 minutes</SelectItem>
|
||||||
|
<SelectItem value="600000">10 minutes</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Badge variant="outline">Current: {Math.floor(Number(localValue) / 60000)}min</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to check if value is boolean
|
// Helper to check if value is boolean
|
||||||
const isBooleanSetting = (value: any) => {
|
const isBooleanSetting = (value: any) => {
|
||||||
return value === true || value === false ||
|
return value === true || value === false ||
|
||||||
@@ -503,7 +746,7 @@ export default function AdminSettings() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="moderation" className="space-y-6">
|
<Tabs defaultValue="moderation" className="space-y-6">
|
||||||
<TabsList className="grid w-full grid-cols-6">
|
<TabsList className="grid w-full grid-cols-7">
|
||||||
<TabsTrigger value="moderation" className="flex items-center gap-2">
|
<TabsTrigger value="moderation" className="flex items-center gap-2">
|
||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Moderation</span>
|
<span className="hidden sm:inline">Moderation</span>
|
||||||
@@ -516,6 +759,10 @@ export default function AdminSettings() {
|
|||||||
<Bell className="w-4 h-4" />
|
<Bell className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">Notifications</span>
|
<span className="hidden sm:inline">Notifications</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="resilience" className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Resilience</span>
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="system" className="flex items-center gap-2">
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
<Settings className="w-4 h-4" />
|
<Settings className="w-4 h-4" />
|
||||||
<span className="hidden sm:inline">System</span>
|
<span className="hidden sm:inline">System</span>
|
||||||
@@ -612,6 +859,61 @@ export default function AdminSettings() {
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="resilience">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-5 h-5" />
|
||||||
|
Resilience & Retry Configuration
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure automatic retry behavior and circuit breaker settings for handling transient failures
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-950 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Info className="w-5 h-5 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-900 dark:text-blue-100">
|
||||||
|
<p className="font-medium mb-2">About Retry & Circuit Breaker</p>
|
||||||
|
<ul className="space-y-1 text-blue-800 dark:text-blue-200">
|
||||||
|
<li>• <strong>Retry Logic:</strong> Automatically retries failed operations (network issues, timeouts)</li>
|
||||||
|
<li>• <strong>Circuit Breaker:</strong> Prevents system overload by blocking requests during outages</li>
|
||||||
|
<li>• <strong>When to adjust:</strong> Increase retries for unstable networks, decrease for fast-fail scenarios</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Retry Settings</h3>
|
||||||
|
{getSettingsByCategory('system')
|
||||||
|
.filter(s => s.setting_key.startsWith('retry.'))
|
||||||
|
.map(setting => <SettingInput key={setting.id} setting={setting} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6 space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">Circuit Breaker Settings</h3>
|
||||||
|
{getSettingsByCategory('system')
|
||||||
|
.filter(s => s.setting_key.startsWith('circuit_breaker.'))
|
||||||
|
.map(setting => <SettingInput key={setting.id} setting={setting} />)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-950 p-4 rounded-lg border border-yellow-200 dark:border-yellow-800">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-yellow-600 dark:text-yellow-400 mt-0.5 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-yellow-900 dark:text-yellow-100">
|
||||||
|
<p className="font-medium mb-1">Configuration Changes</p>
|
||||||
|
<p className="text-yellow-800 dark:text-yellow-200">
|
||||||
|
Settings take effect immediately but may be cached for up to 5 minutes in active sessions. Consider monitoring error logs after changes to verify behavior.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="system">
|
<TabsContent value="system">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -624,8 +926,8 @@ export default function AdminSettings() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
{getSettingsByCategory('system').length > 0 ? (
|
{getSettingsByCategory('system').filter(s => !s.setting_key.startsWith('retry.') && !s.setting_key.startsWith('circuit_breaker.')).length > 0 ? (
|
||||||
getSettingsByCategory('system').map((setting) => (
|
getSettingsByCategory('system').filter(s => !s.setting_key.startsWith('retry.') && !s.setting_key.startsWith('circuit_breaker.')).map((setting) => (
|
||||||
<SettingInput key={setting.id} setting={setting} />
|
<SettingInput key={setting.id} setting={setting} />
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Add retry configuration settings to admin_settings table
|
||||||
|
INSERT INTO admin_settings (setting_key, setting_value, category, description) VALUES
|
||||||
|
('retry.max_attempts', '3', 'system', 'Maximum number of retry attempts for failed operations (1-10)'),
|
||||||
|
('retry.base_delay', '1000', 'system', 'Base delay in milliseconds before first retry (100-5000)'),
|
||||||
|
('retry.max_delay', '10000', 'system', 'Maximum delay in milliseconds between retries (1000-30000)'),
|
||||||
|
('retry.backoff_multiplier', '2', 'system', 'Multiplier for exponential backoff (1.5-3.0)'),
|
||||||
|
('circuit_breaker.failure_threshold', '5', 'system', 'Number of failures before circuit opens (3-20)'),
|
||||||
|
('circuit_breaker.reset_timeout', '60000', 'system', 'Milliseconds to wait before testing recovery (30000-300000)'),
|
||||||
|
('circuit_breaker.monitoring_window', '120000', 'system', 'Time window to track failures in milliseconds (60000-600000)')
|
||||||
|
ON CONFLICT (setting_key) DO NOTHING;
|
||||||
Reference in New Issue
Block a user