diff --git a/src/App.tsx b/src/App.tsx index c794d380..3847e6fc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -74,6 +74,7 @@ const AdminEmailSettings = lazy(() => import("./pages/admin/AdminEmailSettings") const ErrorMonitoring = lazy(() => import("./pages/admin/ErrorMonitoring")); const ErrorLookup = lazy(() => import("./pages/admin/ErrorLookup")); const TraceViewer = lazy(() => import("./pages/admin/TraceViewer")); +const RateLimitMetrics = lazy(() => import("./pages/admin/RateLimitMetrics")); // User routes (lazy-loaded) const Profile = lazy(() => import("./pages/Profile")); @@ -396,6 +397,14 @@ function AppContent(): React.JSX.Element { } /> + + + + } + /> {/* Utility routes - lazy loaded */} } /> diff --git a/src/components/layout/AdminSidebar.tsx b/src/components/layout/AdminSidebar.tsx index b3208d22..066aa4ec 100644 --- a/src/components/layout/AdminSidebar.tsx +++ b/src/components/layout/AdminSidebar.tsx @@ -1,4 +1,4 @@ -import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle } from 'lucide-react'; +import { LayoutDashboard, FileText, Flag, Users, Settings, ArrowLeft, ScrollText, BookOpen, Inbox, Mail, AlertTriangle, Shield } from 'lucide-react'; import { NavLink } from 'react-router-dom'; import { useUserRole } from '@/hooks/useUserRole'; import { useSidebar } from '@/hooks/useSidebar'; @@ -53,6 +53,11 @@ export function AdminSidebar() { url: '/admin/error-monitoring', icon: AlertTriangle, }, + { + title: 'Rate Limit Metrics', + url: '/admin/rate-limit-metrics', + icon: Shield, + }, { title: 'Users', url: '/admin/users', diff --git a/src/hooks/useRateLimitMetrics.ts b/src/hooks/useRateLimitMetrics.ts new file mode 100644 index 00000000..cc8fa0f5 --- /dev/null +++ b/src/hooks/useRateLimitMetrics.ts @@ -0,0 +1,75 @@ +import { useQuery } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; + +export interface RateLimitMetric { + timestamp: number; + functionName: string; + clientIP: string; + userId?: string; + allowed: boolean; + remaining: number; + retryAfter?: number; + tier: string; +} + +export interface MetricsStats { + totalRequests: number; + allowedRequests: number; + blockedRequests: number; + blockRate: number; + uniqueIPs: number; + uniqueUsers: number; + topBlockedIPs: Array<{ ip: string; count: number }>; + topBlockedUsers: Array<{ userId: string; count: number }>; + tierDistribution: Record; +} + +interface MetricsQueryParams { + action: 'stats' | 'recent' | 'function' | 'user' | 'ip'; + limit?: number; + timeWindow?: number; + functionName?: string; + userId?: string; + clientIP?: string; +} + +export function useRateLimitMetrics(params: MetricsQueryParams) { + return useQuery({ + queryKey: ['rateLimitMetrics', params], + queryFn: async () => { + const queryParams = new URLSearchParams(); + queryParams.set('action', params.action); + + if (params.limit) queryParams.set('limit', params.limit.toString()); + if (params.timeWindow) queryParams.set('timeWindow', params.timeWindow.toString()); + if (params.functionName) queryParams.set('functionName', params.functionName); + if (params.userId) queryParams.set('userId', params.userId); + if (params.clientIP) queryParams.set('clientIP', params.clientIP); + + const { data, error } = await supabase.functions.invoke('rate-limit-metrics', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + body: queryParams, + }); + + if (error) throw error; + return data; + }, + refetchInterval: 30000, // Refetch every 30 seconds + staleTime: 15000, // Consider data stale after 15 seconds + }); +} + +export function useRateLimitStats(timeWindow: number = 60000) { + return useRateLimitMetrics({ action: 'stats', timeWindow }); +} + +export function useRecentMetrics(limit: number = 100) { + return useRateLimitMetrics({ action: 'recent', limit }); +} + +export function useFunctionMetrics(functionName: string, limit: number = 100) { + return useRateLimitMetrics({ action: 'function', functionName, limit }); +} diff --git a/src/pages/admin/RateLimitMetrics.tsx b/src/pages/admin/RateLimitMetrics.tsx new file mode 100644 index 00000000..47d01a55 --- /dev/null +++ b/src/pages/admin/RateLimitMetrics.tsx @@ -0,0 +1,337 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAuth } from '@/hooks/useAuth'; +import { useUserRole } from '@/hooks/useUserRole'; +import { useRateLimitStats, useRecentMetrics } from '@/hooks/useRateLimitMetrics'; +import { useDocumentTitle } from '@/hooks/useDocumentTitle'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, LineChart, Line, Legend } from 'recharts'; +import { Activity, Shield, TrendingUp, Users, Clock, AlertTriangle } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { format } from 'date-fns'; + +const COLORS = ['hsl(var(--primary))', 'hsl(var(--secondary))', 'hsl(var(--accent))', 'hsl(var(--muted))', 'hsl(var(--destructive))']; + +export default function RateLimitMetrics() { + useDocumentTitle('Rate Limit Metrics'); + const navigate = useNavigate(); + const { user } = useAuth(); + const { isModerator, loading: rolesLoading } = useUserRole(); + const [timeWindow, setTimeWindow] = useState(60000); // 1 minute default + + const { data: stats, isLoading: statsLoading, error: statsError } = useRateLimitStats(timeWindow); + const { data: recentData, isLoading: recentLoading } = useRecentMetrics(50); + + // Redirect if not authorized + if (!rolesLoading && !isModerator()) { + navigate('/'); + return null; + } + + if (!user || rolesLoading) { + return ( +
+ +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+ ); + } + + const recentMetrics = recentData?.metrics || []; + + // Prepare chart data + const tierData = stats?.tierDistribution ? Object.entries(stats.tierDistribution).map(([name, value]) => ({ + name, + value, + })) : []; + + const topBlockedIPsData = stats?.topBlockedIPs || []; + const topBlockedUsersData = stats?.topBlockedUsers || []; + + // Calculate block rate percentage + const blockRatePercentage = stats?.blockRate ? (stats.blockRate * 100).toFixed(1) : '0.0'; + + return ( +
+ {/* Header */} +
+
+

Rate Limit Metrics

+

Monitor rate limiting activity and patterns

+
+ +
+ + {statsError && ( + + + + Failed to load metrics: {statsError instanceof Error ? statsError.message : 'Unknown error'} + + + )} + + {/* Overview Stats */} + {statsLoading ? ( +
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+ ) : ( +
+ + + Total Requests + + + +
{stats?.totalRequests || 0}
+

+ {stats?.allowedRequests || 0} allowed, {stats?.blockedRequests || 0} blocked +

+
+
+ + + + Block Rate + + + +
{blockRatePercentage}%
+

+ Percentage of blocked requests +

+
+
+ + + + Unique IPs + + + +
{stats?.uniqueIPs || 0}
+

+ Distinct client addresses +

+
+
+ + + + Unique Users + + + +
{stats?.uniqueUsers || 0}
+

+ Authenticated users +

+
+
+
+ )} + + + + Overview + Blocked Requests + Recent Activity + + + +
+ {/* Tier Distribution */} + + + Tier Distribution + Requests by rate limit tier + + + {tierData.length > 0 ? ( + + + `${name}: ${(percent * 100).toFixed(0)}%`} + outerRadius={80} + fill="hsl(var(--primary))" + dataKey="value" + > + {tierData.map((entry, index) => ( + + ))} + + + + + ) : ( +
+ No data available +
+ )} +
+
+ + {/* Request Status */} + + + Request Status + Allowed vs blocked requests + + + + + + + + + + + + + +
+
+ + +
+ {/* Top Blocked IPs */} + + + Top Blocked IPs + Most frequently blocked IP addresses + + + {topBlockedIPsData.length > 0 ? ( + + + + + + + + + + ) : ( +
+ No blocked IPs in this time window +
+ )} +
+
+ + {/* Top Blocked Users */} + + + Top Blocked Users + Most frequently blocked authenticated users + + + {topBlockedUsersData.length > 0 ? ( +
+ {topBlockedUsersData.map((user, idx) => ( +
+ {user.userId} + {user.count} +
+ ))} +
+ ) : ( +
+ No blocked users in this time window +
+ )} +
+
+
+
+ + + + + Recent Activity + Last 50 rate limit checks + + + {recentLoading ? ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ ) : recentMetrics.length > 0 ? ( +
+ {recentMetrics.map((metric, idx) => ( +
+
+ +
+
+ {metric.functionName} + + {metric.allowed ? 'Allowed' : 'Blocked'} + + {metric.tier} +
+
+ IP: {metric.clientIP} {metric.userId && `• User: ${metric.userId.slice(0, 8)}...`} +
+
+
+
+
+ {metric.allowed ? `${metric.remaining} left` : `Retry: ${metric.retryAfter}s`} +
+
+ {format(new Date(metric.timestamp), 'HH:mm:ss')} +
+
+
+ ))} +
+ ) : ( +
+ No recent activity +
+ )} +
+
+
+
+
+ ); +}