Skip to content

Commit e71228e

Browse files
authored
Merge pull request #240 from barry01-hash/feat/rate-limit-dashboard
2 parents 4a773bb + 3cb7d76 commit e71228e

4 files changed

Lines changed: 145 additions & 0 deletions

File tree

app/(merchant)/developers/page.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
import { useOfflineStore } from '@/lib/store/offlineStore';
1919
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
2020
import { Label } from '@/components/ui/label';
21+
import { RateLimitDisplay } from '@/components/developers/RateLimitDisplay';
2122

2223
const CodeExample = dynamic(() => import('@/components/developers/CodeExample').then(m => ({ default: m.CodeExample })), {
2324
loading: () => <Skeleton className="h-64 rounded-xl" />,
@@ -167,6 +168,8 @@ export default function DevelopersPage() {
167168
))}
168169
</div>
169170

171+
<RateLimitDisplay />
172+
170173
{/* Sandbox Credentials Card */}
171174
<Card className="border border-border bg-card shadow-sm">
172175
<CardHeader>

app/api/rate-limit/status/route.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
const RATE_LIMIT = 60;
4+
const WINDOW_SECONDS = 60;
5+
interface RateLimitWindow { count: number; resetAt: number; }
6+
const globalForRateLimits = globalThis as typeof globalThis & { rateLimitStatusWindows?: Map<string, RateLimitWindow>; };
7+
const windows = globalForRateLimits.rateLimitStatusWindows ?? new Map<string, RateLimitWindow>();
8+
globalForRateLimits.rateLimitStatusWindows = windows;
9+
10+
export const dynamic = "force-dynamic";
11+
12+
export function GET(request: NextRequest) {
13+
const now = Math.floor(Date.now() / 1000);
14+
const clientId = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || "local";
15+
const current = windows.get(clientId);
16+
const rateWindow = !current || current.resetAt <= now ? { count: 0, resetAt: now + WINDOW_SECONDS } : current;
17+
rateWindow.count = Math.min(RATE_LIMIT, rateWindow.count + 1);
18+
windows.set(clientId, rateWindow);
19+
const remaining = Math.max(0, RATE_LIMIT - rateWindow.count);
20+
const headers = { "Cache-Control": "no-store", "X-RateLimit-Limit": String(RATE_LIMIT), "X-RateLimit-Remaining": String(remaining), "X-RateLimit-Reset": String(rateWindow.resetAt) };
21+
return NextResponse.json({ limit: RATE_LIMIT, remaining, resetAt: rateWindow.resetAt }, { headers });
22+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useMemo, useState } from "react";
4+
import { AlertTriangle, Gauge, RefreshCcw } from "lucide-react";
5+
import { Button } from "@/components/ui/button";
6+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
7+
8+
interface RateLimitStatus { limit: number; remaining: number; resetAt: number; }
9+
10+
function formatCountdown(seconds: number) {
11+
const hours = Math.floor(seconds / 3600);
12+
const minutes = Math.floor((seconds % 3600) / 60);
13+
const secs = seconds % 60;
14+
return [hours, minutes, secs].map((value) => value.toString().padStart(2, "0")).join(":");
15+
}
16+
17+
export function RateLimitDisplay() {
18+
const [status, setStatus] = useState<RateLimitStatus | null>(null);
19+
const [secondsUntilReset, setSecondsUntilReset] = useState(0);
20+
const [error, setError] = useState<string | null>(null);
21+
const [isLoading, setIsLoading] = useState(true);
22+
23+
const loadStatus = useCallback(async () => {
24+
setIsLoading(true);
25+
setError(null);
26+
try {
27+
const response = await fetch("/api/rate-limit/status", { cache: "no-store" });
28+
const limit = Number(response.headers.get("X-RateLimit-Limit"));
29+
const remaining = Number(response.headers.get("X-RateLimit-Remaining"));
30+
const resetAt = Number(response.headers.get("X-RateLimit-Reset"));
31+
if (!response.ok || !Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(resetAt)) throw new Error();
32+
setStatus({ limit, remaining, resetAt });
33+
setSecondsUntilReset(Math.max(0, resetAt - Math.floor(Date.now() / 1000)));
34+
} catch {
35+
setError("Rate limit status is temporarily unavailable.");
36+
} finally {
37+
setIsLoading(false);
38+
}
39+
}, []);
40+
41+
useEffect(() => { void loadStatus(); }, [loadStatus]);
42+
useEffect(() => {
43+
if (!status) return;
44+
const timer = window.setInterval(() => {
45+
const remaining = Math.max(0, status.resetAt - Math.floor(Date.now() / 1000));
46+
setSecondsUntilReset(remaining);
47+
if (remaining === 0) void loadStatus();
48+
}, 1000);
49+
return () => window.clearInterval(timer);
50+
}, [loadStatus, status]);
51+
52+
const usagePercentage = useMemo(() => {
53+
if (!status || status.limit <= 0) return 0;
54+
return Math.min(100, Math.max(0, ((status.limit - status.remaining) / status.limit) * 100));
55+
}, [status]);
56+
const showWarning = usagePercentage >= 80;
57+
58+
return (
59+
<Card className="border border-border bg-card shadow-sm">
60+
<CardHeader className="flex flex-row items-start justify-between gap-4">
61+
<div>
62+
<CardTitle className="flex items-center gap-2 text-base font-semibold"><Gauge className="h-4 w-4 text-primary" /> API rate limit</CardTitle>
63+
<CardDescription>Current request allowance for this API client.</CardDescription>
64+
</div>
65+
<Button variant="ghost" size="icon" onClick={() => void loadStatus()} disabled={isLoading} aria-label="Refresh rate limit status">
66+
<RefreshCcw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
67+
</Button>
68+
</CardHeader>
69+
<CardContent className="space-y-4" aria-live="polite">
70+
{error ? <p className="text-sm text-destructive">{error}</p> : status ? <>
71+
<div className="flex items-end justify-between gap-4">
72+
<div><p className="text-3xl font-bold tabular-nums">{status.remaining.toLocaleString()}</p><p className="text-xs text-muted-foreground">requests remaining of {status.limit.toLocaleString()}</p></div>
73+
<div className="text-right"><p className="font-mono text-sm font-semibold tabular-nums">{formatCountdown(secondsUntilReset)}</p><p className="text-xs text-muted-foreground">until reset</p></div>
74+
</div>
75+
<div className="h-2.5 overflow-hidden rounded-full bg-muted" role="progressbar" aria-label="API rate limit usage" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(usagePercentage)}>
76+
<div className={`h-full rounded-full transition-all ${showWarning ? "bg-warning" : "bg-primary"}`} style={{ width: `${usagePercentage}%` }} />
77+
</div>
78+
<div className="flex items-center justify-between text-xs">
79+
<span className="text-muted-foreground">{Math.round(usagePercentage)}% used</span>
80+
{showWarning && <span className="flex items-center gap-1 font-medium text-warning"><AlertTriangle className="h-3.5 w-3.5" /> Approaching rate limit</span>}
81+
</div>
82+
</> : <p className="text-sm text-muted-foreground">Loading rate limit status…</p>}
83+
</CardContent>
84+
</Card>
85+
);
86+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { act, render, screen, waitFor } from "@testing-library/react";
2+
3+
import { RateLimitDisplay } from "../RateLimitDisplay";
4+
5+
describe("RateLimitDisplay", () => {
6+
afterEach(() => {
7+
jest.useRealTimers();
8+
jest.restoreAllMocks();
9+
});
10+
11+
it("shows header values, counts down, and warns at 80% usage", async () => {
12+
const now = new Date("2026-07-23T12:00:00Z");
13+
jest.useFakeTimers();
14+
jest.setSystemTime(now);
15+
global.fetch = jest.fn().mockResolvedValue({
16+
ok: true,
17+
headers: new Headers({
18+
"X-RateLimit-Limit": "100",
19+
"X-RateLimit-Remaining": "20",
20+
"X-RateLimit-Reset": String(Math.floor(now.getTime() / 1000) + 60),
21+
}),
22+
});
23+
24+
render(<RateLimitDisplay />);
25+
26+
await waitFor(() => expect(screen.getByText("20")).toBeInTheDocument());
27+
expect(screen.getByText("00:01:00")).toBeInTheDocument();
28+
expect(screen.getByText("80% used")).toBeInTheDocument();
29+
expect(screen.getByText("Approaching rate limit")).toBeInTheDocument();
30+
31+
act(() => { jest.advanceTimersByTime(1000); });
32+
expect(screen.getByText("00:00:59")).toBeInTheDocument();
33+
});
34+
});

0 commit comments

Comments
 (0)