diff --git a/SPARC/api.py b/SPARC/api.py index 1b29d38..b6095bd 100644 --- a/SPARC/api.py +++ b/SPARC/api.py @@ -5,8 +5,9 @@ Provides REST API endpoints for analyzing company patent portfolios. from __future__ import annotations +from collections import deque from contextlib import asynccontextmanager -from datetime import datetime +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, Annotated, List if TYPE_CHECKING: @@ -248,6 +249,9 @@ app.state.limiter = limiter # In-memory rate limit statistics _rate_limit_stats: dict[str, dict] = {} +# Time-series log of rejected requests (capped to last 24 h worth of entries). +_rejected_log: deque[dict] = deque(maxlen=100_000) + def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) -> None: """Record a request against a rate-limited endpoint.""" @@ -262,6 +266,11 @@ def _track_rate_limit_request(endpoint: str, ip: str, rejected: bool = False) -> _rate_limit_stats[key]["total_requests"] += 1 if rejected: _rate_limit_stats[key]["rejected_requests"] += 1 + _rejected_log.append({ + "endpoint": endpoint, + "ip": ip, + "timestamp": datetime.now(timezone.utc).isoformat(), + }) ip_stats = _rate_limit_stats[key].setdefault("by_ip", {}) if ip not in ip_stats: ip_stats[ip] = {"total": 0, "rejected": 0} @@ -507,10 +516,12 @@ async def get_rate_limit_stats( """Get rate limit status and usage statistics (admin only). Returns current rate limit configuration and request statistics - for all rate-limited endpoints. + for all rate-limited endpoints, including per-IP breakdown and + a time-series of throttled (rejected) requests in the last 24 hours. Returns: - List of rate limit stats per endpoint with total/rejected counts + Rate limit stats per endpoint, per-IP breakdown, and throttled + request history bucketed by hour. """ rate_limits_config = { "/auth/register": {"limit": "5/minute"}, @@ -520,14 +531,45 @@ async def get_rate_limit_stats( results = [] for endpoint, conf in rate_limits_config.items(): stats = _rate_limit_stats.get(endpoint, {}) + by_ip_raw = stats.get("by_ip", {}) + by_ip = [ + {"ip": ip, "total": counts["total"], "rejected": counts["rejected"]} + for ip, counts in by_ip_raw.items() + ] results.append({ "endpoint": endpoint, "limit": conf["limit"], "total_requests": stats.get("total_requests", 0), "rejected_requests": stats.get("rejected_requests", 0), + "by_ip": by_ip, }) - return {"rate_limits": results} + # Build hourly buckets of throttled requests for the last 24 hours + now = datetime.now(timezone.utc) + cutoff = now - timedelta(hours=24) + hourly_buckets: dict[str, int] = {} + throttled_24h = 0 + for entry in _rejected_log: + ts_str = entry["timestamp"] + try: + ts = datetime.fromisoformat(ts_str) + except (ValueError, TypeError): + continue + if ts >= cutoff: + throttled_24h += 1 + bucket = ts.strftime("%Y-%m-%dT%H:00:00Z") + hourly_buckets[bucket] = hourly_buckets.get(bucket, 0) + 1 + + throttled_over_time = [ + {"timestamp": k, "count": v} + for k, v in sorted(hourly_buckets.items()) + ] + + return { + "rate_limits": results, + "throttled_24h": throttled_24h, + "throttled_over_time": throttled_over_time, + } @app.get("/admin/alerts", tags=["Admin"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d7ec5ba..41883b0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { Batch } from './pages/Batch'; import { AnalyticsPage } from './pages/Analytics'; import { About } from './pages/About'; import { AdminUsers } from './pages/AdminUsers'; +import { AdminRateLimits } from './pages/AdminRateLimits'; import { Compare } from './pages/Compare'; const queryClient = new QueryClient({ @@ -56,6 +57,14 @@ function App() { } /> + + + + } + /> {/* Default redirect */} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 09a4ae6..bbfe6cb 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -201,6 +201,32 @@ export const analyticsApi = { }, }; +// Rate limit types +export interface RateLimitIpEntry { + ip: string; + total: number; + rejected: number; +} + +export interface RateLimitEndpointStats { + endpoint: string; + limit: string; + total_requests: number; + rejected_requests: number; + by_ip: RateLimitIpEntry[]; +} + +export interface ThrottledBucket { + timestamp: string; + count: number; +} + +export interface RateLimitStatsResponse { + rate_limits: RateLimitEndpointStats[]; + throttled_24h: number; + throttled_over_time: ThrottledBucket[]; +} + // Admin API export const adminApi = { listUsers: async (limit = 100, offset = 0): Promise => { @@ -216,6 +242,11 @@ export const adminApi = { deleteUser: async (userId: number): Promise => { await api.delete(`/admin/users/${userId}`); }, + + getRateLimits: async (): Promise => { + const response = await api.get('/admin/rate-limits'); + return response.data; + }, }; export default api; diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index d0df715..d1bfe41 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,7 +1,7 @@ import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext'; import { useTheme } from '../context/ThemeContext'; -import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon } from 'lucide-react'; +import { Search, Layers, BarChart3, Info, Users, LogOut, GitCompareArrows, Sun, Moon, ShieldAlert } from 'lucide-react'; export function Layout() { const { user, isAdmin, logout } = useAuth(); @@ -23,6 +23,7 @@ export function Layout() { if (isAdmin) { navItems.push({ to: '/admin/users', icon: Users, label: 'Users' }); + navItems.push({ to: '/admin/rate-limits', icon: ShieldAlert, label: 'Rate Limits' }); } return ( diff --git a/frontend/src/pages/AdminRateLimits.tsx b/frontend/src/pages/AdminRateLimits.tsx new file mode 100644 index 0000000..97b41c4 --- /dev/null +++ b/frontend/src/pages/AdminRateLimits.tsx @@ -0,0 +1,240 @@ +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { adminApi } from '../api/client'; +import type { RateLimitStatsResponse } from '../api/client'; +import { ShieldAlert, Activity, AlertCircle, RefreshCw, Clock } from 'lucide-react'; + +const REFRESH_OPTIONS = [ + { label: '15s', value: 15_000 }, + { label: '30s', value: 30_000 }, + { label: '1m', value: 60_000 }, + { label: 'Off', value: 0 }, +]; + +export function AdminRateLimits() { + const [refreshInterval, setRefreshInterval] = useState(30_000); + + const { data, isLoading, isError, dataUpdatedAt } = useQuery({ + queryKey: ['admin-rate-limits'], + queryFn: () => adminApi.getRateLimits(), + refetchInterval: refreshInterval || false, + }); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (isError) { + return ( +
+ + Failed to load rate limit statistics. +
+ ); + } + + const maxThrottledCount = data?.throttled_over_time?.length + ? Math.max(...data.throttled_over_time.map((b) => b.count)) + : 0; + + return ( +
+ {/* Header */} +
+
+

+ Rate Limiting Dashboard +

+

Monitor API rate limits and throttled requests.

+
+
+ {/* Last updated */} + {dataUpdatedAt > 0 && ( + + + Updated {new Date(dataUpdatedAt).toLocaleTimeString()} + + )} + {/* Refresh interval selector */} +
+ + {REFRESH_OPTIONS.map((opt) => ( + + ))} +
+
+
+ + {/* Summary cards */} +
+
+
+ + + Total Requests + +
+
+ {data?.rate_limits.reduce((sum, rl) => sum + rl.total_requests, 0) ?? 0} +
+
+
+
+ + + Throttled (24h) + +
+
+ {data?.throttled_24h ?? 0} +
+
+
+
+ + + Rate-Limited Endpoints + +
+
+ {data?.rate_limits.length ?? 0} +
+
+
+ + {/* Throttled over time chart (simple bar chart) */} + {data?.throttled_over_time && data.throttled_over_time.length > 0 && ( +
+

+ Throttled Requests Over Time (Last 24h) +

+
+ {data.throttled_over_time.map((bucket) => { + const height = maxThrottledCount > 0 ? (bucket.count / maxThrottledCount) * 100 : 0; + const hour = new Date(bucket.timestamp).getHours(); + return ( +
+ {bucket.count} +
+ {hour}:00 +
+ ); + })} +
+
+ )} + + {/* Per-endpoint table */} +
+
+ + + + + + + + + + + {data?.rate_limits.map((rl) => ( + + + + + + + ))} + +
+ Endpoint + + Limit + + Total Requests + + Rejected +
{rl.endpoint} + + {rl.limit} + + + {rl.total_requests} + + 0 ? 'text-error font-semibold' : 'text-text-secondary'}> + {rl.rejected_requests} + +
+
+
+ + {/* Per-IP breakdown */} + {data?.rate_limits.some((rl) => rl.by_ip.length > 0) && ( +
+
+

+ Per-IP Breakdown +

+
+
+ + + + + + + + + + + {data.rate_limits.flatMap((rl) => + rl.by_ip.map((ipEntry) => ( + + + + + + + )) + )} + +
+ Endpoint + + IP Address + + Total + + Rejected +
{rl.endpoint}{ipEntry.ip}{ipEntry.total} + 0 ? 'text-error font-semibold' : 'text-text-secondary'}> + {ipEntry.rejected} + +
+
+
+ )} +
+ ); +} diff --git a/tests/test_rate_limit_admin.py b/tests/test_rate_limit_admin.py index bc63a5a..f10e9da 100644 --- a/tests/test_rate_limit_admin.py +++ b/tests/test_rate_limit_admin.py @@ -20,8 +20,10 @@ def client(): def reset_stats(): """Reset rate limit stats between tests.""" api._rate_limit_stats.clear() + api._rejected_log.clear() yield api._rate_limit_stats.clear() + api._rejected_log.clear() def _mock_admin(): @@ -50,8 +52,7 @@ class TestRateLimitAdminEndpoint: app.dependency_overrides.clear() def test_non_admin_rejected(self, client): - """Non-admin users should get 403.""" - # Without overriding the dependency, it should fail auth + """Non-admin users should get 401/403.""" response = client.get("/admin/rate-limits") assert response.status_code in (401, 403) @@ -77,6 +78,9 @@ class TestRateLimitAdminEndpoint: for rl in data["rate_limits"]: assert rl["total_requests"] == 0 assert rl["rejected_requests"] == 0 + assert rl["by_ip"] == [] + assert data["throttled_24h"] == 0 + assert data["throttled_over_time"] == [] finally: app.dependency_overrides.clear() @@ -107,3 +111,68 @@ class TestRateLimitAdminEndpoint: assert isinstance(rl["limit"], str) finally: app.dependency_overrides.clear() + + def test_per_ip_breakdown(self, client): + """Stats should include per-IP breakdown with total and rejected counts.""" + api._track_rate_limit_request("/auth/login", "10.0.0.1") + api._track_rate_limit_request("/auth/login", "10.0.0.1", rejected=True) + api._track_rate_limit_request("/auth/login", "10.0.0.2") + + app.dependency_overrides[api.get_current_admin] = _mock_admin + try: + response = client.get("/admin/rate-limits") + data = response.json() + login_stats = next(rl for rl in data["rate_limits"] if rl["endpoint"] == "/auth/login") + by_ip = login_stats["by_ip"] + assert len(by_ip) == 2 + ip1 = next(entry for entry in by_ip if entry["ip"] == "10.0.0.1") + assert ip1["total"] == 2 + assert ip1["rejected"] == 1 + ip2 = next(entry for entry in by_ip if entry["ip"] == "10.0.0.2") + assert ip2["total"] == 1 + assert ip2["rejected"] == 0 + finally: + app.dependency_overrides.clear() + + def test_throttled_24h_count(self, client): + """Should report total throttled requests in the last 24 hours.""" + api._track_rate_limit_request("/auth/login", "10.0.0.1", rejected=True) + api._track_rate_limit_request("/auth/register", "10.0.0.2", rejected=True) + + app.dependency_overrides[api.get_current_admin] = _mock_admin + try: + response = client.get("/admin/rate-limits") + data = response.json() + assert data["throttled_24h"] == 2 + finally: + app.dependency_overrides.clear() + + def test_throttled_over_time_structure(self, client): + """Throttled-over-time should be a list of {timestamp, count} buckets.""" + api._track_rate_limit_request("/auth/login", "10.0.0.1", rejected=True) + + app.dependency_overrides[api.get_current_admin] = _mock_admin + try: + response = client.get("/admin/rate-limits") + data = response.json() + assert len(data["throttled_over_time"]) >= 1 + entry = data["throttled_over_time"][0] + assert "timestamp" in entry + assert "count" in entry + assert entry["count"] >= 1 + finally: + app.dependency_overrides.clear() + + def test_response_shape_matches_contract(self, client): + """The full response should match the expected shape for the frontend.""" + app.dependency_overrides[api.get_current_admin] = _mock_admin + try: + response = client.get("/admin/rate-limits") + data = response.json() + # Top-level keys + assert set(data.keys()) == {"rate_limits", "throttled_24h", "throttled_over_time"} + # Each rate_limit entry + for rl in data["rate_limits"]: + assert set(rl.keys()) == {"endpoint", "limit", "total_requests", "rejected_requests", "by_ip"} + finally: + app.dependency_overrides.clear()