veza/apps/web/src/components/RateLimitIndicator.tsx

132 lines
3.7 KiB
TypeScript
Raw Normal View History

/**
* Rate Limit Indicator Component
* Action 5.4.1.2: Display rate limit status to users
*/
import { useEffect, useState } from 'react';
import { useRateLimitStore } from '@/stores/rateLimit';
import { AlertTriangle, Clock } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* RateLimitIndicator - Displays current rate limit status
*
* Shows rate limit information when:
* - User is rate limited (isLimited = true)
* - Remaining requests are low (< 20% of limit)
*
* @example
* ```tsx
* <RateLimitIndicator />
* ```
*/
export function RateLimitIndicator() {
const { limit, remaining, reset, isLimited } = useRateLimitStore();
const [timeUntilReset, setTimeUntilReset] = useState<number | null>(null);
// Calculate time until reset
useEffect(() => {
if (!reset) {
setTimeUntilReset(null);
return;
}
const updateTimer = () => {
const now = Math.floor(Date.now() / 1000);
const secondsUntilReset = reset - now;
setTimeUntilReset(secondsUntilReset > 0 ? secondsUntilReset : 0);
};
// Update immediately
updateTimer();
// Update every second
const interval = setInterval(updateTimer, 1000);
return () => clearInterval(interval);
}, [reset]);
// Calculate percentage remaining
const percentageRemaining =
limit !== null && remaining !== null && limit > 0
? (remaining / limit) * 100
: null;
// Show indicator if:
// 1. User is rate limited, OR
// 2. Remaining is low (< 20% of limit) and we have data
const shouldShow =
isLimited ||
(limit !== null &&
remaining !== null &&
percentageRemaining !== null &&
percentageRemaining < 20);
if (!shouldShow || limit === null) {
return null;
}
// Format time until reset
const formatTimeUntilReset = (seconds: number): string => {
if (seconds <= 0) return '0s';
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) {
return remainingSeconds > 0
? `${minutes}m ${remainingSeconds}s`
: `${minutes}m`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return remainingMinutes > 0
? `${hours}h ${remainingMinutes}m`
: `${hours}h`;
};
// Determine severity and styling
const isCritical = isLimited || (remaining !== null && remaining <= 0);
return (
<div
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-all',
isCritical
? 'bg-kodo-red/10 text-kodo-red border border-kodo-red/30'
: 'bg-kodo-gold/10 text-kodo-gold border border-kodo-gold/30',
)}
role="alert"
aria-live="polite"
>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<div className="flex items-center gap-2">
{isLimited ? (
<>
<span>Rate limit exceeded</span>
{timeUntilReset !== null && (
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTimeUntilReset(timeUntilReset)}
</span>
)}
</>
) : (
<>
<span>
{remaining !== null
? `${remaining}/${limit} requests`
: `${limit} requests`}
</span>
{timeUntilReset !== null && (
<span className="flex items-center gap-1 opacity-75">
<Clock className="w-3 h-3" />
resets in {formatTimeUntilReset(timeUntilReset)}
</span>
)}
</>
)}
</div>
</div>
);
}