diff --git a/src/app/api/admin/cache-stats/route.ts b/src/app/api/admin/cache-stats/route.ts new file mode 100644 index 0000000..8fb5c15 --- /dev/null +++ b/src/app/api/admin/cache-stats/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { searchCache } from '@/lib/cache'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const detailed = searchParams.get('detailed') === 'true'; + + const stats = detailed ? searchCache.getDetailedStats() : searchCache.getStats(); + + return NextResponse.json({ + cache: stats, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('Cache stats error:', error); + return NextResponse.json( + { error: 'Failed to retrieve cache statistics' }, + { status: 500 } + ); + } +} + +export async function DELETE(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const key = searchParams.get('key'); + + if (key) { + // Delete specific cache entry + const deleted = searchCache.delete(key); + return NextResponse.json({ + deleted, + key, + message: deleted ? 'Cache entry deleted' : 'Cache entry not found' + }); + } else { + // Clear entire cache + searchCache.clear(); + return NextResponse.json({ + message: 'Cache cleared successfully' + }); + } + } catch (error) { + console.error('Cache management error:', error); + return NextResponse.json( + { error: 'Failed to manage cache' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/admin/rate-limit-stats/route.ts b/src/app/api/admin/rate-limit-stats/route.ts new file mode 100644 index 0000000..9374eec --- /dev/null +++ b/src/app/api/admin/rate-limit-stats/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { searchRateLimiter, getClientIdentifier } from '@/lib/rate-limit'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const detailed = searchParams.get('detailed') === 'true'; + const clientId = searchParams.get('client'); + + if (clientId) { + // Get stats for specific client + const clientInfo = searchRateLimiter.getClientInfo(clientId); + return NextResponse.json({ + client: clientInfo, + timestamp: new Date().toISOString(), + }); + } + + const stats = detailed ? searchRateLimiter.getDetailedStats() : searchRateLimiter.getStats(); + + return NextResponse.json({ + rateLimiting: stats, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('Rate limit stats error:', error); + return NextResponse.json( + { error: 'Failed to retrieve rate limit statistics' }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { action, clientId, additionalRequests, durationMs } = body; + + if (action === 'reset' && clientId) { + const reset = searchRateLimiter.resetClient(clientId); + return NextResponse.json({ + success: reset, + message: reset ? 'Client rate limit reset' : 'Client not found', + clientId, + }); + } + + if (action === 'increase' && clientId && additionalRequests) { + searchRateLimiter.increaseLimit(clientId, additionalRequests, durationMs); + return NextResponse.json({ + success: true, + message: `Increased limit for client by ${additionalRequests} requests`, + clientId, + additionalRequests, + durationMs: durationMs || 'default', + }); + } + + return NextResponse.json( + { error: 'Invalid action or missing parameters' }, + { status: 400 } + ); + } catch (error) { + console.error('Rate limit management error:', error); + return NextResponse.json( + { error: 'Failed to manage rate limits' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/admin/search-analytics/route.ts b/src/app/api/admin/search-analytics/route.ts new file mode 100644 index 0000000..ea91fe9 --- /dev/null +++ b/src/app/api/admin/search-analytics/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { searchAnalytics } from '@/lib/analytics'; + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url); + const timeRange = searchParams.get('timeRange'); + const query = searchParams.get('query'); + const action = searchParams.get('action'); + + // Handle different actions + if (action === 'recent') { + const limit = parseInt(searchParams.get('limit') || '50'); + const recentSearches = searchAnalytics.getRecentSearches(limit); + return NextResponse.json({ + recentSearches, + count: recentSearches.length, + timestamp: new Date().toISOString(), + }); + } + + if (action === 'history' && query) { + const limit = parseInt(searchParams.get('limit') || '10'); + const searchHistory = searchAnalytics.getSearchHistory(query, limit); + return NextResponse.json({ + query, + searchHistory, + count: searchHistory.length, + timestamp: new Date().toISOString(), + }); + } + + // Default: get analytics stats + let timeRangeMs: number | undefined; + + switch (timeRange) { + case '1h': + timeRangeMs = 60 * 60 * 1000; + break; + case '24h': + timeRangeMs = 24 * 60 * 60 * 1000; + break; + case '7d': + timeRangeMs = 7 * 24 * 60 * 60 * 1000; + break; + case '30d': + timeRangeMs = 30 * 24 * 60 * 60 * 1000; + break; + default: + timeRangeMs = undefined; // All time + } + + const stats = searchAnalytics.getStats(timeRangeMs); + + return NextResponse.json({ + analytics: stats, + timeRange: timeRange || 'all', + timeRangeMs, + timestamp: new Date().toISOString(), + }); + } catch (error) { + console.error('Search analytics error:', error); + return NextResponse.json( + { error: 'Failed to retrieve search analytics' }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/src/app/api/dashboard/stats/route.ts b/src/app/api/dashboard/stats/route.ts index de0531b..f0314ba 100644 --- a/src/app/api/dashboard/stats/route.ts +++ b/src/app/api/dashboard/stats/route.ts @@ -1,5 +1,8 @@ import { NextResponse } from 'next/server'; import { createSupabaseServerClient } from '@/lib/supabase'; +import { searchAnalytics } from '@/lib/analytics'; +import { searchCache } from '@/lib/cache'; +import { searchRateLimiter } from '@/lib/rate-limit'; export async function GET() { try { @@ -80,6 +83,12 @@ export async function GET() { analyzed_at: repo.updated_at, })) || []; + // Get search analytics (last 24 hours) + const searchStats = searchAnalytics.getStats(24 * 60 * 60 * 1000); + const cacheStats = searchCache.getStats(); + const rateLimitStats = searchRateLimiter.getStats(); + const recentSearches = searchAnalytics.getRecentSearches(5); + const dashboardStats = { totalRepositories: totalRepositories || 0, totalAnalyzed: totalAnalyzed || 0, @@ -88,6 +97,36 @@ export async function GET() { averageComplexity, topLanguages, recentAnalyses: formattedRecentAnalyses, + searchAnalytics: { + totalSearches24h: searchStats.totalSearches, + uniqueQueries24h: searchStats.uniqueQueries, + averageResponseTime: searchStats.averageResponseTime, + cacheHitRate: searchStats.cacheHitRate, + errorRate: searchStats.errorRate, + rateLimitRate: searchStats.rateLimitRate, + topQueries: searchStats.topQueries.slice(0, 5), // Top 5 + topFilters: searchStats.topFilters.slice(0, 5), // Top 5 + recentSearches: recentSearches.map(search => ({ + query: search.query, + results_count: search.results_count, + response_time_ms: search.response_time_ms, + cached: search.cached, + timestamp: new Date(search.timestamp).toISOString(), + error: search.error, + })), + }, + systemStats: { + cache: { + size: cacheStats.size, + hitRate: cacheStats.hitRate, + memoryUsage: cacheStats.totalMemoryUsage, + }, + rateLimiting: { + activeClients: rateLimitStats.activeClients, + totalBlocked: rateLimitStats.totalBlocked, + blockRate: rateLimitStats.blockRate, + }, + }, }; return NextResponse.json(dashboardStats); diff --git a/src/app/api/github/search/route.ts b/src/app/api/github/search/route.ts index 6f522bc..b0474b1 100644 --- a/src/app/api/github/search/route.ts +++ b/src/app/api/github/search/route.ts @@ -1,84 +1,353 @@ import { NextRequest, NextResponse } from 'next/server'; import { Octokit } from '@octokit/rest'; import { createSupabaseServerClient } from '@/lib/supabase'; +import { searchCache, createCacheKey } from '@/lib/cache'; +import { searchRateLimiter, getClientIdentifier } from '@/lib/rate-limit'; +import { checkEnvironmentVariables } from '@/lib/env-check'; +import { searchAnalytics } from '@/lib/analytics'; -const octokit = new Octokit({ - auth: process.env.GITHUB_TOKEN, -}); +let octokit: Octokit | null = null; + +function getOctokitInstance() { + if (!octokit) { + const envStatus = checkEnvironmentVariables(); + + if (!envStatus.github) { + throw new Error('GitHub token not configured. Please add GITHUB_TOKEN to your environment variables.'); + } + + octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, + }); + } + + return octokit; +} + +interface SearchFilters { + sort: string; + order: string; + page: number; + per_page: number; + language?: string; + topic?: string; + stars?: string; + forks?: string; + created?: string; + pushed?: string; +} + +function buildSearchQuery(baseQuery: string, filters: Partial): string { + let query = baseQuery; + + if (filters.language) { + query += ` language:${filters.language}`; + } + + if (filters.topic) { + query += ` topic:${filters.topic}`; + } + + if (filters.stars) { + query += ` stars:${filters.stars}`; + } + + if (filters.forks) { + query += ` forks:${filters.forks}`; + } + + if (filters.created) { + query += ` created:${filters.created}`; + } + + if (filters.pushed) { + query += ` pushed:${filters.pushed}`; + } + + return query.trim(); +} + +function parseSearchParams(searchParams: URLSearchParams): { + query: string; + filters: SearchFilters; +} { + const query = searchParams.get('q') || ''; + + const filters: SearchFilters = { + page: Math.max(1, parseInt(searchParams.get('page') || '1')), + per_page: Math.min(100, Math.max(1, parseInt(searchParams.get('per_page') || '30'))), + sort: searchParams.get('sort') || 'stars', + order: searchParams.get('order') || 'desc', + language: searchParams.get('language') || undefined, + topic: searchParams.get('topic') || undefined, + stars: searchParams.get('stars') || undefined, + forks: searchParams.get('forks') || undefined, + created: searchParams.get('created') || undefined, + pushed: searchParams.get('pushed') || undefined, + }; + + return { query, filters }; +} export async function GET(request: NextRequest) { + const startTime = Date.now(); + const clientId = getClientIdentifier(request); + const { searchParams } = new URL(request.url); + const { query, filters } = parseSearchParams(searchParams); + try { - const { searchParams } = new URL(request.url); - const query = searchParams.get('q'); - const page = parseInt(searchParams.get('page') || '1'); - const per_page = parseInt(searchParams.get('per_page') || '30'); - const sort = searchParams.get('sort') || 'stars'; - const order = searchParams.get('order') || 'desc'; + if (!searchRateLimiter.isAllowed(clientId)) { + const resetTime = searchRateLimiter.getResetTime(clientId); + const remaining = Math.ceil((resetTime - Date.now()) / 1000); + + // Track rate limit event + searchAnalytics.trackRateLimit(query, filters, clientId); + + return NextResponse.json( + { + error: 'Rate limit exceeded', + message: `Too many requests. Please try again in ${remaining} seconds.`, + retryAfter: remaining, + suggestions: [ + 'Use more specific search terms to get better results', + 'Try searching for specific topics or languages', + 'Consider using advanced search filters' + ] + }, + { + status: 429, + headers: { + 'Retry-After': remaining.toString(), + 'X-RateLimit-Limit': '30', + 'X-RateLimit-Remaining': searchRateLimiter.getRemainingRequests(clientId).toString(), + 'X-RateLimit-Reset': Math.ceil(resetTime / 1000).toString(), + } + } + ); + } if (!query) { return NextResponse.json( - { error: 'Search query is required' }, + { + error: 'Search query is required', + message: 'Please provide a search query using the "q" parameter', + examples: [ + '/api/github/search?q=react', + '/api/github/search?q=machine+learning&language=python', + '/api/github/search?q=web+framework&topic=javascript' + ] + }, { status: 400 } ); } - const response = await octokit.rest.search.repos({ - q: query, - sort: sort === 'created' ? 'updated' : sort as 'stars' | 'forks' | 'updated', - order: order as 'asc' | 'desc', - page, - per_page, + const cacheKey = createCacheKey(query, filters); + const cachedResult = searchCache.get(cacheKey) as any; + + if (cachedResult) { + const responseTime = Date.now() - startTime; + const searchQuery = buildSearchQuery(query, filters); + + // Track cached search + searchAnalytics.trackSearch({ + query: searchQuery, + filters, + results_count: cachedResult.total_count || 0, + response_time_ms: responseTime, + cached: true, + client_id: clientId, + }); + + return NextResponse.json({ + ...cachedResult, + meta: { + cached: true, + response_time_ms: responseTime, + rate_limit: { + remaining: searchRateLimiter.getRemainingRequests(clientId), + reset_at: searchRateLimiter.getResetTime(clientId), + } + } + }); + } + + const octokitInstance = getOctokitInstance(); + const searchQuery = buildSearchQuery(query, filters); + + const validSorts = ['stars', 'forks', 'updated', 'created'] as const; + const sort = validSorts.includes(filters.sort as typeof validSorts[number]) ? filters.sort as typeof validSorts[number] : 'stars'; + + const response = await octokitInstance.rest.search.repos({ + q: searchQuery, + sort: sort === 'created' ? 'updated' : sort, + order: filters.order as 'asc' | 'desc', + page: filters.page, + per_page: filters.per_page, }); const supabase = await createSupabaseServerClient(); const repositoriesWithStats = await Promise.all( response.data.items.map(async (repo) => { - const { data: existingRepo } = await supabase - .from('repositories') - .select('*') - .eq('repo_url', `https://github.com/${repo.full_name}`) - .single(); - - if (existingRepo) { - // Check if there's analysis data - const { data: analysisData } = await supabase - .from('repository_analysis') + try { + const { data: existingRepo } = await supabase + .from('repositories') .select('*') - .eq('repository_id', existingRepo.id) + .eq('repo_url', `https://github.com/${repo.full_name}`) .single(); - return { - ...repo, - analysis: analysisData ? { - statistics: [analysisData], - documentation: [], - last_analyzed: analysisData.updated_at, - } : undefined, - }; - } + if (existingRepo) { + const { data: analysisData } = await supabase + .from('repository_analysis') + .select('*') + .eq('repository_id', existingRepo.id) + .single(); - return repo; + return { + ...repo, + analysis: analysisData ? { + statistics: [analysisData], + documentation: [], + last_analyzed: analysisData.updated_at, + } : undefined, + }; + } + + return repo; + } catch (error) { + console.warn(`Failed to fetch analysis for ${repo.full_name}:`, error); + return repo; + } }) ); - // Skip search query logging for now since the table doesn't exist in the schema - // await supabase.from('search_queries').insert({ - // query, - // user_id: null, // No user tracking without authentication - // filters: { sort, order, page, per_page }, - // results_count: response.data.total_count, - // }); - - return NextResponse.json({ + const result = { repositories: repositoriesWithStats, total_count: response.data.total_count, incomplete_results: response.data.incomplete_results, + search_query: searchQuery, + filters: filters, + }; + + searchCache.set(cacheKey, result, 5 * 60 * 1000); // Cache for 5 minutes + + const responseTime = Date.now() - startTime; + + // Track successful search + searchAnalytics.trackSearch({ + query: searchQuery, + filters, + results_count: response.data.total_count, + response_time_ms: responseTime, + cached: false, + client_id: clientId, + }); + + try { + const searchLogData = { + query: searchQuery, + user_id: null, + filters: filters, + results_count: response.data.total_count, + response_time_ms: responseTime, + cached: false, + client_id: clientId, + timestamp: new Date().toISOString(), + github_api_remaining: 'unknown', // GitHub API headers not available in Octokit response + github_api_reset: 'unknown', + }; + + console.log('Search performed:', JSON.stringify(searchLogData, null, 2)); + + // Log cache and rate limit stats periodically + if (Math.random() < 0.1) { // 10% chance + console.log('Cache stats:', JSON.stringify(searchCache.getStats(), null, 2)); + console.log('Rate limit stats:', JSON.stringify(searchRateLimiter.getStats(), null, 2)); + console.log('Analytics stats:', JSON.stringify(searchAnalytics.getStats(), null, 2)); + } + } catch (logError) { + console.warn('Failed to log search query:', logError); + } + + return NextResponse.json({ + ...result, + meta: { + cached: false, + response_time_ms: responseTime, + rate_limit: { + remaining: searchRateLimiter.getRemainingRequests(clientId), + reset_at: searchRateLimiter.getResetTime(clientId), + } + } }); - } catch (error) { + + } catch (error: unknown) { console.error('GitHub search error:', error); + + const responseTime = Date.now() - startTime; + + const err = error as any; + + if (err?.status === 403 && err?.message?.includes('rate limit')) { + searchAnalytics.trackError(query, filters, 'GitHub API rate limit exceeded', clientId); + + return NextResponse.json( + { + error: 'GitHub API rate limit exceeded', + message: 'GitHub API rate limit has been exceeded. Please try again later.', + suggestions: [ + 'Try again in a few minutes', + 'Use more specific search terms', + 'Consider searching during off-peak hours' + ], + retryAfter: 60 + }, + { + status: 503, + headers: { + 'Retry-After': '60' + } + } + ); + } + + if (err?.status === 422) { + searchAnalytics.trackError(query, filters, 'Invalid search query', clientId); + + return NextResponse.json( + { + error: 'Invalid search query', + message: 'The search query contains invalid syntax or parameters.', + suggestions: [ + 'Check your search syntax', + 'Avoid special characters in search terms', + 'Use simpler search queries' + ] + }, + { status: 400 } + ); + } + + if (err?.message?.includes('GitHub token not configured')) { + searchAnalytics.trackError(query, filters, 'GitHub token not configured', clientId); + + return NextResponse.json( + { + error: 'Service configuration error', + message: 'GitHub API is not properly configured. Please contact the administrator.' + }, + { status: 503 } + ); + } + + searchAnalytics.trackError(query, filters, err?.message || 'Internal server error', clientId); + return NextResponse.json( - { error: 'Failed to search repositories' }, + { + error: 'Internal server error', + message: 'An unexpected error occurred while searching repositories.', + response_time_ms: responseTime + }, { status: 500 } ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 1fd205d..58f4e01 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -93,9 +93,15 @@ export default function Home() { value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-10 h-12 text-base" + aria-label="Search repositories" /> - diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index 00d4c10..ff14ae1 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -1,16 +1,11 @@ "use client"; import { useState, useEffect } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { Search, Filter, Star, GitFork, Calendar, FileText, BarChart3, ExternalLink } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Card, CardContent } from '@/components/ui/card'; -import { Badge } from '@/components/ui/badge'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Skeleton } from '@/components/ui/skeleton'; +import { useSearchParams, useRouter } from 'next/navigation'; +import { GitBranch } from 'lucide-react'; import { ThemeToggle } from '@/components/theme-toggle'; -// Authentication removed +import { EnhancedSearch } from '@/components/enhanced-search'; +import { SearchResults } from '@/components/search-results'; import Link from 'next/link'; import { useRepositorySearch } from '@/hooks/use-repository-search'; import { useRepositoryAnalysis } from '@/hooks/use-repository-analysis'; @@ -35,29 +30,59 @@ interface Repository { }; } -interface SearchResponse { - repositories: Repository[]; - total_count: number; - incomplete_results: boolean; +interface SearchFilters { + sort: string; + order: string; + language?: string; + topic?: string; + stars?: string; + forks?: string; + created?: string; + pushed?: string; } export default function SearchPage() { const searchParams = useSearchParams(); + const router = useRouter(); const [query, setQuery] = useState(searchParams.get('q') || ''); - const [sort, setSort] = useState('stars'); - const [order, setOrder] = useState('desc'); - const [page, setPage] = useState(1); + const [filters, setFilters] = useState({ + sort: searchParams.get('sort') || 'stars', + order: searchParams.get('order') || 'desc', + language: searchParams.get('language') || undefined, + topic: searchParams.get('topic') || undefined, + stars: searchParams.get('stars') || undefined, + forks: searchParams.get('forks') || undefined, + created: searchParams.get('created') || undefined, + pushed: searchParams.get('pushed') || undefined, + }); + const [page, setPage] = useState(parseInt(searchParams.get('page') || '1')); const { data: searchResults, isLoading: loading, error } = useRepositorySearch({ query, - sort, - order, + ...filters, page, per_page: 30, }); const { mutate: analyzeRepository, isPending: isAnalyzing } = useRepositoryAnalysis(); + useEffect(() => { + const params = new URLSearchParams(); + if (query) params.set('q', query); + if (filters.sort !== 'stars') params.set('sort', filters.sort); + if (filters.order !== 'desc') params.set('order', filters.order); + if (filters.language) params.set('language', filters.language); + if (filters.topic) params.set('topic', filters.topic); + if (filters.stars) params.set('stars', filters.stars); + if (filters.forks) params.set('forks', filters.forks); + if (filters.created) params.set('created', filters.created); + if (filters.pushed) params.set('pushed', filters.pushed); + if (page !== 1) params.set('page', page.toString()); + + const newUrl = `/search${params.toString() ? `?${params.toString()}` : ''}`; + router.replace(newUrl, { scroll: false }); + }, [query, filters, page, router]); + useEffect(() => { const q = searchParams.get('q'); if (q && q !== query) { @@ -66,29 +91,10 @@ export default function SearchPage() { } }, [searchParams, query]); - const handleSearch = (e: React.FormEvent) => { - e.preventDefault(); + const handleSearch = () => { setPage(1); }; - const formatNumber = (num: number) => { - if (num >= 1000000) { - return (num / 1000000).toFixed(1) + 'M'; - } - if (num >= 1000) { - return (num / 1000).toFixed(1) + 'k'; - } - return num.toString(); - }; - - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - }); - }; - const handleAnalyzeRepository = (repo: Repository) => { const [owner, name] = repo.full_name.split('/'); analyzeRepository({ owner, repo: name }); @@ -101,6 +107,7 @@ export default function SearchPage() {
+
Git Search
@@ -114,244 +121,29 @@ export default function SearchPage() {
- {/* Search Form */} -
-
-
- - setQuery(e.target.value)} - className="pl-10" - /> -
- -
- - {/* Sort Controls */} -
-
- - Sort by: -
- - -
-
- - {/* Results */} - {loading && ( -
- {[...Array(5)].map((_, i) => ( - - - - -
- - - -
-
-
- ))} -
- )} - - {searchResults && !loading && ( - <> -
-

- Found {formatNumber(searchResults.total_count)} repositories - {searchResults.incomplete_results && ' (results may be incomplete)'} -

-
- -
- {searchResults.repositories.map((repo) => ( - - -
-
-
-
- - {repo.full_name} - - - - -
- -
-
- - {formatNumber(repo.stargazers_count)} -
-
- - {formatNumber(repo.forks_count)} -
-
- - {formatDate(repo.updated_at)} -
-
-
- -

- {repo.description || 'No description available'} -

- -
- {repo.language && ( - {repo.language} - )} - {repo.topics?.slice(0, 2).map((topic) => ( - - {topic} - - ))} - {repo.topics?.length > 2 && ( - - +{repo.topics.length - 2} - - )} -
-
- -
- {repo.analysis?.statistics && repo.analysis.statistics.length > 0 ? ( - - - - ) : ( - - )} - - {repo.analysis?.last_analyzed && ( -

- {formatDate(repo.analysis.last_analyzed)} -

- )} -
-
-
-
- ))} -
- - {/* Pagination */} - {searchResults.total_count > 30 && ( -
- - - Page {page} - - -
- )} - - )} - - {searchResults && searchResults.repositories.length === 0 && !loading && ( - - -
🔍
-

No repositories found

-

- Try adjusting your search terms or filters -

-
-
- )} - - {!searchResults && !loading && query && ( - - -
👋
-

Welcome to Git Search

-

- Search GitHub repositories and get detailed analysis including: -

-
-
-
- - Lines of code & token estimates -
-
- - Project documentation -
-
-
-
- - Tech stack analysis -
-
- - Architecture flowcharts -
-
-
-
-
- )} + {/* Enhanced Search Component */} + + + {/* Enhanced Search Results Component */} +
); diff --git a/src/components/enhanced-search.tsx b/src/components/enhanced-search.tsx new file mode 100644 index 0000000..71c48a4 --- /dev/null +++ b/src/components/enhanced-search.tsx @@ -0,0 +1,510 @@ +"use client"; + +import React, { useState, useRef, useEffect } from 'react'; +import { Search, Filter, X, Loader2, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { Separator } from '@/components/ui/separator'; +import { Label } from '@/components/ui/label'; + +interface SearchFilters { + sort: string; + order: string; + language?: string; + topic?: string; + stars?: string; + forks?: string; + created?: string; + pushed?: string; +} + +interface EnhancedSearchProps { + query: string; + onQueryChange: (query: string) => void; + filters: SearchFilters; + onFiltersChange: (filters: SearchFilters) => void; + onSearch: () => void; + isLoading: boolean; + error?: Error | null; + className?: string; +} + +const LANGUAGES = [ + 'JavaScript', 'TypeScript', 'Python', 'Java', 'C++', 'C#', 'Go', 'Rust', + 'PHP', 'Ruby', 'Swift', 'Kotlin', 'Dart', 'HTML', 'CSS', 'Shell' +]; + +const TOPICS = [ + 'machine-learning', 'web-development', 'mobile-app', 'game-development', + 'data-science', 'artificial-intelligence', 'blockchain', 'devops', + 'frontend', 'backend', 'fullstack', 'api', 'microservices', 'docker' +]; + +const STAR_RANGES = [ + { label: 'Any', value: '' }, + { label: '1+', value: '>=1' }, + { label: '10+', value: '>=10' }, + { label: '100+', value: '>=100' }, + { label: '1k+', value: '>=1000' }, + { label: '10k+', value: '>=10000' }, +]; + +const FORK_RANGES = [ + { label: 'Any', value: '' }, + { label: '1+', value: '>=1' }, + { label: '5+', value: '>=5' }, + { label: '50+', value: '>=50' }, + { label: '500+', value: '>=500' }, +]; + +const DATE_RANGES = [ + { label: 'Any time', value: '' }, + { label: 'Past week', value: '>=2024-01-01' }, + { label: 'Past month', value: '>=2023-12-01' }, + { label: 'Past year', value: '>=2023-01-01' }, + { label: '2+ years ago', value: '<2022-01-01' }, +]; + +export function EnhancedSearch({ + query, + onQueryChange, + filters, + onFiltersChange, + onSearch, + isLoading, + error, + className = '' +}: EnhancedSearchProps) { + const [showAdvanced, setShowAdvanced] = useState(false); + const [suggestions, setSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const inputRef = useRef(null); + const suggestionsRef = useRef(null); + + const SEARCH_SUGGESTIONS = [ + 'react typescript', + 'machine learning python', + 'web framework javascript', + 'mobile app react-native', + 'blockchain ethereum', + 'data visualization d3', + 'api nodejs express', + 'ui components react', + 'game engine unity', + 'devops docker kubernetes' + ]; + + useEffect(() => { + if (query.length > 0) { + const filtered = SEARCH_SUGGESTIONS + .filter(suggestion => + suggestion.toLowerCase().includes(query.toLowerCase()) && + suggestion.toLowerCase() !== query.toLowerCase() + ) + .slice(0, 5); + setSuggestions(filtered); + setShowSuggestions(filtered.length > 0); + } else { + setSuggestions([]); + setShowSuggestions(false); + } + }, [query]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setShowSuggestions(false); + onSearch(); + }; + + const handleFilterChange = (key: keyof SearchFilters, value: string) => { + onFiltersChange({ + ...filters, + [key]: value || undefined + }); + }; + + const clearFilter = (key: keyof SearchFilters) => { + const newFilters = { ...filters }; + delete newFilters[key]; + onFiltersChange(newFilters); + }; + + const clearAllFilters = () => { + onFiltersChange({ + sort: filters.sort, + order: filters.order + }); + }; + + const hasActiveFilters = Object.keys(filters).some(key => + key !== 'sort' && key !== 'order' && filters[key as keyof SearchFilters] + ); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + setShowSuggestions(false); + } + }; + + const selectSuggestion = (suggestion: string) => { + onQueryChange(suggestion); + setShowSuggestions(false); + inputRef.current?.focus(); + }; + + return ( +
+ {/* Main Search Bar */} +
+
+
+
+ + onQueryChange(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => suggestions.length > 0 && setShowSuggestions(true)} + className="pl-10 h-12 text-base" + disabled={isLoading} + aria-label="Search repositories" + aria-expanded={showSuggestions} + aria-haspopup="listbox" + role="combobox" + /> + {query && ( + + )} +
+ + {/* Search Suggestions */} + {showSuggestions && suggestions.length > 0 && ( + + +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+
+
+ )} +
+ + +
+
+ + {/* Error Display */} + {error && ( + + + + {error.message === 'Rate limit exceeded' ? ( + <> + Too many requests. Please wait a moment before searching again. +
+ + Try using more specific search terms to get better results. + + + ) : error.message === 'GitHub API rate limit exceeded' ? ( + <> + GitHub API rate limit reached. Please try again later. +
+ + Consider searching during off-peak hours for better availability. + + + ) : ( + error.message || 'An error occurred while searching. Please try again.' + )} +
+
+ )} + + {/* Basic Filters */} +
+
+ + +
+ + + + + + + + + + +
+ + {/* Active Filters Display */} + {hasActiveFilters && ( +
+ Active filters: + {filters.language && ( + + Language: {filters.language} + + + )} + {filters.topic && ( + + Topic: {filters.topic} + + + )} + {filters.stars && ( + + Stars: {filters.stars} + + + )} + {filters.forks && ( + + Forks: {filters.forks} + + + )} + +
+ )} + + {/* Advanced Filters */} + + + +
+ {/* Language Filter */} +
+ + +
+ + {/* Topic Filter */} +
+ + +
+ + {/* Stars Filter */} +
+ + +
+ + {/* Forks Filter */} +
+ + +
+ + {/* Created Filter */} +
+ + +
+ + {/* Last Updated Filter */} +
+ + +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/search-results.tsx b/src/components/search-results.tsx new file mode 100644 index 0000000..a35b3a0 --- /dev/null +++ b/src/components/search-results.tsx @@ -0,0 +1,458 @@ +"use client"; + +import React from 'react'; +import { + Star, + GitFork, + Calendar, + FileText, + BarChart3, + ExternalLink, + Clock, + Zap, + TrendingUp, + AlertTriangle +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from '@/components/ui/pagination'; +import Link from 'next/link'; + +interface Repository { + id: number; + full_name: string; + name: string; + description: string; + html_url: string; + stargazers_count: number; + forks_count: number; + watchers_count: number; + language: string; + topics: string[]; + updated_at: string; + size: number; + analysis?: { + statistics: unknown[]; + documentation: unknown[]; + last_analyzed: string; + }; +} + +interface SearchMeta { + cached: boolean; + response_time_ms: number; + rate_limit: { + remaining: number; + reset_at: number; + }; +} + +interface SearchResponse { + repositories: Repository[]; + total_count: number; + incomplete_results: boolean; + search_query?: string; + filters?: Record; + meta?: SearchMeta; +} + +interface SearchResultsProps { + data?: SearchResponse; + isLoading: boolean; + error?: Error | null; + page: number; + onPageChange: (page: number) => void; + perPage?: number; + onAnalyzeRepository?: (repo: Repository) => void; + isAnalyzing?: boolean; + className?: string; +} + +export function SearchResults({ + data, + isLoading, + error, + page, + onPageChange, + perPage = 30, + onAnalyzeRepository, + isAnalyzing = false, + className = '' +}: SearchResultsProps) { + const formatNumber = (num: number) => { + if (num >= 1000000) { + return (num / 1000000).toFixed(1) + 'M'; + } + if (num >= 1000) { + return (num / 1000).toFixed(1) + 'k'; + } + return num.toString(); + }; + + const formatDate = (dateString: string) => { + const date = new Date(dateString); + const now = new Date(); + const diffTime = Math.abs(now.getTime() - date.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays === 1) return 'yesterday'; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; + if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`; + return `${Math.floor(diffDays / 365)} years ago`; + }; + + const getLanguageColor = (language: string) => { + const colors: Record = { + 'JavaScript': 'bg-yellow-500', + 'TypeScript': 'bg-blue-600', + 'Python': 'bg-blue-500', + 'Java': 'bg-orange-600', + 'C++': 'bg-blue-700', + 'C#': 'bg-purple-600', + 'Go': 'bg-cyan-500', + 'Rust': 'bg-orange-700', + 'PHP': 'bg-indigo-600', + 'Ruby': 'bg-red-600', + 'Swift': 'bg-orange-500', + 'Kotlin': 'bg-purple-500', + }; + return colors[language] || 'bg-gray-500'; + }; + + const totalPages = data ? Math.ceil(data.total_count / perPage) : 0; + + // Loading State + if (isLoading) { + return ( +
+
+ + +
+ {Array.from({ length: 5 }).map((_, i) => ( + + +
+
+ + +
+ + +
+ + + +
+
+ + + +
+
+
+
+ ))} +
+ ); + } + + // Error State + if (error && !data) { + return ( +
+ + + + {error.message || 'An error occurred while searching repositories.'} + + +
+ ); + } + + // No Data State + if (!data || data.repositories.length === 0) { + return ( +
+ + +
🔍
+

No repositories found

+

+ Try adjusting your search terms or filters to find what you're looking for. +

+
+

Suggestions:

+
    +
  • Use more general keywords
  • +
  • Remove some filters
  • +
  • Check for typos in your search query
  • +
  • Try different programming languages or topics
  • +
+
+
+
+
+ ); + } + + return ( +
+ {/* Results Header */} +
+
+

+ Found {formatNumber(data.total_count)} repositories + {data.incomplete_results && ( + (results may be incomplete) + )} +

+ {data.meta && ( +
+
+ + {data.meta.response_time_ms}ms +
+ {data.meta.cached && ( +
+ + Cached +
+ )} +
+ + {data.meta.rate_limit.remaining} requests left +
+
+ )} +
+ + {data.search_query && ( +
+ Query: {data.search_query} +
+ )} +
+ + {/* Results List */} +
+ {data.repositories.map((repo) => ( + + +
+
+ {/* Repository Header */} +
+
+ + {repo.full_name} + + + + +
+ + {/* Repository Stats */} +
+
+ + {formatNumber(repo.stargazers_count)} +
+
+ + {formatNumber(repo.forks_count)} +
+
+ + {formatDate(repo.updated_at)} +
+
+
+ + {/* Description */} +

+ {repo.description || 'No description available'} +

+ + {/* Tags */} +
+ {repo.language && ( + + +
+ + {/* Action Buttons */} +
+ {repo.analysis?.statistics && repo.analysis.statistics.length > 0 ? ( + + + + ) : onAnalyzeRepository ? ( + + ) : null} + + {repo.analysis?.last_analyzed && ( +

+ Analyzed {formatDate(repo.analysis.last_analyzed)} +

+ )} +
+
+ + + ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + + + onPageChange(Math.max(1, page - 1))} + className={page === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'} + aria-disabled={page === 1} + /> + + + {/* Show first page */} + {page > 3 && ( + <> + + onPageChange(1)} + className="cursor-pointer" + > + 1 + + + {page > 4 && ( + + ... + + )} + + )} + + {/* Show pages around current page */} + {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + const pageNum = Math.max(1, Math.min(totalPages, page - 2 + i)); + if (pageNum < 1 || pageNum > totalPages) return null; + if (page > 3 && pageNum === 1) return null; + if (page < totalPages - 2 && pageNum === totalPages) return null; + + return ( + + onPageChange(pageNum)} + isActive={pageNum === page} + className="cursor-pointer" + aria-current={pageNum === page ? 'page' : undefined} + > + {pageNum} + + + ); + })} + + {/* Show last page */} + {page < totalPages - 2 && ( + <> + {page < totalPages - 3 && ( + + ... + + )} + + onPageChange(totalPages)} + className="cursor-pointer" + > + {totalPages} + + + + )} + + + onPageChange(Math.min(totalPages, page + 1))} + className={page === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'} + aria-disabled={page === totalPages} + /> + + + +
+ )} + + {/* Results Footer */} + {data.repositories.length > 0 && ( +
+ Showing {(page - 1) * perPage + 1} to {Math.min(page * perPage, data.total_count)} of {formatNumber(data.total_count)} results +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/hooks/use-dashboard-stats.ts b/src/hooks/use-dashboard-stats.ts index 052b585..51b03fe 100644 --- a/src/hooks/use-dashboard-stats.ts +++ b/src/hooks/use-dashboard-stats.ts @@ -15,6 +15,36 @@ interface DashboardStats { }; analyzed_at: string; }>; + searchAnalytics: { + totalSearches24h: number; + uniqueQueries24h: number; + averageResponseTime: number; + cacheHitRate: number; + errorRate: number; + rateLimitRate: number; + topQueries: Array<{ query: string; count: number }>; + topFilters: Array<{ filter: string; value: string; count: number }>; + recentSearches: Array<{ + query: string; + results_count: number; + response_time_ms: number; + cached: boolean; + timestamp: string; + error?: string; + }>; + }; + systemStats: { + cache: { + size: number; + hitRate: number; + memoryUsage: number; + }; + rateLimiting: { + activeClients: number; + totalBlocked: number; + blockRate: number; + }; + }; } export function useDashboardStats() { diff --git a/src/hooks/use-repository-search.ts b/src/hooks/use-repository-search.ts index 6dd2435..8424362 100644 --- a/src/hooks/use-repository-search.ts +++ b/src/hooks/use-repository-search.ts @@ -20,10 +20,30 @@ interface Repository { }; } +interface SearchMeta { + cached: boolean; + response_time_ms: number; + rate_limit: { + remaining: number; + reset_at: number; + }; +} + interface SearchResponse { repositories: Repository[]; total_count: number; incomplete_results: boolean; + search_query?: string; + filters?: Record; + meta?: SearchMeta; +} + +interface SearchError { + error: string; + message: string; + suggestions?: string[]; + retryAfter?: number; + response_time_ms?: number; } interface UseRepositorySearchParams { @@ -32,14 +52,35 @@ interface UseRepositorySearchParams { order?: string; page?: number; per_page?: number; + language?: string; + topic?: string; + stars?: string; + forks?: string; + created?: string; + pushed?: string; } export function useRepositorySearch(params: UseRepositorySearchParams) { - const { query, sort = 'stars', order = 'desc', page = 1, per_page = 30 } = params; + const { + query, + sort = 'stars', + order = 'desc', + page = 1, + per_page = 30, + language, + topic, + stars, + forks, + created, + pushed + } = params; - return useQuery({ - queryKey: ['repository-search', { query, sort, order, page, per_page }], - queryFn: async () => { + return useQuery({ + queryKey: ['repository-search', { + query, sort, order, page, per_page, + language, topic, stars, forks, created, pushed + }], + queryFn: async (): Promise => { const searchParams = new URLSearchParams({ q: query, sort, @@ -48,14 +89,54 @@ export function useRepositorySearch(params: UseRepositorySearchParams) { per_page: per_page.toString(), }); + // Add optional filters + if (language) searchParams.set('language', language); + if (topic) searchParams.set('topic', topic); + if (stars) searchParams.set('stars', stars); + if (forks) searchParams.set('forks', forks); + if (created) searchParams.set('created', created); + if (pushed) searchParams.set('pushed', pushed); + const response = await fetch(`/api/github/search?${searchParams}`); + if (!response.ok) { - throw new Error('Failed to search repositories'); + const errorData: SearchError = await response.json().catch(() => ({ + error: 'Unknown error', + message: `HTTP ${response.status}: ${response.statusText}` + })); + + if (response.status === 429) { + const error = new Error('Rate limit exceeded'); + error.name = 'RateLimitError'; + throw error; + } else if (response.status === 503) { + const error = new Error(errorData.message || 'GitHub API rate limit exceeded'); + error.name = 'ServiceUnavailable'; + throw error; + } else if (response.status === 400) { + const error = new Error(errorData.message || 'Invalid search query'); + error.name = 'ValidationError'; + throw error; + } else { + const error = new Error(errorData.message || 'Failed to search repositories'); + error.name = 'SearchError'; + throw error; + } } + return response.json(); }, enabled: !!query?.trim(), - staleTime: 2 * 60 * 1000, // 2 minutes - gcTime: 5 * 60 * 1000, // 5 minutes + staleTime: 3 * 60 * 1000, // 3 minutes (longer due to caching) + gcTime: 10 * 60 * 1000, // 10 minutes + retry: (failureCount, error) => { + // Don't retry rate limit errors + if (error?.name === 'RateLimitError') return false; + // Don't retry validation errors + if (error?.name === 'ValidationError') return false; + // Retry other errors up to 2 times + return failureCount < 2; + }, + retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000), }); } \ No newline at end of file diff --git a/src/lib/analytics.ts b/src/lib/analytics.ts new file mode 100644 index 0000000..087dad4 --- /dev/null +++ b/src/lib/analytics.ts @@ -0,0 +1,232 @@ +interface SearchEvent { + id: string; + query: string; + filters: Record; + results_count: number; + response_time_ms: number; + cached: boolean; + client_id: string; + timestamp: number; + error?: string; + rate_limited?: boolean; +} + +interface SearchStats { + totalSearches: number; + uniqueQueries: number; + averageResponseTime: number; + cacheHitRate: number; + errorRate: number; + rateLimitRate: number; + topQueries: Array<{ query: string; count: number }>; + topFilters: Array<{ filter: string; value: string; count: number }>; + searchTrends: Array<{ hour: number; count: number }>; + responseTimePercentiles: { + p50: number; + p90: number; + p95: number; + p99: number; + }; +} + +class SearchAnalytics { + private events: SearchEvent[] = []; + private readonly maxEvents = 10000; // Keep last 10k events in memory + private cleanupInterval: NodeJS.Timeout; + + constructor() { + // Clean up old events every hour + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 60 * 60 * 1000); + } + + trackSearch(event: Omit): void { + const searchEvent: SearchEvent = { + ...event, + id: this.generateId(), + timestamp: Date.now(), + }; + + this.events.push(searchEvent); + + // Keep only the most recent events + if (this.events.length > this.maxEvents) { + this.events = this.events.slice(-this.maxEvents); + } + + console.log(`Analytics: Search tracked - Query: "${event.query}", Results: ${event.results_count}, Time: ${event.response_time_ms}ms`); + } + + trackError(query: string, filters: Record, error: string, client_id: string): void { + this.trackSearch({ + query, + filters, + results_count: 0, + response_time_ms: 0, + cached: false, + client_id, + error, + }); + } + + trackRateLimit(query: string, filters: Record, client_id: string): void { + this.trackSearch({ + query, + filters, + results_count: 0, + response_time_ms: 0, + cached: false, + client_id, + rate_limited: true, + }); + } + + getStats(timeRangeMs?: number): SearchStats { + const now = Date.now(); + const cutoff = timeRangeMs ? now - timeRangeMs : 0; + const relevantEvents = this.events.filter(event => event.timestamp >= cutoff); + + if (relevantEvents.length === 0) { + return this.getEmptyStats(); + } + + const totalSearches = relevantEvents.length; + const uniqueQueries = new Set(relevantEvents.map(e => e.query.toLowerCase().trim())).size; + + const responseTimes = relevantEvents.filter(e => e.response_time_ms > 0).map(e => e.response_time_ms); + const averageResponseTime = responseTimes.length > 0 + ? responseTimes.reduce((sum, time) => sum + time, 0) / responseTimes.length + : 0; + + const cachedEvents = relevantEvents.filter(e => e.cached); + const cacheHitRate = totalSearches > 0 ? (cachedEvents.length / totalSearches) * 100 : 0; + + const errorEvents = relevantEvents.filter(e => e.error); + const errorRate = totalSearches > 0 ? (errorEvents.length / totalSearches) * 100 : 0; + + const rateLimitEvents = relevantEvents.filter(e => e.rate_limited); + const rateLimitRate = totalSearches > 0 ? (rateLimitEvents.length / totalSearches) * 100 : 0; + + // Top queries + const queryCount = new Map(); + relevantEvents.forEach(event => { + const normalizedQuery = event.query.toLowerCase().trim(); + queryCount.set(normalizedQuery, (queryCount.get(normalizedQuery) || 0) + 1); + }); + const topQueries = Array.from(queryCount.entries()) + .map(([query, count]) => ({ query, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Top filters + const filterCount = new Map(); + relevantEvents.forEach(event => { + Object.entries(event.filters).forEach(([key, value]) => { + if (value && key !== 'sort' && key !== 'order') { + const filterKey = `${key}:${value}`; + filterCount.set(filterKey, (filterCount.get(filterKey) || 0) + 1); + } + }); + }); + const topFilters = Array.from(filterCount.entries()) + .map(([filter, count]) => { + const [filterName, filterValue] = filter.split(':'); + return { filter: filterName, value: filterValue, count }; + }) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + // Search trends by hour + const hourlyCount = new Array(24).fill(0); + relevantEvents.forEach(event => { + const hour = new Date(event.timestamp).getHours(); + hourlyCount[hour]++; + }); + const searchTrends = hourlyCount.map((count, hour) => ({ hour, count })); + + // Response time percentiles + const sortedTimes = responseTimes.sort((a, b) => a - b); + const responseTimePercentiles = { + p50: this.getPercentile(sortedTimes, 50), + p90: this.getPercentile(sortedTimes, 90), + p95: this.getPercentile(sortedTimes, 95), + p99: this.getPercentile(sortedTimes, 99), + }; + + return { + totalSearches, + uniqueQueries, + averageResponseTime: Math.round(averageResponseTime), + cacheHitRate: Math.round(cacheHitRate * 100) / 100, + errorRate: Math.round(errorRate * 100) / 100, + rateLimitRate: Math.round(rateLimitRate * 100) / 100, + topQueries, + topFilters, + searchTrends, + responseTimePercentiles, + }; + } + + getRecentSearches(limit = 50): SearchEvent[] { + return this.events + .slice(-limit) + .reverse(); // Most recent first + } + + getSearchHistory(query: string, limit = 10): SearchEvent[] { + const normalizedQuery = query.toLowerCase().trim(); + return this.events + .filter(event => event.query.toLowerCase().trim().includes(normalizedQuery)) + .slice(-limit) + .reverse(); // Most recent first + } + + private cleanup(): void { + const oneWeekAgo = Date.now() - (7 * 24 * 60 * 60 * 1000); + const originalLength = this.events.length; + + this.events = this.events.filter(event => event.timestamp > oneWeekAgo); + + const removedCount = originalLength - this.events.length; + if (removedCount > 0) { + console.log(`Analytics CLEANUP: ${removedCount} old events removed`); + } + } + + private generateId(): string { + return `search_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } + + private getPercentile(sortedArray: number[], percentile: number): number { + if (sortedArray.length === 0) return 0; + + const index = Math.ceil((percentile / 100) * sortedArray.length) - 1; + return sortedArray[Math.max(0, Math.min(index, sortedArray.length - 1))]; + } + + private getEmptyStats(): SearchStats { + return { + totalSearches: 0, + uniqueQueries: 0, + averageResponseTime: 0, + cacheHitRate: 0, + errorRate: 0, + rateLimitRate: 0, + topQueries: [], + topFilters: [], + searchTrends: new Array(24).fill(0).map((_, hour) => ({ hour, count: 0 })), + responseTimePercentiles: { p50: 0, p90: 0, p95: 0, p99: 0 }, + }; + } + + // Graceful shutdown + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + } +} + +export const searchAnalytics = new SearchAnalytics(); +export type { SearchEvent, SearchStats }; \ No newline at end of file diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..05c15b6 --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,244 @@ +interface CacheEntry { + data: T; + timestamp: number; + expiresAt: number; + hits: number; + lastAccessed: number; +} + +interface CacheStats { + size: number; + totalHits: number; + totalMisses: number; + hitRate: number; + oldestEntry?: number; + newestEntry?: number; + totalMemoryUsage: number; + averageEntrySize: number; +} + +class InMemoryCache { + private cache = new Map>(); + private readonly defaultTTL = 5 * 60 * 1000; // 5 minutes + private readonly maxSize = 1000; // Maximum cache entries + private readonly maxMemoryMB = 100; // Maximum memory usage in MB + private totalHits = 0; + private totalMisses = 0; + private cleanupInterval: NodeJS.Timeout; + + constructor() { + // Run cleanup every 2 minutes + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 2 * 60 * 1000); + } + + set(key: string, data: T, ttl?: number): void { + const now = Date.now(); + const expiration = ttl || this.defaultTTL; + + // Check if we need to evict entries + this.evictIfNeeded(); + + const entry: CacheEntry = { + data, + timestamp: now, + expiresAt: now + expiration, + hits: 0, + lastAccessed: now, + }; + + this.cache.set(key, entry); + + // Log cache set operation + console.log(`Cache SET: ${key} (TTL: ${expiration}ms, Size: ${this.cache.size})`); + } + + get(key: string): T | null { + const entry = this.cache.get(key); + + if (!entry) { + this.totalMisses++; + console.log(`Cache MISS: ${key}`); + return null; + } + + const now = Date.now(); + + if (now > entry.expiresAt) { + this.cache.delete(key); + this.totalMisses++; + console.log(`Cache EXPIRED: ${key}`); + return null; + } + + // Update access statistics + entry.hits++; + entry.lastAccessed = now; + this.totalHits++; + + console.log(`Cache HIT: ${key} (hits: ${entry.hits})`); + return entry.data as T; + } + + has(key: string): boolean { + const entry = this.cache.get(key); + + if (!entry) { + return false; + } + + if (Date.now() > entry.expiresAt) { + this.cache.delete(key); + return false; + } + + return true; + } + + delete(key: string): boolean { + const deleted = this.cache.delete(key); + if (deleted) { + console.log(`Cache DELETE: ${key}`); + } + return deleted; + } + + clear(): void { + const size = this.cache.size; + this.cache.clear(); + this.totalHits = 0; + this.totalMisses = 0; + console.log(`Cache CLEAR: ${size} entries removed`); + } + + size(): number { + this.cleanup(); + return this.cache.size; + } + + private cleanup(): void { + const now = Date.now(); + let expiredCount = 0; + + for (const [key, entry] of this.cache.entries()) { + if (now > entry.expiresAt) { + this.cache.delete(key); + expiredCount++; + } + } + + if (expiredCount > 0) { + console.log(`Cache CLEANUP: ${expiredCount} expired entries removed`); + } + } + + private evictIfNeeded(): void { + // Check size limit + if (this.cache.size >= this.maxSize) { + this.evictLeastRecentlyUsed(Math.floor(this.maxSize * 0.1)); // Remove 10% + } + + // Check memory limit + const memoryUsage = this.getMemoryUsageMB(); + if (memoryUsage > this.maxMemoryMB) { + this.evictLeastRecentlyUsed(Math.floor(this.cache.size * 0.2)); // Remove 20% + } + } + + private evictLeastRecentlyUsed(count: number): void { + // Sort entries by last accessed time (oldest first) + const sortedEntries = Array.from(this.cache.entries()) + .sort(([, a], [, b]) => a.lastAccessed - b.lastAccessed); + + let evictedCount = 0; + for (let i = 0; i < Math.min(count, sortedEntries.length); i++) { + const [key] = sortedEntries[i]; + this.cache.delete(key); + evictedCount++; + } + + if (evictedCount > 0) { + console.log(`Cache EVICTION: ${evictedCount} LRU entries removed`); + } + } + + private getMemoryUsageMB(): number { + let totalSize = 0; + for (const [key, entry] of this.cache.entries()) { + totalSize += key.length * 2; // Unicode characters are 2 bytes + totalSize += JSON.stringify(entry.data).length * 2; + totalSize += 64; // Approximate overhead per entry + } + return totalSize / (1024 * 1024); // Convert to MB + } + + getStats(): CacheStats { + this.cleanup(); + + const entries = Array.from(this.cache.values()); + const totalRequests = this.totalHits + this.totalMisses; + const hitRate = totalRequests > 0 ? (this.totalHits / totalRequests) * 100 : 0; + + const timestamps = entries.map(e => e.timestamp); + const memoryUsage = this.getMemoryUsageMB(); + + return { + size: this.cache.size, + totalHits: this.totalHits, + totalMisses: this.totalMisses, + hitRate: Math.round(hitRate * 100) / 100, + oldestEntry: timestamps.length > 0 ? Math.min(...timestamps) : undefined, + newestEntry: timestamps.length > 0 ? Math.max(...timestamps) : undefined, + totalMemoryUsage: Math.round(memoryUsage * 100) / 100, + averageEntrySize: entries.length > 0 ? Math.round((memoryUsage * 1024 * 1024) / entries.length) : 0, + }; + } + + getDetailedStats() { + this.cleanup(); + const stats = this.getStats(); + + return { + ...stats, + entries: Array.from(this.cache.entries()).map(([key, entry]) => ({ + key, + timestamp: entry.timestamp, + expiresAt: entry.expiresAt, + hits: entry.hits, + lastAccessed: entry.lastAccessed, + dataSize: JSON.stringify(entry.data).length, + ttl: entry.expiresAt - Date.now(), + age: Date.now() - entry.timestamp, + })), + memoryLimits: { + maxSize: this.maxSize, + maxMemoryMB: this.maxMemoryMB, + currentMemoryMB: this.getMemoryUsageMB(), + } + }; + } + + // Graceful shutdown + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + this.clear(); + } +} + +export const searchCache = new InMemoryCache(); + +export function createCacheKey(query: string, params: Record): string { + const sortedParams = Object.keys(params) + .sort() + .reduce((result, key) => { + if (params[key] !== undefined && params[key] !== null) { + result[key] = params[key]; + } + return result; + }, {} as Record); + + return `search:${query}:${JSON.stringify(sortedParams)}`; +} \ No newline at end of file diff --git a/src/lib/env-check.ts b/src/lib/env-check.ts index 3b9a9c3..d864c80 100644 --- a/src/lib/env-check.ts +++ b/src/lib/env-check.ts @@ -12,6 +12,9 @@ export function checkEnvironmentVariables() { openai: process.env.OPENAI_API_KEY, anthropic: process.env.ANTHROPIC_API_KEY, }, + github: { + token: process.env.GITHUB_TOKEN, + }, }; const status = { @@ -22,10 +25,11 @@ export function checkEnvironmentVariables() { requiredEnvVars.supabase.url && requiredEnvVars.supabase.anonKey ), ai: !!(requiredEnvVars.ai.openai || requiredEnvVars.ai.anthropic), + github: !!requiredEnvVars.github.token, allConfigured: false, }; - status.allConfigured = status.clerk && status.supabase && status.ai; + status.allConfigured = status.clerk && status.supabase && status.ai && status.github; return status; } @@ -62,5 +66,15 @@ export function getSetupInstructions() { ], envVars: ["OPENAI_API_KEY"], }, + { + service: "GitHub", + description: "GitHub API access for repository search", + steps: [ + "Go to https://github.com/settings/tokens", + "Create a personal access token with repo scope", + "Copy GITHUB_TOKEN to .env.local", + ], + envVars: ["GITHUB_TOKEN"], + }, ]; } diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts new file mode 100644 index 0000000..790dc83 --- /dev/null +++ b/src/lib/rate-limit.ts @@ -0,0 +1,231 @@ +interface RateLimitEntry { + count: number; + resetTime: number; + firstRequest: number; + lastRequest: number; + blocked: number; // Number of blocked requests +} + +interface RateLimitStats { + activeClients: number; + totalRequests: number; + totalBlocked: number; + blockRate: number; + maxRequestsPerWindow: number; + windowMs: number; + averageRequestsPerClient: number; + peakUsage: number; +} + +class RateLimiter { + private requests = new Map(); + private readonly maxRequests: number; + private readonly windowMs: number; + private readonly cleanupInterval: NodeJS.Timeout; + private totalRequests = 0; + private totalBlocked = 0; + private peakActiveClients = 0; + + constructor(maxRequests = 60, windowMs = 60 * 1000) { // 60 requests per minute by default + this.maxRequests = maxRequests; + this.windowMs = windowMs; + + // Run cleanup every 30 seconds + this.cleanupInterval = setInterval(() => { + this.cleanup(); + }, 30 * 1000); + } + + isAllowed(identifier: string): boolean { + const now = Date.now(); + const entry = this.requests.get(identifier); + + this.totalRequests++; + + // Track peak usage + if (this.requests.size > this.peakActiveClients) { + this.peakActiveClients = this.requests.size; + } + + if (!entry || now > entry.resetTime) { + this.requests.set(identifier, { + count: 1, + resetTime: now + this.windowMs, + firstRequest: now, + lastRequest: now, + blocked: 0, + }); + + console.log(`Rate Limit ALLOW: ${identifier} (1/${this.maxRequests})`); + return true; + } + + entry.lastRequest = now; + + if (entry.count >= this.maxRequests) { + entry.blocked++; + this.totalBlocked++; + + const resetIn = Math.ceil((entry.resetTime - now) / 1000); + console.log(`Rate Limit BLOCK: ${identifier} (${entry.count}/${this.maxRequests}, blocked: ${entry.blocked}, reset in: ${resetIn}s)`); + return false; + } + + entry.count++; + console.log(`Rate Limit ALLOW: ${identifier} (${entry.count}/${this.maxRequests})`); + return true; + } + + getRemainingRequests(identifier: string): number { + const entry = this.requests.get(identifier); + + if (!entry || Date.now() > entry.resetTime) { + return this.maxRequests; + } + + return Math.max(0, this.maxRequests - entry.count); + } + + getResetTime(identifier: string): number { + const entry = this.requests.get(identifier); + + if (!entry || Date.now() > entry.resetTime) { + return Date.now() + this.windowMs; + } + + return entry.resetTime; + } + + getClientInfo(identifier: string) { + const entry = this.requests.get(identifier); + const now = Date.now(); + + if (!entry || now > entry.resetTime) { + return { + exists: false, + remaining: this.maxRequests, + resetTime: now + this.windowMs, + windowStart: now, + requests: 0, + blocked: 0, + }; + } + + return { + exists: true, + remaining: Math.max(0, this.maxRequests - entry.count), + resetTime: entry.resetTime, + windowStart: entry.firstRequest, + requests: entry.count, + blocked: entry.blocked, + requestRate: entry.count / ((now - entry.firstRequest) / 1000), // requests per second + }; + } + + cleanup(): void { + const now = Date.now(); + let cleanedCount = 0; + + for (const [key, entry] of this.requests.entries()) { + if (now > entry.resetTime) { + this.requests.delete(key); + cleanedCount++; + } + } + + if (cleanedCount > 0) { + console.log(`Rate Limit CLEANUP: ${cleanedCount} expired entries removed`); + } + } + + getStats(): RateLimitStats { + this.cleanup(); + + const entries = Array.from(this.requests.values()); + const totalActiveRequests = entries.reduce((sum, entry) => sum + entry.count, 0); + const blockRate = this.totalRequests > 0 ? (this.totalBlocked / this.totalRequests) * 100 : 0; + const averageRequestsPerClient = entries.length > 0 ? totalActiveRequests / entries.length : 0; + + return { + activeClients: this.requests.size, + totalRequests: this.totalRequests, + totalBlocked: this.totalBlocked, + blockRate: Math.round(blockRate * 100) / 100, + maxRequestsPerWindow: this.maxRequests, + windowMs: this.windowMs, + averageRequestsPerClient: Math.round(averageRequestsPerClient * 100) / 100, + peakUsage: this.peakActiveClients, + }; + } + + getDetailedStats() { + this.cleanup(); + const stats = this.getStats(); + + return { + ...stats, + clients: Array.from(this.requests.entries()).map(([identifier, entry]) => ({ + identifier: identifier.substring(0, 20) + '...', // Truncate for privacy + requests: entry.count, + blocked: entry.blocked, + firstRequest: entry.firstRequest, + lastRequest: entry.lastRequest, + resetTime: entry.resetTime, + remaining: Math.max(0, this.maxRequests - entry.count), + timeToReset: Math.max(0, entry.resetTime - Date.now()), + requestRate: entry.count / ((Date.now() - entry.firstRequest) / 1000), + })), + }; + } + + // Reset limits for a specific client (admin function) + resetClient(identifier: string): boolean { + const deleted = this.requests.delete(identifier); + if (deleted) { + console.log(`Rate Limit RESET: ${identifier}`); + } + return deleted; + } + + // Temporarily increase limits for a client (admin function) + increaseLimit(identifier: string, additionalRequests: number, durationMs?: number): void { + const entry = this.requests.get(identifier); + const now = Date.now(); + + if (!entry || now > entry.resetTime) { + // Create new entry with increased limit + this.requests.set(identifier, { + count: 0, + resetTime: now + (durationMs || this.windowMs), + firstRequest: now, + lastRequest: now, + blocked: 0, + }); + } else { + // Effectively increase limit by reducing count + entry.count = Math.max(0, entry.count - additionalRequests); + } + + console.log(`Rate Limit INCREASE: ${identifier} (+${additionalRequests} requests)`); + } + + // Graceful shutdown + destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + } + this.requests.clear(); + } +} + +export const searchRateLimiter = new RateLimiter(30, 60 * 1000); // 30 requests per minute for search + +export function getClientIdentifier(request: Request): string { + const forwarded = request.headers.get('x-forwarded-for'); + const realIp = request.headers.get('x-real-ip'); + const userAgent = request.headers.get('user-agent') || 'unknown'; + + const ip = forwarded?.split(',')[0] || realIp || 'unknown'; + + return `${ip}:${userAgent.substring(0, 50)}`; +} \ No newline at end of file