'use client'; import React, { useState, useCallback, useMemo } from 'react'; // Types interface ScraperVersion { id: string; version: string; variant: 'stable' | 'beta' | 'canary'; traffic_percentage: number; jobs_24h: number; success_rate: number; avg_duration: number; status: 'active' | 'deprecated' | 'inactive'; module_path: string; function_name: string; created_at: string; promoted_at?: string; } // Mock data - replace with API calls const mockScraperVersions: ScraperVersion[] = [ { id: '1', version: '1.0.0', variant: 'stable', traffic_percentage: 90, jobs_24h: 150, success_rate: 95.2, avg_duration: 42, status: 'active', module_path: 'scrapers.google_reviews', function_name: 'scrape_reviews_v1', created_at: '2024-01-01T00:00:00Z', promoted_at: '2024-01-15T00:00:00Z', }, { id: '2', version: '1.1.0', variant: 'beta', traffic_percentage: 10, jobs_24h: 15, success_rate: 97.1, avg_duration: 38, status: 'active', module_path: 'scrapers.google_reviews_v2', function_name: 'scrape_reviews_v2', created_at: '2024-01-20T00:00:00Z', }, { id: '3', version: '1.2.0-alpha', variant: 'canary', traffic_percentage: 0, jobs_24h: 0, success_rate: 0, avg_duration: 0, status: 'inactive', module_path: 'scrapers.google_reviews_v3', function_name: 'scrape_reviews_v3', created_at: '2024-01-22T00:00:00Z', }, ]; export default function ScrapersPage() { const [versions, setVersions] = useState(mockScraperVersions); const [showAddForm, setShowAddForm] = useState(false); const [editingTraffic, setEditingTraffic] = useState(null); const [trafficValues, setTrafficValues] = useState>({}); const [isUpdatingTraffic, setIsUpdatingTraffic] = useState(false); // Confirmation modal state const [confirmAction, setConfirmAction] = useState<{ type: 'promote' | 'deprecate' | 'delete'; version: ScraperVersion; } | null>(null); const [isProcessing, setIsProcessing] = useState(false); // New version form state const [newVersion, setNewVersion] = useState({ version: '', variant: 'beta' as 'stable' | 'beta' | 'canary', module_path: '', function_name: '', traffic_percentage: 0, }); // Calculate total traffic for active versions const totalTraffic = useMemo(() => { return versions .filter(v => v.status === 'active') .reduce((sum, v) => sum + (trafficValues[v.id] ?? v.traffic_percentage), 0); }, [versions, trafficValues]); // Initialize traffic values when editing starts const startEditingTraffic = useCallback(() => { const initial: Record = {}; versions.forEach(v => { initial[v.id] = v.traffic_percentage; }); setTrafficValues(initial); setEditingTraffic('all'); }, [versions]); // Update traffic allocation const handleTrafficUpdate = async () => { if (totalTraffic !== 100) { alert('Traffic allocation must equal 100%'); return; } setIsUpdatingTraffic(true); try { // Mock API call await new Promise(resolve => setTimeout(resolve, 500)); setVersions(prev => prev.map(v => ({ ...v, traffic_percentage: trafficValues[v.id] ?? v.traffic_percentage, }))); setEditingTraffic(null); } catch (error) { console.error('Failed to update traffic:', error); } finally { setIsUpdatingTraffic(false); } }; // Promote version to stable const promoteVersion = async (version: ScraperVersion) => { setIsProcessing(true); try { // Mock API call await new Promise(resolve => setTimeout(resolve, 500)); setVersions(prev => prev.map(v => { if (v.variant === 'stable') { return { ...v, variant: 'beta' as const, traffic_percentage: 10 }; } if (v.id === version.id) { return { ...v, variant: 'stable' as const, traffic_percentage: 90, promoted_at: new Date().toISOString(), }; } return v; })); setConfirmAction(null); } catch (error) { console.error('Failed to promote version:', error); } finally { setIsProcessing(false); } }; // Deprecate version const deprecateVersion = async (version: ScraperVersion) => { setIsProcessing(true); try { // Mock API call await new Promise(resolve => setTimeout(resolve, 500)); // Redistribute traffic const activeVersions = versions.filter(v => v.status === 'active' && v.id !== version.id); const redistributedTraffic = version.traffic_percentage / activeVersions.length; setVersions(prev => prev.map(v => { if (v.id === version.id) { return { ...v, status: 'deprecated' as const, traffic_percentage: 0 }; } if (v.status === 'active') { return { ...v, traffic_percentage: v.traffic_percentage + redistributedTraffic }; } return v; })); setConfirmAction(null); } catch (error) { console.error('Failed to deprecate version:', error); } finally { setIsProcessing(false); } }; // Add new version const handleAddVersion = async (e: React.FormEvent) => { e.preventDefault(); const newScraperVersion: ScraperVersion = { id: Date.now().toString(), version: newVersion.version, variant: newVersion.variant, traffic_percentage: newVersion.traffic_percentage, jobs_24h: 0, success_rate: 0, avg_duration: 0, status: newVersion.traffic_percentage > 0 ? 'active' : 'inactive', module_path: newVersion.module_path, function_name: newVersion.function_name, created_at: new Date().toISOString(), }; setVersions(prev => [...prev, newScraperVersion]); setNewVersion({ version: '', variant: 'beta', module_path: '', function_name: '', traffic_percentage: 0, }); setShowAddForm(false); }; // Variant badge styling const getVariantStyle = (variant: string) => { switch (variant) { case 'stable': return 'bg-green-100 text-green-800 border-green-300'; case 'beta': return 'bg-blue-100 text-blue-800 border-blue-300'; case 'canary': return 'bg-yellow-100 text-yellow-800 border-yellow-300'; default: return 'bg-gray-100 text-gray-800 border-gray-300'; } }; // Status badge styling const getStatusStyle = (status: string) => { switch (status) { case 'active': return 'bg-green-100 text-green-800'; case 'deprecated': return 'bg-red-100 text-red-800'; case 'inactive': return 'bg-gray-100 text-gray-600'; default: return 'bg-gray-100 text-gray-800'; } }; return (
{/* Header */}

Scraper Versions

Manage scraper versions and A/B testing

{/* Add New Version Form */} {showAddForm && (

Add New Version

{/* Version */}
setNewVersion(prev => ({ ...prev, version: e.target.value }))} placeholder="e.g., 1.2.0" className="w-full px-3 py-2 border-2 border-gray-200 rounded-lg focus:border-blue-500 focus:outline-none text-sm" required />
{/* Variant */}
{/* Initial Traffic */}
setNewVersion(prev => ({ ...prev, traffic_percentage: parseInt(e.target.value) || 0 }))} className="w-full px-3 py-2 border-2 border-gray-200 rounded-lg focus:border-blue-500 focus:outline-none text-sm" required />
{/* Module Path */}
setNewVersion(prev => ({ ...prev, module_path: e.target.value }))} placeholder="e.g., scrapers.google_reviews" className="w-full px-3 py-2 border-2 border-gray-200 rounded-lg focus:border-blue-500 focus:outline-none text-sm font-mono" required />
{/* Function Name */}
setNewVersion(prev => ({ ...prev, function_name: e.target.value }))} placeholder="e.g., scrape_reviews" className="w-full px-3 py-2 border-2 border-gray-200 rounded-lg focus:border-blue-500 focus:outline-none text-sm font-mono" required />
)} {/* Traffic Allocation Section */}

Traffic Allocation

{editingTraffic ? (
Total: {totalTraffic}%
) : ( )}
{/* Traffic Bar Visualization */}
{versions .filter(v => v.status === 'active') .sort((a, b) => b.traffic_percentage - a.traffic_percentage) .map((version) => { const percentage = trafficValues[version.id] ?? version.traffic_percentage; if (percentage === 0) return null; return (
{percentage >= 10 && `${version.version} (${percentage}%)`}
); })}
0% 50% 100%
{/* Traffic Sliders (when editing) */} {editingTraffic && (
{versions .filter(v => v.status === 'active') .map((version) => (
{version.variant}
v{version.version} setTrafficValues(prev => ({ ...prev, [version.id]: parseInt(e.target.value), }))} className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600" /> setTrafficValues(prev => ({ ...prev, [version.id]: parseInt(e.target.value) || 0, }))} className="w-20 px-2 py-1 border-2 border-gray-200 rounded-lg text-sm text-center focus:border-blue-500 focus:outline-none" /> %
))}
)}
{/* Scraper Versions Table */}
{versions.map((version) => ( {/* Version */} {/* Variant */} {/* Traffic % */} {/* Jobs (24h) */} {/* Success Rate */} {/* Avg Duration */} {/* Status */} {/* Actions */} ))}
Version Variant Traffic % Jobs (24h) Success Rate Avg Duration Status Actions
{version.version} {version.promoted_at && (
Promoted {new Date(version.promoted_at).toLocaleDateString()}
)}
{version.variant}
{version.traffic_percentage}%
{version.jobs_24h.toLocaleString()} = 95 ? 'text-green-600' : version.success_rate >= 80 ? 'text-yellow-600' : version.success_rate > 0 ? 'text-red-600' : 'text-gray-400' }`}> {version.success_rate > 0 ? `${version.success_rate.toFixed(1)}%` : '-'} {version.avg_duration > 0 ? `${version.avg_duration}s` : '-'} {version.status === 'active' && ( )} {version.status.charAt(0).toUpperCase() + version.status.slice(1)}
{/* Promote Button (for beta/canary) */} {version.variant !== 'stable' && version.status === 'active' && ( )} {/* Deprecate Button */} {version.status === 'active' && ( )} {/* Edit Button */} {/* View Details */}
{/* Empty State */} {versions.length === 0 && (

No Scraper Versions

Add your first scraper version to get started

)}
{/* Confirmation Modal */} {confirmAction && (
setConfirmAction(null)} >
e.stopPropagation()} >
{confirmAction.type === 'promote' && ( )} {confirmAction.type === 'deprecate' && ( )}

{confirmAction.type === 'promote' ? 'Promote to Stable' : confirmAction.type === 'deprecate' ? 'Deprecate Version' : 'Delete Version'}

v{confirmAction.version.version} ({confirmAction.version.variant})

{confirmAction.type === 'promote' && (

This will promote v{confirmAction.version.version} to the stable channel.

Note: The current stable version will be demoted to beta with reduced traffic.

)} {confirmAction.type === 'deprecate' && (

This will deprecate v{confirmAction.version.version} and redistribute its traffic.

Warning: This version will no longer receive traffic. Any in-progress jobs will complete, but no new jobs will use this version.

)}
)}
); }