diff --git a/src/app/admin/data/page.tsx b/src/app/admin/data/page.tsx index 5dc23aa..6227fd2 100644 --- a/src/app/admin/data/page.tsx +++ b/src/app/admin/data/page.tsx @@ -6,6 +6,39 @@ export default function AdminDataPage() { return (
+ {/* Quick Actions */} +
+

+ Admin Actions +

+
+ + 🔄 +
+

Migrate Redis

+

+ Move data from old to new Redis instance +

+
+
+ + âš ī¸ +
+

Reset Communities

+

+ Clear and re-seed Redis data +

+
+
+
+
+ {/* System Info */}

@@ -95,6 +128,8 @@ export default function AdminDataPage() { + +

diff --git a/src/app/admin/migrate-redis/page.tsx b/src/app/admin/migrate-redis/page.tsx new file mode 100644 index 0000000..17e4f75 --- /dev/null +++ b/src/app/admin/migrate-redis/page.tsx @@ -0,0 +1,397 @@ +/** + * Admin Redis Migration Page + * Migrate data from old Redis instance to new one + */ + +'use client'; + +import { useState, useEffect } from 'react'; + +interface MigrationProgress { + status: 'running' | 'completed' | 'failed'; + progress: number; + total: number; + currentKey?: string; + migratedKeys: number; + skippedKeys: number; + failedKeys: number; + errors: string[]; + logs: string[]; + startedAt: string; + completedAt?: string; +} + +export default function MigrateRedisPage() { + const [oldRedisUrl, setOldRedisUrl] = useState(''); + const [dryRun, setDryRun] = useState(true); + const [migrating, setMigrating] = useState(false); + const [migrationId, setMigrationId] = useState(null); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + const [autoScroll, setAutoScroll] = useState(true); + + // Poll for progress updates + useEffect(() => { + if (!migrationId || !migrating) return; + + const interval = setInterval(async () => { + try { + const response = await fetch( + `/api/admin/migrate-redis?migrationId=${migrationId}` + ); + const data = await response.json(); + + if (response.ok) { + setProgress(data); + + // Stop polling if completed or failed + if (data.status === 'completed' || data.status === 'failed') { + setMigrating(false); + } + } else { + setError(data.error || 'Failed to get migration status'); + setMigrating(false); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + setMigrating(false); + } + }, 1000); + + return () => clearInterval(interval); + }, [migrationId, migrating]); + + // Auto-scroll logs to bottom + useEffect(() => { + if (autoScroll && progress?.logs.length) { + const logsContainer = document.getElementById('logs-container'); + if (logsContainer) { + logsContainer.scrollTop = logsContainer.scrollHeight; + } + } + }, [progress?.logs, autoScroll]); + + const handleMigrate = async () => { + if (!oldRedisUrl.trim()) { + setError('Please enter the old Redis URL'); + return; + } + + setMigrating(true); + setError(null); + setProgress(null); + setMigrationId(null); + + try { + const response = await fetch('/api/admin/migrate-redis', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ oldRedisUrl, dryRun }), + }); + + const data = await response.json(); + + if (response.ok) { + setMigrationId(data.migrationId); + } else { + setError(data.error || 'Failed to start migration'); + setMigrating(false); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + setMigrating(false); + } + }; + + const progressPercent = progress + ? Math.round((progress.progress / progress.total) * 100) + : 0; + + return ( +
+ {/* Header */} +
+
+
+ 🔄 +
+
+

+ Redis Migration +

+

+ Migrate all data from your old Redis instance to the new one. + This will copy all keys while preserving types and TTLs. +

+
+
+
+ + {/* Migration Form */} + {!migrating && !progress && ( +
+

+ Configuration +

+ +
+ {/* Old Redis URL */} +
+ + setOldRedisUrl(e.target.value)} + placeholder="redis://username:password@host:port" + className="w-full px-4 py-3 bg-background border border-border rounded-lg text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary" + /> +

+ Format: redis://[username:password@]host:port[/database] +

+
+ + {/* Dry Run Option */} +
+ setDryRun(e.target.checked)} + className="mt-1 w-4 h-4 accent-primary" + /> +
+ +

+ Test the migration without actually copying data. This will + show you what would be migrated without making any changes. +

+
+
+ + {/* Info Box */} +
+

+ â„šī¸ Migration Details +

+
    +
  • Scans all keys from the old Redis instance
  • +
  • Preserves key types (string, hash, list, set, zset)
  • +
  • Maintains TTL (expiration) values
  • +
  • Skips keys that already exist in the new instance
  • +
  • Provides real-time progress updates
  • +
+
+ + {/* Error Display */} + {error && ( +
+

+ ❌ {error} +

+
+ )} + + {/* Start Migration Button */} + +
+
+ )} + + {/* Migration Progress */} + {migrating && progress && ( +
+

+ {progress.status === 'running' && 'âŗ Migration in Progress'} + {progress.status === 'completed' && '✅ Migration Completed'} + {progress.status === 'failed' && '❌ Migration Failed'} +

+ + {/* Progress Bar */} +
+
+ + {progress.progress} / {progress.total} keys + + + {progressPercent}% + +
+
+
+
+ {progress.currentKey && ( +

+ Current: {progress.currentKey} +

+ )} +
+ + {/* Stats */} +
+ + + +
+ + {/* Live Logs */} + {progress.logs.length > 0 && ( +
+
+

+ 📝 Migration Logs ({progress.logs.length}) +

+ +
+
+ {progress.logs.map((log, i) => ( +
+ {log} +
+ ))} +
+
+ )} + + {/* Errors Summary */} + {progress.errors.length > 0 && ( +
+

+ ❌ Error Summary ({progress.errors.length}) +

+
+ {progress.errors.map((err, i) => ( +

+ {err} +

+ ))} +
+
+ )} + + {/* Completion Info */} + {progress.status === 'completed' && ( +
+

+ ✅ Migration completed successfully! +

+

+ Started: {new Date(progress.startedAt).toLocaleString()} +
+ Completed: {progress.completedAt && new Date(progress.completedAt).toLocaleString()} +

+
+ )} + + {/* Actions */} + {progress.status !== 'running' && ( + + )} +
+ )} + + {/* Info Panel */} +
+

+ Migration Guide +

+
+

+ 1. Test First: Always + run a dry run first to see what will be migrated. +

+

+ 2. Check Results:{' '} + Review the dry run results for any errors or unexpected keys. +

+

+ 3. Live Migration:{' '} + Once satisfied, uncheck "Dry Run" and migrate for real. +

+

+ 4. Verification: After + migration, verify your application is working correctly. +

+
+
+
+ ); +} + +function StatBox({ + label, + value, + color, +}: { + label: string; + value: number; + color: string; +}) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index bcaad16..25c920f 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -22,6 +22,12 @@ export default function AdminHomePage() { title="Data Overview" description="View system stats and data sources" /> + (); + +export async function POST(request: Request) { + try { + // Check authentication and admin status + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const isAdmin = await UserManagementService.isAdmin(session.user.email); + if (!isAdmin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + const body = await request.json(); + const { oldRedisUrl, dryRun = false } = body; + + if (!oldRedisUrl || typeof oldRedisUrl !== 'string') { + return NextResponse.json( + { error: 'oldRedisUrl is required and must be a string' }, + { status: 400 } + ); + } + + // Generate migration ID + const migrationId = `migration-${Date.now()}`; + + // Initialize progress tracking + migrationProgress.set(migrationId, { + status: 'running', + progress: 0, + total: 0, + migratedKeys: 0, + skippedKeys: 0, + failedKeys: 0, + errors: [], + logs: [], + startedAt: new Date().toISOString(), + }); + + // Start migration in background + performMigration(migrationId, oldRedisUrl, dryRun, session.user.email).catch((error) => { + logger.error('Migration failed:', error); + const progress = migrationProgress.get(migrationId); + if (progress) { + progress.status = 'failed'; + progress.errors.push(error.message || 'Unknown error'); + progress.completedAt = new Date().toISOString(); + } + }); + + return NextResponse.json({ + migrationId, + message: 'Migration started', + dryRun, + }); + } catch (error) { + logger.error('Error starting migration:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to start migration' }, + { status: 500 } + ); + } +} + +export async function GET(request: Request) { + try { + // Check authentication and admin status + const session = await getServerSession(authOptions); + if (!session?.user?.email) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const isAdmin = await UserManagementService.isAdmin(session.user.email); + if (!isAdmin) { + return NextResponse.json({ error: 'Admin access required' }, { status: 403 }); + } + + const { searchParams } = new URL(request.url); + const migrationId = searchParams.get('migrationId'); + + if (!migrationId) { + return NextResponse.json( + { error: 'migrationId is required' }, + { status: 400 } + ); + } + + const progress = migrationProgress.get(migrationId); + if (!progress) { + return NextResponse.json( + { error: 'Migration not found' }, + { status: 404 } + ); + } + + return NextResponse.json(progress); + } catch (error) { + logger.error('Error getting migration status:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to get migration status' }, + { status: 500 } + ); + } +} + +async function performMigration( + migrationId: string, + oldRedisUrl: string, + dryRun: boolean, + userEmail: string +) { + let oldRedis: Redis | null = null; + let newRedis: Redis | null = null; + + // Helper to add logs to progress + const addLog = (message: string, level: 'info' | 'warn' | 'error' = 'info') => { + const progress = migrationProgress.get(migrationId); + if (progress) { + const timestamp = new Date().toLocaleTimeString(); + const icon = level === 'error' ? '❌' : level === 'warn' ? 'âš ī¸' : 'â„šī¸'; + progress.logs.push(`[${timestamp}] ${icon} ${message}`); + // Keep only last 100 logs + if (progress.logs.length > 100) { + progress.logs.shift(); + } + } + // Also log to console + if (level === 'error') { + logger.error(message); + } else if (level === 'warn') { + logger.warn(message); + } else { + logger.info(message); + } + }; + + try { + addLog(`🚀 Starting Redis migration (${dryRun ? 'DRY RUN' : 'LIVE'}) by ${userEmail}`); + + // Connect to old Redis + addLog('Connecting to old Redis instance...'); + oldRedis = new Redis(oldRedisUrl, { + maxRetriesPerRequest: 3, + retryStrategy(times) { + const delay = Math.min(times * 50, 2000); + return delay; + }, + }); + addLog('✅ Connected to old Redis'); + + // Connect to new Redis + addLog('Connecting to new Redis instance...'); + newRedis = getRedisClient(); + addLog('✅ Connected to new Redis'); + + // Get all keys from old Redis + addLog('Scanning for keys in old Redis...'); + const keys = await scanAllKeys(oldRedis); + const progress = migrationProgress.get(migrationId); + if (!progress) throw new Error('Migration progress not found'); + + progress.total = keys.length; + addLog(`📊 Found ${keys.length} keys to migrate`); + + // Migrate each key + addLog(`Starting migration of ${keys.length} keys...`); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + progress.currentKey = key; + progress.progress = i + 1; + + try { + // Get key type + const type = await oldRedis.type(key); + + if (dryRun) { + addLog(`[DRY RUN] Would migrate ${type} key: ${key}`); + progress.migratedKeys++; + continue; + } + + // Check if key already exists in new Redis + const exists = await newRedis.exists(key); + if (exists) { + addLog(`â­ī¸ Skipping existing key: ${key}`, 'warn'); + progress.skippedKeys++; + continue; + } + + // Migrate based on type + switch (type) { + case 'string': { + const value = await oldRedis.get(key); + if (value !== null) { + const ttl = await oldRedis.ttl(key); + if (ttl > 0) { + await newRedis.setex(key, ttl, value); + } else { + await newRedis.set(key, value); + } + } + break; + } + + case 'hash': { + const hash = await oldRedis.hgetall(key); + if (Object.keys(hash).length > 0) { + await newRedis.hset(key, hash); + const ttl = await oldRedis.ttl(key); + if (ttl > 0) { + await newRedis.expire(key, ttl); + } + } + break; + } + + case 'list': { + const list = await oldRedis.lrange(key, 0, -1); + if (list.length > 0) { + await newRedis.rpush(key, ...list); + const ttl = await oldRedis.ttl(key); + if (ttl > 0) { + await newRedis.expire(key, ttl); + } + } + break; + } + + case 'set': { + const set = await oldRedis.smembers(key); + if (set.length > 0) { + await newRedis.sadd(key, ...set); + const ttl = await oldRedis.ttl(key); + if (ttl > 0) { + await newRedis.expire(key, ttl); + } + } + break; + } + + case 'zset': { + const zset = await oldRedis.zrange(key, 0, -1, 'WITHSCORES'); + if (zset.length > 0) { + // Convert array to score-member pairs + const pairs: (string | number)[] = []; + for (let j = 0; j < zset.length; j += 2) { + pairs.push(parseFloat(zset[j + 1]), zset[j]); + } + await newRedis.zadd(key, ...pairs); + const ttl = await oldRedis.ttl(key); + if (ttl > 0) { + await newRedis.expire(key, ttl); + } + } + break; + } + + default: + addLog(`âš ī¸ Unknown key type: ${type} for key: ${key}`, 'warn'); + progress.skippedKeys++; + continue; + } + + progress.migratedKeys++; + // Only log every 10th key to avoid spam, or if it's the last key + if (i % 10 === 0 || i === keys.length - 1) { + addLog(`✅ Migrated ${type} key: ${key} (${i + 1}/${keys.length})`); + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + addLog(`❌ Failed to migrate key ${key}: ${errorMsg}`, 'error'); + progress.failedKeys++; + progress.errors.push(`${key}: ${errorMsg}`); + } + } + + // Mark as completed + progress.status = 'completed'; + progress.completedAt = new Date().toISOString(); + progress.currentKey = undefined; + + addLog(`✅ Migration completed! Total: ${progress.total}, Migrated: ${progress.migratedKeys}, Skipped: ${progress.skippedKeys}, Failed: ${progress.failedKeys}`); + + } catch (error) { + const errorMsg = error instanceof Error ? error.message : 'Unknown error'; + logger.error('Migration failed:', error); + const progress = migrationProgress.get(migrationId); + if (progress) { + progress.status = 'failed'; + progress.errors.push(errorMsg); + progress.completedAt = new Date().toISOString(); + const timestamp = new Date().toLocaleTimeString(); + progress.logs.push(`[${timestamp}] ❌ Migration failed: ${errorMsg}`); + } + throw error; + } finally { + // Clean up old Redis connection + if (oldRedis) { + await oldRedis.quit(); + } + } +} + +/** + * Scan all keys in Redis using SCAN to avoid blocking + */ +async function scanAllKeys(redis: Redis): Promise { + const keys: string[] = []; + let cursor = '0'; + + do { + const result = await redis.scan(cursor, 'COUNT', 100); + cursor = result[0]; + keys.push(...result[1]); + } while (cursor !== '0'); + + return keys; +}