feat: add admin user leaderboard analytics

This commit is contained in:
Administrator committed 2026-06-04 19:35:11 +08:00
1 parent 1e4ef070cf
commit 8f80b7c1ef
7 files changed
+1060 -1

No files matched your search

+111
View File
@@ -35,6 +35,88 @@ export interface AdminMemoryResponse {
buckets: AdminMemoryBucket[]; buckets: AdminMemoryBucket[];
} }
export interface AdminLeaderboardUser {
user_id: string;
email: string;
system_role: string;
thread_count: number;
run_count: number;
qa_count: number;
llm_call_count: number;
tool_call_count: number;
skill_call_count: number;
total_tokens: number;
input_tokens: number;
output_tokens: number;
last_active_at: string;
}
export interface AdminLeaderboardSkill {
name: string;
call_count: number;
user_count: number;
success_count: number;
error_count: number;
avg_duration_ms: number;
last_used_at: string;
}
export interface AdminLeaderboardQuestion {
text: string;
user_id: string;
email: string;
thread_id: string;
run_id: string;
asked_at: string;
}
export interface AdminLeaderboardTrendPoint {
date: string;
runs: number;
questions: number;
tokens: number;
users: number;
}
export interface AdminLeaderboardOverview {
total_users: number;
active_users: number;
total_threads: number;
total_runs: number;
total_questions: number;
total_tokens: number;
total_skill_calls: number;
total_tool_calls: number;
error_runs: number;
}
export interface AdminLeaderboardResponse {
range_days: number;
since: string;
until: string;
overview: AdminLeaderboardOverview;
users_by_activity: AdminLeaderboardUser[];
users_by_questions: AdminLeaderboardUser[];
users_by_tokens: AdminLeaderboardUser[];
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
recent_questions: AdminLeaderboardQuestion[];
frequent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
export interface AdminUserAnalyticsResponse {
range_days: number;
since: string;
until: string;
user: AdminLeaderboardUser;
overview: AdminLeaderboardOverview;
skills: AdminLeaderboardSkill[];
tools: AdminLeaderboardSkill[];
recent_questions: AdminLeaderboardQuestion[];
trends: AdminLeaderboardTrendPoint[];
}
const base = () => `${getBackendBaseURL()}/api/admin/users`; const base = () => `${getBackendBaseURL()}/api/admin/users`;
async function parse<T>(res: Response, what: string): Promise<T> { async function parse<T>(res: Response, what: string): Promise<T> {
@@ -73,6 +155,35 @@ export async function getUserMemory(
); );
} }
export async function getUserLeaderboard(
days: number,
limit = 20,
): Promise<AdminLeaderboardResponse> {
const params = new URLSearchParams({
days: String(days),
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/leaderboard?${params}`),
"Failed to load leaderboard",
);
}
export async function getUserAnalytics(
userId: string,
days: number,
limit = 20,
): Promise<AdminUserAnalyticsResponse> {
const params = new URLSearchParams({
days: String(days),
limit: String(limit),
});
return parse(
await apiFetch(`${base()}/${encodeURIComponent(userId)}/analytics?${params}`),
"Failed to load user analytics",
);
}
export async function addAdminMemoryEntry( export async function addAdminMemoryEntry(
userId: string, userId: string,
content: string, content: string,
+17
View File
@@ -6,6 +6,8 @@ import {
deleteAdminMemoryEntry, deleteAdminMemoryEntry,
deleteAdminV1Fact, deleteAdminV1Fact,
getUserMemory, getUserMemory,
getUserAnalytics,
getUserLeaderboard,
listUserThreadMessages, listUserThreadMessages,
listUserThreads, listUserThreads,
replaceAdminMemoryEntry, replaceAdminMemoryEntry,
@@ -13,6 +15,21 @@ import {
updateAdminV1Section, updateAdminV1Section,
} from "./api"; } from "./api";
export function useUserLeaderboard(days: number, limit = 20) {
return useQuery({
queryKey: ["admin-activity", "leaderboard", days, limit],
queryFn: () => getUserLeaderboard(days, limit),
});
}
export function useUserAnalytics(userId: string | null, days: number, limit = 20) {
return useQuery({
queryKey: ["admin-activity", "analytics", userId, days, limit],
queryFn: () => getUserAnalytics(userId!, days, limit),
enabled: !!userId,
});
}
/** Threads owned by a given user. Disabled until a user is picked. */ /** Threads owned by a given user. Disabled until a user is picked. */
export function useUserThreads(userId: string | null) { export function useUserThreads(userId: string | null) {
return useQuery({ return useQuery({
@@ -35,6 +35,7 @@ import {
ShieldCheck, ShieldCheck,
Sparkles, Sparkles,
Target, Target,
Trophy,
Users, Users,
} from "lucide-react"; } from "lucide-react";
@@ -259,6 +260,13 @@ export const sidebarMenuItems: MenuItem[] = [
path: "/page/strategy/admin/scheduled-tasks", path: "/page/strategy/admin/scheduled-tasks",
adminOnly: true, adminOnly: true,
}, },
{
id: "mgmt-user-leaderboard",
label: "用户排行榜",
icon: Trophy,
path: "/page/strategy/admin/user-leaderboard",
adminOnly: true,
},
{ {
id: "mgmt-user-activity", id: "mgmt-user-activity",
label: "用户活动审计", label: "用户活动审计",
@@ -1,7 +1,8 @@
"use client"; "use client";
import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react"; import { Loader2, MessageSquareIcon, SearchIcon, UserIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@@ -109,11 +110,13 @@ function MemoryBucketView({ bucket }: { bucket: AdminMemoryBucket }) {
} }
export default function AdminUserActivityPage() { export default function AdminUserActivityPage() {
const [searchParams] = useSearchParams();
const { data: users, isLoading: usersLoading } = useAllUsers(true); const { data: users, isLoading: usersLoading } = useAllUsers(true);
const [userId, setUserId] = useState<string | null>(null); const [userId, setUserId] = useState<string | null>(null);
const [threadId, setThreadId] = useState<string | null>(null); const [threadId, setThreadId] = useState<string | null>(null);
const [userQuery, setUserQuery] = useState(""); const [userQuery, setUserQuery] = useState("");
const [tab, setTab] = useState<"qa" | "memory">("qa"); const [tab, setTab] = useState<"qa" | "memory">("qa");
const targetUserId = searchParams.get("user");
const { data: threads, isLoading: threadsLoading } = useUserThreads(userId); const { data: threads, isLoading: threadsLoading } = useUserThreads(userId);
const { data: messages, isLoading: messagesLoading } = useUserThreadMessages( const { data: messages, isLoading: messagesLoading } = useUserThreadMessages(
@@ -122,6 +125,14 @@ export default function AdminUserActivityPage() {
); );
const { data: memory, isLoading: memoryLoading } = useUserMemory(userId); const { data: memory, isLoading: memoryLoading } = useUserMemory(userId);
useEffect(() => {
if (targetUserId) {
setUserId(targetUserId);
setThreadId(null);
setTab("qa");
}
}, [targetUserId]);
const filteredUsers = useMemo(() => { const filteredUsers = useMemo(() => {
const needle = userQuery.trim().toLowerCase(); const needle = userQuery.trim().toLowerCase();
const list = users ?? []; const list = users ?? [];
@@ -0,0 +1,470 @@
"use client";
import {
Activity,
BarChart3,
Clock3,
Loader2,
Medal,
MessageSquareText,
MousePointerClick,
Search,
Sparkles,
TrendingUp,
UserRound,
Users,
type LucideIcon,
} from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type {
AdminLeaderboardQuestion,
AdminLeaderboardSkill,
AdminLeaderboardTrendPoint,
AdminLeaderboardUser,
} from "@/core/admin/api";
import { useUserAnalytics, useUserLeaderboard } from "@/core/admin/hooks";
import { cn } from "@/lib/utils";
type UserRankTab = "activity" | "questions" | "tokens";
function fmtNumber(value?: number | null): string {
return Number(value ?? 0).toLocaleString();
}
function fmtTime(value?: string | null): string {
if (!value) return "-";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function scoreOf(user: AdminLeaderboardUser): number {
return user.run_count + user.tool_call_count;
}
function clampText(value: string, max = 120): string {
const text = value.trim();
return text.length > max ? `${text.slice(0, max)}...` : text;
}
function OverviewCard({
icon: Icon,
label,
value,
hint,
}: {
icon: LucideIcon;
label: string;
value: string;
hint: string;
}) {
return (
<div className="rounded-lg border bg-card p-4 shadow-sm">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground text-sm">{label}</span>
<Icon className="text-muted-foreground size-4" />
</div>
<div className="mt-3 text-2xl font-semibold tabular-nums">{value}</div>
<div className="text-muted-foreground mt-1 text-xs">{hint}</div>
</div>
);
}
function MiniBars({ points }: { points: AdminLeaderboardTrendPoint[] }) {
const max = Math.max(1, ...points.map((p) => p.runs));
return (
<div className="flex h-24 items-end gap-1">
{points.length === 0 ? (
<div className="text-muted-foreground flex w-full items-center justify-center text-sm">
暂无趋势数据
</div>
) : (
points.slice(-30).map((p) => (
<div
key={p.date}
title={`${p.date}: ${p.runs} 次使用,${p.questions} 次问答`}
className="min-w-2 flex-1 rounded-t bg-primary/70"
style={{ height: `${Math.max(8, (p.runs / max) * 96)}px` }}
/>
))
)}
</div>
);
}
function UserRankList({
users,
activeUserId,
onSelect,
tab,
}: {
users: AdminLeaderboardUser[];
activeUserId: string | null;
onSelect: (user: AdminLeaderboardUser) => void;
tab: UserRankTab;
}) {
if (users.length === 0) {
return <div className="text-muted-foreground py-10 text-center text-sm">暂无用户统计</div>;
}
return (
<div className="flex flex-col">
{users.map((user, index) => {
const main =
tab === "questions"
? `${fmtNumber(user.qa_count)} 次问答`
: tab === "tokens"
? `${fmtNumber(user.total_tokens)} tokens`
: `${fmtNumber(scoreOf(user))} 次使用`;
return (
<button
key={user.user_id}
type="button"
onClick={() => onSelect(user)}
className={cn(
"grid grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b px-4 py-3 text-left hover:bg-muted/70",
activeUserId === user.user_id && "bg-primary/5",
)}
>
<div className="flex size-8 items-center justify-center rounded-full bg-muted text-sm font-semibold">
{index < 3 ? <Medal className="size-4 text-primary" /> : index + 1}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate font-medium">{user.email || user.user_id}</span>
{user.system_role === "admin" && (
<Badge variant="outline" className="shrink-0 text-xs">
管理员
</Badge>
)}
</div>
<div className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 gap-y-1 text-xs">
<span>会话 {fmtNumber(user.thread_count)}</span>
<span>运行 {fmtNumber(user.run_count)}</span>
<span>技能 {fmtNumber(user.skill_call_count)}</span>
<span>最近 {fmtTime(user.last_active_at)}</span>
</div>
</div>
<div className="text-right">
<div className="font-semibold tabular-nums">{main}</div>
<div className="text-muted-foreground mt-1 text-xs">
LLM {fmtNumber(user.llm_call_count)}
</div>
</div>
</button>
);
})}
</div>
);
}
function RankingTable({
title,
icon: Icon,
items,
empty,
}: {
title: string;
icon: LucideIcon;
items: AdminLeaderboardSkill[];
empty: string;
}) {
return (
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<Icon className="text-muted-foreground size-4" />
<h2 className="font-semibold">{title}</h2>
</div>
<div className="divide-y">
{items.length === 0 && (
<div className="text-muted-foreground py-10 text-center text-sm">{empty}</div>
)}
{items.map((item, index) => (
<div key={item.name} className="grid grid-cols-[32px_minmax(0,1fr)_auto] gap-3 px-4 py-3">
<span className="text-muted-foreground text-sm tabular-nums">{index + 1}</span>
<div className="min-w-0">
<div className="truncate font-medium">{item.name}</div>
<div className="text-muted-foreground mt-1 text-xs">
{fmtNumber(item.user_count)} 位用户 · 平均 {fmtNumber(item.avg_duration_ms)} ms · 最近 {fmtTime(item.last_used_at)}
</div>
</div>
<div className="text-right text-sm">
<div className="font-semibold tabular-nums">{fmtNumber(item.call_count)}</div>
<div className="text-muted-foreground text-xs">
成功 {fmtNumber(item.success_count)} / 失败 {fmtNumber(item.error_count)}
</div>
</div>
</div>
))}
</div>
</div>
);
}
function QuestionList({
title,
items,
showUser = true,
}: {
title: string;
items: AdminLeaderboardQuestion[];
showUser?: boolean;
}) {
return (
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<MessageSquareText className="text-muted-foreground size-4" />
<h2 className="font-semibold">{title}</h2>
</div>
<div className="divide-y">
{items.length === 0 && (
<div className="text-muted-foreground py-10 text-center text-sm">暂无问题记录</div>
)}
{items.map((item, index) => (
<div key={`${item.run_id || item.text}-${index}`} className="px-4 py-3">
<div className="text-sm leading-6">{clampText(item.text, 180)}</div>
<div className="text-muted-foreground mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs">
{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>
);
}
function UserDetailPanel({
userId,
days,
onOpenAudit,
}: {
userId: string | null;
days: number;
onOpenAudit: (userId: string) => void;
}) {
const { data, isLoading } = useUserAnalytics(userId, days, 12);
if (!userId) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center text-sm">
点击排行榜中的用户查看个人统计
</div>
);
}
if (isLoading || !data) {
return (
<div className="text-muted-foreground flex h-full items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
加载用户详情
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="flex flex-col gap-4 p-4">
<div className="rounded-lg border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-lg font-semibold">{data.user.email || data.user.user_id}</div>
<div className="text-muted-foreground mt-1 text-xs">最近活跃 {fmtTime(data.user.last_active_at)}</div>
</div>
<Button size="sm" variant="outline" onClick={() => onOpenAudit(data.user.user_id)}>
审计详情
</Button>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
<Metric label="问答次数" value={data.user.qa_count} />
<Metric label="运行次数" value={data.user.run_count} />
<Metric label="技能调用" value={data.user.skill_call_count} />
<Metric label="总 Tokens" value={data.user.total_tokens} />
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center gap-2 font-semibold">
<TrendingUp className="text-muted-foreground size-4" />
个人使用趋势
</div>
<MiniBars points={data.trends} />
</div>
<RankingTable title="个人常用技能" icon={Sparkles} items={data.skills} empty="暂无技能调用" />
<QuestionList title="最近提问" items={data.recent_questions} showUser={false} />
</div>
</ScrollArea>
);
}
function Metric({ label, value }: { label: string; value: number }) {
return (
<div className="rounded-md bg-muted/60 px-3 py-2">
<div className="text-muted-foreground text-xs">{label}</div>
<div className="mt-1 font-semibold tabular-nums">{fmtNumber(value)}</div>
</div>
);
}
export default function AdminUserLeaderboardPage() {
const navigate = useNavigate();
const [days, setDays] = useState(30);
const [tab, setTab] = useState<UserRankTab>("activity");
const [query, setQuery] = useState("");
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const { data, isLoading, error } = useUserLeaderboard(days, 30);
const users = useMemo(() => {
const source =
tab === "questions"
? data?.users_by_questions
: tab === "tokens"
? data?.users_by_tokens
: data?.users_by_activity;
const needle = query.trim().toLowerCase();
return (source ?? []).filter((user) =>
needle ? `${user.email} ${user.user_id}`.toLowerCase().includes(needle) : true,
);
}, [data, query, tab]);
const overview = data?.overview;
return (
<div className="flex size-full min-h-0 bg-muted/20">
<main className="flex min-w-0 flex-1 flex-col">
<div className="border-b bg-background px-6 py-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">用户排行榜</h1>
<p className="text-muted-foreground mt-1 text-sm">
汇总用户活跃、问答、Token、技能、工具和近期问题,按时间范围统计。
</p>
</div>
<Select value={String(days)} onValueChange={(value) => setDays(Number(value))}>
<SelectTrigger className="w-36">
<Clock3 className="mr-2 size-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7">近 7 天</SelectItem>
<SelectItem value="30">近 30 天</SelectItem>
<SelectItem value="90">近 90 天</SelectItem>
<SelectItem value="365">近 1 年</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<ScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-4 p-6">
{isLoading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
<Loader2 className="size-4 animate-spin" />
加载统计数据
</div>
)}
{!isLoading && error && (
<div className="text-destructive rounded-lg border bg-card p-6 text-sm">
加载失败:{error.message}
</div>
)}
{!isLoading && data && overview && (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<OverviewCard icon={Users} label="总用户" value={fmtNumber(overview.total_users)} hint={`${fmtNumber(overview.active_users)} 位在当前周期活跃`} />
<OverviewCard icon={MousePointerClick} label="使用次数" value={fmtNumber(overview.total_runs)} hint={`${fmtNumber(overview.total_threads)} 个会话`} />
<OverviewCard icon={MessageSquareText} label="问答次数" value={fmtNumber(overview.total_questions)} hint={`失败运行 ${fmtNumber(overview.error_runs)} 次`} />
<OverviewCard icon={Sparkles} label="技能/工具调用" value={fmtNumber(overview.total_skill_calls)} hint={`工具总调用 ${fmtNumber(overview.total_tool_calls)} 次`} />
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.4fr)_minmax(360px,0.8fr)]">
<div className="rounded-lg border bg-card">
<div className="flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-center gap-2">
<BarChart3 className="text-muted-foreground size-4" />
<h2 className="font-semibold">用户排行</h2>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="搜索用户"
className="h-9 w-52 pl-8"
/>
</div>
<Tabs value={tab} onValueChange={(value) => setTab(value as UserRankTab)}>
<TabsList>
<TabsTrigger value="activity">使用</TabsTrigger>
<TabsTrigger value="questions">问答</TabsTrigger>
<TabsTrigger value="tokens">消耗</TabsTrigger>
</TabsList>
</Tabs>
</div>
</div>
<UserRankList
users={users}
activeUserId={selectedUserId}
tab={tab}
onSelect={(user) => setSelectedUserId(user.user_id)}
/>
</div>
<div className="rounded-lg border bg-card">
<div className="flex items-center gap-2 border-b px-4 py-3">
<UserRound className="text-muted-foreground size-4" />
<h2 className="font-semibold">用户详情</h2>
</div>
<div className="h-[560px]">
<UserDetailPanel
userId={selectedUserId}
days={days}
onOpenAudit={(userId) => {
navigate(`/page/strategy/admin/user-activity?user=${encodeURIComponent(userId)}`);
}}
/>
</div>
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<div className="mb-3 flex items-center gap-2 font-semibold">
<TrendingUp className="text-muted-foreground size-4" />
时间趋势
</div>
<MiniBars points={data.trends} />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<RankingTable title="最常用技能" icon={Sparkles} items={data.skills} empty="当前周期暂无技能调用" />
<RankingTable title="最常用工具" icon={Activity} items={data.tools} empty="当前周期暂无工具调用" />
</div>
<div className="grid gap-4 xl:grid-cols-2">
<QuestionList title="最近经常问的问题" items={data.frequent_questions} />
<QuestionList title="最新问题" items={data.recent_questions} />
</div>
</>
)}
</div>
</ScrollArea>
</main>
</div>
);
}
+11
View File
@@ -24,6 +24,7 @@ import LlmMetricsPage from "./LlmMetricsPage";
import UsersPage from "./UsersPage"; import UsersPage from "./UsersPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage"; import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import AdminUserActivityPage from "./AdminUserActivityPage"; import AdminUserActivityPage from "./AdminUserActivityPage";
import AdminUserLeaderboardPage from "./AdminUserLeaderboardPage";
import TagsPage from "./TagsPage"; import TagsPage from "./TagsPage";
import LightAppNewNotePage from "./LightAppNewNotePage"; import LightAppNewNotePage from "./LightAppNewNotePage";
import LightAppNoteListPage from "./LightAppNoteListPage"; import LightAppNoteListPage from "./LightAppNoteListPage";
@@ -244,6 +245,16 @@ export default function StrategyRoutes() {
) )
} }
/> />
<Route
path="admin/user-leaderboard"
element={
getStoredAuth()?.system_role === "admin" ? (
<AdminUserLeaderboardPage />
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route <Route
path="admin/user-activity" path="admin/user-activity"
element={ element={
@@ -15,8 +15,11 @@ from __future__ import annotations
import logging import logging
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 app.gateway.deps import ( from app.gateway.deps import (
get_agent_store, get_agent_store,
@@ -28,6 +31,11 @@ from deerflow.agents.memory import BuiltinFileProvider
from deerflow.config.paths import get_paths 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.run.model import RunRow
from deerflow.persistence.thread_meta.model import ThreadMetaRow
from deerflow.persistence.tool_metrics.model import ToolCallMetricRow
from deerflow.persistence.user.model import UserRow
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/admin/users", tags=["admin-users"]) router = APIRouter(prefix="/api/admin/users", tags=["admin-users"])
@@ -45,6 +53,429 @@ async def _require_admin(request: Request) -> None:
raise HTTPException(status_code=403, detail="Admin only") raise HTTPException(status_code=403, detail="Admin only")
# ── Leaderboard / analytics ─────────────────────────────────────────────────
class AdminLeaderboardUser(BaseModel):
user_id: str
email: str = ""
system_role: str = "user"
thread_count: int = 0
run_count: int = 0
qa_count: int = 0
llm_call_count: int = 0
tool_call_count: int = 0
skill_call_count: int = 0
total_tokens: int = 0
input_tokens: int = 0
output_tokens: int = 0
last_active_at: str = ""
class AdminLeaderboardSkill(BaseModel):
name: str
call_count: int = 0
user_count: int = 0
success_count: int = 0
error_count: int = 0
avg_duration_ms: int = 0
last_used_at: str = ""
class AdminLeaderboardQuestion(BaseModel):
text: str
user_id: str = ""
email: str = ""
thread_id: str = ""
run_id: str = ""
asked_at: str = ""
class AdminLeaderboardTrendPoint(BaseModel):
date: str
runs: int = 0
questions: int = 0
tokens: int = 0
users: int = 0
class AdminLeaderboardOverview(BaseModel):
total_users: int = 0
active_users: int = 0
total_threads: int = 0
total_runs: int = 0
total_questions: int = 0
total_tokens: int = 0
total_skill_calls: int = 0
total_tool_calls: int = 0
error_runs: int = 0
class AdminLeaderboardResponse(BaseModel):
range_days: int
since: str
until: str
overview: AdminLeaderboardOverview
users_by_activity: list[AdminLeaderboardUser] = Field(default_factory=list)
users_by_questions: list[AdminLeaderboardUser] = Field(default_factory=list)
users_by_tokens: list[AdminLeaderboardUser] = Field(default_factory=list)
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = 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)
class AdminUserAnalyticsResponse(BaseModel):
range_days: int
since: str
until: str
user: AdminLeaderboardUser
overview: AdminLeaderboardOverview
skills: list[AdminLeaderboardSkill] = Field(default_factory=list)
tools: list[AdminLeaderboardSkill] = Field(default_factory=list)
recent_questions: list[AdminLeaderboardQuestion] = Field(default_factory=list)
trends: list[AdminLeaderboardTrendPoint] = Field(default_factory=list)
def _now() -> datetime:
return datetime.now()
def _iso(value) -> str:
return value.isoformat() if isinstance(value, datetime) else str(value or "")
def _range_start(days: int) -> datetime:
return _now() - timedelta(days=max(1, min(days, 365)))
def _question_count_expr():
return func.sum(case((RunRow.first_human_message.is_not(None), 1), else_=0))
def _run_time_filter(since: datetime, until: datetime):
return and_(RunRow.created_at >= since, RunRow.created_at <= until)
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]:
run_agg = (
select(
RunRow.user_id.label("user_id"),
func.count(RunRow.run_id).label("run_count"),
_question_count_expr().label("qa_count"),
func.coalesce(func.sum(RunRow.llm_call_count), 0).label("llm_call_count"),
func.coalesce(func.sum(RunRow.total_tokens), 0).label("total_tokens"),
func.coalesce(func.sum(RunRow.total_input_tokens), 0).label("input_tokens"),
func.coalesce(func.sum(RunRow.total_output_tokens), 0).label("output_tokens"),
func.max(RunRow.updated_at).label("last_active_at"),
)
.where(_run_time_filter(since, until))
.group_by(RunRow.user_id)
.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()
)
tool_agg = (
select(
ToolCallMetricRow.user_id.label("user_id"),
func.count(ToolCallMetricRow.id).label("tool_call_count"),
func.sum(case((ToolCallMetricRow.skill_name.is_not(None), 1), else_=0)).label("skill_call_count"),
)
.where(_tool_time_filter(since, until))
.group_by(ToolCallMetricRow.user_id)
.subquery()
)
stmt = (
select(
UserRow.id,
UserRow.email,
UserRow.system_role,
func.coalesce(thread_agg.c.thread_count, 0),
func.coalesce(run_agg.c.run_count, 0),
func.coalesce(run_agg.c.qa_count, 0),
func.coalesce(run_agg.c.llm_call_count, 0),
func.coalesce(tool_agg.c.tool_call_count, 0),
func.coalesce(tool_agg.c.skill_call_count, 0),
func.coalesce(run_agg.c.total_tokens, 0),
func.coalesce(run_agg.c.input_tokens, 0),
func.coalesce(run_agg.c.output_tokens, 0),
run_agg.c.last_active_at,
)
.outerjoin(run_agg, run_agg.c.user_id == UserRow.id)
.outerjoin(thread_agg, thread_agg.c.user_id == UserRow.id)
.outerjoin(tool_agg, tool_agg.c.user_id == UserRow.id)
)
if user_id:
stmt = stmt.where(UserRow.id == user_id)
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)),
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardUser(
user_id=r[0],
email=r[1] or "",
system_role=r[2] or "user",
thread_count=int(r[3] or 0),
run_count=int(r[4] or 0),
qa_count=int(r[5] or 0),
llm_call_count=int(r[6] or 0),
tool_call_count=int(r[7] or 0),
skill_call_count=int(r[8] or 0),
total_tokens=int(r[9] or 0),
input_tokens=int(r[10] or 0),
output_tokens=int(r[11] or 0),
last_active_at=_iso(r[12]),
)
for r in rows
]
async def _query_call_rankings(session, since: datetime, until: datetime, *, user_id: str | None, field: str, limit: int) -> list[AdminLeaderboardSkill]:
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")
stmt = (
select(
name_col.label("name"),
call_count,
func.count(func.distinct(ToolCallMetricRow.user_id)).label("user_count"),
func.sum(case((ToolCallMetricRow.status == "success", 1), else_=0)).label("success_count"),
func.sum(case((ToolCallMetricRow.status != "success", 1), else_=0)).label("error_count"),
func.coalesce(func.avg(ToolCallMetricRow.duration_ms), 0).label("avg_duration_ms"),
last_used_at,
)
.where(_tool_time_filter(since, until), name_col.is_not(None), name_col != "")
.group_by(name_col)
.order_by(call_count.desc(), last_used_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(ToolCallMetricRow.user_id == user_id)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardSkill(
name=r[0] or "",
call_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),
avg_duration_ms=int(r[5] or 0),
last_used_at=_iso(r[6]),
)
for r in rows
]
async def _query_recent_questions(session, since: datetime, until: datetime, *, user_id: str | None, limit: int) -> list[AdminLeaderboardQuestion]:
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)
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
.order_by(RunRow.created_at.desc())
.limit(limit)
)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardQuestion(
text=(r[0] or "")[:500],
user_id=r[1] or "",
email=r[2] or "",
thread_id=r[3] or "",
run_id=r[4] or "",
asked_at=_iso(r[5]),
)
for r in rows
]
async def _query_frequent_questions(session, since: datetime, until: datetime, limit: int) -> list[AdminLeaderboardQuestion]:
stmt = (
select(
RunRow.first_human_message,
func.count(RunRow.run_id).label("count"),
func.max(RunRow.user_id).label("user_id"),
func.max(UserRow.email).label("email"),
func.max(RunRow.created_at).label("asked_at"),
)
.outerjoin(UserRow, UserRow.id == RunRow.user_id)
.where(_run_time_filter(since, until), RunRow.first_human_message.is_not(None), RunRow.first_human_message != "")
.group_by(RunRow.first_human_message)
.having(func.count(RunRow.run_id) > 1)
.order_by(desc("count"), desc("asked_at"))
.limit(limit)
)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardQuestion(
text=(r[0] or "")[:500],
user_id=r[2] or "",
email=r[3] or "",
asked_at=_iso(r[4]),
)
for r in rows
]
async def _query_trends(session, since: datetime, until: datetime, *, user_id: str | None = None) -> list[AdminLeaderboardTrendPoint]:
bucket = func.date(RunRow.created_at)
stmt = (
select(
bucket.label("date"),
func.count(RunRow.run_id).label("runs"),
_question_count_expr().label("questions"),
func.coalesce(func.sum(RunRow.total_tokens), 0).label("tokens"),
func.count(func.distinct(RunRow.user_id)).label("users"),
)
.where(_run_time_filter(since, until))
.group_by(bucket)
.order_by(bucket.asc())
)
if user_id:
stmt = stmt.where(RunRow.user_id == user_id)
rows = (await session.execute(stmt)).all()
return [
AdminLeaderboardTrendPoint(
date=str(r[0]),
runs=int(r[1] or 0),
questions=int(r[2] or 0),
tokens=int(r[3] or 0),
users=int(r[4] or 0),
)
for r in rows
]
async def _query_overview(session, since: datetime, until: datetime, *, user_id: str | None = None) -> AdminLeaderboardOverview:
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)]
if 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)
total_users = 1 if user_id else (await session.execute(select(func.count(UserRow.id)))).scalar_one()
run_row = (await session.execute(
select(
func.count(RunRow.run_id),
_question_count_expr(),
func.coalesce(func.sum(RunRow.total_tokens), 0),
func.count(func.distinct(RunRow.user_id)),
func.sum(case((RunRow.status == "error", 1), else_=0)),
).where(*run_where)
)).one()
thread_count = (await session.execute(select(func.count(ThreadMetaRow.thread_id)).where(*thread_where))).scalar_one()
tool_row = (await session.execute(
select(
func.count(ToolCallMetricRow.id),
func.sum(case((ToolCallMetricRow.skill_name.is_not(None), 1), else_=0)),
).where(*tool_where)
)).one()
return AdminLeaderboardOverview(
total_users=int(total_users or 0),
active_users=int(run_row[3] or 0),
total_threads=int(thread_count or 0),
total_runs=int(run_row[0] or 0),
total_questions=int(run_row[1] or 0),
total_tokens=int(run_row[2] or 0),
total_tool_calls=int(tool_row[0] or 0),
total_skill_calls=int(tool_row[1] or 0),
error_runs=int(run_row[4] or 0),
)
@router.get("/leaderboard", response_model=AdminLeaderboardResponse)
async def get_user_leaderboard(
request: Request,
days: int = Query(default=30, ge=1, le=365),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminLeaderboardResponse:
"""Return admin analytics for user activity, Q&A, skills, questions, and trends."""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
until = _now()
since = _range_start(days)
async with session_factory() as session:
overview = await _query_overview(session, since, until)
users = await _query_user_leaderboard(session, since, until)
skills = await _query_call_rankings(session, since, until, user_id=None, field="skill", limit=limit)
tools = await _query_call_rankings(session, since, until, user_id=None, field="tool", limit=limit)
recent_questions = await _query_recent_questions(session, since, until, user_id=None, limit=limit)
frequent_questions = await _query_frequent_questions(session, since, until, limit=limit)
trends = await _query_trends(session, since, until)
return AdminLeaderboardResponse(
range_days=days,
since=_iso(since),
until=_iso(until),
overview=overview,
users_by_activity=sorted(users, key=lambda u: (u.run_count + u.tool_call_count, u.total_tokens), reverse=True)[:limit],
users_by_questions=sorted(users, key=lambda u: (u.qa_count, u.run_count), reverse=True)[:limit],
users_by_tokens=sorted(users, key=lambda u: u.total_tokens, reverse=True)[:limit],
skills=skills,
tools=tools,
recent_questions=recent_questions,
frequent_questions=frequent_questions,
trends=trends,
)
@router.get("/{user_id}/analytics", response_model=AdminUserAnalyticsResponse)
async def get_user_analytics(
user_id: str,
request: Request,
days: int = Query(default=30, ge=1, le=365),
limit: int = Query(default=20, ge=5, le=100),
) -> AdminUserAnalyticsResponse:
"""Return one user's admin analytics profile."""
await _require_admin(request)
session_factory = get_session_factory()
if session_factory is None:
raise HTTPException(status_code=503, detail="Analytics requires database persistence")
until = _now()
since = _range_start(days)
async with session_factory() as session:
users = await _query_user_leaderboard(session, since, until, user_id=user_id)
if not users:
raise HTTPException(status_code=404, detail="User not found")
overview = await _query_overview(session, since, until, user_id=user_id)
skills = await _query_call_rankings(session, since, until, user_id=user_id, field="skill", limit=limit)
tools = await _query_call_rankings(session, since, until, user_id=user_id, field="tool", limit=limit)
recent_questions = await _query_recent_questions(session, since, until, user_id=user_id, limit=limit)
trends = await _query_trends(session, since, until, user_id=user_id)
return AdminUserAnalyticsResponse(
range_days=days,
since=_iso(since),
until=_iso(until),
user=users[0],
overview=overview,
skills=skills,
tools=tools,
recent_questions=recent_questions,
trends=trends,
)
# ── Threads ──────────────────────────────────────────────────────────────── # ── Threads ────────────────────────────────────────────────────────────────