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; 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 { export interface AdminLeaderboardQuestion {
text: string; text: string;
user_id: string; user_id: string;
@@ -100,6 +110,9 @@ export interface AdminLeaderboardResponse {
users_by_tokens: AdminLeaderboardUser[]; users_by_tokens: AdminLeaderboardUser[];
skills: AdminLeaderboardSkill[]; skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[]; tools: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[]; recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[]; frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[]; trends: AdminLeaderboardTrendPoint[];
@@ -113,6 +126,9 @@ export interface AdminUserAnalyticsResponse {
overview: AdminLeaderboardOverview; overview: AdminLeaderboardOverview;
skills: AdminLeaderboardSkill[]; skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[]; tools: AdminLeaderboardSkill[];
models: AdminLeaderboardDimension[];
agents: AdminLeaderboardDimension[];
run_statuses: AdminLeaderboardDimension[];
recent_questions: AdminLeaderboardQuestion[]; recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[]; trends: AdminLeaderboardTrendPoint[];
} }
@@ -156,7 +172,15 @@ export async function getUserMemory(
} }
export async function getUserLeaderboard( 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, limit = 20,
): Promise<AdminLeaderboardResponse> { ): Promise<AdminLeaderboardResponse> {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -164,7 +188,13 @@ export async function getUserLeaderboard(
since: range.since, since: range.since,
until: range.until, until: range.until,
limit: String(limit), 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( return parse(
await apiFetch(`${base()}/leaderboard?${params}`), await apiFetch(`${base()}/leaderboard?${params}`),
"Failed to load leaderboard", "Failed to load leaderboard",
+20 -2
View File
@@ -16,11 +16,29 @@ import {
} from "./api"; } from "./api";
export function useUserLeaderboard( 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, limit = 20,
) { ) {
return useQuery({ 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), queryFn: () => getUserLeaderboard(range, limit),
}); });
} }
@@ -2,72 +2,97 @@
import { import {
Activity, Activity,
ArrowLeft,
ArrowRight, ArrowRight,
Award, Award,
BarChart3, BarChart3,
Bot,
CalendarDays, CalendarDays,
Crown, Crown,
Gauge,
Layers3,
Loader2, Loader2,
Maximize2,
Medal, Medal,
MessageSquareText, MessageSquareText,
MousePointerClick, MousePointerClick,
Search, Search,
ShieldOff,
Settings2,
Sparkles, Sparkles,
TrendingUp, TrendingUp,
UserRound, UserRound,
Users, Users,
X,
type LucideIcon, type LucideIcon,
} from "lucide-react"; } from "lucide-react";
import { useMemo, useState } from "react"; import { useEffect, useId, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { import type {
AdminLeaderboardDimension,
AdminLeaderboardQuestion, AdminLeaderboardQuestion,
AdminLeaderboardSkill, AdminLeaderboardSkill,
AdminLeaderboardTrendPoint, AdminLeaderboardTrendPoint,
AdminLeaderboardUser, AdminLeaderboardUser,
} from "@/core/admin/api"; } from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks"; 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"; 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 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<LeaderboardAccent, { soft: string; text: string; bar: string; border: string }> = {
indigo: {
const accentClasses: Record<GoogleAccent, { soft: string; text: string; bar: string; border: string }> = { soft: "bg-[#EEF2FF] dark:bg-indigo-400/15",
blue: { text: "text-[#4F46E5] dark:text-indigo-200",
soft: "bg-[#E8F0FE] dark:bg-[#4285F4]/15", bar: "bg-[#4F46E5]",
text: "text-[#4285F4] dark:text-[#8AB4F8]", border: "border-[#C7D2FE] dark:border-indigo-300/25",
bar: "bg-[#4285F4]",
border: "border-[#D2E3FC] dark:border-[#4285F4]/35",
}, },
red: { rose: {
soft: "bg-[#FCE8E6] dark:bg-[#DB4437]/15", soft: "bg-[#FFF1F2] dark:bg-rose-400/15",
text: "text-[#DB4437] dark:text-[#F28B82]", text: "text-[#E11D48] dark:text-rose-200",
bar: "bg-[#DB4437]", bar: "bg-[#E11D48]",
border: "border-[#FAD2CF] dark:border-[#DB4437]/35", border: "border-[#FECDD3] dark:border-rose-300/25",
}, },
yellow: { amber: {
soft: "bg-[#FEF7E0] dark:bg-[#F4B400]/15", soft: "bg-[#FFFBEB] dark:bg-amber-300/15",
text: "text-[#B06000] dark:text-[#FDD663]", text: "text-[#B45309] dark:text-amber-100",
bar: "bg-[#F4B400]", bar: "bg-[#F59E0B]",
border: "border-[#FEEFC3] dark:border-[#F4B400]/35", border: "border-[#FDE68A] dark:border-amber-200/25",
}, },
green: { teal: {
soft: "bg-[#E6F4EA] dark:bg-[#0F9D58]/15", soft: "bg-[#ECFDF5] dark:bg-teal-300/15",
text: "text-[#0F9D58] dark:text-[#81C995]", text: "text-[#0F766E] dark:text-teal-100",
bar: "bg-[#0F9D58]", bar: "bg-[#0F766E]",
border: "border-[#CEEAD6] dark:border-[#0F9D58]/35", border: "border-[#A7F3D0] dark:border-teal-200/25",
}, },
}; };
const RANK_COLORS = ["#4F46E5", "#0F766E", "#F59E0B", "#E11D48", "#7C3AED"] as const;
function toDateInput(date: Date): string { function toDateInput(date: Date): string {
const year = date.getFullYear(); const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0"); 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 { function userScore(user: AdminLeaderboardUser, tab: UserRankTab): number {
if (tab === "questions") return user.qa_count; if (tab === "questions") return user.qa_count;
if (tab === "tokens") return user.total_tokens; 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; return user.run_count + user.tool_call_count;
} }
function scoreLabel(user: AdminLeaderboardUser, tab: UserRankTab): string { function scoreLabel(user: AdminLeaderboardUser, tab: UserRankTab): string {
if (tab === "questions") return `${fmtNumber(user.qa_count)} 次问答`; if (tab === "questions") return `${fmtNumber(user.qa_count)} 次问答`;
if (tab === "tokens") return `${fmtNumber(user.total_tokens)} tokens`; 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))} 次使用`; 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 { function initials(user: AdminLeaderboardUser): string {
const source = user.email || user.user_id || "?"; const source = user.email || user.user_id || "?";
return source.slice(0, 1).toUpperCase(); return source.slice(0, 1).toUpperCase();
@@ -136,7 +177,7 @@ function StatCard({
label: string; label: string;
value: string; value: string;
note: string; note: string;
accent: GoogleAccent; accent: LeaderboardAccent;
}) { }) {
const color = accentClasses[accent]; const color = accentClasses[accent];
return ( return (
@@ -215,25 +256,295 @@ function PodiumCard({
); );
} }
function TrendBars({ points }: { points: AdminLeaderboardTrendPoint[] }) { function SpotlightCarousel({
const max = Math.max(1, ...points.map((p) => p.runs)); users,
if (points.length === 0) { tab,
return <div className="flex h-28 items-center justify-center text-sm text-[#5F6368]">暂无趋势数据</div>; 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 ( return (
<div className="flex h-28 items-end gap-1.5"> <section className="overflow-hidden rounded-lg border border-[#D8DEE9] bg-white shadow-sm dark:border-[#243B58] dark:bg-[#061B32]">
{points.slice(-32).map((point, index) => ( <div className="grid lg:grid-cols-[minmax(0,1fr)_360px]">
<div key={point.date} className="group flex min-w-2 flex-1 items-end"> <button
<div type="button"
title={`${point.date}: 使用 ${point.runs} 次,问答 ${point.questions} 次`} onClick={() => onSelect(active)}
className="w-full rounded-t-sm transition" className="min-h-52 bg-[#111827] p-5 text-left text-white dark:bg-[#08111F]"
style={{ >
height: `${Math.max(8, (point.runs / max) * 112)}px`, <div className="flex items-start justify-between gap-3">
backgroundColor: GOOGLE_COLORS[index % GOOGLE_COLORS.length], <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>
))} </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> </div>
); );
} }
@@ -342,7 +653,7 @@ function SkillRanking({
className="h-2 rounded-full" className="h-2 rounded-full"
style={{ style={{
width: `${Math.max(4, (item.call_count / max) * 100)}%`, width: `${Math.max(4, (item.call_count / max) * 100)}%`,
backgroundColor: GOOGLE_COLORS[index % GOOGLE_COLORS.length], backgroundColor: RANK_COLORS[index % RANK_COLORS.length],
}} }}
/> />
</div> </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({ function QuestionPanel({
title, title,
items, items,
@@ -372,7 +740,7 @@ function QuestionPanel({
showUser?: boolean; showUser?: boolean;
}) { }) {
return ( 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 justify-between gap-3 border-b border-[#EEF0F1] px-4 py-3 dark:border-[#17395F]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" /> <MessageSquareText className="size-4 text-[#5F6368] dark:text-slate-300" />
@@ -380,19 +748,21 @@ function QuestionPanel({
</div> </div>
<Badge variant="outline">{items.length}</Badge> <Badge variant="outline">{items.length}</Badge>
</div> </div>
<div className="divide-y divide-[#EEF0F1] dark:divide-[#17395F]"> <ScrollArea className="min-h-0 flex-1">
{items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>} <div className="divide-y divide-[#EEF0F1] dark:divide-[#17395F]">
{items.slice(0, 8).map((item, index) => ( {items.length === 0 && <div className="py-12 text-center text-sm text-[#5F6368]">暂无问题记录</div>}
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3"> {items.map((item, index) => (
<div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div> <div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400"> <div className="text-sm leading-6 text-[#202124] dark:text-slate-100">{clampText(item.text, 180)}</div>
{showUser && <span>{item.email || item.user_id || "未知用户"}</span>} <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-[#5F6368] dark:text-slate-400">
<span>{fmtTime(item.asked_at)}</span> {showUser && <span>{item.email || item.user_id || "未知用户"}</span>}
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>} <span>{fmtTime(item.asked_at)}</span>
{item.thread_id && <span>会话 {item.thread_id.slice(0, 8)}</span>}
</div>
</div> </div>
</div> ))}
))} </div>
</div> </ScrollArea>
</section> </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() { export default function AdminUserLeaderboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [range, setRange] = useState<RangeState>(() => defaultRange(30)); const [range, setRange] = useState<RangeState>(() => defaultRange(30));
const [tab, setTab] = useState<UserRankTab>("activity"); const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState(""); 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 [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 { data, isLoading, error } = useUserLeaderboard(apiRange, 30);
const rankedUsers = useMemo(() => { const rankedUsers = useMemo(() => {
const base = data?.users_by_activity ?? [];
const source = const source =
tab === "questions" tab === "questions"
? data?.users_by_questions ? data?.users_by_questions
: tab === "tokens" : tab === "tokens"
? data?.users_by_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(); const needle = query.trim().toLowerCase();
return (source ?? []).filter((user) => return (source ?? []).filter((user) =>
needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true, needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true,
@@ -501,10 +1060,30 @@ export default function AdminUserLeaderboardPage() {
const overview = data?.overview; const overview = data?.overview;
const topUsers = rankedUsers.slice(0, 3); 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) => { const setQuickRange = (days: number) => {
setRange(defaultRange(days)); 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 ( return (
<div className="flex size-full min-h-0 bg-[#F8FAFD] text-[#202124] dark:bg-[#031A33] dark:text-white"> <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"> <main className="flex min-w-0 flex-1 flex-col">
@@ -522,6 +1101,15 @@ export default function AdminUserLeaderboardPage() {
</div> </div>
<div className="flex flex-wrap items-end gap-2"> <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"> <div className="flex flex-col gap-1">
<label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label> <label className="text-xs text-[#5F6368] dark:text-slate-400">开始日期</label>
<Input <Input
@@ -555,6 +1143,21 @@ export default function AdminUserLeaderboardPage() {
<ScrollArea className="min-h-0 flex-1"> <ScrollArea className="min-h-0 flex-1">
<div className="space-y-5 p-6"> <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 && ( {isLoading && (
<div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]"> <div className="flex items-center justify-center gap-2 py-20 text-sm text-[#5F6368]">
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />
@@ -571,23 +1174,31 @@ export default function AdminUserLeaderboardPage() {
{!isLoading && data && overview && ( {!isLoading && data && overview && (
<> <>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4"> <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={Users} accent="indigo" 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={MousePointerClick} accent="teal" 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={MessageSquareText} accent="amber" 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={Sparkles} accent="rose" label="技能调用" value={fmtNumber(overview.total_skill_calls)} note={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
</div> </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]"> <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 className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div> <div>
<h2 className="font-semibold text-[#202124] dark:text-white">榜单前三</h2> <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> </div>
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}> <Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
<TabsList> <TabsList>
<TabsTrigger value="activity">使用排行</TabsTrigger> <TabsTrigger value="activity">使用排行</TabsTrigger>
<TabsTrigger value="questions">问答排行</TabsTrigger> <TabsTrigger value="questions">问答排行</TabsTrigger>
<TabsTrigger value="tokens">消耗排行</TabsTrigger> <TabsTrigger value="tokens">消耗排行</TabsTrigger>
<TabsTrigger value="skills">技能排行</TabsTrigger>
<TabsTrigger value="efficiency">效率排行</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
</div> </div>
@@ -603,7 +1214,10 @@ export default function AdminUserLeaderboardPage() {
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<BarChart3 className="size-4 text-[#5F6368] dark:text-slate-300" /> <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>
<div className="relative"> <div className="relative">
<Search className="absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[#80868B]" /> <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="当前时间段暂无工具调用" /> <SkillRanking title="最常用工具" icon={Activity} items={data.tools} empty="当前时间段暂无工具调用" />
</div> </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"> <div className="grid gap-5 xl:grid-cols-2">
<QuestionPanel title="最近经常问的问题" items={data.frequent_questions} /> <QuestionPanel title="最近经常问的问题" items={data.frequent_questions} />
<QuestionPanel title="最新问题" items={data.recent_questions} /> <QuestionPanel title="最新问题" items={data.recent_questions} />
@@ -19,7 +19,7 @@ from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query, Request from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field 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 ( from app.gateway.deps import (
get_agent_store, get_agent_store,
@@ -32,7 +32,9 @@ from deerflow.config.paths import get_paths
from deerflow.runtime import serialize_channel_values from deerflow.runtime import serialize_channel_values
from app.gateway.routers.memory import _build_v1_provider, _is_v1 from app.gateway.routers.memory import _build_v1_provider, _is_v1
from deerflow.persistence.engine import get_session_factory 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.run.model import RunRow
from deerflow.persistence.scheduled_tasks.model import ScheduledTaskRunRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow from deerflow.persistence.user.model import UserRow
@@ -82,6 +84,16 @@ class AdminLeaderboardSkill(BaseModel):
last_used_at: str = "" 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): class AdminLeaderboardQuestion(BaseModel):
text: str text: str
user_id: str = "" user_id: str = ""
@@ -121,6 +133,9 @@ class AdminLeaderboardResponse(BaseModel):
users_by_tokens: list[AdminLeaderboardUser] = Field(default_factory=list) users_by_tokens: list[AdminLeaderboardUser] = Field(default_factory=list)
skills: list[AdminLeaderboardSkill] = Field(default_factory=list) skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: 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) recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
frequent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list) frequent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list) trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
@@ -134,6 +149,9 @@ class AdminUserAnalyticsResponse(BaseModel):
overview: AdminLeaderboardOverview overview: AdminLeaderboardOverview
skills: list[AdminLeaderboardSkill] = Field(default_factory=list) skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: 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) recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = 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) 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 = ( run_agg = (
select( select(
RunRow.user_id.label("user_id"), 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)) .where(_run_time_filter(since, until))
.group_by(RunRow.user_id) .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 = ( thread_agg = (
select(ThreadMetaRow.user_id.label("user_id"), func.count(ThreadMetaRow.thread_id).label("thread_count")) 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)) .where(and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until))
.group_by(ThreadMetaRow.user_id) .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 = ( tool_agg = (
select( select(
ToolCallMetricRow.user_id.label("user_id"), 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)) .where(_tool_time_filter(since, until))
.group_by(ToolCallMetricRow.user_id) .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 = ( stmt = (
select( select(
@@ -236,6 +343,7 @@ async def _query_user_leaderboard(session, since: datetime, until: datetime, use
) )
if user_id: if user_id:
stmt = stmt.where(UserRow.id == 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( 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.run_count, 0) + func.coalesce(tool_agg.c.tool_call_count, 0)),
desc(func.coalesce(run_agg.c.total_tokens, 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 name_col = ToolCallMetricRow.skill_name if field == "skill" else ToolCallMetricRow.tool_name
call_count = func.count(ToolCallMetricRow.id).label("call_count") call_count = func.count(ToolCallMetricRow.id).label("call_count")
last_used_at = func.max(ToolCallMetricRow.created_at).label("last_used_at") 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: if user_id:
stmt = stmt.where(ToolCallMetricRow.user_id == 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() rows = (await session.execute(stmt)).all()
return [ return [
AdminLeaderboardSkill( 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 = ( stmt = (
select(RunRow.first_human_message, RunRow.user_id, UserRow.email, RunRow.thread_id, RunRow.run_id, RunRow.created_at) 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) .outerjoin(UserRow, UserRow.id == RunRow.user_id)
@@ -307,6 +568,15 @@ async def _query_recent_questions(session, since: datetime, until: datetime, *,
) )
if user_id: if user_id:
stmt = stmt.where(RunRow.user_id == 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() rows = (await session.execute(stmt)).all()
return [ return [
AdminLeaderboardQuestion( 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 = ( stmt = (
select( select(
RunRow.first_human_message, 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")) .order_by(desc("count"), desc("asked_at"))
.limit(limit) .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() rows = (await session.execute(stmt)).all()
return [ return [
AdminLeaderboardQuestion( 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) bucket = func.date(RunRow.created_at)
stmt = ( stmt = (
select( select(
@@ -365,6 +665,15 @@ async def _query_trends(session, since: datetime, until: datetime, *, user_id: s
) )
if user_id: if user_id:
stmt = stmt.where(RunRow.user_id == 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() rows = (await session.execute(stmt)).all()
return [ return [
AdminLeaderboardTrendPoint( 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)] run_where = [_run_time_filter(since, until)]
thread_where = [and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until)] thread_where = [and_(ThreadMetaRow.created_at >= since, ThreadMetaRow.created_at <= until)]
tool_where = [_tool_time_filter(since, 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) run_where.append(RunRow.user_id == user_id)
thread_where.append(ThreadMetaRow.user_id == user_id) thread_where.append(ThreadMetaRow.user_id == user_id)
tool_where.append(ToolCallMetricRow.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( run_row = (await session.execute(
select( select(
func.count(RunRow.run_id), func.count(RunRow.run_id),
@@ -424,6 +759,10 @@ async def get_user_leaderboard(
days: int = Query(default=30, ge=1, le=365), days: int = Query(default=30, ge=1, le=365),
since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"), since: str | None = Query(default=None, description="Inclusive range start, ISO datetime"),
until: str | None = Query(default=None, description="Inclusive range end, 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), limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse: ) -> AdminLeaderboardResponse:
"""Return admin analytics for user activity, Q&A, skills, questions, and trends.""" """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") raise HTTPException(status_code=503, detail="Analytics requires database persistence")
since_dt, until_dt, range_days = _resolve_range(days, since, until) 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: async with session_factory() as session:
overview = await _query_overview(session, since_dt, until_dt) common = {
users = await _query_user_leaderboard(session, since_dt, until_dt) "exclude_user_ids": excluded,
skills = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="skill", limit=limit) "include_scheduled": include_scheduled,
tools = await _query_call_rankings(session, since_dt, until_dt, user_id=None, field="tool", limit=limit) "include_admins": include_admins,
recent_questions = await _query_recent_questions(session, since_dt, until_dt, user_id=None, limit=limit) "include_failed": include_failed,
frequent_questions = await _query_frequent_questions(session, since_dt, until_dt, limit=limit) }
trends = await _query_trends(session, since_dt, until_dt) 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( return AdminLeaderboardResponse(
range_days=range_days, 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], users_by_tokens=sorted(users, key=lambda u: u.total_tokens, reverse=True)[:limit],
skills=skills, skills=skills,
tools=tools, tools=tools,
models=models,
agents=agents,
run_statuses=run_statuses,
recent_questions=recent_questions, recent_questions=recent_questions,
frequent_questions=frequent_questions, frequent_questions=frequent_questions,
trends=trends, trends=trends,
@@ -481,6 +840,11 @@ async def get_user_analytics(
overview = await _query_overview(session, since_dt, until_dt, user_id=user_id) 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) 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) 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) 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) trends = await _query_trends(session, since_dt, until_dt, user_id=user_id)
@@ -492,6 +856,9 @@ async def get_user_analytics(
overview=overview, overview=overview,
skills=skills, skills=skills,
tools=tools, tools=tools,
models=models,
agents=agents,
run_statuses=run_statuses,
recent_questions=recent_questions, recent_questions=recent_questions,
trends=trends, trends=trends,
) )