+ {/* 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 */}
+
+
+
+
+ {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 */}
+
+
+
+
+
+ |
+ Endpoint
+ |
+
+ Limit
+ |
+
+ Total Requests
+ |
+
+ Rejected
+ |
+
+
+
+ {data?.rate_limits.map((rl) => (
+
+ | {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
+
+
+
+
+
+
+ |
+ Endpoint
+ |
+
+ IP Address
+ |
+
+ Total
+ |
+
+ Rejected
+ |
+
+
+
+ {data.rate_limits.flatMap((rl) =>
+ rl.by_ip.map((ipEntry) => (
+
+ | {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()