From 74bbd14425123d76867527afb632a0ec2dd3c90e Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Mon, 9 Feb 2026 15:31:56 -0800 Subject: [PATCH 1/7] hidemetadata --- kernelboard/api/leaderboard.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/kernelboard/api/leaderboard.py b/kernelboard/api/leaderboard.py index eb475b7..a739940 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -281,21 +281,22 @@ def get_ai_trend(leaderboard_id: int): def parse_model_from_filename(file_name: str) -> str: """ - Extract model name from file names ending with ka_submission.py: - - trimul_H100_claude-opus-4.5_ka_submission.py -> claude-opus-4.5 + Extract model name - the segment right before _ka_submission.py + Examples: + - matmul_py_H100_claude-opus-4.5_ka_submission.py -> claude-opus-4.5 - trimul_H100_gpt-52_ka_submission.py -> gpt-52 - - trimul_H100_gpt-5_ka_submission.py -> gpt-5 Returns None if file doesn't match pattern. """ - if not file_name or not file_name.endswith("_ka_submission.py"): + suffix = "_ka_submission.py" + if not file_name or not file_name.endswith(suffix): return None - # Extract model name: everything between last GPU type and _ka_submission - # Pattern: {anything}_{gpu}_{model}_ka_submission.py - pattern = r"^.+_[A-Za-z0-9]+_(.+?)_ka_submission\.py$" - match = re.match(pattern, file_name) - if match: - return match.group(1) + # Remove the suffix and get everything before it + base = file_name[:-len(suffix)] + # Split by underscore and get the last segment + parts = base.rsplit("_", 1) + if len(parts) == 2: + return parts[1] return None From 4a9933130c98cb02ee7d79aa144928ed004ec0d5 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Mon, 9 Feb 2026 17:02:43 -0800 Subject: [PATCH 2/7] hidemetadata --- .../leaderboard/components/AiTrendChart.tsx | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/frontend/src/pages/leaderboard/components/AiTrendChart.tsx b/frontend/src/pages/leaderboard/components/AiTrendChart.tsx index a11c10d..8ce7bf3 100644 --- a/frontend/src/pages/leaderboard/components/AiTrendChart.tsx +++ b/frontend/src/pages/leaderboard/components/AiTrendChart.tsx @@ -6,6 +6,7 @@ import { formatMicrosecondsNum, formatMicroseconds, } from "../../../lib/utils/ranking"; +import { useThemeStore } from "../../../lib/store/themeStore"; interface AiTrendChartProps { leaderboardId: string; @@ -31,6 +32,9 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const resolvedMode = useThemeStore((state) => state.resolvedMode); + const isDark = resolvedMode === "dark"; + const textColor = isDark ? "#e0e0e0" : "#333"; useEffect(() => { const loadData = async () => { @@ -153,6 +157,7 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { textStyle: { fontSize: 16, fontWeight: "bold", + color: textColor, }, }, tooltip: { @@ -172,6 +177,9 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { legend: { data: Object.keys(h100Data), bottom: 0, + textStyle: { + color: textColor, + }, }, grid: { left: "3%", @@ -185,21 +193,44 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { 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, }; From 075c3071e49dd3cc1df416c6d70247d31267b65a Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 10 Feb 2026 09:02:56 -0800 Subject: [PATCH 3/7] hidemetadata --- frontend/src/api/api.ts | 24 +++ .../leaderboard/components/AiTrendChart.tsx | 188 +++++++++++++----- kernelboard/api/leaderboard.py | 161 +++++++++++++++ 3 files changed, 326 insertions(+), 47 deletions(-) diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index d3dad05..35d473b 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -218,3 +218,27 @@ 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; +} diff --git a/frontend/src/pages/leaderboard/components/AiTrendChart.tsx b/frontend/src/pages/leaderboard/components/AiTrendChart.tsx index 8ce7bf3..c3aedb2 100644 --- a/frontend/src/pages/leaderboard/components/AiTrendChart.tsx +++ b/frontend/src/pages/leaderboard/components/AiTrendChart.tsx @@ -1,7 +1,20 @@ import { useEffect, useState } from "react"; import ReactECharts from "echarts-for-react"; -import { Box, Typography, CircularProgress } from "@mui/material"; -import { fetchAiTrend, type AiTrendResponse } from "../../../api/api"; +import { + Box, + Typography, + CircularProgress, + TextField, + InputAdornment, + IconButton, +} from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import ClearIcon from "@mui/icons-material/Clear"; +import { + fetchAiTrend, + fetchUserTrend, + type AiTrendResponse, +} from "../../../api/api"; import { formatMicrosecondsNum, formatMicroseconds, @@ -32,51 +45,119 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [userInput, setUserInput] = useState(""); + const [searchedUser, setSearchedUser] = useState(null); const resolvedMode = useThemeStore((state) => state.resolvedMode); const isDark = resolvedMode === "dark"; const textColor = isDark ? "#e0e0e0" : "#333"; - useEffect(() => { - const loadData = async () => { - 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", - ); - } finally { - setLoading(false); + const loadData = async (userIdInput?: string) => { + try { + setLoading(true); + setError(null); + + let result: AiTrendResponse; + if (userIdInput) { + // Parse comma-separated user IDs + const userIds = userIdInput.split(",").map((s) => s.trim()).filter(Boolean); + result = await fetchUserTrend(leaderboardId, userIds); + setSearchedUser(userIdInput); + } else { + // Use default ai_trend API + result = await fetchAiTrend(leaderboardId); + setSearchedUser(null); } - }; + setData(result); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to load trend data", + ); + } finally { + setLoading(false); + } + }; + useEffect(() => { loadData(); }, [leaderboardId]); + const handleSearch = () => { + if (userInput.trim()) { + loadData(userInput.trim()); + } + }; + + const handleClear = () => { + setUserInput(""); + loadData(); + }; + + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + handleSearch(); + } + }; + + const renderSearchInput = () => ( + + setUserInput(e.target.value)} + onKeyPress={handleKeyPress} + sx={{ width: 280 }} + InputProps={{ + endAdornment: ( + + {userInput && ( + + + + )} + + + + + ), + }} + /> + {searchedUser && ( + + Showing: {searchedUser} + + )} + + ); + if (loading) { return ( - - + + {renderSearchInput()} + + + ); } if (error) { return ( - - {error} + + {renderSearchInput()} + + {error} + ); } @@ -87,13 +168,18 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { Object.keys(data.time_series).length === 0 ) { return ( - - No AI data available + + {renderSearchInput()} + + + {searchedUser ? "No data available for this user" : "No AI data available"} + + ); } @@ -102,15 +188,18 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { const h100Data = data.time_series["H100"]; if (!h100Data || Object.keys(h100Data).length === 0) { return ( - - - No H100 AI data available - + + {renderSearchInput()} + + + {searchedUser ? "No H100 data available for this user" : "No H100 AI data available"} + + ); } @@ -150,9 +239,13 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { }); }); + const chartTitle = searchedUser + ? `Performance Trend (H100) - ${searchedUser}` + : "AI Model Performance Trend (H100)"; + const option = { title: { - text: "AI Model Performance Trend (H100)", + text: chartTitle, left: "center", textStyle: { fontSize: 16, @@ -237,6 +330,7 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { return ( + {renderSearchInput()} ); diff --git a/kernelboard/api/leaderboard.py b/kernelboard/api/leaderboard.py index d9a4864..b830bbc 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -279,6 +279,167 @@ 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, username FROM leaderboard.user " + 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, + }) + + +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 From 9d2a02236d2ce90ec989d7aafb4d16e9a56cfe98 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 10 Feb 2026 09:20:22 -0800 Subject: [PATCH 4/7] hidemetadata --- frontend/src/api/api.ts | 33 ++++ .../leaderboard/components/AiTrendChart.tsx | 172 +++++------------- kernelboard/api/leaderboard.py | 55 ++++++ 3 files changed, 136 insertions(+), 124 deletions(-) diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 35d473b..61aa66b 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 { @@ -242,3 +245,33 @@ export async function fetchUserTrend( 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}/search_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/components/AiTrendChart.tsx b/frontend/src/pages/leaderboard/components/AiTrendChart.tsx index 0318013..40835e8 100644 --- a/frontend/src/pages/leaderboard/components/AiTrendChart.tsx +++ b/frontend/src/pages/leaderboard/components/AiTrendChart.tsx @@ -1,20 +1,7 @@ import { useEffect, useState } from "react"; import ReactECharts from "echarts-for-react"; -import { - Box, - Typography, - CircularProgress, - TextField, - InputAdornment, - IconButton, -} from "@mui/material"; -import SearchIcon from "@mui/icons-material/Search"; -import ClearIcon from "@mui/icons-material/Clear"; -import { - fetchAiTrend, - fetchUserTrend, - type AiTrendResponse, -} from "../../../api/api"; +import { Box, Typography, CircularProgress } from "@mui/material"; +import { fetchAiTrend, type AiTrendResponse } from "../../../api/api"; import { formatMicrosecondsNum, formatMicroseconds, @@ -30,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}%)`; } @@ -50,86 +36,43 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { const textColor = isDark ? "#e0e0e0" : "#333"; useEffect(() => { + const loadData = async () => { + setLoading(true); + setError(null); + try { + const result = await fetchAiTrend(leaderboardId); + setData(result); + } catch (err: any) { + setError(err.message || "Failed to load data"); + } finally { + setLoading(false); + } + }; loadData(); }, [leaderboardId]); - const handleSearch = () => { - if (userInput.trim()) { - loadData(userInput.trim()); - } - }; - - const handleClear = () => { - setUserInput(""); - loadData(); - }; - - const handleKeyPress = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - handleSearch(); - } - }; - - const renderSearchInput = () => ( - - setUserInput(e.target.value)} - onKeyPress={handleKeyPress} - sx={{ width: 280 }} - InputProps={{ - endAdornment: ( - - {userInput && ( - - - - )} - - - - - ), - }} - /> - {searchedUser && ( - - Showing: {searchedUser} - - )} - - ); - if (loading) { return ( - - {renderSearchInput()} - - - + + ); } if (error) { return ( - - {renderSearchInput()} - - {error} - + + {error} ); } @@ -140,18 +83,13 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { Object.keys(data.time_series).length === 0 ) { return ( - - {renderSearchInput()} - - - {searchedUser ? "No data available for this user" : "No AI data available"} - - + + No AI data available ); } @@ -160,18 +98,13 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { const h100Data = data.time_series["H100"]; if (!h100Data || Object.keys(h100Data).length === 0) { return ( - - {renderSearchInput()} - - - {searchedUser ? "No H100 data available for this user" : "No H100 AI data available"} - - + + No H100 AI data available ); } @@ -185,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({ @@ -211,13 +144,9 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { }); }); - const chartTitle = searchedUser - ? `Performance Trend (H100) - ${searchedUser}` - : "AI Model Performance Trend (H100)"; - const option = { title: { - text: chartTitle, + text: "AI Model Performance Trend (H100)", left: "center", textStyle: { fontSize: 16, @@ -300,10 +229,5 @@ export default function AiTrendChart({ leaderboardId }: AiTrendChartProps) { series, }; - return ( - - {renderSearchInput()} - - - ); + return ; } diff --git a/kernelboard/api/leaderboard.py b/kernelboard/api/leaderboard.py index b830bbc..fe98fe3 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -405,6 +405,61 @@ def get_user_trend(leaderboard_id: int): }) +@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.username + FROM leaderboard.user u + JOIN leaderboard.submission s ON s.user_id = u.id + WHERE s.leaderboard_id = %s + AND u.username ILIKE %s + ORDER BY u.username + LIMIT %s + """ + cur.execute(sql, (leaderboard_id, f"%{query}%", limit)) + else: + sql = """ + SELECT DISTINCT u.id, u.username + FROM leaderboard.user u + JOIN leaderboard.submission s ON s.user_id = u.id + WHERE s.leaderboard_id = %s + ORDER BY u.username + 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 From 30c4c40f2b21d487d18a6b2adec41072831149ea Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 10 Feb 2026 09:42:31 -0800 Subject: [PATCH 5/7] hidemetadata --- .../src/pages/leaderboard/Leaderboard.tsx | 7 + .../leaderboard/components/UserTrendChart.tsx | 431 ++++++++++++++++++ kernelboard/api/leaderboard.py | 16 +- 3 files changed, 446 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/leaderboard/components/UserTrendChart.tsx 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/UserTrendChart.tsx b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx new file mode 100644 index 0000000..ff42b73 --- /dev/null +++ b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx @@ -0,0 +1,431 @@ +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 { + // When input is empty, reload initial 5 users + const limit = inputValue === "" ? 10 : 30; + 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 fe98fe3..80cf3c3 100644 --- a/kernelboard/api/leaderboard.py +++ b/kernelboard/api/leaderboard.py @@ -314,7 +314,7 @@ def get_user_trend(leaderboard_id: int): # Fetch display names for all user_ids in batch placeholders = ",".join(["%s"] * len(user_ids)) cur.execute( - f"SELECT id, username FROM leaderboard.user " + f"SELECT id, user_name FROM leaderboard.user_info " f"WHERE id IN ({placeholders})", tuple(user_ids) ) @@ -427,22 +427,22 @@ def search_users(leaderboard_id: int): with conn.cursor() as cur: if query: sql = """ - SELECT DISTINCT u.id, u.username - FROM leaderboard.user u + 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.username ILIKE %s - ORDER BY u.username + 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.username - FROM leaderboard.user u + 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.username + ORDER BY u.user_name LIMIT %s """ cur.execute(sql, (leaderboard_id, limit)) From fb5aebada8a1e179ebe6d012bab1d7f4bac719b8 Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 10 Feb 2026 09:45:49 -0800 Subject: [PATCH 6/7] hidemetadata --- frontend/src/api/api.ts | 2 +- .../leaderboard/components/UserTrendChart.tsx | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts index 61aa66b..c77ba75 100644 --- a/frontend/src/api/api.ts +++ b/frontend/src/api/api.ts @@ -265,7 +265,7 @@ export async function searchUsers( if (query) params.set("q", query); if (limit) params.set("limit", limit.toString()); - const url = `/api/leaderboard/${leaderboardId}/search_users?${params.toString()}`; + const url = `/api/leaderboard/${leaderboardId}/users?${params.toString()}`; const res = await fetch(url); if (!res.ok) { const json = await res.json(); diff --git a/frontend/src/pages/leaderboard/components/UserTrendChart.tsx b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx index ff42b73..761b499 100644 --- a/frontend/src/pages/leaderboard/components/UserTrendChart.tsx +++ b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx @@ -100,12 +100,12 @@ export default function UserTrendChart({ leaderboardId }: UserTrendChartProps) { // Search users when input changes useEffect(() => { + if (inputValue === "") return; // Skip if empty, initial load handles it + const searchTimeout = setTimeout(async () => { setSearchLoading(true); try { - // When input is empty, reload initial 5 users - const limit = inputValue === "" ? 10 : 30; - const result = await searchUsers(leaderboardId, inputValue, limit); + const result = await searchUsers(leaderboardId, inputValue, 20); setUserOptions(result.users); } catch (err) { console.error("Failed to search users:", err); @@ -132,7 +132,6 @@ export default function UserTrendChart({ leaderboardId }: UserTrendChartProps) { - {option.username} + + {option.username} + + ID: {option.user_id} + + ); }} From e6a56470e54eec721338acd152bbc8676e76592a Mon Sep 17 00:00:00 2001 From: Yang Wang Date: Tue, 10 Feb 2026 09:46:21 -0800 Subject: [PATCH 7/7] hidemetadata --- .../leaderboard/components/UserTrendChart.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/frontend/src/pages/leaderboard/components/UserTrendChart.tsx b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx index 761b499..2c3932c 100644 --- a/frontend/src/pages/leaderboard/components/UserTrendChart.tsx +++ b/frontend/src/pages/leaderboard/components/UserTrendChart.tsx @@ -100,12 +100,11 @@ export default function UserTrendChart({ leaderboardId }: UserTrendChartProps) { // Search users when input changes useEffect(() => { - if (inputValue === "") return; // Skip if empty, initial load handles it - const searchTimeout = setTimeout(async () => { setSearchLoading(true); try { - const result = await searchUsers(leaderboardId, inputValue, 20); + const limit = inputValue === "" ? 5 : 20; + const result = await searchUsers(leaderboardId, inputValue, limit); setUserOptions(result.users); } catch (err) { console.error("Failed to search users:", err); @@ -131,8 +130,9 @@ export default function UserTrendChart({ leaderboardId }: UserTrendChartProps) { const renderSearchInput = () => ( - - {option.username} - - ID: {option.user_id} - - + {option.username} ); }}