Phase 10: Per-User Rate Limits — messages + tokens, quota UI, admin usage
Backend:
- max_ai_messages_per_day + max_ai_tokens_per_day on User model (nullable, override)
- Migration 008: add columns + seed default settings (100 msgs, 500K tokens)
- usage_service: count today's messages + tokens, check quota, get limits
- GET /chats/quota returns usage vs limits + reset time
- POST /chats/{id}/messages checks quota before streaming (429 if exceeded)
- Admin user schemas expose both limit fields
- GET /admin/usage returns per-user daily message + token counts
- admin_user_service allows updating both limit fields
Frontend:
- Chat header shows "X/Y messages · XK/YK tokens" with red highlight at limit
- Quota refreshes every 30s via TanStack Query
- Admin usage page with table: user, messages today, tokens today
- Route + sidebar entry for admin usage
- English + Russian translations
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
71
frontend/src/pages/admin/usage.tsx
Normal file
71
frontend/src/pages/admin/usage.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import api from "@/api/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface UserUsage {
|
||||
user_id: string;
|
||||
username: string;
|
||||
messages_today: number;
|
||||
message_limit: number;
|
||||
tokens_today: number;
|
||||
token_limit: number;
|
||||
}
|
||||
|
||||
export function AdminUsagePage() {
|
||||
const { t } = useTranslation();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["admin-usage"],
|
||||
queryFn: async () => {
|
||||
const { data } = await api.get<{ users: UserUsage[] }>("/admin/usage");
|
||||
return data.users;
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
if (user?.role !== "admin") return <Navigate to="/" replace />;
|
||||
|
||||
const users = data || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-semibold">{t("admin_usage.title")}</h1>
|
||||
|
||||
<div className="rounded-lg border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("admin_usage.user")}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t("admin_usage.messages")}</th>
|
||||
<th className="px-4 py-3 text-right font-medium">{t("admin_usage.tokens")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u) => (
|
||||
<tr key={u.user_id} className="border-b last:border-0">
|
||||
<td className="px-4 py-3">{u.username}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={cn(u.messages_today >= u.message_limit && "text-destructive font-medium")}>
|
||||
{u.messages_today} / {u.message_limit}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className={cn(u.tokens_today >= u.token_limit && "text-destructive font-medium")}>
|
||||
{Math.round(u.tokens_today / 1000)}K / {Math.round(u.token_limit / 1000)}K
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<tr><td colSpan={3} className="px-4 py-8 text-center text-muted-foreground">{t("admin_usage.no_data")}</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user