polish admin leaderboard analytics

This commit is contained in:
Administrator committed 2026-06-04 21:09:36 +08:00
1 parent 5590f7d85a
commit fe0ede178e
4 files changed
+1122 -86

No files matched your search

+31 -1
View File
@@ -61,6 +61,16 @@ export interface AdminLeaderboardSkill {
last_used_at: string;
}
export interface AdminLeaderboardDimension {
name: string;
count: number;
user_count: number;
success_count: number;
error_count: number;
total_tokens: number;
last_seen_at: string;
}
export interface AdminLeaderboardQuestion {
text: string;
user_id: string;
@@ -100,6 +110,9 @@ export interface AdminLeaderboardResponse {
users_by_tokens: AdminLeaderboardUser[];
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
@@ -113,6 +126,9 @@ export interface AdminUserAnalyticsResponse {
overview: AdminLeaderboardOverview;
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
@@ -156,7 +172,15 @@ export async function getUserMemory(
}
export async function getUserLeaderboard(
range: { since: string; until: string; days?: number },
range: {
since: string;
until: string;
days?: number;
exclude_user_ids?: string[];
include_scheduled?: boolean;
include_admins?: boolean;
include_failed?: boolean;
},
limit = 20,
): Promise<AdminLeaderboardResponse> {
const params = new URLSearchParams({
@@ -164,7 +188,13 @@ export async function getUserLeaderboard(
since: range.since,
until: range.until,
limit: String(limit),
include_scheduled: String(range.include_scheduled ?? true),
include_admins: String(range.include_admins ?? true),
include_failed: String(range.include_failed ?? true),
});
for (const userId of range.exclude_user_ids ?? []) {
params.append("exclude_user_ids", userId);
}
return parse(
await apiFetch(`${base()}/leaderboard?${params}`),
"Failed to load leaderboard",
+20 -2
View File
@@ -16,11 +16,29 @@ import {
} from "./api";
export function useUserLeaderboard(
range: { since: string; until: string; days?: number },
range: {
since: string;
until: string;
days?: number;
exclude_user_ids?: string[];
include_scheduled?: boolean;
include_admins?: boolean;
include_failed?: boolean;
},
limit = 20,
) {
return useQuery({
queryKey: ["admin-activity", "leaderboard", range.since, range.until, limit],
queryKey: [
"admin-activity",
"leaderboard",
range.since,
range.until,
range.exclude_user_ids ?? [],
range.include_scheduled ?? true,
range.include_admins ?? true,
range.include_failed ?? true,
limit,
],
queryFn: () => getUserLeaderboard(range, limit),
});
}
@@ -2,72 +2,97 @@
import {
Activity,
ArrowLeft,
ArrowRight,
Award,
BarChart3,
Bot,
CalendarDays,
Crown,
Gauge,
Layers3,
Loader2,
Maximize2,
Medal,
MessageSquareText,
MousePointerClick,
Search,
ShieldOff,
Settings2,
Sparkles,
TrendingUp,
UserRound,
Users,
X,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardDimension,
AdminLeaderboardQuestion,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import type { UserListItem } from "@/core/auth/api";
import { useAllUsers } from "@/core/auth/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens";
type UserRankTab = "activity" | "questions" | "tokens" | "skills" | "efficiency";
type RangeState = { since: string; until: string; days?: number };
type GoogleAccent = "blue" | "red" | "yellow" | "green";
type StatsConfig = {
include_scheduled: boolean;
include_admins: boolean;
include_failed: boolean;
};
type LeaderboardAccent = "indigo" | "rose" | "amber" | "teal";
const GOOGLE_COLORS = ["#4285F4", "#DB4437", "#F4B400", "#0F9D58"] as const;
const accentClasses: Record<GoogleAccent, { soft: string; text: string; bar: string; border: string }> = {
blue: {
soft: "bg-[#E8F0FE] dark:bg-[#4285F4]/15",
text: "text-[#4285F4] dark:text-[#8AB4F8]",
bar: "bg-[#4285F4]",
border: "border-[#D2E3FC] dark:border-[#4285F4]/35",
const accentClasses: Record<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
indigo: {
soft: "bg-[#EEF2FF] dark:bg-indigo-400/15",
text: "text-[#4F46E5] dark:text-indigo-200",
bar: "bg-[#4F46E5]",
border: "border-[#C7D2FE] dark:border-indigo-300/25",
},
red: {
soft: "bg-[#FCE8E6] dark:bg-[#DB4437]/15",
text: "text-[#DB4437] dark:text-[#F28B82]",
bar: "bg-[#DB4437]",
border: "border-[#FAD2CF] dark:border-[#DB4437]/35",
rose: {
soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
text: "text-[#E11D48] dark:text-rose-200",
bar: "bg-[#E11D48]",
border: "border-[#FECDD3] dark:border-rose-300/25",
},
yellow: {
soft: "bg-[#FEF7E0] dark:bg-[#F4B400]/15",
text: "text-[#B06000] dark:text-[#FDD663]",
bar: "bg-[#F4B400]",
border: "border-[#FEEFC3] dark:border-[#F4B400]/35",
amber: {
soft: "bg-[#FFFBEB] dark:bg-amber-300/15",
text: "text-[#B45309] dark:text-amber-100",
bar: "bg-[#F59E0B]",
border: "border-[#FDE68A] dark:border-amber-200/25",
},
green: {
soft: "bg-[#E6F4EA] dark:bg-[#0F9D58]/15",
text: "text-[#0F9D58] dark:text-[#81C995]",
bar: "bg-[#0F9D58]",
border: "border-[#CEEAD6] dark:border-[#0F9D58]/35",
teal: {
soft: "bg-[#ECFDF5] dark:bg-teal-300/15",
text: "text-[#0F766E] dark:text-teal-100",
bar: "bg-[#0F766E]",
border: "border-[#A7F3D0] dark:border-teal-200/25",
},
};
const RANK_COLORS = ["#4F46E5", "#0F766E", "#F59E0B", "#E11D48", "#7C3AED"] as const;
function toDateInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
@@ -111,15 +136,31 @@ function clampText(value: string, max = 120): string {
function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens;
if (tab === "skills") return user.skill_call_count;
if (tab === "efficiency") {
return user.run_count > 0 ? Math.round((user.qa_count / user.run_count) * 100) : 0;
}
return user.run_count + user.tool_call_count;
}
function scoreLabel(user: AdminLeaderboardUser, tab: UserRankTab): string {
if (tab === "questions") return `${fmtNumber(user.qa_count)} 次问答`;
if (tab === "tokens") return `${fmtNumber(user.total_tokens)} tokens`;
if (tab === "skills") return `${fmtNumber(user.skill_call_count)} 次技能`;
if (tab === "efficiency") {
return `${(user.run_count > 0 ? user.qa_count / user.run_count : 0).toFixed(1)} 问答/运行`;
}
return `${fmtNumber(userScore(user, tab))} 次使用`;
}
function rankTabDescription(tab: UserRankTab): string {
if (tab === "questions") return "问答排行按用户提出有效问题的运行次数排序。";
if (tab === "tokens") return "消耗排行按模型输入与输出 Token 总量排序。";
if (tab === "skills") return "技能排行按带有技能名称的工具调用次数排序。";
if (tab === "efficiency") return "效率排行 = 问答次数 / 运行次数,用来衡量每次运行承载的问答密度。";
return "使用排行按运行次数与工具调用次数合计排序。";
}
function initials(user: AdminLeaderboardUser): string {
const source = user.email || user.user_id || "?";
return source.slice(0, 1).toUpperCase();
@@ -136,7 +177,7 @@ function StatCard({
label: string;
value: string;
note: string;
accent: GoogleAccent;
accent: LeaderboardAccent;
}) {
const color = accentClasses[accent];
return (
@@ -215,25 +256,295 @@ function PodiumCard({
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const max = Math.max(1, ...points.map((p) => p.runs));
if (points.length === 0) {
return <div className="flex h-28 items-center justify-center text-sm text-[#5F6368]">暂无趋势数据</div>;
function SpotlightCarousel({
users,
tab,
onSelect,
}: {
users: AdminLeaderboardUser[];
tab: UserRankTab;
onSelect: (user: AdminLeaderboardUser) => void;
}) {
const [index, setIndex] = useState(0);
const visible = users.slice(0, 5);
const active = visible[index % Math.max(visible.length, 1)];
useEffect(() => {
if (visible.length <= 1) return;
const timer = window.setInterval(() => {
setIndex((value) => (value + 1) % visible.length);
}, 5000);
return () => window.clearInterval(timer);
}, [visible.length]);
useEffect(() => {
if (index >= visible.length) setIndex(0);
}, [index, visible.length]);
if (!active) {
return (
<section className="rounded-lg border border-dashed border-[#DADCE0] bg-white p-5 dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="text-sm text-[#5F6368]">暂无可轮播用户</div>
</section>
);
}
const maxScore = Math.max(1, ...visible.map((user) => userScore(user, tab)));
return (
<div className="flex h-28 items-end gap-1.5">
{points.slice(-32).map((point, index) => (
<div key={point.date} className="group flex min-w-2 flex-1 items-end">
<div
title={`${point.date}: 使用 ${point.runs} 次,问答 ${point.questions} 次`}
className="w-full rounded-t-sm transition"
style={{
height: `${Math.max(8, (point.runs / max) * 112)}px`,
backgroundColor: GOOGLE_COLORS[index % GOOGLE_COLORS.length],
}}
/>
<section className="overflow-hidden rounded-lg border border-[#D8DEE9] bg-white shadow-sm dark:border-[#243B58] dark:bg-[#061B32]">
<div className="grid lg:grid-cols-[minmax(0,1fr)_360px]">
<button
type="button"
onClick={() => onSelect(active)}
className="min-h-52 bg-[#111827] p-5 text-left text-white dark:bg-[#08111F]"
>
<div className="flex items-start justify-between gap-3">
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs font-medium text-white shadow-sm">
<Crown className="size-3.5" />
榜单焦点
</span>
<span className="rounded-full bg-white/10 px-2 py-1 text-xs text-white/70">
{index + 1} / {visible.length}
</span>
</div>
<div className="mt-5 grid gap-5 md:grid-cols-[minmax(0,1fr)_260px]">
<div className="min-w-0">
<div className="mb-4 flex size-14 items-center justify-center rounded-xl bg-white text-xl font-semibold text-[#111827] shadow-sm">
{initials(active)}
</div>
<div className="truncate text-2xl font-semibold">{active.email || active.user_id}</div>
<div className="mt-2 text-sm font-medium text-[#A7F3D0]">{scoreLabel(active, tab)}</div>
<div className="mt-4 h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full bg-[#2DD4BF]"
style={{ width: `${Math.min(100, Math.max(8, (userScore(active, tab) / maxScore) * 100))}%` }}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
{[
["运行", active.run_count],
["问答", active.qa_count],
["技能", active.skill_call_count],
["Token", active.total_tokens],
].map(([label, value]) => (
<div key={label} className="rounded-md border border-white/10 bg-white/8 px-3 py-2">
<div className="text-xs text-white/55">{label}</div>
<div className="mt-1 text-lg font-semibold tabular-nums text-white">{fmtNumber(Number(value))}</div>
</div>
))}
</div>
</div>
</button>
<div className="flex flex-col justify-between p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold text-[#202124] dark:text-white">焦点队列</div>
<div className="text-xs text-[#6B7280] dark:text-slate-400">自动轮播 Top 5</div>
</div>
<div className="space-y-2">
{visible.map((user, itemIndex) => (
<button
key={user.user_id}
type="button"
onClick={() => {
setIndex(itemIndex);
onSelect(user);
}}
className={cn(
"grid w-full grid-cols-[28px_minmax(0,1fr)_72px] items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-[#F8FAFD] dark:hover:bg-[#0B2A50]",
itemIndex === index && "bg-[#EEF2FF] dark:bg-indigo-400/10",
)}
>
<span className="text-xs font-semibold text-[#6B7280]">{itemIndex + 1}</span>
<span className="min-w-0">
<span className="block truncate text-[#202124] dark:text-white">{user.email || user.user_id}</span>
<span className="mt-1 block h-1 overflow-hidden rounded-full bg-[#E5E7EB] dark:bg-[#17395F]">
<span
className="block h-full rounded-full bg-[#4F46E5]"
style={{ width: `${Math.min(100, Math.max(6, (userScore(user, tab) / maxScore) * 100))}%` }}
/>
</span>
</span>
<span className="text-right text-xs font-medium tabular-nums text-[#4F46E5]">{fmtNumber(userScore(user, tab))}</span>
</button>
))}
</div>
<div className="mt-3 flex gap-1">
{visible.map((user, itemIndex) => (
<button
key={user.user_id}
type="button"
aria-label={`切换到第 ${itemIndex + 1} 位用户`}
onClick={() => setIndex(itemIndex)}
className={cn(
"h-1.5 flex-1 rounded-full transition",
itemIndex === index ? "bg-[#4F46E5]" : "bg-[#DADCE0] dark:bg-[#17395F]",
)}
/>
))}
</div>
</div>
))}
</div>
</section>
);
}
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const [metric, setMetric] = useState<keyof Pick<AdminLeaderboardTrendPoint, "runs" | "questions" | "users" | "tokens">>("runs");
const chartId = useId().replace(/:/g, "");
if (points.length === 0) {
return <div className="flex h-56 items-center justify-center text-sm text-[#5F6368]">暂无趋势数据</div>;
}
const visible = points.slice(-32);
const series = [
{ key: "runs", label: "运行", color: "#4F46E5", soft: "bg-[#EEF2FF] text-[#4F46E5]" },
{ key: "questions", label: "问答", color: "#0F766E", soft: "bg-[#ECFDF5] text-[#0F766E]" },
{ key: "users", label: "活跃用户", color: "#B45309", soft: "bg-[#FFFBEB] text-[#B45309]" },
{ key: "tokens", label: "Token", color: "#E11D48", soft: "bg-[#FFF1F2] text-[#E11D48]" },
] as const;
const activeSeries = series.find((item) => item.key === metric) ?? series[0];
const metricMax = Math.max(1, ...visible.map((point) => Number(point[metric] ?? 0)));
const latest = visible[visible.length - 1];
const previous = visible[visible.length - 2];
const latestValue = Number(latest?.[metric] ?? 0);
const previousValue = Number(previous?.[metric] ?? 0);
const delta = latestValue - previousValue;
const width = 920;
const height = 280;
const padding = { top: 22, right: 28, bottom: 36, left: 48 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const xFor = (index: number) =>
padding.left + (visible.length <= 1 ? chartWidth / 2 : (index / (visible.length - 1)) * chartWidth);
const yFor = (value: number, max: number) => padding.top + chartHeight - (value / max) * chartHeight;
const gridLines = [0, 0.25, 0.5, 0.75, 1];
const selectedPath = visible
.map((point, index) => {
const value = Number(point[metric] ?? 0);
return `${index === 0 ? "M" : "L"} ${xFor(index).toFixed(2)} ${yFor(value, metricMax).toFixed(2)}`;
})
.join(" ");
const selectedArea = `${selectedPath} L ${xFor(visible.length - 1).toFixed(2)} ${padding.top + chartHeight} L ${xFor(0).toFixed(2)} ${padding.top + chartHeight} Z`;
const peak = visible.reduce(
(best, point) => (Number(point[metric] ?? 0) > Number(best[metric] ?? 0) ? point : best),
visible[0],
);
const chart = (large = false) => (
<div className={cn("overflow-hidden rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#17395F] dark:bg-[#071F3B]", large && "p-5")}>
<svg viewBox={`0 0 ${width} ${height}`} className={cn("w-full", large ? "h-[520px]" : "h-72")} role="img" aria-label="使用趋势折线图">
<defs>
<linearGradient id={`trend-area-${chartId}-${metric}`} x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor={activeSeries.color} stopOpacity="0.22" />
<stop offset="100%" stopColor={activeSeries.color} stopOpacity="0.02" />
</linearGradient>
</defs>
{gridLines.map((ratio) => {
const y = padding.top + chartHeight * ratio;
return (
<g key={ratio}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="currentColor"
className="text-[#E5E7EB] dark:text-[#17395F]"
strokeWidth="1"
/>
<text x={12} y={y + 4} className="fill-[#9CA3AF] text-[11px]">
{fmtNumber(Math.round(metricMax * (1 - ratio)))}
</text>
</g>
);
})}
<path d={selectedArea} fill={`url(#trend-area-${chartId}-${metric})`} />
<path d={selectedPath} fill="none" stroke={activeSeries.color} strokeWidth={large ? "4" : "3"} strokeLinecap="round" strokeLinejoin="round" />
{visible.map((point, index) => {
const value = Number(point[metric] ?? 0);
const isPeak = point.date === peak.date;
const isLast = index === visible.length - 1;
return (
<circle
key={`${metric}-${point.date}`}
cx={xFor(index)}
cy={yFor(value, metricMax)}
r={isPeak || isLast ? 5 : 3}
fill="#fff"
stroke={activeSeries.color}
strokeWidth={isPeak || isLast ? "3" : "2"}
>
<title>{`${point.date} ${activeSeries.label}: ${fmtNumber(value)}`}</title>
</circle>
);
})}
{visible.map((point, index) => {
if (index !== 0 && index !== visible.length - 1 && index % Math.ceil(visible.length / 6) !== 0) return null;
return (
<text key={point.date} x={xFor(index)} y={height - 12} textAnchor="middle" className="fill-[#6B7280] text-[11px]">
{point.date.slice(5)}
</text>
);
})}
</svg>
</div>
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-4">
{series.map((item) => (
<button
key={item.key}
type="button"
onClick={() => setMetric(item.key)}
className={cn(
"rounded-lg border px-3 py-2 text-left transition hover:-translate-y-0.5 hover:shadow-sm",
metric === item.key
? "border-[#4F46E5] bg-white shadow-sm dark:border-indigo-300/40 dark:bg-[#0B2A50]"
: "border-[#E5E7EB] bg-[#F9FAFB] dark:border-[#17395F] dark:bg-[#071F3B]",
)}
>
<span className={cn("inline-flex rounded-full px-2 py-0.5 text-xs font-medium", item.soft)}>{item.label}</span>
<span className="mt-2 block text-lg font-semibold tabular-nums text-[#111827] dark:text-white">
{fmtNumber(visible.reduce((sum, point) => sum + Number(point[item.key] ?? 0), 0))}
</span>
</button>
))}
</div>
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
<Maximize2 className="mr-1 size-4" />
放大分析
</Button>
</DialogTrigger>
<DialogContent className="max-w-[min(1180px,calc(100vw-2rem))]">
<DialogHeader>
<DialogTitle>时间趋势分析</DialogTitle>
<DialogDescription>
当前查看 {activeSeries.label},峰值 {peak.date} / {fmtNumber(Number(peak[metric] ?? 0))}。
</DialogDescription>
</DialogHeader>
{chart(true)}
</DialogContent>
</Dialog>
</div>
<div className="grid gap-3 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#17395F] dark:bg-[#071F3B]">
<div className="text-xs text-[#6B7280] dark:text-slate-400">最新 {activeSeries.label}</div>
<div className="mt-2 text-3xl font-semibold tabular-nums text-[#111827] dark:text-white">{fmtNumber(latestValue)}</div>
<div className={cn("mt-2 text-sm", delta >= 0 ? "text-[#0F766E]" : "text-[#E11D48]")}>
较上一日 {delta >= 0 ? "+" : ""}{fmtNumber(delta)}
</div>
<div className="mt-3 text-xs text-[#6B7280] dark:text-slate-400">
峰值 {peak.date.slice(5)} / {fmtNumber(Number(peak[metric] ?? 0))}
</div>
</div>
{chart(false)}
</div>
</div>
);
}
@@ -342,7 +653,7 @@ function SkillRanking({
className="h-2 rounded-full"
style={{
width: `${Math.max(4, (item.call_count / max) * 100)}%`,
backgroundColor: GOOGLE_COLORS[index % GOOGLE_COLORS.length],
backgroundColor: RANK_COLORS[index % RANK_COLORS.length],
}}
/>
</div>
@@ -362,6 +673,63 @@ function SkillRanking({
);
}
function DimensionRanking({
title,
icon: Icon,
items,
empty,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardDimension[];
empty: string;
}) {
const max = Math.max(1, ...items.map((item) => item.count));
return (
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="mb-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Icon className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">{title}</h2>
</div>
<Badge variant="outline">{items.length} 项</Badge>
</div>
<div className="space-y-3">
{items.length === 0 && <div className="py-10 text-center text-sm text-[#5F6368]">{empty}</div>}
{items.slice(0, 8).map((item, index) => (
<div key={item.name} className="grid grid-cols-[28px_minmax(0,1fr)_88px] items-center gap-3">
<span className="text-sm font-medium text-[#80868B] tabular-nums">{index + 1}</span>
<div className="min-w-0">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-[#202124] dark:text-slate-100">{item.name}</span>
<span className="text-xs text-[#5F6368] dark:text-slate-400">
{fmtNumber(item.user_count)} 人
</span>
</div>
<div className="mt-2 h-2 rounded-full bg-[#E8EAED] dark:bg-[#11345E]">
<div
className="h-2 rounded-full"
style={{
width: `${Math.max(4, (item.count / max) * 100)}%`,
backgroundColor: RANK_COLORS[index % RANK_COLORS.length],
}}
/>
</div>
<div className="mt-1 text-xs text-[#5F6368] dark:text-slate-400">
成功 {fmtNumber(item.success_count)} · 失败 {fmtNumber(item.error_count)} · {fmtNumber(item.total_tokens)} tokens
</div>
</div>
<div className="text-right">
<div className="text-sm font-semibold text-[#202124] tabular-nums dark:text-white">{fmtNumber(item.count)}</div>
<div className="text-xs text-[#5F6368] dark:text-slate-400">次</div>
</div>
</div>
))}
</div>
</section>
);
}
function QuestionPanel({
title,
items,
@@ -372,7 +740,7 @@ function QuestionPanel({
showUser?: boolean;
}) {
return (
<section className="rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<section className="flex h-[430px] flex-col overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex items-center justify-between gap-3 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -380,19 +748,21 @@ function QuestionPanel({
</div>
<Badge variant="outline">{items.length}</Badge>
</div>
<div className="divide-y divide-[#EEF0F1] dark:divide-[#17395F]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
{items.slice(0, 8).map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
<span>{fmtTime(item.asked_at)}</span>
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
<ScrollArea className="min-h-0 flex-1">
<div className="divide-y divide-[#EEF0F1] dark:divide-[#17395F]">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
<span>{fmtTime(item.asked_at)}</span>
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
</div>
</div>
</div>
))}
</div>
))}
</div>
</ScrollArea>
</section>
);
}
@@ -476,22 +846,211 @@ function UserDetailPanel({
);
}
function ExclusionSelector({
users,
excludedUserIds,
query,
onQueryChange,
onAdd,
onRemove,
onClear,
}: {
users: UserListItem[];
excludedUserIds: string[];
query: string;
onQueryChange: (value: string) => void;
onAdd: (userId: string) => void;
onRemove: (userId: string) => void;
onClear: () => void;
}) {
const excluded = new Set(excludedUserIds);
const selectedUsers = users.filter((user) => excluded.has(user.id));
const candidates = users
.filter((user) => !excluded.has(user.id))
.filter((user) => {
const needle = query.trim().toLowerCase();
if (!needle) return false;
return `${user.email} ${user.id}`.toLowerCase().includes(needle);
})
.slice(0, 6);
return (
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-[#202124] dark:text-white">
<ShieldOff className="size-4 text-[#5F6368] dark:text-slate-300" />
统计排除名单
</div>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">
名单中的账号不参与当前排行榜、KPI、趋势和问题统计。
</p>
</div>
{excludedUserIds.length > 0 && (
<Button size="sm" variant="outline" onClick={onClear}>
清空名单
</Button>
)}
</div>
<div className="mt-3 grid gap-3 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder="搜索账号邮箱或 ID"
className="h-9 bg-white pl-8 dark:bg-[#0B2A50]"
/>
{query.trim() && candidates.length > 0 && (
<div className="absolute z-20 mt-2 max-h-64 w-full overflow-hidden rounded-lg border border-[#DADCE0] bg-white shadow-lg dark:border-[#213D5E] dark:bg-[#06213F]">
<ScrollArea className="max-h-64">
{candidates.map((user) => (
<button
key={user.id}
type="button"
onClick={() => {
onAdd(user.id);
onQueryChange("");
}}
className="flex w-full items-center justify-between gap-3 border-b border-[#EEF0F1] px-3 py-2 text-left text-sm last:border-b-0 hover:bg-[#F8FAFD] dark:border-[#17395F] dark:hover:bg-[#0B2A50]"
>
<span className="min-w-0">
<span className="block truncate font-medium text-[#202124] dark:text-white">{user.email}</span>
<span className="block truncate text-xs text-[#5F6368] dark:text-slate-400">{user.id}</span>
</span>
<Badge variant="outline">{user.system_role === "admin" ? "管理员" : "用户"}</Badge>
</button>
))}
</ScrollArea>
</div>
)}
</div>
<div className="flex min-h-9 flex-wrap items-center gap-2 rounded-md bg-[#F8FAFD] px-3 py-2 dark:bg-[#0B2A50]">
{selectedUsers.length === 0 ? (
<span className="text-sm text-[#5F6368] dark:text-slate-400">暂无排除账号</span>
) : (
selectedUsers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-[260px] items-center gap-1 rounded-full border border-[#D2E3FC] bg-[#E8F0FE] px-2.5 py-1 text-xs text-[#1967D2] dark:border-[#4285F4]/35 dark:bg-[#4285F4]/15 dark:text-[#8AB4F8]"
title={user.email}
>
<span className="truncate">{user.email}</span>
<button type="button" onClick={() => onRemove(user.id)} aria-label={`移除 ${user.email}`}>
<X className="size-3.5" />
</button>
</span>
))
)}
</div>
</div>
</section>
);
}
function StatsRulePanel({
config,
onChange,
excludedCount,
}: {
config: StatsConfig;
onChange: (patch: Partial<StatsConfig>) => void;
excludedCount: number;
}) {
const rules: Array<{
key: keyof StatsConfig;
title: string;
description: string;
}> = [
{
key: "include_scheduled",
title: "统计定时任务问答",
description: "开启后,定时任务触发的运行、问答、Token、模型调用会进入榜单。",
},
{
key: "include_admins",
title: "统计管理员账号",
description: "关闭后,管理员账号不会参与排行榜和整体指标。",
},
{
key: "include_failed",
title: "统计失败运行",
description: "关闭后,失败运行不进入使用、问答、趋势和维度排行。",
},
];
return (
<section className="rounded-lg border border-[#D8DEE9] bg-white p-4 shadow-sm dark:border-[#243B58] dark:bg-[#061B32]">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2 font-semibold text-[#202124] dark:text-white">
<Settings2 className="size-4 text-[#4F46E5]" />
统计规则
</div>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">
当前排除 {fmtNumber(excludedCount)} 个账号,以下开关会影响榜单、趋势、模型、技能和问题统计。
</p>
</div>
<Badge variant="outline">管理员配置</Badge>
</div>
<div className="grid gap-3 md:grid-cols-3">
{rules.map((rule) => (
<label
key={rule.key}
className="flex min-h-28 cursor-pointer flex-col justify-between rounded-lg border border-[#E5E7EB] bg-[#F9FAFB] p-3 dark:border-[#17395F] dark:bg-[#071F3B]"
>
<span className="flex items-start justify-between gap-3">
<span>
<span className="block text-sm font-medium text-[#202124] dark:text-white">{rule.title}</span>
<span className="mt-1 block text-xs leading-5 text-[#6B7280] dark:text-slate-400">{rule.description}</span>
</span>
<Switch
checked={config[rule.key]}
onCheckedChange={(checked) => onChange({ [rule.key]: checked } as Partial<StatsConfig>)}
aria-label={rule.title}
/>
</span>
</label>
))}
</div>
</section>
);
}
export default function AdminUserLeaderboardPage() {
const navigate = useNavigate();
const [range, setRange] = useState<RangeState>(() => defaultRange(30));
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [excludeQuery, setExcludeQuery] = useState("");
const [excludedUserIds, setExcludedUserIds] = useState<string[]>([]);
const [statsConfig, setStatsConfig] = useState<StatsConfig>({
include_scheduled: true,
include_admins: true,
include_failed: true,
});
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const apiRange = useMemo(() => rangeForApi(range), [range]);
const { data: allUsers = [] } = useAllUsers(true);
const apiRange = useMemo(
() => ({ ...rangeForApi(range), exclude_user_ids: excludedUserIds, ...statsConfig }),
[range, excludedUserIds, statsConfig],
);
const { data, isLoading, error } = useUserLeaderboard(apiRange, 30);
const rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? [];
const source =
tab === "questions"
? data?.users_by_questions
: tab === "tokens"
? data?.users_by_tokens
: data?.users_by_activity;
: tab === "skills"
? [...base].sort((a, b) => b.skill_call_count - a.skill_call_count)
: tab === "efficiency"
? [...base].sort((a, b) => userScore(b, "efficiency") - userScore(a, "efficiency"))
: data?.users_by_activity;
const needle = query.trim().toLowerCase();
return (source ?? []).filter((user) =>
needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true,
@@ -501,10 +1060,30 @@ export default function AdminUserLeaderboardPage() {
const overview = data?.overview;
const topUsers = rankedUsers.slice(0, 3);
useEffect(() => {
if (rankedUsers.length === 0) {
setSelectedUserId(null);
return;
}
if (!selectedUserId || !rankedUsers.some((user) => user.user_id === selectedUserId)) {
setSelectedUserId(rankedUsers[0].user_id);
}
}, [rankedUsers, selectedUserId]);
const setQuickRange = (days: number) => {
setRange(defaultRange(days));
};
const addExcludedUser = (userId: string) => {
setExcludedUserIds((current) =>
current.includes(userId) ? current : [...current, userId],
);
};
const removeExcludedUser = (userId: string) => {
setExcludedUserIds((current) => current.filter((id) => id !== userId));
};
return (
<div className="flex size-full min-h-0 bg-[#F8FAFD] text-[#202124] dark:bg-[#031A33] dark:text-white">
<main className="flex min-w-0 flex-1 flex-col">
@@ -522,6 +1101,15 @@ export default function AdminUserLeaderboardPage() {
</div>
<div className="flex flex-wrap items-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate("/page/workspace/chats/new")}
className="h-9"
>
<ArrowLeft className="mr-1 size-4" />
返回系统
</Button>
<div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
<Input
@@ -555,6 +1143,21 @@ export default function AdminUserLeaderboardPage() {
<ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6">
<ExclusionSelector
users={allUsers}
excludedUserIds={excludedUserIds}
query={excludeQuery}
onQueryChange={setExcludeQuery}
onAdd={addExcludedUser}
onRemove={removeExcludedUser}
onClear={() => setExcludedUserIds([])}
/>
<StatsRulePanel
config={statsConfig}
excludedCount={excludedUserIds.length}
onChange={(patch) => setStatsConfig((current) => ({ ...current, ...patch }))}
/>
{isLoading && (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]">
<Loader2 className="size-4 animate-spin" />
@@ -571,23 +1174,31 @@ export default function AdminUserLeaderboardPage() {
{!isLoading && data && overview && (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Users} accent="blue" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<StatCard icon={MousePointerClick} accent="green" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
<StatCard icon={MessageSquareText} accent="yellow" label="问答次数" value={fmtNumber(overview.total_questions)} note={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
<StatCard icon={Sparkles} accent="red" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
<StatCard icon={Users} accent="indigo" label="总用户" value={fmtNumber(overview.total_users)} note={`${fmtNumber(overview.active_users)} 位在当前时间段活跃`} />
<StatCard icon={MousePointerClick} accent="teal" label="使用次数" value={fmtNumber(overview.total_runs)} note={`${fmtNumber(overview.total_threads)} 个会话被创建`} />
<StatCard icon={MessageSquareText} accent="amber" label="问答次数" value={fmtNumber(overview.total_questions)} note={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
<StatCard icon={Sparkles} accent="rose" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
</div>
<SpotlightCarousel
users={rankedUsers}
tab={tab}
onSelect={(user) => setSelectedUserId(user.user_id)}
/>
<section className="rounded-lg border border-[#DADCE0] bg-white p-4 shadow-sm dark:border-[#213D5E] dark:bg-[#06213F]">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">根据当前榜单维度实时切换。</p>
<p className="mt-1 text-sm text-[#5F6368] dark:text-slate-400">{rankTabDescription(tab)}</p>
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
<TabsList>
<TabsTrigger value="activity">使用排行</TabsTrigger>
<TabsTrigger value="questions">问答排行</TabsTrigger>
<TabsTrigger value="tokens">消耗排行</TabsTrigger>
<TabsTrigger value="skills">技能排行</TabsTrigger>
<TabsTrigger value="efficiency">效率排行</TabsTrigger>
</TabsList>
</Tabs>
</div>
@@ -603,7 +1214,10 @@ export default function AdminUserLeaderboardPage() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<BarChart3 className="size-4 text-[#5F6368] dark:text-slate-300" />
<h2 className="font-semibold text-[#202124] dark:text-white">完整排行榜</h2>
<div>
<h2 className="font-semibold text-[#202124] dark:text-white">完整排行榜</h2>
<p className="mt-1 text-xs text-[#6B7280] dark:text-slate-400">{rankTabDescription(tab)}</p>
</div>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" />
@@ -659,6 +1273,13 @@ export default function AdminUserLeaderboardPage() {
<SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
</div>
<div className="grid gap-5 xl:grid-cols-3">
<DimensionRanking title="模型使用排行" icon={Layers3} items={data.models} empty="当前时间段暂无模型调用" />
<DimensionRanking title="Agent 使用排行" icon={Bot} items={data.agents} empty="当前时间段暂无 Agent 运行" />
<DimensionRanking title="运行状态分布" icon={Gauge} items={data.run_statuses} empty="当前时间段暂无运行状态" />
</div>
<div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} />
@@ -19,7 +19,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
from sqlalchemy import and_, case, desc, func, select
from sqlalchemy import and_, case, desc, func, or_, select
from app.gateway.deps import (
get_agent_store,
@@ -32,7 +32,9 @@ from deerflow.config.paths import get_paths
from deerflow.runtime import serialize_channel_values
from app.gateway.routers.memory import _build_v1_provider, _is_v1
from deerflow.persistence.engine import get_session_factory
from deerflow.persistence.llm_metrics.model import LlmCallMetricRow
from deerflow.persistence.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRunRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
@@ -82,6 +84,16 @@ class AdminLeaderboardSkill(BaseModel):
last_used_at: str = ""
class AdminLeaderboardDimension(BaseModel):
name: str
count: int = 0
user_count: int = 0
success_count: int = 0
error_count: int = 0
total_tokens: int = 0
last_seen_at: str = ""
class AdminLeaderboardQuestion(BaseModel):
text: str
user_id: str = ""
@@ -121,6 +133,9 @@ class AdminLeaderboardResponse(BaseModel):
users_by_tokens: list[AdminLeaderboardUser] = Field(default_factory=list)
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
frequent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
@@ -134,6 +149,9 @@ class AdminUserAnalyticsResponse(BaseModel):
overview: AdminLeaderboardOverview
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
models: list[AdminLeaderboardDimension] = Field(default_factory=list)
agents: list[AdminLeaderboardDimension] = Field(default_factory=list)
run_statuses: list[AdminLeaderboardDimension] = Field(default_factory=list)
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
@@ -181,7 +199,78 @@ def _tool_time_filter(since: datetime, until: datetime):
return and_(ToolCallMetricRow.created_at >= since, ToolCallMetricRow.created_at <= until)
async def _query_user_leaderboard(session, since: datetime, until: datetime, user_id: str | None = None) -> list[AdminLeaderboardUser]:
def _llm_time_filter(since: datetime, until: datetime):
return and_(LlmCallMetricRow.created_at >= since, LlmCallMetricRow.created_at <= until)
def _exclude_user_filter(column, exclude_user_ids: set[str]):
if not exclude_user_ids:
return None
return or_(column.is_(None), column.not_in(list(exclude_user_ids)))
def _apply_exclude(stmt, column, exclude_user_ids: set[str]):
condition = _exclude_user_filter(column, exclude_user_ids)
return stmt.where(condition) if condition is not None else stmt
def _admin_user_ids_stmt():
return select(UserRow.id).where(UserRow.system_role == "admin")
def _scheduled_run_ids_stmt():
return select(ScheduledTaskRunRow.agent_run_id).where(ScheduledTaskRunRow.agent_run_id.is_not(None))
def _apply_user_scope(stmt, column, *, exclude_user_ids: set[str], include_admins: bool):
stmt = _apply_exclude(stmt, column, exclude_user_ids)
if not include_admins:
stmt = stmt.where(or_(column.is_(None), column.not_in(_admin_user_ids_stmt())))
return stmt
def _apply_run_scope(
stmt,
*,
user_column,
run_id_column,
exclude_user_ids: set[str],
include_admins: bool,
include_failed: bool,
include_scheduled: bool,
):
stmt = _apply_user_scope(stmt, user_column, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
if not include_failed:
stmt = stmt.where(RunRow.status != "error")
if not include_scheduled:
stmt = stmt.where(or_(run_id_column.is_(None), run_id_column.not_in(_scheduled_run_ids_stmt())))
return stmt
def _apply_tool_scope(
stmt,
*,
exclude_user_ids: set[str],
include_admins: bool,
include_scheduled: bool,
):
stmt = _apply_user_scope(stmt, ToolCallMetricRow.user_id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
if not include_scheduled:
stmt = stmt.where(or_(ToolCallMetricRow.run_id.is_(None), ToolCallMetricRow.run_id.not_in(_scheduled_run_ids_stmt())))
return stmt
async def _query_user_leaderboard(
session,
since: datetime,
until: datetime,
user_id: str | None = None,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> list[AdminLeaderboardUser]:
exclude_user_ids = exclude_user_ids or set()
run_agg = (
select(
RunRow.user_id.label("user_id"),
@@ -195,14 +284,27 @@ async def _query_user_leaderboard(session, since: datetime, until: datetime, use
)
.where(_run_time_filter(since, until))
.group_by(RunRow.user_id)
.subquery()
)
run_agg = _apply_run_scope(
run_agg,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
).subquery()
thread_agg = (
select(ThreadMetaRow.user_id.label("user_id"), func.count(ThreadMetaRow.thread_id).label("thread_count"))
.where(and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until))
.group_by(ThreadMetaRow.user_id)
.subquery()
)
thread_agg = _apply_user_scope(
thread_agg,
ThreadMetaRow.user_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
).subquery()
tool_agg = (
select(
ToolCallMetricRow.user_id.label("user_id"),
@@ -211,8 +313,13 @@ async def _query_user_leaderboard(session, since: datetime, until: datetime, use
)
.where(_tool_time_filter(since, until))
.group_by(ToolCallMetricRow.user_id)
.subquery()
)
tool_agg = _apply_tool_scope(
tool_agg,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_scheduled=include_scheduled,
).subquery()
stmt = (
select(
@@ -236,6 +343,7 @@ async def _query_user_leaderboard(session, since: datetime, until: datetime, use
)
if user_id:
stmt = stmt.where(UserRow.id == user_id)
stmt = _apply_user_scope(stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
stmt = stmt.order_by(
desc(func.coalesce(run_agg.c.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
desc(func.coalesce(run_agg.c.total_tokens, 0)),
@@ -261,7 +369,19 @@ async def _query_user_leaderboard(session, since: datetime, until: datetime, use
]
async def _query_call_rankings(session, since: datetime, until: datetime, *, user_id: str | None, field: str, limit: int) -> list[AdminLeaderboardSkill]:
async def _query_call_rankings(
session,
since: datetime,
until: datetime,
*,
user_id: str | None,
field: str,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
) -> list[AdminLeaderboardSkill]:
exclude_user_ids = exclude_user_ids or set()
name_col = ToolCallMetricRow.skill_name if field == "skill" else ToolCallMetricRow.tool_name
call_count = func.count(ToolCallMetricRow.id).label("call_count")
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at")
@@ -282,6 +402,12 @@ async def _query_call_rankings(session, since: datetime, until: datetime, *, use
)
if user_id:
stmt = stmt.where(ToolCallMetricRow.user_id == user_id)
stmt = _apply_tool_scope(
stmt,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardSkill(
@@ -297,7 +423,142 @@ async def _query_call_rankings(session, since: datetime, until: datetime, *, use
]
async def _query_recent_questions(session, since: datetime, until: datetime, *, user_id: str | None, limit: int) -> list[AdminLeaderboardQuestion]:
async def _query_run_dimensions(
session,
since: datetime,
until: datetime,
*,
user_id: str | None,
field: str,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> list[AdminLeaderboardDimension]:
exclude_user_ids = exclude_user_ids or set()
if field == "model":
name_col = RunRow.model_name
elif field == "agent":
name_col = RunRow.assistant_id
elif field == "status":
name_col = RunRow.status
else:
raise ValueError(f"Unknown run dimension: {field}")
count = func.count(RunRow.run_id).label("count")
last_seen_at = func.max(RunRow.updated_at).label("last_seen_at")
stmt = (
select(
name_col.label("name"),
count,
func.count(func.distinct(RunRow.user_id)).label("user_count"),
func.sum(case((RunRow.status == "success", 1), else_=0)).label("success_count"),
func.sum(case((RunRow.status == "error", 1), else_=0)).label("error_count"),
func.coalesce(func.sum(RunRow.total_tokens), 0).label("total_tokens"),
last_seen_at,
)
.where(_run_time_filter(since, until), name_col.is_not(None), name_col != "")
.group_by(name_col)
.order_by(count.desc(), last_seen_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
stmt = _apply_run_scope(
stmt,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardDimension(
name=r[0] or "",
count=int(r[1] or 0),
user_count=int(r[2] or 0),
success_count=int(r[3] or 0),
error_count=int(r[4] or 0),
total_tokens=int(r[5] or 0),
last_seen_at=_iso(r[6]),
)
for r in rows
]
async def _query_model_rankings(
session,
since: datetime,
until: datetime,
*,
user_id: str | None,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> list[AdminLeaderboardDimension]:
exclude_user_ids = exclude_user_ids or set()
count = func.count(LlmCallMetricRow.id).label("count")
last_seen_at = func.max(LlmCallMetricRow.created_at).label("last_seen_at")
stmt = (
select(
LlmCallMetricRow.model_name.label("name"),
count,
func.count(func.distinct(LlmCallMetricRow.user_id)).label("user_count"),
func.sum(case((LlmCallMetricRow.status == "success", 1), else_=0)).label("success_count"),
func.sum(case((LlmCallMetricRow.status == "error", 1), else_=0)).label("error_count"),
func.coalesce(func.sum(LlmCallMetricRow.total_tokens), 0).label("total_tokens"),
last_seen_at,
)
.where(_llm_time_filter(since, until), LlmCallMetricRow.model_name.is_not(None), LlmCallMetricRow.model_name != "")
.group_by(LlmCallMetricRow.model_name)
.order_by(count.desc(), last_seen_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(LlmCallMetricRow.user_id == user_id)
stmt = _apply_user_scope(
stmt,
LlmCallMetricRow.user_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
)
if not include_failed:
stmt = stmt.where(LlmCallMetricRow.status != "error")
if not include_scheduled:
stmt = stmt.where(or_(LlmCallMetricRow.run_id.is_(None), LlmCallMetricRow.run_id.not_in(_scheduled_run_ids_stmt())))
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardDimension(
name=r[0] or "",
count=int(r[1] or 0),
user_count=int(r[2] or 0),
success_count=int(r[3] or 0),
error_count=int(r[4] or 0),
total_tokens=int(r[5] or 0),
last_seen_at=_iso(r[6]),
)
for r in rows
]
async def _query_recent_questions(
session,
since: datetime,
until: datetime,
*,
user_id: str | None,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> list[AdminLeaderboardQuestion]:
exclude_user_ids = exclude_user_ids or set()
stmt = (
select(RunRow.first_human_message, RunRow.user_id, UserRow.email, RunRow.thread_id, RunRow.run_id, RunRow.created_at)
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
@@ -307,6 +568,15 @@ async def _query_recent_questions(session, since: datetime, until: datetime, *,
)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
stmt = _apply_run_scope(
stmt,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardQuestion(
@@ -321,7 +591,17 @@ async def _query_recent_questions(session, since: datetime, until: datetime, *,
]
async def _query_frequent_questions(session, since: datetime, until: datetime, limit: int) -> list[AdminLeaderboardQuestion]:
async def _query_frequent_questions(
session,
since: datetime,
until: datetime,
limit: int,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> list[AdminLeaderboardQuestion]:
exclude_user_ids = exclude_user_ids or set()
stmt = (
select(
RunRow.first_human_message,
@@ -337,6 +617,15 @@ async def _query_frequent_questions(session, since: datetime, until: datetime, l
.order_by(desc("count"), desc("asked_at"))
.limit(limit)
)
stmt = _apply_run_scope(
stmt,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardQuestion(
@@ -349,7 +638,18 @@ async def _query_frequent_questions(session, since: datetime, until: datetime, l
]
async def _query_trends(session, since: datetime, until: datetime, *, user_id: str | None = None) -> list[AdminLeaderboardTrendPoint]:
async def _query_trends(
session,
since: datetime,
until: datetime,
*,
user_id: str | None = None,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> list[AdminLeaderboardTrendPoint]:
exclude_user_ids = exclude_user_ids or set()
bucket = func.date(RunRow.created_at)
stmt = (
select(
@@ -365,6 +665,15 @@ async def _query_trends(session, since: datetime, until: datetime, *, user_id: s
)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
stmt = _apply_run_scope(
stmt,
user_column=RunRow.user_id,
run_id_column=RunRow.run_id,
exclude_user_ids=exclude_user_ids,
include_admins=include_admins,
include_failed=include_failed,
include_scheduled=include_scheduled,
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardTrendPoint(
@@ -378,7 +687,18 @@ async def _query_trends(session, since: datetime, until: datetime, *, user_id: s
]
async def _query_overview(session, since: datetime, until: datetime, *, user_id: str | None = None) -> AdminLeaderboardOverview:
async def _query_overview(
session,
since: datetime,
until: datetime,
*,
user_id: str | None = None,
exclude_user_ids: set[str] | None = None,
include_scheduled: bool = True,
include_admins: bool = True,
include_failed: bool = True,
) -> AdminLeaderboardOverview:
exclude_user_ids = exclude_user_ids or set()
run_where = [_run_time_filter(since, until)]
thread_where = [and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until)]
tool_where = [_tool_time_filter(since, until)]
@@ -386,8 +706,23 @@ async def _query_overview(session, since: datetime, until: datetime, *, user_id:
run_where.append(RunRow.user_id == user_id)
thread_where.append(ThreadMetaRow.user_id == user_id)
tool_where.append(ToolCallMetricRow.user_id == user_id)
if exclude_user_ids:
run_where.append(_exclude_user_filter(RunRow.user_id, exclude_user_ids))
thread_where.append(_exclude_user_filter(ThreadMetaRow.user_id, exclude_user_ids))
tool_where.append(_exclude_user_filter(ToolCallMetricRow.user_id, exclude_user_ids))
if not include_admins:
run_where.append(or_(RunRow.user_id.is_(None), RunRow.user_id.not_in(_admin_user_ids_stmt())))
thread_where.append(or_(ThreadMetaRow.user_id.is_(None), ThreadMetaRow.user_id.not_in(_admin_user_ids_stmt())))
tool_where.append(or_(ToolCallMetricRow.user_id.is_(None), ToolCallMetricRow.user_id.not_in(_admin_user_ids_stmt())))
if not include_failed:
run_where.append(RunRow.status != "error")
if not include_scheduled:
run_where.append(or_(RunRow.run_id.is_(None), RunRow.run_id.not_in(_scheduled_run_ids_stmt())))
tool_where.append(or_(ToolCallMetricRow.run_id.is_(None), ToolCallMetricRow.run_id.not_in(_scheduled_run_ids_stmt())))
total_users = 1 if user_id else (await session.execute(select(func.count(UserRow.id)))).scalar_one()
total_users_stmt = select(func.count(UserRow.id))
total_users_stmt = _apply_user_scope(total_users_stmt, UserRow.id, exclude_user_ids=exclude_user_ids, include_admins=include_admins)
total_users = 1 if user_id else (await session.execute(total_users_stmt)).scalar_one()
run_row = (await session.execute(
select(
func.count(RunRow.run_id),
@@ -424,6 +759,10 @@ async def get_user_leaderboard(
days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, ISO datetime"),
exclude_user_ids: list[str] | None = Query(default=None, description="User ids excluded from this leaderboard aggregation"),
include_scheduled: bool = Query(default=True, description="Include runs created by scheduled tasks"),
include_admins: bool = Query(default=True, description="Include admin accounts in leaderboard aggregation"),
include_failed: bool = Query(default=True, description="Include failed runs and LLM calls"),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse:
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
@@ -433,14 +772,31 @@ async def get_user_leaderboard(
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
since_dt, until_dt, range_days = _resolve_range(days, since, until)
excluded = {uid for uid in (exclude_user_ids or []) if uid}
async with session_factory() as session:
overview = await _query_overview(session, since_dt, until_dt)
users = await _query_user_leaderboard(session, since_dt, until_dt)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit)
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit)
trends = await _query_trends(session, since_dt, until_dt)
common = {
"exclude_user_ids": excluded,
"include_scheduled": include_scheduled,
"include_admins": include_admins,
"include_failed": include_failed,
}
tool_common = {
"exclude_user_ids": excluded,
"include_scheduled": include_scheduled,
"include_admins": include_admins,
}
overview = await _query_overview(session, since_dt, until_dt, **common)
users = await _query_user_leaderboard(session, since_dt, until_dt, **common)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit, **tool_common)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit, **tool_common)
models = await _query_model_rankings(session, since_dt, until_dt, user_id=None, limit=limit, **common)
if not models:
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="model", limit=limit, **common)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="agent", limit=limit, **common)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=None, field="status", limit=limit, **common)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit, **common)
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit, **common)
trends = await _query_trends(session, since_dt, until_dt, **common)
return AdminLeaderboardResponse(
range_days=range_days,
@@ -452,6 +808,9 @@ async def get_user_leaderboard(
users_by_tokens=sorted(users, key=lambda u: u.total_tokens, reverse=True)[:limit],
skills=skills,
tools=tools,
models=models,
agents=agents,
run_statuses=run_statuses,
recent_questions=recent_questions,
frequent_questions=frequent_questions,
trends=trends,
@@ -481,6 +840,11 @@ async def get_user_analytics(
overview = await _query_overview(session, since_dt, until_dt, user_id=user_id)
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="skill", limit=limit)
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=user_id, field="tool", limit=limit)
models = await _query_model_rankings(session, since_dt, until_dt, user_id=user_id, limit=limit)
if not models:
models = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="model", limit=limit)
agents = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="agent", limit=limit)
run_statuses = await _query_run_dimensions(session, since_dt, until_dt, user_id=user_id, field="status", limit=limit)
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=user_id, limit=limit)
trends = await _query_trends(session, since_dt, until_dt, user_id=user_id)
@@ -492,6 +856,9 @@ async def get_user_analytics(
overview=overview,
skills=skills,
tools=tools,
models=models,
agents=agents,
run_statuses=run_statuses,
recent_questions=recent_questions,
trends=trends,
)