diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index d3dad05..c77ba75 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -195,6 +195,9 @@ export interface AiTrendDataPoint { submission_id: number; submission_time: string; gpu_type: string; + user_id?: string; + user_name?: string; + model?: string; } export interface AiTrendTimeSeries { @@ -218,3 +221,57 @@ export async function fetchAiTrend(leaderboardId: string): Promise { + if (!userIds || userIds.length === 0) { + throw new Error("At least one user ID is required"); + } + const url = `/api/leaderboard/${leaderboardId}/user_trend?user_id=${userIds.join(",")}`; + const res = await fetch(url); + if (!res.ok) { + const json = await res.json(); + const message = json?.message || "Unknown error"; + throw new APIError(`Failed to fetch user trend: ${message}`, res.status); + } + const r = await res.json(); + return r.data; +} + +export interface UserSearchResult { + user_id: string; + username: string; +} + +export interface SearchUsersResponse { + leaderboard_id: number; + users: UserSearchResult[]; +} + +export async function searchUsers( + leaderboardId: string, + query: string = "", + limit: number = 20 +): Promise { + const params = new URLSearchParams(); + if (query) params.set("q", query); + if (limit) params.set("limit", limit.toString()); + + const url = `/api/leaderboard/${leaderboardId}/users?${params.toString()}`; + const res = await fetch(url); + if (!res.ok) { + const json = await res.json(); + const message = json?.message || "Unknown error"; + throw new APIError(`Failed to search users: ${message}`, res.status); + } + const r = await res.json(); + return r.data; +} diff --git a/frontend/src/pages/leaderboard/Leaderboard.tsx b/frontend/src/pages/leaderboard/Leaderboard.tsx index 0a972cc..7e55744 100644 --- a/frontend/src/pages/leaderboard/Leaderboard.tsx +++ b/frontend/src/pages/leaderboard/Leaderboard.tsx @@ -25,6 +25,7 @@ import { useAuthStore } from "../../lib/store/authStore"; import SubmissionHistorySection from "./components/submission-history/SubmissionHistorySection"; import LeaderboardSubmit from "./components/LeaderboardSubmit"; import AiTrendChart from "./components/AiTrendChart"; +import UserTrendChart from "./components/UserTrendChart"; export const CardTitle = styled(Typography)(() => ({ fontSize: "1.5rem", fontWeight: "bold", @@ -263,6 +264,12 @@ export default function Leaderboard() { + + + User Performance Trend + + + )} diff --git a/frontend/src/pages/leaderboard/components/AiTrendChart.tsx b/frontend/src/pages/leaderboard/components/AiTrendChart.tsx index 8ce7bf3..40835e8 100644 --- a/frontend/src/pages/leaderboard/components/AiTrendChart.tsx +++ b/frontend/src/pages/leaderboard/components/AiTrendChart.tsx @@ -17,13 +17,12 @@ function hashStringToColor(str: string): string { let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); - hash = hash & hash; // Convert to 32bit integer + hash = hash & hash; } - // Generate HSL color with good saturation and lightness for visibility const hue = Math.abs(hash) % 360; - const saturation = 65 + (Math.abs(hash >> 8) % 20); // 65-85% - const lightness = 45 + (Math.abs(hash >> 16) % 15); // 45-60% + const saturation = 65 + (Math.abs(hash >> 8) % 20); + const lightness = 45 + (Math.abs(hash >> 16) % 15); return `hsl(${hue}, ${saturation}%, ${lightness}%)`; } @@ -38,20 +37,17 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { useEffect(() => { const loadData = async () => { + setLoading(true); + setError(null); try { - setLoading(true); - setError(null); const result = await fetchAiTrend(leaderboardId); setData(result); - } catch (err) { - setError( - err instanceof Error ? err.message : "Failed to load AI trend data", - ); + } catch (err: any) { + setError(err.message || "Failed to load data"); } finally { setLoading(false); } }; - loadData(); }, [leaderboardId]); @@ -108,9 +104,7 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { alignItems="center" minHeight={400} > - - No H100 AI data available - + No H100 AI data available ); } @@ -124,7 +118,7 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { const sortedData = [...dataPoints].sort( (a, b) => new Date(a.submission_time).getTime() - - new Date(b.submission_time).getTime(), + new Date(b.submission_time).getTime() ); series.push({ @@ -235,9 +229,5 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { series, }; - return ( - - - - ); + return ; } diff --git a/frontend/src/pages/leaderboard/components/UserTrendChart.tsx b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx new file mode 100644 index 0000000..2c3932c --- /dev/null +++ b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx @@ -0,0 +1,430 @@ +import { useEffect, useState, useCallback } from "react"; +import ReactECharts from "echarts-for-react"; +import { + Box, + Typography, + CircularProgress, + Autocomplete, + TextField, + Chip, + FormControl, + InputLabel, + Select, + MenuItem, +} from "@mui/material"; +import { + fetchUserTrend, + searchUsers, + type UserTrendResponse, + type UserSearchResult, +} from "../../../api/api"; +import { + formatMicrosecondsNum, + formatMicroseconds, +} from "../../../lib/utils/ranking"; +import { useThemeStore } from "../../../lib/store/themeStore"; + +interface UserTrendChartProps { + leaderboardId: string; +} + +function hashStringToColor(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = str.charCodeAt(i) + ((hash << 5) - hash); + hash = hash & hash; + } + + const hue = Math.abs(hash) % 360; + const saturation = 65 + (Math.abs(hash >> 8) % 20); + const lightness = 45 + (Math.abs(hash >> 16) % 15); + + return `hsl(${hue}, ${saturation}%, ${lightness}%)`; +} + +export default function UserTrendChart({ leaderboardId }: UserTrendChartProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedGpuType, setSelectedGpuType] = useState(""); + const resolvedMode = useThemeStore((state) => state.resolvedMode); + const isDark = resolvedMode === "dark"; + const textColor = isDark ? "#e0e0e0" : "#333"; + + const [selectedUsers, setSelectedUsers] = useState([]); + const [userOptions, setUserOptions] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + const [inputValue, setInputValue] = useState(""); + + const loadData = useCallback( + async (userIds: string[]) => { + if (userIds.length === 0) { + setData(null); + setSelectedGpuType(""); + return; + } + setLoading(true); + setError(null); + try { + const result = await fetchUserTrend(leaderboardId, userIds); + setData(result); + // Set default GPU type to the first available + const gpuTypes = Object.keys(result.time_series || {}); + if (gpuTypes.length > 0 && !gpuTypes.includes(selectedGpuType)) { + setSelectedGpuType(gpuTypes[0]); + } + } catch (err: any) { + setError(err.message || "Failed to load data"); + } finally { + setLoading(false); + } + }, + [leaderboardId, selectedGpuType] + ); + + // Load initial suggestions on mount (first 5 alphabetically) + useEffect(() => { + const loadInitialUsers = async () => { + setSearchLoading(true); + try { + const result = await searchUsers(leaderboardId, "", 5); + setUserOptions(result.users); + } catch (err) { + console.error("Failed to load initial users:", err); + } finally { + setSearchLoading(false); + } + }; + loadInitialUsers(); + }, [leaderboardId]); + + // Search users when input changes + useEffect(() => { + const searchTimeout = setTimeout(async () => { + setSearchLoading(true); + try { + const limit = inputValue === "" ? 5 : 20; + const result = await searchUsers(leaderboardId, inputValue, limit); + setUserOptions(result.users); + } catch (err) { + console.error("Failed to search users:", err); + } finally { + setSearchLoading(false); + } + }, 300); + + return () => clearTimeout(searchTimeout); + }, [inputValue, leaderboardId]); + + const handleUserSelectionChange = ( + _event: any, + newValue: UserSearchResult[] + ) => { + setSelectedUsers(newValue); + const userIds = newValue.map((u) => u.user_id); + loadData(userIds); + }; + + const gpuTypes = data?.time_series ? Object.keys(data.time_series) : []; + + const renderSearchInput = () => ( + + setInputValue(newInputValue)} + getOptionLabel={(option) => option.username} + isOptionEqualToValue={(option, value) => + option.user_id === value.user_id + } + loading={searchLoading} + renderInput={(params) => ( + + {searchLoading ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + renderTags={(value, getTagProps) => + value.map((option, index) => { + const { key, ...tagProps } = getTagProps({ index }); + return ( + + ); + }) + } + renderOption={(props, option) => { + const { key, ...restProps } = props; + return ( +
  • + {option.username} +
  • + ); + }} + noOptionsText="No users found" + sx={{ minWidth: 350, flexGrow: 1, maxWidth: 500 }} + /> + {gpuTypes.length > 0 && ( + + GPU Type + + + )} +
    + ); + + if (selectedUsers.length === 0) { + return ( + + {renderSearchInput()} + + + Select users to view their performance trends + + + + ); + } + + if (loading) { + return ( + + {renderSearchInput()} + + + + + ); + } + + if (error) { + return ( + + {renderSearchInput()} + + {error} + + + ); + } + + if ( + !data || + !data.time_series || + Object.keys(data.time_series).length === 0 + ) { + return ( + + {renderSearchInput()} + + + No data available for selected users + + + + ); + } + + const gpuData = data.time_series[selectedGpuType]; + if (!gpuData || Object.keys(gpuData).length === 0) { + return ( + + {renderSearchInput()} + + + No {selectedGpuType} data available for selected users + + + + ); + } + + const series: any[] = []; + + Object.entries(gpuData).forEach(([userId, dataPoints]) => { + const sortedData = [...dataPoints].sort( + (a, b) => + new Date(a.submission_time).getTime() - + new Date(b.submission_time).getTime() + ); + + const displayName = sortedData[0]?.user_name || userId; + const color = hashStringToColor(userId); + + series.push({ + name: displayName, + type: "line", + data: sortedData.map((point) => ({ + value: [ + new Date(point.submission_time).getTime(), + parseFloat(point.score), + ], + gpu_type: point.gpu_type, + user_name: point.user_name, + })), + smooth: true, + symbol: "circle", + symbolSize: 8, + lineStyle: { + width: 2, + color, + }, + itemStyle: { + color, + }, + }); + }); + + const chartTitle = `User Performance Trend (${selectedGpuType})`; + + const option = { + title: { + text: chartTitle, + left: "center", + textStyle: { + fontSize: 16, + fontWeight: "bold", + color: textColor, + }, + }, + tooltip: { + trigger: "item", + formatter: (params: any) => { + const date = new Date(params.value[0]); + const score = formatMicroseconds(params.value[1]); + const gpuType = params.data.gpu_type || "Unknown"; + const userName = params.data.user_name || params.seriesName; + return ` + ${userName}
    + GPU Type: ${gpuType}
    + Time: ${date.toLocaleString()}
    + Score: ${score} + `; + }, + }, + legend: { + data: series.map((s) => s.name), + bottom: 0, + textStyle: { + color: textColor, + }, + }, + grid: { + left: "3%", + right: "4%", + bottom: "15%", + top: "15%", + containLabel: true, + }, + xAxis: { + type: "time", + name: "Submission Time", + nameLocation: "middle", + nameGap: 30, + nameTextStyle: { + color: textColor, + }, + axisLabel: { + color: textColor, + formatter: (value: number) => { + const date = new Date(value); + return `${date.getMonth() + 1}/${date.getDate()}`; + }, + }, + axisLine: { + lineStyle: { + color: textColor, + }, + }, + }, + yAxis: { + type: "value", + name: "Score (lower is better)", + nameLocation: "middle", + nameGap: 70, + nameTextStyle: { + color: textColor, + }, + axisLabel: { + color: textColor, + formatter: (value: number) => `${formatMicrosecondsNum(value)}μs`, + }, + axisLine: { + lineStyle: { + color: textColor, + }, + }, + splitLine: { + lineStyle: { + color: isDark ? "#444" : "#ccc", + }, + }, + }, + series, + }; + + return ( + + {renderSearchInput()} + + + ); +} diff --git a/kernelboard/api/leaderboard.py b/kernelboard/api/leaderboard.py index d9a4864..80cf3c3 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -279,6 +279,222 @@ def get_ai_trend(leaderboard_id: int): }) +@leaderboard_bp.route("//user_trend", methods=["GET"]) +def get_user_trend(leaderboard_id: int): + """ + GET /leaderboard//user_trend?user_id=... + + Query parameters: + - user_id: User ID(s), comma-separated for multiple (required) + + Examples: + - ?user_id=123 + - ?user_id=123,456,789 + + Returns time series data for all submissions from the specified users. + """ + from flask import request + + total_start = time.perf_counter() + + user_id_param = request.args.get("user_id", "") + user_ids = [uid.strip() for uid in user_id_param.split(",") if uid.strip()] + + if not user_ids: + return http_error( + "user_id is required", + 10000 + HTTPStatus.BAD_REQUEST, + HTTPStatus.BAD_REQUEST, + ) + + conn = get_db_connection() + user_map = {} # user_id -> display_name + + with conn.cursor() as cur: + # Fetch display names for all user_ids in batch + placeholders = ",".join(["%s"] * len(user_ids)) + cur.execute( + f"SELECT id, user_name FROM leaderboard.user_info " + f"WHERE id IN ({placeholders})", + tuple(user_ids) + ) + for row in cur.fetchall(): + user_map[str(row[0])] = row[1] if row[1] else str(row[0]) + + # Log warning for any user_ids not found in database + for uid in user_ids: + if uid not in user_map: + logger.warning("User ID not found in database: %s", uid) + user_map[uid] = uid + + query_start = time.perf_counter() + + # Query for all users at once + user_id_list = list(user_map.keys()) + placeholders = ",".join(["%s"] * len(user_id_list)) + + sql = f""" + SELECT + s.id AS submission_id, + s.user_id, + s.file_name, + s.submission_time, + r.score, + r.passed, + r.runner AS gpu_type, + r.mode + FROM leaderboard.submission s + JOIN leaderboard.runs r ON r.submission_id = s.id + WHERE s.user_id IN ({placeholders}) + AND s.leaderboard_id = %s + AND r.score IS NOT NULL + AND r.passed = true + AND NOT r.secret + ORDER BY s.submission_time ASC + """ + cur.execute(sql, (*user_id_list, leaderboard_id)) + rows = cur.fetchall() + + query_time = (time.perf_counter() - query_start) * 1000 + + if not rows: + return http_success(data={ + "leaderboard_id": leaderboard_id, + "user_ids": user_id_list, + "time_series": {}, + }) + + # Group items by user_id first + items_by_user = {} + for row in rows: + (submission_id, user_id, file_name, submission_time, + score, passed, gpu_type, mode) = row + user_id_str = str(user_id) + + if user_id_str not in items_by_user: + items_by_user[user_id_str] = [] + + items_by_user[user_id_str].append({ + "submission_id": submission_id, + "user_id": user_id_str, + "user_name": user_map.get(user_id_str, user_id_str), + "file_name": file_name, + "submission_time": ( + submission_time.isoformat() if submission_time else None + ), + "score": score, + "passed": passed, + "gpu_type": gpu_type, + "mode": mode, + }) + + # Group by gpu_type with username as series name + series_by_gpu = group_multi_user_submissions(items_by_user, user_map) + + total_time = (time.perf_counter() - total_start) * 1000 + logger.info( + "[Perf] user_trend leaderboard_id=%s users=%s | " + "query=%.2fms | total=%.2fms", + leaderboard_id, list(user_map.values()), query_time, total_time, + ) + + return http_success(data={ + "leaderboard_id": leaderboard_id, + "user_ids": user_id_list, + "time_series": series_by_gpu, + }) + + +@leaderboard_bp.route("//users", methods=["GET"]) +def search_users(leaderboard_id: int): + """ + GET /leaderboard//users?q=... + Search for users who have submissions on this leaderboard. + + Query parameters: + - q: Search query for username (partial match, case-insensitive) + - limit: Maximum number of results to return (default 20) + + Returns list of users with their user_id and username. + """ + from flask import request + + query = request.args.get("q", "").strip() + limit = min(int(request.args.get("limit", 20)), 100) + + conn = get_db_connection() + + with conn.cursor() as cur: + if query: + sql = """ + SELECT DISTINCT u.id, u.user_name + FROM leaderboard.user_info u + JOIN leaderboard.submission s ON s.user_id = u.id + WHERE s.leaderboard_id = %s + AND u.user_name ILIKE %s + ORDER BY u.user_name + LIMIT %s + """ + cur.execute(sql, (leaderboard_id, f"%{query}%", limit)) + else: + sql = """ + SELECT DISTINCT u.id, u.user_name + FROM leaderboard.user_info u + JOIN leaderboard.submission s ON s.user_id = u.id + WHERE s.leaderboard_id = %s + ORDER BY u.user_name + LIMIT %s + """ + cur.execute(sql, (leaderboard_id, limit)) + + rows = cur.fetchall() + + users = [ + {"user_id": str(row[0]), "username": row[1] or str(row[0])} + for row in rows + ] + + return http_success(data={ + "leaderboard_id": leaderboard_id, + "users": users, + }) + + +def group_multi_user_submissions( + items_by_user: dict, + user_map: dict +) -> dict: + """ + Group submissions from multiple users by gpu_type with username as series. + Same format as ai_trend: { "H100": { "user1": [...], "user2": [...] } } + """ + series = {} + for user_id, items in items_by_user.items(): + display_name = user_map.get(user_id, user_id) + + for item in items: + gpu_type = item.get("gpu_type") + + if not gpu_type or gpu_type == "unknown": + continue + + if gpu_type not in series: + series[gpu_type] = {} + + if user_id not in series[gpu_type]: + series[gpu_type][user_id] = [] + + series[gpu_type][user_id].append({ + "submission_time": item["submission_time"], + "score": item["score"], + "user_id": user_id, + "user_name": display_name, + "gpu_type": gpu_type, + "submission_id": item["submission_id"], + }) + return series + + def parse_model_from_filename(file_name: str) -> str: """ Extract model name - the segment right before _ka_submission.py