mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-11 03:12:55 +00:00
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.
This commit is contained in:
1 parent
21f43b2966
commit
1c9c94eba7
23 files changed
+1334
-3
No files matched your search
@@ -119,6 +119,8 @@ const EVENT_LABELS: Record<string, string> = {
|
||||
scim_created: 'Provisioned',
|
||||
scim_deactivated: 'Deactivated (SCIM)',
|
||||
scim_activated: 'Activated (SCIM)',
|
||||
quota_policy_set: 'Quota set',
|
||||
quota_policy_deleted: 'Quota removed',
|
||||
};
|
||||
|
||||
export function eventLabel(event: string): string {
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import adminService, { type QuotaScope } from '../api/services/adminService';
|
||||
import teamsService from '../api/services/teamsService';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '../components/ui/table';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import {
|
||||
LoadError,
|
||||
Loading,
|
||||
Pill,
|
||||
fmtDate,
|
||||
fmtNumber,
|
||||
fmtRelative,
|
||||
} from './AdminUI';
|
||||
import QuotaEditor from './QuotaEditor';
|
||||
import { describeBudget, type QuotaPolicy } from './quotaUtils';
|
||||
|
||||
type TeamPolicy = QuotaPolicy & {
|
||||
team_name?: string | null;
|
||||
team_slug?: string | null;
|
||||
member_count?: number | null;
|
||||
};
|
||||
|
||||
type Editing = {
|
||||
scope: QuotaScope;
|
||||
subjectId: string | null;
|
||||
title: string;
|
||||
policy: QuotaPolicy | null;
|
||||
};
|
||||
|
||||
const HINTS: Record<QuotaScope, string> = {
|
||||
instance:
|
||||
'Applies to every user that no team allowance or user override covers.',
|
||||
team: 'Each member gets this allowance; it is not a shared pool. A member of several teams gets the most generous one.',
|
||||
user: 'Overrides team allowances and the instance default for this user.',
|
||||
};
|
||||
|
||||
function PolicyCells({ policy }: { policy: QuotaPolicy }) {
|
||||
return (
|
||||
<>
|
||||
<TableCell className="tabular-nums">
|
||||
{describeBudget(policy.token_limit, policy.token_unlimited, 'tokens')}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{describeBudget(policy.cost_limit_usd, policy.cost_unlimited, 'cost')}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground max-w-56 truncate">
|
||||
{policy.note || '—'}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground whitespace-nowrap">
|
||||
{fmtRelative(policy.updated_at)}
|
||||
</TableCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Quotas() {
|
||||
const token = useSelector(selectToken);
|
||||
const [data, setData] = useState<any | null>(null);
|
||||
const [teams, setTeams] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editing, setEditing] = useState<Editing | null>(null);
|
||||
const [teamPick, setTeamPick] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [quotasRes, teamsJson] = await Promise.all([
|
||||
adminService.getQuotas(token),
|
||||
teamsService.listAll(token).catch(() => ({})),
|
||||
]);
|
||||
setData(await quotasRes.json().catch(() => ({ success: false })));
|
||||
setTeams(teamsJson?.teams ?? []);
|
||||
} catch {
|
||||
setData({ success: false });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// The editor covers the ``all`` bucket; other buckets are listed read-only.
|
||||
const isAll = (p: QuotaPolicy) => p.bucket === 'all';
|
||||
const instancePolicy: QuotaPolicy | null =
|
||||
(data?.instance ?? []).find(isAll) ?? null;
|
||||
const teamPolicies: TeamPolicy[] = data?.teams ?? [];
|
||||
const userPolicies: QuotaPolicy[] = data?.users ?? [];
|
||||
const teamsWithoutPolicy = useMemo(() => {
|
||||
const covered = new Set(
|
||||
teamPolicies.filter(isAll).map((p) => String(p.subject_id)),
|
||||
);
|
||||
return teams.filter((team) => !covered.has(String(team.id)));
|
||||
}, [teams, teamPolicies]);
|
||||
|
||||
if (data === null && loading) return <Loading />;
|
||||
if (!data?.success) return <LoadError message="Failed to load quotas." />;
|
||||
|
||||
const bucketPill = (policy: QuotaPolicy) =>
|
||||
isAll(policy) ? null : <Pill tone="muted">{policy.bucket} traffic</Pill>;
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-8">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Usage is counted per user over each calendar {data.period} (UTC). The
|
||||
current window resets {fmtDate(data.resets_at)}. A request is refused
|
||||
once a budget is used up; the request that crosses it still completes.
|
||||
</p>
|
||||
|
||||
{(data.unpriced_models ?? []).length > 0 ? (
|
||||
<div className="rounded-2xl border border-amber-300 bg-amber-50 px-5 py-4 text-sm dark:border-amber-800 dark:bg-amber-950/30">
|
||||
<p className="text-foreground font-medium">
|
||||
Models without a price are invisible to cost limits
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
These were used this {data.period} and recorded at $0:{' '}
|
||||
{(data.unpriced_models as any[])
|
||||
.map((m) => `${m.model_id} (${fmtNumber(m.tokens)} tokens)`)
|
||||
.join(', ')}
|
||||
. Use a token limit for them, or declare their rates in the model
|
||||
catalog.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-foreground font-bold">Instance default</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
scope: 'instance',
|
||||
subjectId: null,
|
||||
title: 'Instance default',
|
||||
policy: instancePolicy,
|
||||
})
|
||||
}
|
||||
>
|
||||
{instancePolicy ? 'Edit' : 'Set default'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{instancePolicy
|
||||
? `Tokens: ${describeBudget(instancePolicy.token_limit, instancePolicy.token_unlimited, 'tokens')} · Cost: ${describeBudget(instancePolicy.cost_limit_usd, instancePolicy.cost_unlimited, 'cost')}`
|
||||
: 'No default: users without a team allowance or override are unlimited.'}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-foreground font-bold">Team allowances</p>
|
||||
{teamsWithoutPolicy.length > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={teamPick} onValueChange={setTeamPick}>
|
||||
<SelectTrigger className="w-52" aria-label="Team">
|
||||
<SelectValue placeholder="Choose a team" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teamsWithoutPolicy.map((team) => (
|
||||
<SelectItem key={team.id} value={String(team.id)}>
|
||||
{team.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!teamPick}
|
||||
onClick={() => {
|
||||
const team = teams.find((tm) => String(tm.id) === teamPick);
|
||||
setEditing({
|
||||
scope: 'team',
|
||||
subjectId: teamPick,
|
||||
title: team?.name ?? 'Team',
|
||||
policy: null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Add allowance
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{teamPolicies.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
No team has an allowance.
|
||||
</p>
|
||||
) : (
|
||||
<TableContainer className="mt-3">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>Team</TableHeader>
|
||||
<TableHeader>Members</TableHeader>
|
||||
<TableHeader>Tokens</TableHeader>
|
||||
<TableHeader>Cost</TableHeader>
|
||||
<TableHeader>Note</TableHeader>
|
||||
<TableHeader>Updated</TableHeader>
|
||||
<TableHeader className="text-right">Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{teamPolicies.map((policy) => (
|
||||
<TableRow key={`${policy.subject_id}:${policy.bucket}`}>
|
||||
<TableCell>
|
||||
<span className="mr-2">
|
||||
{policy.team_name ?? policy.subject_id}
|
||||
</span>
|
||||
{bucketPill(policy)}
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{fmtNumber(policy.member_count)}
|
||||
</TableCell>
|
||||
<PolicyCells policy={policy} />
|
||||
<TableCell className="text-right">
|
||||
{isAll(policy) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
scope: 'team',
|
||||
subjectId: policy.subject_id,
|
||||
title: policy.team_name ?? 'Team',
|
||||
policy,
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<p className="text-foreground font-bold">User overrides</p>
|
||||
{userPolicies.length === 0 ? (
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
No user has an override. Add one from a user's menu on the
|
||||
Users tab.
|
||||
</p>
|
||||
) : (
|
||||
<TableContainer className="mt-3">
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeader>User</TableHeader>
|
||||
<TableHeader>Tokens</TableHeader>
|
||||
<TableHeader>Cost</TableHeader>
|
||||
<TableHeader>Note</TableHeader>
|
||||
<TableHeader>Updated</TableHeader>
|
||||
<TableHeader className="text-right">Actions</TableHeader>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{userPolicies.map((policy) => (
|
||||
<TableRow key={`${policy.subject_id}:${policy.bucket}`}>
|
||||
<TableCell>
|
||||
<span className="mr-2 break-all">
|
||||
{policy.subject_id}
|
||||
</span>
|
||||
{bucketPill(policy)}
|
||||
</TableCell>
|
||||
<PolicyCells policy={policy} />
|
||||
<TableCell className="text-right">
|
||||
{isAll(policy) ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setEditing({
|
||||
scope: 'user',
|
||||
subjectId: policy.subject_id,
|
||||
title: policy.subject_id ?? 'User',
|
||||
policy,
|
||||
})
|
||||
}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
) : null}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Modal
|
||||
open={editing !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditing(null);
|
||||
}}
|
||||
title={editing ? `Quota · ${editing.title}` : 'Quota'}
|
||||
>
|
||||
{editing ? (
|
||||
<QuotaEditor
|
||||
scope={editing.scope}
|
||||
subjectId={editing.subjectId}
|
||||
policy={editing.policy}
|
||||
inheritHint={HINTS[editing.scope]}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
setTeamPick('');
|
||||
load();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import adminService from '../api/services/adminService';
|
||||
import teamsService from '../api/services/teamsService';
|
||||
import { Modal } from '../components/ui/modal';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { LoadError, Loading, fmtDate } from './AdminUI';
|
||||
import QuotaEditor, { UsageBar } from './QuotaEditor';
|
||||
import {
|
||||
sourceLabel,
|
||||
type BucketStatus,
|
||||
type Budget,
|
||||
type QuotaPolicy,
|
||||
} from './quotaUtils';
|
||||
|
||||
/** A user's effective limits and usage, with the editor for their override. */
|
||||
export default function UserQuotaModal({
|
||||
userId,
|
||||
onClose,
|
||||
}: {
|
||||
userId: string | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const token = useSelector(selectToken);
|
||||
const [data, setData] = useState<any | null>(null);
|
||||
const [teamNames, setTeamNames] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setData(null);
|
||||
try {
|
||||
const [res, teamsJson] = await Promise.all([
|
||||
adminService.getUserQuota(userId, token),
|
||||
teamsService.listAll(token).catch(() => ({})),
|
||||
]);
|
||||
setData(await res.json().catch(() => ({ success: false })));
|
||||
setTeamNames(
|
||||
Object.fromEntries(
|
||||
(teamsJson?.teams ?? []).map((team: any) => [
|
||||
String(team.id),
|
||||
team.name,
|
||||
]),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
setData({ success: false });
|
||||
}
|
||||
}, [userId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const overall: BucketStatus | undefined = (data?.effective ?? []).find(
|
||||
(status: BucketStatus) => status.bucket === 'all',
|
||||
);
|
||||
const override: QuotaPolicy | null =
|
||||
(data?.policies ?? []).find((p: QuotaPolicy) => p.bucket === 'all') ?? null;
|
||||
const caption = (budget: Budget) =>
|
||||
sourceLabel(
|
||||
budget,
|
||||
budget.source_id ? teamNames[budget.source_id] : undefined,
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={userId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
title={userId ? `Quota · ${userId}` : 'Quota'}
|
||||
>
|
||||
{data === null ? (
|
||||
<Loading />
|
||||
) : !data.success ? (
|
||||
<LoadError message="Failed to load this user's quota." />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{overall ? (
|
||||
<div className="space-y-3">
|
||||
<UsageBar
|
||||
label={`Tokens this ${data.period}`}
|
||||
kind="tokens"
|
||||
budget={overall.tokens}
|
||||
caption={caption(overall.tokens)}
|
||||
/>
|
||||
<UsageBar
|
||||
label={`Cost this ${data.period}`}
|
||||
kind="cost"
|
||||
budget={overall.cost}
|
||||
caption={caption(overall.cost)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Resets {fmtDate(overall.resets_at)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="border-border border-t pt-4">
|
||||
<p className="text-foreground mb-2 text-sm font-bold">
|
||||
User override
|
||||
</p>
|
||||
<QuotaEditor
|
||||
scope="user"
|
||||
subjectId={userId}
|
||||
policy={override}
|
||||
inheritHint="Overrides team allowances and the instance default for this user. Leave a budget as “Not set here” to keep what the user inherits."
|
||||
onSaved={load}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Eye,
|
||||
Gauge,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
fmtNumber,
|
||||
fmtRelative,
|
||||
} from './AdminUI';
|
||||
import UserQuotaModal from './UserQuotaModal';
|
||||
|
||||
type AdminUser = {
|
||||
user_id: string;
|
||||
@@ -70,6 +72,7 @@ export default function Users() {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [menuUserId, setMenuUserId] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<any | null>(null);
|
||||
const [quotaUserId, setQuotaUserId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<{
|
||||
ok: boolean;
|
||||
message: string;
|
||||
@@ -157,7 +160,14 @@ export default function Users() {
|
||||
isAdmin: boolean,
|
||||
active: boolean,
|
||||
): Action[] => {
|
||||
const acts: Action[] = [];
|
||||
const acts: Action[] = [
|
||||
{
|
||||
key: 'quota',
|
||||
label: 'Quota',
|
||||
icon: Gauge,
|
||||
perform: () => setQuotaUserId(userId),
|
||||
},
|
||||
];
|
||||
if (isAdmin) {
|
||||
acts.push({
|
||||
key: 'revoke',
|
||||
@@ -433,6 +443,11 @@ export default function Users() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<UserQuotaModal
|
||||
userId={quotaUserId}
|
||||
onClose={() => setQuotaUserId(null)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={detail !== null}
|
||||
onOpenChange={(open) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Tabs, TabsList, TabsTrigger } from '../components/ui/tabs';
|
||||
import Admins from './Admins';
|
||||
import Audit from './Audit';
|
||||
import Overview from './Overview';
|
||||
import Quotas from './Quotas';
|
||||
import Usage from './Usage';
|
||||
import Users from './Users';
|
||||
|
||||
@@ -19,6 +20,7 @@ const TABS = [
|
||||
{ key: 'users', label: 'Users', path: '/admin/users' },
|
||||
{ key: 'admins', label: 'Admins', path: '/admin/roles' },
|
||||
{ key: 'usage', label: 'Usage', path: '/admin/usage' },
|
||||
{ key: 'quotas', label: 'Quotas', path: '/admin/quotas' },
|
||||
{ key: 'audit', label: 'Audit', path: '/admin/audit' },
|
||||
];
|
||||
|
||||
@@ -63,6 +65,7 @@ export default function Admin() {
|
||||
<Route path="users" element={<Users />} />
|
||||
<Route path="roles" element={<Admins />} />
|
||||
<Route path="usage" element={<Usage />} />
|
||||
<Route path="quotas" element={<Quotas />} />
|
||||
<Route path="audit" element={<Audit />} />
|
||||
<Route path="*" element={<Navigate to="/admin" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
describeBudget,
|
||||
formToPolicy,
|
||||
isEmptyForm,
|
||||
policyToForm,
|
||||
sourceLabel,
|
||||
usagePercent,
|
||||
type QuotaPolicy,
|
||||
} from './quotaUtils';
|
||||
|
||||
const policy = (fields: Partial<QuotaPolicy>): QuotaPolicy => ({
|
||||
scope: 'user',
|
||||
subject_id: 'u1',
|
||||
bucket: 'all',
|
||||
token_limit: null,
|
||||
token_unlimited: false,
|
||||
cost_limit_usd: null,
|
||||
cost_unlimited: false,
|
||||
enabled: true,
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe('policyToForm', () => {
|
||||
it('starts a missing policy as inherit', () => {
|
||||
const form = policyToForm(null);
|
||||
expect(form.tokenMode).toBe('inherit');
|
||||
expect(form.costMode).toBe('inherit');
|
||||
expect(isEmptyForm(form)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps zero as a limit, not as inherit', () => {
|
||||
const form = policyToForm(policy({ token_limit: 0, cost_unlimited: true }));
|
||||
expect(form.tokenMode).toBe('limit');
|
||||
expect(form.tokenLimit).toBe('0');
|
||||
expect(form.costMode).toBe('unlimited');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formToPolicy', () => {
|
||||
const base = policyToForm(null);
|
||||
|
||||
it('round-trips limits and trims the note', () => {
|
||||
const result = formToPolicy({
|
||||
...base,
|
||||
tokenMode: 'limit',
|
||||
tokenLimit: ' 5000 ',
|
||||
costMode: 'limit',
|
||||
costLimit: '2.5',
|
||||
note: ' trial ',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
policy: {
|
||||
bucket: 'all',
|
||||
token_limit: 5000,
|
||||
token_unlimited: false,
|
||||
cost_limit_usd: 2.5,
|
||||
cost_unlimited: false,
|
||||
note: 'trial',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sends unlimited without a limit', () => {
|
||||
const result = formToPolicy({
|
||||
...base,
|
||||
tokenMode: 'unlimited',
|
||||
tokenLimit: '99',
|
||||
});
|
||||
expect(result.ok && result.policy.token_limit).toBeNull();
|
||||
expect(result.ok && result.policy.token_unlimited).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['', '1.5', '-1', 'abc', '1e3'])(
|
||||
'rejects token limit %j',
|
||||
(tokenLimit) => {
|
||||
expect(formToPolicy({ ...base, tokenMode: 'limit', tokenLimit }).ok).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['', '-0.01', 'abc', 'Infinity'])(
|
||||
'rejects cost limit %j',
|
||||
(costLimit) => {
|
||||
expect(formToPolicy({ ...base, costMode: 'limit', costLimit }).ok).toBe(
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('usagePercent', () => {
|
||||
it('handles unlimited, zero and overshoot', () => {
|
||||
expect(usagePercent(50, null)).toBe(0);
|
||||
expect(usagePercent(0, 0)).toBe(100);
|
||||
expect(usagePercent(25, 100)).toBe(25);
|
||||
expect(usagePercent(500, 100)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('labels', () => {
|
||||
it('describes budgets', () => {
|
||||
expect(describeBudget(null, true, 'tokens')).toBe('Unlimited');
|
||||
expect(describeBudget(null, false, 'cost')).toBe('—');
|
||||
expect(describeBudget(1000, false, 'tokens')).toContain('tokens');
|
||||
});
|
||||
|
||||
it('names the layer a limit came from', () => {
|
||||
expect(sourceLabel({ limit: null, used: 0 })).toBe('No limit set');
|
||||
expect(sourceLabel({ limit: 1, used: 0, source: 'team' }, 'Eng')).toBe(
|
||||
'Team: Eng',
|
||||
);
|
||||
expect(sourceLabel({ limit: 1, used: 0, source: 'default' })).toBe(
|
||||
'Plan default',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// Pure helpers behind the quota editor and usage bars.
|
||||
|
||||
export type BudgetMode = 'inherit' | 'limit' | 'unlimited';
|
||||
|
||||
export type QuotaPolicy = {
|
||||
scope: 'instance' | 'team' | 'user';
|
||||
subject_id: string | null;
|
||||
bucket: string;
|
||||
token_limit: number | null;
|
||||
token_unlimited: boolean;
|
||||
cost_limit_usd: number | null;
|
||||
cost_unlimited: boolean;
|
||||
enabled: boolean;
|
||||
note?: string | null;
|
||||
updated_by?: string | null;
|
||||
updated_at?: string | null;
|
||||
};
|
||||
|
||||
export type Budget = {
|
||||
limit: number | null;
|
||||
used: number;
|
||||
source?: string | null;
|
||||
source_id?: string | null;
|
||||
};
|
||||
|
||||
export type BucketStatus = {
|
||||
bucket: string;
|
||||
tokens: Budget;
|
||||
cost: Budget;
|
||||
resets_at: string;
|
||||
};
|
||||
|
||||
export type QuotaForm = {
|
||||
tokenMode: BudgetMode;
|
||||
tokenLimit: string;
|
||||
costMode: BudgetMode;
|
||||
costLimit: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
const mode = (limit: number | null, unlimited: boolean): BudgetMode => {
|
||||
if (unlimited) return 'unlimited';
|
||||
return limit === null || limit === undefined ? 'inherit' : 'limit';
|
||||
};
|
||||
|
||||
export function policyToForm(policy?: QuotaPolicy | null): QuotaForm {
|
||||
return {
|
||||
tokenMode: policy
|
||||
? mode(policy.token_limit, policy.token_unlimited)
|
||||
: 'inherit',
|
||||
tokenLimit: policy?.token_limit != null ? String(policy.token_limit) : '',
|
||||
costMode: policy
|
||||
? mode(policy.cost_limit_usd, policy.cost_unlimited)
|
||||
: 'inherit',
|
||||
costLimit:
|
||||
policy?.cost_limit_usd != null ? String(policy.cost_limit_usd) : '',
|
||||
note: policy?.note ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export type FormResult =
|
||||
{ ok: true; policy: Record<string, unknown> } | { ok: false; error: string };
|
||||
|
||||
// An empty form (both budgets inherited) is not a policy: the caller deletes instead.
|
||||
export function isEmptyForm(form: QuotaForm): boolean {
|
||||
return form.tokenMode === 'inherit' && form.costMode === 'inherit';
|
||||
}
|
||||
|
||||
export function formToPolicy(form: QuotaForm): FormResult {
|
||||
const policy: Record<string, unknown> = {
|
||||
bucket: 'all',
|
||||
token_limit: null,
|
||||
token_unlimited: form.tokenMode === 'unlimited',
|
||||
cost_limit_usd: null,
|
||||
cost_unlimited: form.costMode === 'unlimited',
|
||||
note: form.note.trim() || null,
|
||||
};
|
||||
if (form.tokenMode === 'limit') {
|
||||
const raw = form.tokenLimit.trim();
|
||||
if (!/^\d+$/.test(raw))
|
||||
return { ok: false, error: 'Token limit must be a whole number.' };
|
||||
const tokens = Number(raw);
|
||||
if (!Number.isSafeInteger(tokens))
|
||||
return { ok: false, error: 'Token limit is too large.' };
|
||||
policy.token_limit = tokens;
|
||||
}
|
||||
if (form.costMode === 'limit') {
|
||||
const raw = form.costLimit.trim();
|
||||
const cost = Number(raw);
|
||||
if (raw === '' || !Number.isFinite(cost) || cost < 0)
|
||||
return { ok: false, error: 'Cost limit must be a number, 0 or more.' };
|
||||
policy.cost_limit_usd = cost;
|
||||
}
|
||||
return { ok: true, policy };
|
||||
}
|
||||
|
||||
export function usagePercent(used: number, limit: number | null): number {
|
||||
if (limit === null || limit === undefined) return 0;
|
||||
if (limit <= 0) return 100;
|
||||
return Math.min(100, Math.max(0, (used / limit) * 100));
|
||||
}
|
||||
|
||||
export function fmtUsd(value?: number | null): string {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value != null && value < 1 ? 4 : 2,
|
||||
}).format(value ?? 0);
|
||||
}
|
||||
|
||||
export function describeBudget(
|
||||
limit: number | null,
|
||||
unlimited: boolean,
|
||||
kind: 'tokens' | 'cost',
|
||||
): string {
|
||||
if (unlimited) return 'Unlimited';
|
||||
if (limit === null || limit === undefined) return '—';
|
||||
return kind === 'cost'
|
||||
? fmtUsd(limit)
|
||||
: `${new Intl.NumberFormat().format(limit)} tokens`;
|
||||
}
|
||||
|
||||
export function sourceLabel(budget: Budget, teamName?: string): string {
|
||||
if (!budget.source) return 'No limit set';
|
||||
if (budget.source === 'user') return 'User override';
|
||||
if (budget.source === 'team')
|
||||
return teamName ? `Team: ${teamName}` : 'Team allowance';
|
||||
if (budget.source === 'instance') return 'Instance default';
|
||||
return 'Plan default';
|
||||
}
|
||||
@@ -2,6 +2,7 @@ const endpoints = {
|
||||
USER: {
|
||||
CONFIG: '/api/config',
|
||||
ME: '/api/user/me',
|
||||
QUOTA: '/api/user/quota',
|
||||
NEW_TOKEN: '/api/generate_token',
|
||||
OIDC_LOGIN: '/api/auth/oidc/login',
|
||||
OIDC_TOKEN: '/api/auth/oidc/token',
|
||||
@@ -173,6 +174,12 @@ const endpoints = {
|
||||
USAGE: '/api/admin/usage',
|
||||
AUDIT: '/api/admin/audit',
|
||||
DEVICE_AUDIT: '/api/admin/devices/audit',
|
||||
QUOTAS: '/api/admin/quotas',
|
||||
QUOTA_INSTANCE: '/api/admin/quotas/instance',
|
||||
QUOTA_TEAM: (id: string) =>
|
||||
`/api/admin/quotas/teams/${encodeURIComponent(id)}`,
|
||||
QUOTA_USER: (id: string) =>
|
||||
`/api/admin/quotas/users/${encodeURIComponent(id)}`,
|
||||
},
|
||||
CONVERSATION: {
|
||||
ANSWER: '/api/answer',
|
||||
|
||||
@@ -10,6 +10,14 @@ const qs = (params: Record<string, string | number | undefined>): string => {
|
||||
return str ? `?${str}` : '';
|
||||
};
|
||||
|
||||
export type QuotaScope = 'instance' | 'team' | 'user';
|
||||
|
||||
const quotaUrl = (scope: QuotaScope, subjectId?: string | null): string => {
|
||||
if (scope === 'team') return endpoints.ADMIN.QUOTA_TEAM(subjectId ?? '');
|
||||
if (scope === 'user') return endpoints.ADMIN.QUOTA_USER(subjectId ?? '');
|
||||
return endpoints.ADMIN.QUOTA_INSTANCE;
|
||||
};
|
||||
|
||||
const adminService = {
|
||||
getOverview: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.OVERVIEW, token),
|
||||
@@ -54,6 +62,23 @@ const adminService = {
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.get(`${endpoints.ADMIN.DEVICE_AUDIT}${qs(params)}`, token),
|
||||
getQuotas: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.QUOTAS, token),
|
||||
getUserQuota: (userId: string, token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.ADMIN.QUOTA_USER(userId), token),
|
||||
setQuota: (
|
||||
scope: QuotaScope,
|
||||
subjectId: string | null,
|
||||
policy: Record<string, unknown>,
|
||||
token: string | null,
|
||||
): Promise<any> => apiClient.put(quotaUrl(scope, subjectId), policy, token),
|
||||
deleteQuota: (
|
||||
scope: QuotaScope,
|
||||
subjectId: string | null,
|
||||
bucket: string,
|
||||
token: string | null,
|
||||
): Promise<any> =>
|
||||
apiClient.delete(`${quotaUrl(scope, subjectId)}${qs({ bucket })}`, token),
|
||||
};
|
||||
|
||||
export default adminService;
|
||||
@@ -7,6 +7,8 @@ const userService = {
|
||||
throttledApiClient.get(endpoints.USER.CONFIG, null),
|
||||
getMe: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.ME, token),
|
||||
getQuota: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.QUOTA, token),
|
||||
getNewToken: (): Promise<any> =>
|
||||
throttledApiClient.get(endpoints.USER.NEW_TOKEN, null),
|
||||
// Token deliberately null: a stale Authorization header must not be able
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { baseURL } from '../api/client';
|
||||
import conversationService from '../api/services/conversationService';
|
||||
import { Doc } from '../models/misc';
|
||||
import { Answer, FEEDBACK, RetrievalPayload } from './conversationModels';
|
||||
import { isQuotaError, quotaErrorMessage } from './quotaError';
|
||||
import { ToolCallsType } from './types';
|
||||
|
||||
/**
|
||||
@@ -48,7 +51,9 @@ async function _handlePreStreamHttpError(
|
||||
if (text) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
if (isQuotaError(parsed)) {
|
||||
message = quotaErrorMessage(parsed, i18n.t.bind(i18n), i18n.language);
|
||||
} else if (parsed && typeof parsed === 'object') {
|
||||
message =
|
||||
(typeof parsed.message === 'string' && parsed.message) ||
|
||||
(typeof parsed.error === 'string' && parsed.error) ||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isQuotaError, quotaErrorMessage } from './quotaError';
|
||||
|
||||
const t = ((key: string, values: Record<string, string>) =>
|
||||
`${key}|${values.used}|${values.limit}|${values.resetsAt}`) as any;
|
||||
|
||||
describe('isQuotaError', () => {
|
||||
it('matches only the quota error code', () => {
|
||||
expect(isQuotaError({ error_code: 'quota-exceeded' })).toBe(true);
|
||||
expect(isQuotaError({ message: 'Exceeding usage limit' })).toBe(false);
|
||||
expect(isQuotaError(null)).toBe(false);
|
||||
expect(isQuotaError('quota-exceeded')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('quotaErrorMessage', () => {
|
||||
it('formats token budgets as numbers', () => {
|
||||
const message = quotaErrorMessage(
|
||||
{
|
||||
dimension: 'tokens',
|
||||
usage: 1200000,
|
||||
limit: 1000000,
|
||||
resets_at: '2026-10-01T00:00:00+00:00',
|
||||
},
|
||||
t,
|
||||
'en-US',
|
||||
);
|
||||
const [key, used, limit, resetsAt] = message.split('|');
|
||||
expect(key).toBe('conversation.quotaExceeded.tokens');
|
||||
expect([used, limit]).toEqual(['1,200,000', '1,000,000']);
|
||||
expect(resetsAt).not.toBe('');
|
||||
});
|
||||
|
||||
it('formats cost budgets as dollars', () => {
|
||||
const message = quotaErrorMessage(
|
||||
{ dimension: 'cost', usage: 5.25, limit: 5 },
|
||||
t,
|
||||
'en-US',
|
||||
);
|
||||
expect(message).toBe('conversation.quotaExceeded.cost|$5.25|$5.00|');
|
||||
});
|
||||
|
||||
it('tolerates a malformed reset time', () => {
|
||||
const message = quotaErrorMessage(
|
||||
{ dimension: 'tokens', usage: 1, limit: 1, resets_at: 'soon' },
|
||||
t,
|
||||
'en-US',
|
||||
);
|
||||
expect(message.endsWith('|')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
export type QuotaErrorBody = {
|
||||
error_code?: string;
|
||||
dimension?: string;
|
||||
usage?: number;
|
||||
limit?: number;
|
||||
resets_at?: string;
|
||||
};
|
||||
|
||||
export function isQuotaError(body: unknown): body is QuotaErrorBody {
|
||||
return (
|
||||
!!body &&
|
||||
typeof body === 'object' &&
|
||||
(body as QuotaErrorBody).error_code === 'quota-exceeded'
|
||||
);
|
||||
}
|
||||
|
||||
/** The chat message for a 429 ``quota-exceeded`` body, in the user's language. */
|
||||
export function quotaErrorMessage(
|
||||
body: QuotaErrorBody,
|
||||
t: TFunction,
|
||||
locale?: string,
|
||||
): string {
|
||||
const isCost = body.dimension === 'cost';
|
||||
const amount = (value?: number) =>
|
||||
isCost
|
||||
? new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
}).format(value ?? 0)
|
||||
: new Intl.NumberFormat(locale).format(value ?? 0);
|
||||
const reset = body.resets_at ? new Date(body.resets_at) : null;
|
||||
const resetsAt =
|
||||
reset && !Number.isNaN(reset.getTime())
|
||||
? new Intl.DateTimeFormat(locale, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(reset)
|
||||
: '';
|
||||
return t(
|
||||
isCost
|
||||
? 'conversation.quotaExceeded.cost'
|
||||
: 'conversation.quotaExceeded.tokens',
|
||||
{ used: amount(body.usage), limit: amount(body.limit), resetsAt },
|
||||
);
|
||||
}
|
||||
@@ -375,6 +375,13 @@
|
||||
"toolCalls": "Werkzeugaufrufe",
|
||||
"runSuccess": "Erfolgsquote",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"quota": {
|
||||
"title": "Ihr Nutzungskontingent",
|
||||
"resets": "Wird zurückgesetzt: {{resetsAt}}",
|
||||
"tokens": "Tokens",
|
||||
"cost": "Kosten",
|
||||
"usedOf": "{{used}} von {{limit}}"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1262,6 +1269,10 @@
|
||||
"running": "Läuft…",
|
||||
"denied": "Vom Benutzer abgelehnt",
|
||||
"failed": "fehlgeschlagen"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "Sie haben {{used}} von Ihrem Kontingent von {{limit}} Tokens verbraucht. Es wird am {{resetsAt}} zurückgesetzt.",
|
||||
"cost": "Sie haben {{used}} von Ihrem Nutzungsbudget von {{limit}} verbraucht. Es wird am {{resetsAt}} zurückgesetzt."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -380,6 +380,13 @@
|
||||
"toolCalls": "Tool Calls",
|
||||
"runSuccess": "Run Success",
|
||||
"feedback": "Feedback"
|
||||
},
|
||||
"quota": {
|
||||
"title": "Your usage quota",
|
||||
"resets": "Resets {{resetsAt}}",
|
||||
"tokens": "Tokens",
|
||||
"cost": "Cost",
|
||||
"usedOf": "{{used}} of {{limit}}"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1273,6 +1280,10 @@
|
||||
"running": "Running…",
|
||||
"denied": "Denied by user",
|
||||
"failed": "failed"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "You've used {{used}} of your {{limit}} token quota. It resets {{resetsAt}}.",
|
||||
"cost": "You've used {{used}} of your {{limit}} usage budget. It resets {{resetsAt}}."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -375,6 +375,13 @@
|
||||
"toolCalls": "Llamadas a Herramientas",
|
||||
"runSuccess": "Éxito de Ejecución",
|
||||
"feedback": "Retroalimentación"
|
||||
},
|
||||
"quota": {
|
||||
"title": "Tu cuota de uso",
|
||||
"resets": "Se restablece el {{resetsAt}}",
|
||||
"tokens": "Tokens",
|
||||
"cost": "Coste",
|
||||
"usedOf": "{{used}} de {{limit}}"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1262,6 +1269,10 @@
|
||||
"running": "Ejecutando…",
|
||||
"denied": "Denegado por el usuario",
|
||||
"failed": "falló"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "Has usado {{used}} de tu cuota de {{limit}} tokens. Se restablece el {{resetsAt}}.",
|
||||
"cost": "Has usado {{used}} de tu presupuesto de uso de {{limit}}. Se restablece el {{resetsAt}}."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -375,6 +375,13 @@
|
||||
"toolCalls": "ツール呼び出し",
|
||||
"runSuccess": "実行成功率",
|
||||
"feedback": "フィードバック"
|
||||
},
|
||||
"quota": {
|
||||
"title": "利用クォータ",
|
||||
"resets": "{{resetsAt}} にリセット",
|
||||
"tokens": "トークン",
|
||||
"cost": "コスト",
|
||||
"usedOf": "{{used}} / {{limit}}"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1262,6 +1269,10 @@
|
||||
"running": "実行中…",
|
||||
"denied": "ユーザーによって拒否されました",
|
||||
"failed": "失敗"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "トークンクォータ {{limit}} のうち {{used}} を使用しました。{{resetsAt}} にリセットされます。",
|
||||
"cost": "利用予算 {{limit}} のうち {{used}} を使用しました。{{resetsAt}} にリセットされます。"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -375,6 +375,13 @@
|
||||
"toolCalls": "Вызовы инструментов",
|
||||
"runSuccess": "Успешность запусков",
|
||||
"feedback": "Обратная связь"
|
||||
},
|
||||
"quota": {
|
||||
"title": "Ваша квота использования",
|
||||
"resets": "Сброс: {{resetsAt}}",
|
||||
"tokens": "Токены",
|
||||
"cost": "Стоимость",
|
||||
"usedOf": "{{used}} из {{limit}}"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1282,6 +1289,10 @@
|
||||
"running": "Выполняется…",
|
||||
"denied": "Отклонено пользователем",
|
||||
"failed": "не удалось"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "Вы использовали {{used}} из квоты в {{limit}} токенов. Квота сбросится {{resetsAt}}.",
|
||||
"cost": "Вы использовали {{used}} из бюджета в {{limit}}. Бюджет сбросится {{resetsAt}}."
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -375,6 +375,13 @@
|
||||
"toolCalls": "工具呼叫",
|
||||
"runSuccess": "執行成功率",
|
||||
"feedback": "回饋"
|
||||
},
|
||||
"quota": {
|
||||
"title": "您的用量配額",
|
||||
"resets": "{{resetsAt}} 重設",
|
||||
"tokens": "權杖",
|
||||
"cost": "費用",
|
||||
"usedOf": "{{used}} / {{limit}}"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1262,6 +1269,10 @@
|
||||
"running": "執行中…",
|
||||
"denied": "已被使用者拒絕",
|
||||
"failed": "失敗"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "您已使用 {{limit}} 權杖配額中的 {{used}}。配額將於 {{resetsAt}} 重設。",
|
||||
"cost": "您已使用 {{limit}} 用量預算中的 {{used}}。預算將於 {{resetsAt}} 重設。"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -375,6 +375,13 @@
|
||||
"toolCalls": "工具调用",
|
||||
"runSuccess": "运行成功率",
|
||||
"feedback": "反馈"
|
||||
},
|
||||
"quota": {
|
||||
"title": "您的用量配额",
|
||||
"resets": "{{resetsAt}} 重置",
|
||||
"tokens": "令牌",
|
||||
"cost": "费用",
|
||||
"usedOf": "{{used}} / {{limit}}"
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -1262,6 +1269,10 @@
|
||||
"running": "正在运行…",
|
||||
"denied": "已被用户拒绝",
|
||||
"failed": "失败"
|
||||
},
|
||||
"quotaExceeded": {
|
||||
"tokens": "您已使用 {{limit}} 令牌配额中的 {{used}}。配额将于 {{resetsAt}} 重置。",
|
||||
"cost": "您已使用 {{limit}} 用量预算中的 {{used}}。预算将于 {{resetsAt}} 重置。"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useDarkTheme, useLoaderState } from '../hooks';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { htmlLegendPlugin } from '../utils/chartUtils';
|
||||
import { formatDate } from '../utils/dateTimeUtils';
|
||||
import UsageQuota from './components/UsageQuota';
|
||||
|
||||
/**
|
||||
* Resolve a CSS custom property on `:root` to a concrete color string.
|
||||
@@ -377,6 +378,7 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
{agentId ? null : <UsageQuota />}
|
||||
<div className="mb-5 flex flex-row flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm leading-6">
|
||||
{t('settings.analytics.subtitle')}
|
||||
@@ -412,7 +414,7 @@ export default function Analytics({ agentId }: AnalyticsProps) {
|
||||
<div
|
||||
key={card.label}
|
||||
title={card.hint}
|
||||
className={`border-border dark:border-border rounded-2xl border px-6 py-5${card.hint ? ' cursor-help' : ''}`}
|
||||
className={`border-border dark:border-border rounded-2xl border px-6 py-5${card.hint ? 'cursor-help' : ''}`}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">{card.label}</p>
|
||||
<p className="text-foreground dark:text-foreground mt-1 text-2xl font-bold">
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import userService from '../../api/services/userService';
|
||||
import { selectToken } from '../../preferences/preferenceSlice';
|
||||
|
||||
type Budget = { limit: number | null; used: number };
|
||||
type Bucket = {
|
||||
bucket: string;
|
||||
tokens: Budget;
|
||||
cost: Budget;
|
||||
resets_at: string;
|
||||
};
|
||||
|
||||
function Meter({
|
||||
label,
|
||||
budget,
|
||||
format,
|
||||
}: {
|
||||
label: string;
|
||||
budget: Budget;
|
||||
format: (value: number) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (budget.limit === null) return null;
|
||||
const percent =
|
||||
budget.limit <= 0
|
||||
? 100
|
||||
: Math.min(100, Math.max(0, (budget.used / budget.limit) * 100));
|
||||
const tone =
|
||||
percent >= 100
|
||||
? 'bg-red-500'
|
||||
: percent >= 80
|
||||
? 'bg-amber-500'
|
||||
: 'bg-[#7D54D1]';
|
||||
return (
|
||||
<div className="min-w-48 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="text-foreground tabular-nums">
|
||||
{t('settings.analytics.quota.usedOf', {
|
||||
used: format(budget.used),
|
||||
limit: format(budget.limit),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The caller's usage against the quota an admin set; renders nothing when unlimited. */
|
||||
export default function UsageQuota() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const [bucket, setBucket] = useState<Bucket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
userService
|
||||
.getQuota(token)
|
||||
.then((res: Response) => (res.ok ? res.json() : null))
|
||||
.then((json: { buckets?: Bucket[] } | null) => {
|
||||
if (cancelled) return;
|
||||
const buckets = json?.buckets ?? [];
|
||||
setBucket(
|
||||
buckets.find((b) => b.bucket === 'all') ?? buckets[0] ?? null,
|
||||
);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
if (!bucket) return null;
|
||||
|
||||
const number = new Intl.NumberFormat(i18n.language);
|
||||
const usd = new Intl.NumberFormat(i18n.language, {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
});
|
||||
const reset = new Date(bucket.resets_at);
|
||||
const resetsAt = Number.isNaN(reset.getTime())
|
||||
? ''
|
||||
: new Intl.DateTimeFormat(i18n.language, {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(reset);
|
||||
|
||||
return (
|
||||
<div className="border-border mb-6 rounded-2xl border px-6 py-5">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p className="text-foreground font-bold">
|
||||
{t('settings.analytics.quota.title')}
|
||||
</p>
|
||||
{resetsAt ? (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.analytics.quota.resets', { resetsAt })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-6">
|
||||
<Meter
|
||||
label={t('settings.analytics.quota.tokens')}
|
||||
budget={bucket.tokens}
|
||||
format={(value) => number.format(value)}
|
||||
/>
|
||||
<Meter
|
||||
label={t('settings.analytics.quota.cost')}
|
||||
budget={bucket.cost}
|
||||
format={(value) => usd.format(value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user