You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

209 lines
6.6 KiB
TypeScript

import {
type Component,
createSignal,
createResource,
createEffect,
Show,
For,
} from 'solid-js';
import { Gauge, Save } from 'lucide-solid';
import { adminRateLimitsApi } from '~/api/admin';
import { useI18n } from '~/i18n';
import { useToast } from '~/components/ui/Toast';
import { isApiError } from '~/types';
import type { AdminRateLimit } from '~/types';
import LoadingSpinner from '~/components/ui/LoadingSpinner';
import Button from '~/components/ui/Button';
/** Local mutable copy of a rate-limit row for editing without modifying the resource. */
interface LocalRateLimit {
id: string;
provider_name: string;
max_requests: number;
time_window_seconds: number;
}
/**
* Admin page for viewing and updating per-provider API rate-limit configuration.
*
* Fetched rate limits are copied into local signals so form inputs can mutate
* freely. Saving PUTs the updated values to the backend and refetches.
*/
const RateLimits: Component = () => {
const { t } = useI18n();
const { addToast } = useToast();
const [rateLimits, { refetch }] = createResource(() =>
adminRateLimitsApi.list(),
);
const [localLimits, setLocalLimits] = createSignal<LocalRateLimit[]>([]);
const [savingId, setSavingId] = createSignal<string | null>(null);
// Sync fetched data to local state
createEffect(() => {
const data = rateLimits();
if (data) {
setLocalLimits(
data.map((rl) => ({
id: rl.id,
provider_name: rl.provider_name,
max_requests: rl.max_requests,
time_window_seconds: rl.time_window_seconds,
})),
);
}
});
const updateLocal = (
id: string,
field: 'max_requests' | 'time_window_seconds',
value: number,
) => {
setLocalLimits((prev) =>
prev.map((rl) => (rl.id === id ? { ...rl, [field]: value } : rl)),
);
};
const handleSave = async (limit: LocalRateLimit) => {
setSavingId(limit.id);
try {
await adminRateLimitsApi.update(limit.id, {
max_requests: limit.max_requests,
time_window_seconds: limit.time_window_seconds,
});
addToast({
type: 'success',
message: t('admin.rateLimits.saved'),
duration: 3000,
});
refetch();
} catch (err) {
const message = isApiError(err)
? err.message
: t('admin.rateLimits.saveError');
addToast({ type: 'error', message, duration: 5000 });
} finally {
setSavingId(null);
}
};
return (
<div class="max-w-4xl mx-auto">
<div class="mb-8">
<div class="flex items-center">
<Gauge class="h-8 w-8 text-indigo-600 mr-3" />
<h1 class="text-2xl font-bold text-gray-900">
{t('admin.rateLimits.title')}
</h1>
</div>
<p class="mt-1 text-sm text-gray-500">
{t('admin.rateLimits.subtitle')}
</p>
</div>
{/* Loading state */}
<Show when={rateLimits.loading}>
<LoadingSpinner />
</Show>
{/* Error state */}
<Show when={rateLimits.error}>
<div class="bg-red-50 border border-red-200 text-red-800 rounded-md p-4">
{t('admin.rateLimits.loadError')}
</div>
</Show>
{/* Empty state */}
<Show
when={
!rateLimits.loading &&
!rateLimits.error &&
localLimits().length === 0
}
>
<div class="bg-white shadow-sm border border-gray-200 rounded-lg p-8 text-center">
<Gauge class="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p class="text-gray-500 font-medium">
{t('admin.rateLimits.empty')}
</p>
<p class="text-sm text-gray-400 mt-1">
{t('admin.rateLimits.emptyHint')}
</p>
</div>
</Show>
{/* Rate limit cards */}
<div class="space-y-4">
<For each={localLimits()}>
{(limit) => (
<div class="bg-white shadow-sm border border-gray-200 rounded-lg">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-semibold text-gray-900">
{limit.provider_name}
</h2>
</div>
<div class="px-6 py-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700">
{t('admin.rateLimits.maxRequests')}
</label>
<input
type="number"
min="1"
class="mt-1 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md py-2 px-3 border focus:ring-indigo-500 focus:border-indigo-500"
value={limit.max_requests}
onInput={(e) =>
updateLocal(
limit.id,
'max_requests',
parseInt(e.currentTarget.value) || 1,
)
}
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">
{t('admin.rateLimits.timeWindow')}
</label>
<input
type="number"
min="1"
class="mt-1 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md py-2 px-3 border focus:ring-indigo-500 focus:border-indigo-500"
value={limit.time_window_seconds}
onInput={(e) =>
updateLocal(
limit.id,
'time_window_seconds',
parseInt(e.currentTarget.value) || 1,
)
}
/>
</div>
</div>
<p class="mt-3 text-sm text-gray-500">
{t('admin.rateLimits.effectiveRate', {
count: limit.max_requests,
seconds: limit.time_window_seconds,
})}
</p>
</div>
<div class="px-6 py-3 bg-gray-50 border-t border-gray-200 flex justify-end rounded-b-lg">
<Button
icon={Save}
loading={savingId() === limit.id}
onClick={() => handleSave(limit)}
>
{t('admin.rateLimits.save')}
</Button>
</div>
</div>
)}
</For>
</div>
</div>
);
};
export default RateLimits;