Files
DocsGPT/frontend/src/admin/QuotaEditor.tsx
T
Alex 1c9c94eba7 feat(frontend): admin quota management, a usage meter and the quota chat error
The admin dashboard gets a Quotas tab for the instance default, team
allowances and user overrides, with a notice listing models that cost
limits cannot see. A Quota action on the Users tab shows a user's effective
limits, the layer each comes from and their usage, next to the editor for
their override. Each budget is either not set at that layer, a limit, or
unlimited.

Users with a quota see a usage meter with the reset time on the Analytics
settings page, and a refused chat request shows the used amount, the limit
and the reset time in the user's language.
2026-09-21 12:11:12 +01:00

257 lines
6.6 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import adminService, { type QuotaScope } from '../api/services/adminService';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../components/ui/select';
import { selectToken } from '../preferences/preferenceSlice';
import { fmtNumber } from './AdminUI';
import {
fmtUsd,
formToPolicy,
isEmptyForm,
policyToForm,
usagePercent,
type Budget,
type BudgetMode,
type QuotaForm,
type QuotaPolicy,
} from './quotaUtils';
const MODES: { value: BudgetMode; label: string }[] = [
{ value: 'inherit', label: 'Not set here' },
{ value: 'limit', label: 'Limit' },
{ value: 'unlimited', label: 'Unlimited' },
];
export function UsageBar({
label,
budget,
kind,
caption,
}: {
label: string;
budget: Budget;
kind: 'tokens' | 'cost';
caption?: string;
}) {
const fmt = (n: number) => (kind === 'cost' ? fmtUsd(n) : fmtNumber(n));
const percent = usagePercent(budget.used, budget.limit);
const tone =
percent >= 100
? 'bg-red-500'
: percent >= 80
? 'bg-amber-500'
: 'bg-[#7D54D1]';
return (
<div>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="tabular-nums">
{fmt(budget.used)}
{budget.limit === null ? ' · no limit' : ` of ${fmt(budget.limit)}`}
</span>
</div>
{budget.limit !== null ? (
<div
className="bg-muted mt-1 h-1.5 w-full overflow-hidden rounded-full"
role="progressbar"
aria-label={label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(percent)}
>
<div
className={`h-full rounded-full ${tone}`}
style={{ width: `${percent}%` }}
/>
</div>
) : null}
{caption ? (
<p className="text-muted-foreground mt-1 text-xs">{caption}</p>
) : null}
</div>
);
}
function BudgetField({
label,
hint,
mode,
value,
step,
onMode,
onValue,
}: {
label: string;
hint: string;
mode: BudgetMode;
value: string;
step: string;
onMode: (mode: BudgetMode) => void;
onValue: (value: string) => void;
}) {
return (
<div>
<p className="text-foreground text-sm font-medium">{label}</p>
<div className="mt-1 flex items-center gap-2">
<Select value={mode} onValueChange={(v) => onMode(v as BudgetMode)}>
<SelectTrigger className="w-40" aria-label={`${label} mode`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{MODES.map((m) => (
<SelectItem key={m.value} value={m.value}>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
{mode === 'limit' ? (
<Input
type="number"
min="0"
step={step}
inputMode="decimal"
value={value}
aria-label={label}
placeholder={hint}
onChange={(e) => onValue(e.target.value)}
className="flex-1"
/>
) : null}
</div>
</div>
);
}
/**
* Edits the ``all``-bucket policy of one subject. Saving a form with neither
* budget set removes the policy, since a policy without an opinion is not stored.
*/
export default function QuotaEditor({
scope,
subjectId,
policy,
inheritHint,
onSaved,
}: {
scope: QuotaScope;
subjectId: string | null;
policy: QuotaPolicy | null;
inheritHint: string;
onSaved: () => void;
}) {
const token = useSelector(selectToken);
const [form, setForm] = useState<QuotaForm>(() => policyToForm(policy));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setForm(policyToForm(policy));
setError(null);
}, [policy, scope, subjectId]);
const patch = (fields: Partial<QuotaForm>) =>
setForm((prev) => ({ ...prev, ...fields }));
const submit = async (request: () => Promise<Response>) => {
setBusy(true);
setError(null);
try {
const res = await request();
const json = await res.json().catch(() => ({}));
if (res.ok && json.success !== false) onSaved();
else setError(json.message || 'Could not save the quota.');
} catch {
setError('Could not save the quota.');
} finally {
setBusy(false);
}
};
const remove = () =>
submit(() => adminService.deleteQuota(scope, subjectId, 'all', token));
const save = () => {
if (isEmptyForm(form)) {
if (policy) remove();
return;
}
const result = formToPolicy(form);
if (!result.ok) {
setError(result.error);
return;
}
submit(() => adminService.setQuota(scope, subjectId, result.policy, token));
};
return (
<div className="space-y-4">
<p className="text-muted-foreground text-xs">{inheritHint}</p>
<BudgetField
label="Tokens"
hint="e.g. 2000000"
step="1"
mode={form.tokenMode}
value={form.tokenLimit}
onMode={(tokenMode) => patch({ tokenMode })}
onValue={(tokenLimit) => patch({ tokenLimit })}
/>
<BudgetField
label="Cost (USD)"
hint="e.g. 25"
step="0.01"
mode={form.costMode}
value={form.costLimit}
onMode={(costMode) => patch({ costMode })}
onValue={(costLimit) => patch({ costLimit })}
/>
<div>
<p className="text-foreground text-sm font-medium">Note</p>
<Input
value={form.note}
maxLength={500}
aria-label="Note"
placeholder="Optional, visible to admins only"
onChange={(e) => patch({ note: e.target.value })}
className="mt-1"
/>
</div>
{error ? (
<p role="alert" className="text-sm text-red-600 dark:text-red-400">
{error}
</p>
) : null}
<div className="flex justify-end gap-2">
{policy ? (
<Button
type="button"
variant="destructive-outline"
size="sm"
disabled={busy}
onClick={remove}
>
Remove
</Button>
) : null}
<Button
type="button"
size="sm"
disabled={busy || (isEmptyForm(form) && !policy)}
onClick={save}
>
Save
</Button>
</div>
</div>
);
}