|
| 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 | +} |
0 commit comments