mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 06:31:13 -05:00
Fix migration for admin settings
This commit is contained in:
@@ -16,6 +16,8 @@ import { useAuth } from "@/hooks/useAuth";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Camera, CheckCircle, AlertCircle, Info } from "lucide-react";
|
||||
import { UppyPhotoSubmissionUploadProps } from "@/types/submissions";
|
||||
import { withRetry } from "@/lib/retryHelpers";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export function UppyPhotoSubmissionUpload({
|
||||
onSubmissionComplete,
|
||||
@@ -94,66 +96,92 @@ export function UppyPhotoSubmissionUpload({
|
||||
setPhotos((prev) => prev.map((p) => (p === photo ? { ...p, uploadStatus: "uploading" as const } : p)));
|
||||
|
||||
try {
|
||||
// Get upload URL from edge function
|
||||
const { data: uploadData, error: uploadError } = await invokeWithTracking(
|
||||
"upload-image",
|
||||
{ metadata: { requireSignedURLs: false }, variant: "public" },
|
||||
user?.id,
|
||||
);
|
||||
// Wrap Cloudflare upload in retry logic
|
||||
const cloudflareUrl = await withRetry(
|
||||
async () => {
|
||||
// Get upload URL from edge function
|
||||
const { data: uploadData, error: uploadError } = await invokeWithTracking(
|
||||
"upload-image",
|
||||
{ metadata: { requireSignedURLs: false }, variant: "public" },
|
||||
user?.id,
|
||||
);
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
const { uploadURL, id: cloudflareId } = uploadData;
|
||||
const { uploadURL, id: cloudflareId } = uploadData;
|
||||
|
||||
// Upload file to Cloudflare
|
||||
if (!photo.file) {
|
||||
throw new Error("Photo file is missing");
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", photo.file);
|
||||
// Upload file to Cloudflare
|
||||
if (!photo.file) {
|
||||
throw new Error("Photo file is missing");
|
||||
}
|
||||
const formData = new FormData();
|
||||
formData.append("file", photo.file);
|
||||
|
||||
const uploadResponse = await fetch(uploadURL, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
const uploadResponse = await fetch(uploadURL, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("Failed to upload to Cloudflare");
|
||||
}
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("Failed to upload to Cloudflare");
|
||||
}
|
||||
|
||||
// Poll for processing completion
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
let cloudflareUrl = "";
|
||||
// Poll for processing completion
|
||||
let attempts = 0;
|
||||
const maxAttempts = 30;
|
||||
let cloudflareUrl = "";
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const supabaseUrl = "https://api.thrillwiki.com";
|
||||
const statusResponse = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${cloudflareId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session?.access_token || ""}`,
|
||||
apikey:
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4",
|
||||
},
|
||||
});
|
||||
while (attempts < maxAttempts) {
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession();
|
||||
const supabaseUrl = "https://api.thrillwiki.com";
|
||||
const statusResponse = await fetch(`${supabaseUrl}/functions/v1/upload-image?id=${cloudflareId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session?.access_token || ""}`,
|
||||
apikey:
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InlkdnRtbnJzenlicW5iY3FiZGN5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTgzMjYzNTYsImV4cCI6MjA3MzkwMjM1Nn0.DM3oyapd_omP5ZzIlrT0H9qBsiQBxBRgw2tYuqgXKX4",
|
||||
},
|
||||
});
|
||||
|
||||
if (statusResponse.ok) {
|
||||
const status = await statusResponse.json();
|
||||
if (status.uploaded && status.urls) {
|
||||
cloudflareUrl = status.urls.public;
|
||||
break;
|
||||
if (statusResponse.ok) {
|
||||
const status = await statusResponse.json();
|
||||
if (status.uploaded && status.urls) {
|
||||
cloudflareUrl = status.urls.public;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!cloudflareUrl) {
|
||||
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'
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
attempts++;
|
||||
}
|
||||
|
||||
if (!cloudflareUrl) {
|
||||
throw new Error("Upload processing timeout");
|
||||
}
|
||||
);
|
||||
|
||||
// Revoke object URL
|
||||
URL.revokeObjectURL(photo.url);
|
||||
@@ -185,59 +213,78 @@ export function UppyPhotoSubmissionUpload({
|
||||
|
||||
setUploadProgress(null);
|
||||
|
||||
// Create content_submission record first
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from("content_submissions")
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
submission_type: "photo",
|
||||
content: {}, // Empty content, all data is in relational tables
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
// Create submission records with retry logic
|
||||
await withRetry(
|
||||
async () => {
|
||||
// Create content_submission record first
|
||||
const { data: submissionData, error: submissionError } = await supabase
|
||||
.from("content_submissions")
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
submission_type: "photo",
|
||||
content: {}, // Empty content, all data is in relational tables
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (submissionError || !submissionData) {
|
||||
throw submissionError || new Error("Failed to create submission record");
|
||||
}
|
||||
if (submissionError || !submissionData) {
|
||||
throw submissionError || new Error("Failed to create submission record");
|
||||
}
|
||||
|
||||
// Create photo_submission record
|
||||
const { data: photoSubmissionData, error: photoSubmissionError } = await supabase
|
||||
.from("photo_submissions")
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
parent_id: parentId || null,
|
||||
title: title.trim() || null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
// Create photo_submission record
|
||||
const { data: photoSubmissionData, error: photoSubmissionError } = await supabase
|
||||
.from("photo_submissions")
|
||||
.insert({
|
||||
submission_id: submissionData.id,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
parent_id: parentId || null,
|
||||
title: title.trim() || null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (photoSubmissionError || !photoSubmissionData) {
|
||||
throw photoSubmissionError || new Error("Failed to create photo submission");
|
||||
}
|
||||
if (photoSubmissionError || !photoSubmissionData) {
|
||||
throw photoSubmissionError || new Error("Failed to create photo submission");
|
||||
}
|
||||
|
||||
// Insert all photo items
|
||||
const photoItems = photos.map((photo, index) => ({
|
||||
photo_submission_id: photoSubmissionData.id,
|
||||
cloudflare_image_id: photo.url.split("/").slice(-2, -1)[0] || "", // Extract ID from URL
|
||||
cloudflare_image_url:
|
||||
photo.uploadStatus === "uploaded"
|
||||
? photo.url
|
||||
: uploadedPhotos.find((p) => p.order === photo.order)?.url || photo.url,
|
||||
caption: photo.caption.trim() || null,
|
||||
title: photo.title?.trim() || null,
|
||||
filename: photo.file?.name || null,
|
||||
order_index: index,
|
||||
file_size: photo.file?.size || null,
|
||||
mime_type: photo.file?.type || null,
|
||||
}));
|
||||
// Insert all photo items
|
||||
const photoItems = photos.map((photo, index) => ({
|
||||
photo_submission_id: photoSubmissionData.id,
|
||||
cloudflare_image_id: photo.url.split("/").slice(-2, -1)[0] || "", // Extract ID from URL
|
||||
cloudflare_image_url:
|
||||
photo.uploadStatus === "uploaded"
|
||||
? photo.url
|
||||
: uploadedPhotos.find((p) => p.order === photo.order)?.url || photo.url,
|
||||
caption: photo.caption.trim() || null,
|
||||
title: photo.title?.trim() || null,
|
||||
filename: photo.file?.name || null,
|
||||
order_index: index,
|
||||
file_size: photo.file?.size || null,
|
||||
mime_type: photo.file?.type || null,
|
||||
}));
|
||||
|
||||
const { error: itemsError } = await supabase.from("photo_submission_items").insert(photoItems);
|
||||
const { error: itemsError } = await supabase.from("photo_submission_items").insert(photoItems);
|
||||
|
||||
if (itemsError) {
|
||||
throw itemsError;
|
||||
}
|
||||
if (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({
|
||||
title: "Submission Successful",
|
||||
@@ -259,7 +306,12 @@ export function UppyPhotoSubmissionUpload({
|
||||
handleError(error, {
|
||||
action: 'Submit Photo Submission',
|
||||
userId: user?.id,
|
||||
metadata: { entityType, entityId, photoCount: photos.length }
|
||||
metadata: {
|
||||
entityType,
|
||||
entityId,
|
||||
photoCount: photos.length,
|
||||
retriesExhausted: true
|
||||
}
|
||||
});
|
||||
|
||||
toast({
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
|
||||
import { logger } from './logger';
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
export interface CircuitBreakerConfig {
|
||||
/** Number of failures before opening circuit (default: 5) */
|
||||
@@ -29,7 +30,7 @@ export class CircuitBreaker {
|
||||
private failures: number[] = []; // Timestamps of recent failures
|
||||
private lastFailureTime: number | null = null;
|
||||
private successCount: number = 0;
|
||||
private readonly config: Required<CircuitBreakerConfig>;
|
||||
private config: Required<CircuitBreakerConfig>;
|
||||
|
||||
constructor(config: Partial<CircuitBreakerConfig> = {}) {
|
||||
this.config = {
|
||||
@@ -39,6 +40,18 @@ export class CircuitBreaker {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
@@ -140,12 +153,62 @@ export class CircuitBreaker {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, // 1 minute
|
||||
monitoringWindow: 120000 // 2 minutes
|
||||
resetTimeout: 60000,
|
||||
monitoringWindow: 120000
|
||||
});
|
||||
|
||||
// Load config from admin settings on startup
|
||||
loadCircuitBreakerConfig().then(config => {
|
||||
supabaseCircuitBreaker.updateConfig(config);
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { logger } from './logger';
|
||||
import { supabaseCircuitBreaker } from './circuitBreaker';
|
||||
import { supabase } from './supabaseClient';
|
||||
|
||||
export interface RetryOptions {
|
||||
/** Maximum number of attempts (default: 3) */
|
||||
@@ -97,6 +98,81 @@ function calculateBackoffDelay(attempt: number, options: Required<RetryOptions>)
|
||||
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
|
||||
*
|
||||
@@ -122,14 +198,18 @@ export async function withRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
options?: RetryOptions
|
||||
): Promise<T> {
|
||||
// Load config from admin settings
|
||||
const adminConfig = await getCachedRetryConfig();
|
||||
|
||||
// Merge: options override admin settings
|
||||
const config: Required<RetryOptions> = {
|
||||
maxAttempts: options?.maxAttempts ?? 3,
|
||||
baseDelay: options?.baseDelay ?? 1000,
|
||||
maxDelay: options?.maxDelay ?? 10000,
|
||||
backoffMultiplier: options?.backoffMultiplier ?? 2,
|
||||
jitter: options?.jitter ?? true,
|
||||
onRetry: options?.onRetry ?? (() => {}),
|
||||
shouldRetry: options?.shouldRetry ?? isRetryableError,
|
||||
maxAttempts: options?.maxAttempts ?? adminConfig.maxAttempts,
|
||||
baseDelay: options?.baseDelay ?? adminConfig.baseDelay,
|
||||
maxDelay: options?.maxDelay ?? adminConfig.maxDelay,
|
||||
backoffMultiplier: options?.backoffMultiplier ?? adminConfig.backoffMultiplier,
|
||||
jitter: options?.jitter ?? adminConfig.jitter,
|
||||
onRetry: options?.onRetry ?? adminConfig.onRetry,
|
||||
shouldRetry: options?.shouldRetry ?? adminConfig.shouldRetry,
|
||||
};
|
||||
|
||||
let lastError: unknown;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useAdminSettings } from '@/hooks/useAdminSettings';
|
||||
import { NovuMigrationUtility } from '@/components/admin/NovuMigrationUtility';
|
||||
import { TestDataGenerator } from '@/components/admin/TestDataGenerator';
|
||||
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';
|
||||
|
||||
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
|
||||
const isBooleanSetting = (value: any) => {
|
||||
return value === true || value === false ||
|
||||
@@ -503,7 +746,7 @@ export default function AdminSettings() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<Shield className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Moderation</span>
|
||||
@@ -516,6 +759,10 @@ export default function AdminSettings() {
|
||||
<Bell className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Notifications</span>
|
||||
</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">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">System</span>
|
||||
@@ -612,6 +859,61 @@ export default function AdminSettings() {
|
||||
</Card>
|
||||
</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">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -624,8 +926,8 @@ export default function AdminSettings() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{getSettingsByCategory('system').length > 0 ? (
|
||||
getSettingsByCategory('system').map((setting) => (
|
||||
{getSettingsByCategory('system').filter(s => !s.setting_key.startsWith('retry.') && !s.setting_key.startsWith('circuit_breaker.')).length > 0 ? (
|
||||
getSettingsByCategory('system').filter(s => !s.setting_key.startsWith('retry.') && !s.setting_key.startsWith('circuit_breaker.')).map((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