import { Form } from "@remix-run/react"; import { useEffect, useState } from "react"; import { Button } from "~/components/primitives/Buttons"; import { FormError } from "~/components/primitives/FormError"; import { Header2 } from "~/components/primitives/Headers"; import { Input } from "~/components/primitives/Input"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; // Local shape mirrors the server-side discriminated union just enough for this // view. Decoupled from the .server module so the component stays client-safe. // Duration fields are always suffixed strings — the server's DurationSchema // rejects anything else, so non-string overrides fall back to the default. export type RateLimitConfig = | { type: "tokenBucket"; refillRate: number; interval: string; maxTokens: number; } | { type: "fixedWindow" | "slidingWindow"; window: string; tokens: number; }; export type EffectiveRateLimit = { source: "override" | "default"; config: RateLimitConfig; }; export type FieldErrors = Record | null; // Props shared by every per-domain wrapper (Api / Batch / future ones). export type RateLimitWrapperProps = { effective: EffectiveRateLimit; errors: FieldErrors; savedJustNow: boolean; isSubmitting: boolean; }; type Props = RateLimitWrapperProps & { title: string; intent: string; }; export function RateLimitSection({ title, intent, effective, errors, savedJustNow, isSubmitting, }: Props) { const hasFieldErrors = !!errors && Object.keys(errors).length > 0; const fieldError = (field: string) => errors && field in errors ? errors[field]?.[0] : undefined; const current = effective.config.type === "tokenBucket" ? effective.config : null; const [isEditing, setIsEditing] = useState(hasFieldErrors); const [refillRate, setRefillRate] = useState(current ? String(current.refillRate) : ""); const [intervalStr, setIntervalStr] = useState(current ? String(current.interval) : ""); const [maxTokens, setMaxTokens] = useState(current ? String(current.maxTokens) : ""); useEffect(() => { if (hasFieldErrors) setIsEditing(true); }, [hasFieldErrors]); useEffect(() => { if (savedJustNow && !hasFieldErrors) setIsEditing(false); }, [savedJustNow, hasFieldErrors]); const currentDescription = current ? describeRateLimit( current.refillRate, parseDurationToMs(String(current.interval)), current.maxTokens ) : null; const previewDescription = describeRateLimit( Number(refillRate) || 0, parseDurationToMs(intervalStr), Number(maxTokens) || 0 ); const cancelEdit = () => { setRefillRate(current ? String(current.refillRate) : ""); setIntervalStr(current ? String(current.interval) : ""); setMaxTokens(current ? String(current.maxTokens) : ""); setIsEditing(false); }; return (
{title} {!isEditing && ( )}
{savedJustNow && (
Saved.
)} Status:{" "} {effective.source === "override" ? "Custom override active." : "Using system default."} {!isEditing ? ( <> {effective.config.type === "tokenBucket" ? ( currentDescription ? ( <> Sustained rate {currentDescription.sustained} Burst allowance {currentDescription.burst} ) : ( Invalid interval on the stored config. ) ) : ( <> Type {effective.config.type} Window {String(effective.config.window)} Tokens {effective.config.tokens.toLocaleString()} )} {effective.config.type !== "tokenBucket" && ( This override is a {effective.config.type} limit and can't be edited from this form. Change it in the database directly. )} ) : (
setRefillRate(e.target.value)} required /> {fieldError("refillRate")}
setIntervalStr(e.target.value)} required /> {fieldError("interval")}
setMaxTokens(e.target.value)} required /> {fieldError("maxTokens")}
{previewDescription ? `Preview: ${previewDescription.sustained} · ${previewDescription.burst}.` : "Preview: enter valid values to see the effective limit."}
)}
); } export function parseDurationToMs(duration: string): number { const match = duration.trim().match(/^(\d+)\s*(ms|s|m|h|d)$/); if (!match) return 0; const value = parseInt(match[1], 10); switch (match[2]) { case "ms": return value; case "s": return value * 1_000; case "m": return value * 60_000; case "h": return value * 3_600_000; case "d": return value * 86_400_000; default: return 0; } } function describeRateLimit( refillRate: number, intervalMs: number, maxTokens: number ): { sustained: string; burst: string } | null { if (refillRate <= 0 || intervalMs <= 0 || maxTokens <= 0) return null; const perMin = (refillRate * 60_000) / intervalMs; let sustained: string; if (perMin >= 1) { sustained = `${formatRateValue(perMin)} requests per minute`; } else { const perHour = perMin * 60; if (perHour >= 1) { sustained = `${formatRateValue(perHour)} requests per hour`; } else { const perDay = perHour * 24; sustained = `${formatRateValue(perDay)} requests per day`; } } return { sustained, burst: `${maxTokens.toLocaleString()} request burst allowance`, }; } function formatRateValue(value: number): string { return value >= 10 ? Math.round(value).toLocaleString() : value.toFixed(1); }