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
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;
|