diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d7ec5ba..d2fcc54 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,7 +11,9 @@ 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'; +import { HistoryDiff } from './pages/HistoryDiff'; const queryClient = new QueryClient({ defaultOptions: { @@ -45,6 +47,7 @@ function App() { } /> } /> } /> + } /> } /> {/* Admin routes */} @@ -56,6 +59,14 @@ function App() { } /> + + + + } + /> {/* Default redirect */} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 09a4ae6..b29a736 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -148,8 +148,43 @@ export const analysisApi = { const response = await api.get(`/jobs?${params}`); return response.data; }, + + getCompanyHistory: async (companyName: string, limit = 20): Promise => { + const response = await api.get( + `/analyze/${encodeURIComponent(companyName)}/history?limit=${limit}` + ); + return response.data; + }, + + diffAnalyses: async (companyName: string, fromId: number, toId: number): Promise => { + const response = await api.get( + `/analyze/${encodeURIComponent(companyName)}/diff?from=${fromId}&to=${toId}` + ); + return response.data; + }, }; +// Analysis diff types +export interface AnalysisHistoryItem { + id: number; + analysis_type: string | null; + model: string | null; + timestamp: string; +} + +export interface AnalysisDiff { + company_name: string; + from_id: number; + to_id: number; + from_timestamp: string; + to_timestamp: string; + patent_count_delta: number; + added_patents: string[]; + removed_patents: string[]; + changed_fields: Record; + summary: string; +} + // Export API export const exportApi = { exportCsv: async (companyName: string): Promise => { @@ -201,6 +236,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 +277,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..d4b11b7 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, History, ShieldAlert } from 'lucide-react'; export function Layout() { const { user, isAdmin, logout } = useAuth(); @@ -18,11 +18,13 @@ export function Layout() { { to: '/batch', icon: Layers, label: 'Batch' }, { to: '/analytics', icon: BarChart3, label: 'Analytics' }, { to: '/compare', icon: GitCompareArrows, label: 'Compare' }, + { to: '/history-diff', icon: History, label: 'Diff' }, { to: '/about', icon: Info, label: 'About' }, ]; 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()